1#![feature(let_chains)]
2use std::borrow::Cow;
3
4use common::{
5 comp::{
6 BuffKind, ChatMsg, ChatType, Content,
7 body::Gender,
8 chat::{KillSource, KillType},
9 },
10 uid::Uid,
11};
12use common_net::msg::{ChatTypeContext, PlayerInfo};
13use i18n::Localization;
14
15pub fn localize_chat_message(
16 msg: &ChatMsg,
17 info: &ChatTypeContext,
18 localization: &Localization,
19 show_char_name: bool,
20) -> (ChatType<String>, String) {
21 let name_format_or_complex = |complex, uid: &Uid| match info.player_info.get(uid).cloned() {
22 Some(pi) => {
23 if complex {
24 insert_alias(info.you == *uid, pi, localization)
25 } else {
26 pi.player_alias
27 }
28 },
29 None => localization.get_content(
30 info.entity_name
31 .get(uid)
32 .expect("client didn't provided enough info"),
33 ),
34 };
35
36 let name_format = |uid: &Uid| name_format_or_complex(false, uid);
39
40 let gender_str = |uid: &Uid| {
77 if let Some(pi) = info.player_info.get(uid) {
78 match pi.character.as_ref().and_then(|c| c.gender) {
79 Some(Gender::Feminine) => "she".to_owned(),
80 Some(Gender::Masculine) => "he".to_owned(),
81 Some(Gender::Neuter) | None => "??".to_owned(),
84 }
85 } else {
86 "??".to_owned()
87 }
88 };
89
90 let _gender_str_npc = || "idk".to_owned();
133
134 let message_format = |from: &Uid, content: &Content, group: Option<&String>| {
135 let alias = name_format_or_complex(true, from);
136
137 let name = if let Some(pi) = info.player_info.get(from)
138 && show_char_name
139 {
140 pi.character
141 .as_ref()
142 .map(|c| localization.get_content(&c.name))
143 } else {
144 None
145 };
146
147 let message = localization.get_content(content);
148
149 let line = match group {
150 Some(group) => match name {
151 Some(name) => localization.get_msg_ctx(
152 "hud-chat-message-in-group-with-name",
153 &i18n::fluent_args! {
154 "group" => group,
155 "alias" => alias,
156 "name" => name,
157 "msg" => message,
158 },
159 ),
160 None => {
161 localization.get_msg_ctx("hud-chat-message-in-group", &i18n::fluent_args! {
162 "group" => group,
163 "alias" => alias,
164 "msg" => message,
165 })
166 },
167 },
168 None => match name {
169 Some(name) => {
170 localization.get_msg_ctx("hud-chat-message-with-name", &i18n::fluent_args! {
171 "alias" => alias,
172 "name" => name,
173 "msg" => message,
174 })
175 },
176 None => localization.get_msg_ctx("hud-chat-message", &i18n::fluent_args! {
177 "alias" => alias,
178 "msg" => message,
179 }),
180 },
181 };
182
183 line.into_owned()
184 };
185
186 let new_msg = match &msg.chat_type {
187 ChatType::Online(uid) => localization
188 .get_msg_ctx("hud-chat-online_msg", &i18n::fluent_args! {
189 "user_gender" => gender_str(uid),
190 "name" => name_format(uid),
191 })
192 .into_owned(),
193 ChatType::Offline(uid) => localization
194 .get_msg_ctx("hud-chat-offline_msg", &i18n::fluent_args! {
195 "user_gender" => gender_str(uid),
196 "name" => name_format(uid),
197 })
198 .into_owned(),
199 ChatType::CommandError
200 | ChatType::CommandInfo
201 | ChatType::Meta
202 | ChatType::FactionMeta(_)
203 | ChatType::GroupMeta(_) => localization.get_content(msg.content()),
204 ChatType::Tell(from, to) => {
205 let (key, person_to_show) = if info.you == *from {
211 ("hud-chat-tell-to", to)
212 } else {
213 ("hud-chat-tell-from", from)
214 };
215
216 localization
217 .get_msg_ctx(key, &i18n::fluent_args! {
218 "alias" => name_format(person_to_show),
219 "user_gender" => gender_str(person_to_show),
220 "msg" => localization.get_content(msg.content()),
221 })
222 .into_owned()
223 },
224 ChatType::Say(uid) | ChatType::Region(uid) | ChatType::World(uid) => {
225 message_format(uid, msg.content(), None)
226 },
227 ChatType::Group(uid, descriptor) | ChatType::Faction(uid, descriptor) => {
228 message_format(uid, msg.content(), Some(descriptor))
229 },
230 ChatType::Npc(uid) | ChatType::NpcSay(uid) => message_format(uid, msg.content(), None),
231 ChatType::NpcTell(from, to) => {
232 let (key, person_to_show) = if info.you == *from {
241 ("hud-chat-tell-to-npc", to)
242 } else {
243 ("hud-chat-tell-from-npc", from)
244 };
245
246 localization
247 .get_msg_ctx(key, &i18n::fluent_args! {
248 "alias" => name_format(person_to_show),
249 "msg" => localization.get_content(msg.content()),
250 })
251 .into_owned()
252 },
253 ChatType::Kill(kill_source, victim) => {
254 localize_kill_message(kill_source, victim, name_format, gender_str, localization)
255 },
256 };
257
258 (msg.chat_type.clone(), new_msg)
259}
260
261fn localize_kill_message(
262 kill_source: &KillSource,
263 victim: &Uid,
264 name_format: impl Fn(&Uid) -> String,
265 gender_str: impl Fn(&Uid) -> String,
266 localization: &Localization,
267) -> String {
268 match kill_source {
269 KillSource::Player(attacker, kill_type) => {
271 let key = match kill_type {
272 KillType::Melee => "hud-chat-pvp_melee_kill_msg",
273 KillType::Projectile => "hud-chat-pvp_ranged_kill_msg",
274 KillType::Explosion => "hud-chat-pvp_explosion_kill_msg",
275 KillType::Energy => "hud-chat-pvp_energy_kill_msg",
276 KillType::Other => "hud-chat-pvp_other_kill_msg",
277 KillType::Buff(buff_kind) => {
278 let buff_ident = get_buff_ident(*buff_kind);
279
280 return localization
281 .get_attr_ctx(
282 "hud-chat-died_of_pvp_buff_msg",
283 buff_ident,
284 &i18n::fluent_args! {
285 "victim" => name_format(victim),
286 "victim_gender" => gender_str(victim),
287 "attacker" => name_format(attacker),
288 "attacker_gender" => gender_str(attacker),
289 },
290 )
291 .into_owned();
292 },
293 };
294 localization.get_msg_ctx(key, &i18n::fluent_args! {
295 "victim" => name_format(victim),
296 "victim_gender" => gender_str(victim),
297 "attacker" => name_format(attacker),
298 "attacker_gender" => gender_str(attacker),
299 })
300 },
301 KillSource::NonPlayer(attacker_name, kill_type) => {
303 let key = match kill_type {
304 KillType::Melee => "hud-chat-npc_melee_kill_msg",
305 KillType::Projectile => "hud-chat-npc_ranged_kill_msg",
306 KillType::Explosion => "hud-chat-npc_explosion_kill_msg",
307 KillType::Energy => "hud-chat-npc_energy_kill_msg",
308 KillType::Other => "hud-chat-npc_other_kill_msg",
309 KillType::Buff(buff_kind) => {
310 let buff_ident = get_buff_ident(*buff_kind);
311
312 return localization
313 .get_attr_ctx(
314 "hud-chat-died_of_npc_buff_msg",
315 buff_ident,
316 &i18n::fluent_args! {
317 "victim" => name_format(victim),
318 "victim_gender" => gender_str(victim),
319 "attacker" => localization.get_content(attacker_name),
320 },
321 )
322 .into_owned();
323 },
324 };
325 localization.get_msg_ctx(key, &i18n::fluent_args! {
326 "victim" => name_format(victim),
327 "victim_gender" => gender_str(victim),
328 "attacker" => localization.get_content(attacker_name),
329 })
330 },
331 KillSource::FallDamage => {
333 localization.get_msg_ctx("hud-chat-fall_kill_msg", &i18n::fluent_args! {
334 "name" => name_format(victim),
335 "victim_gender" => gender_str(victim),
336 })
337 },
338 KillSource::Suicide => {
339 localization.get_msg_ctx("hud-chat-suicide_msg", &i18n::fluent_args! {
340 "name" => name_format(victim),
341 "victim_gender" => gender_str(victim),
342 })
343 },
344 KillSource::NonExistent(KillType::Buff(buff_kind)) => {
345 let buff_ident = get_buff_ident(*buff_kind);
346
347 let s = localization
348 .get_attr_ctx(
349 "hud-chat-died_of_buff_nonexistent_msg",
350 buff_ident,
351 &i18n::fluent_args! {
352 "victim" => name_format(victim),
353 "victim_gender" => gender_str(victim),
354 },
355 )
356 .into_owned();
357 Cow::Owned(s)
358 },
359 KillSource::NonExistent(_) | KillSource::Other => {
360 localization.get_msg_ctx("hud-chat-default_death_msg", &i18n::fluent_args! {
361 "name" => name_format(victim),
362 "victim_gender" => gender_str(victim),
363 })
364 },
365 }
366 .into_owned()
367}
368
369fn get_buff_ident(buff: BuffKind) -> &'static str {
371 match buff {
372 BuffKind::Burning => "burning",
373 BuffKind::Bleeding => "bleeding",
374 BuffKind::Cursed => "curse",
375 BuffKind::Crippled => "crippled",
376 BuffKind::Frozen => "frozen",
377 BuffKind::Regeneration
378 | BuffKind::Saturation
379 | BuffKind::Potion
380 | BuffKind::Agility
381 | BuffKind::RestingHeal
382 | BuffKind::EnergyRegen
383 | BuffKind::IncreaseMaxEnergy
384 | BuffKind::IncreaseMaxHealth
385 | BuffKind::Invulnerability
386 | BuffKind::ProtectingWard
387 | BuffKind::Frenzied
388 | BuffKind::Hastened
389 | BuffKind::Fortitude
390 | BuffKind::Reckless
391 | BuffKind::Flame
392 | BuffKind::Frigid
393 | BuffKind::Lifesteal
394 | BuffKind::ImminentCritical
396 | BuffKind::Fury
397 | BuffKind::Sunderer
398 | BuffKind::Defiance
399 | BuffKind::Bloodfeast
400 | BuffKind::Berserk
401 | BuffKind::ScornfulTaunt
402 | BuffKind::Tenacity
403 | BuffKind::Resilience => {
404 tracing::error!("Player was killed by a positive buff!");
405 "mysterious"
406 },
407 BuffKind::Wet
408 | BuffKind::Ensnared
409 | BuffKind::Poisoned
410 | BuffKind::Parried
411 | BuffKind::PotionSickness
412 | BuffKind::Polymorphed
413 | BuffKind::Heatstroke
414 | BuffKind::Rooted
415 | BuffKind::Winded
416 | BuffKind::Concussion
417 | BuffKind::Staggered => {
418 tracing::error!("Player was killed by a debuff that doesn't do damage!");
419 "mysterious"
420 },
421 }
422}
423
424fn insert_alias(_replace_you: bool, info: PlayerInfo, _localization: &Localization) -> String {
428 const MOD_SPACING: &str = " ";
430
431 if info.is_moderator {
432 format!("{}{}", MOD_SPACING, info.player_alias)
433 } else {
434 info.player_alias
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 #[expect(unused)] use super::*;
441 use common::{
442 assets::AssetExt,
443 comp::{
444 Body, Content,
445 inventory::item::{ItemDesc, ItemI18n, all_items_expect},
446 },
447 generation::{BodyBuilder, EntityConfig, EntityInfo, try_all_entity_configs},
448 npc::NPC_NAMES,
449 };
450 use i18n::LocalizationHandle;
451 use std::collections::HashSet;
452 use vek::Vec3;
453
454 #[test]
458 fn test_item_text() {
459 let manifest = ItemI18n::new_expect();
460 let localization = LocalizationHandle::load_expect("en").read();
461 let items = all_items_expect();
462
463 for item in items {
464 let (name, desc) = item.i18n(&manifest);
465
466 let Content::Key(key) = name else {
468 panic!("name is expected to be Key, please fix the test");
469 };
470 localization.try_msg(&key).unwrap_or_else(|| {
471 panic!("'{key}' name doesn't have i18n");
472 });
473
474 let Content::Attr(key, attr) = desc else {
476 panic!("desc is expected to be Attr, please fix the test");
477 };
478 localization.try_attr(&key, &attr).unwrap_or_else(|| {
479 panic!("'{key}' description doesn't have i18n");
480 });
481 }
482 }
483
484 #[test]
485 fn test_body_names() {
486 let mut no_names = HashSet::new();
487 let mut no_i18n = HashSet::new();
488
489 let localization = LocalizationHandle::load_expect("en").read();
490 let npc_names = NPC_NAMES.read();
491 for body in Body::iter().filter(|b| {
495 !matches!(
496 b,
497 Body::Item(_) | Body::Object(_) | Body::Ship(_) | Body::Plugin(_)
498 )
499 }) {
500 let Some(name) = npc_names.get_default_name(&body) else {
501 no_names.insert(body);
502 continue;
503 };
504 let Content::Attr(key, attr) = name else {
505 panic!("name is expected to be Attr, please fix the test");
506 };
507 if localization.try_attr(&key, &attr).is_none() {
508 no_i18n.insert((key, attr));
509 };
510 }
511 if !no_names.is_empty() {
512 let mut no_names = no_names.iter().collect::<Vec<_>>();
513 no_names.sort();
514 panic!(
515 "Following configs have neither custom name nor default: {:#?}",
516 no_names
517 );
518 }
519
520 if !no_i18n.is_empty() {
521 let mut no_i18n = no_i18n.iter().collect::<Vec<_>>();
522 no_i18n.sort();
523 panic!("Following entities don't have proper i18n: {:#?}", no_i18n);
524 }
525 }
526
527 #[test]
528 fn test_npc_names() {
529 let mut no_names = HashSet::new();
530 let mut no_i18n = HashSet::new();
531
532 let mut rng = rand::thread_rng();
533 let localization = LocalizationHandle::load_expect("en").read();
534
535 let entity_configs =
536 try_all_entity_configs().expect("Failed to access entity configs directory");
537
538 let all_bodies = Body::iter().collect::<Vec<_>>();
540 for config_asset in entity_configs {
541 let event = None;
543
544 let entity_config = EntityConfig::load_expect_cloned(&config_asset);
546 let template_entity = EntityInfo::at(Vec3::zero()).with_entity_config(
548 entity_config.clone(),
549 Some(&config_asset),
550 &mut rng,
551 event,
552 );
553
554 let entity_body = template_entity.body;
556 for body in all_bodies
557 .iter()
558 .filter(|b| b.is_same_species_as(&entity_body))
559 {
560 let entity_config = entity_config.clone().with_body(BodyBuilder::Exact(*body));
562 let entity = EntityInfo::at(Vec3::zero()).with_entity_config(
564 entity_config,
565 Some(&config_asset),
566 &mut rng,
567 event,
568 );
569
570 let name = entity.name;
571 let Some(name) = name else {
572 no_names.insert(config_asset.to_owned());
573 continue;
574 };
575
576 let Content::Attr(key, attr) = name else {
577 panic!("name is expected to be Attr, please fix the test");
578 };
579 if localization.try_attr(&key, &attr).is_none() {
580 no_i18n.insert((key, attr));
581 };
582 }
583 }
584
585 if !no_names.is_empty() {
586 let mut no_names = no_names.iter().collect::<Vec<_>>();
587 no_names.sort();
588 panic!(
589 "Following configs have neither custom name nor default: {:#?}",
590 no_names
591 );
592 }
593
594 if !no_i18n.is_empty() {
595 let mut no_i18n = no_i18n.iter().collect::<Vec<_>>();
596 no_i18n.sort();
597 panic!("Following entities don't have proper i18n: {:#?}", no_i18n);
598 }
599 }
600}