1use std::borrow::Cow;
2
3use common::{
4 comp::{
5 BuffKind, ChatMsg, ChatType, Content,
6 body::Gender,
7 chat::{KillSource, KillType},
8 },
9 uid::Uid,
10};
11use common_net::msg::{ChatTypeContext, PlayerInfo};
12use i18n::Localization;
13
14pub fn localize_chat_message(
15 msg: &ChatMsg,
16 info: &ChatTypeContext,
17 localization: &Localization,
18 show_char_name: bool,
19) -> (ChatType<String>, String) {
20 let name_format_or_complex = |complex, uid: &Uid| match info.player_info.get(uid).cloned() {
21 Some(pi) => {
22 if complex {
23 insert_alias(info.you == *uid, pi, localization)
24 } else {
25 pi.player_alias
26 }
27 },
28 None => localization.get_content(
29 info.entity_name
30 .get(uid)
31 .expect("client didn't provided enough info"),
32 ),
33 };
34
35 let name_format = |uid: &Uid| name_format_or_complex(false, uid);
38
39 let gender_str = |uid: &Uid| {
76 if let Some(pi) = info.player_info.get(uid) {
77 match pi.character.as_ref().and_then(|c| c.gender) {
78 Some(Gender::Feminine) => "she".to_owned(),
79 Some(Gender::Masculine) => "he".to_owned(),
80 Some(Gender::Neuter) | None => "??".to_owned(),
83 }
84 } else {
85 "??".to_owned()
86 }
87 };
88
89 let _gender_str_npc = || "idk".to_owned();
132
133 let message_format = |from: &Uid, content: &Content, group: Option<&String>| {
134 let alias = name_format_or_complex(true, from);
135
136 let name = if let Some(pi) = info.player_info.get(from)
137 && show_char_name
138 {
139 pi.character
140 .as_ref()
141 .map(|c| localization.get_content(&c.name))
142 } else {
143 None
144 };
145
146 let message = localization.get_content(content);
147
148 let line = match group {
149 Some(group) => match name {
150 Some(name) => localization.get_msg_ctx(
151 "hud-chat-message-in-group-with-name",
152 &i18n::fluent_args! {
153 "group" => group,
154 "alias" => alias,
155 "name" => name,
156 "msg" => message,
157 },
158 ),
159 None => {
160 localization.get_msg_ctx("hud-chat-message-in-group", &i18n::fluent_args! {
161 "group" => group,
162 "alias" => alias,
163 "msg" => message,
164 })
165 },
166 },
167 None => match name {
168 Some(name) => {
169 localization.get_msg_ctx("hud-chat-message-with-name", &i18n::fluent_args! {
170 "alias" => alias,
171 "name" => name,
172 "msg" => message,
173 })
174 },
175 None => localization.get_msg_ctx("hud-chat-message", &i18n::fluent_args! {
176 "alias" => alias,
177 "msg" => message,
178 }),
179 },
180 };
181
182 line.into_owned()
183 };
184
185 let new_msg = match &msg.chat_type {
186 ChatType::Online(uid) => localization
187 .get_msg_ctx("hud-chat-online_msg", &i18n::fluent_args! {
188 "user_gender" => gender_str(uid),
189 "name" => name_format(uid),
190 })
191 .into_owned(),
192 ChatType::Offline(uid) => localization
193 .get_msg_ctx("hud-chat-offline_msg", &i18n::fluent_args! {
194 "user_gender" => gender_str(uid),
195 "name" => name_format(uid),
196 })
197 .into_owned(),
198 ChatType::CommandError
199 | ChatType::CommandInfo
200 | ChatType::Meta
201 | ChatType::FactionMeta(_)
202 | ChatType::GroupMeta(_) => localization.get_content(msg.content()),
203 ChatType::Tell(from, to) => {
204 let (key, person_to_show) = if info.you == *from {
210 ("hud-chat-tell-to", to)
211 } else {
212 ("hud-chat-tell-from", from)
213 };
214
215 localization
216 .get_msg_ctx(key, &i18n::fluent_args! {
217 "alias" => name_format(person_to_show),
218 "user_gender" => gender_str(person_to_show),
219 "msg" => localization.get_content(msg.content()),
220 })
221 .into_owned()
222 },
223 ChatType::Say(uid) | ChatType::Region(uid) | ChatType::World(uid) => {
224 message_format(uid, msg.content(), None)
225 },
226 ChatType::Group(uid, descriptor) | ChatType::Faction(uid, descriptor) => {
227 message_format(uid, msg.content(), Some(descriptor))
228 },
229 ChatType::Npc(uid) | ChatType::NpcSay(uid) => message_format(uid, msg.content(), None),
230 ChatType::NpcTell(from, to) => {
231 let (key, person_to_show) = if info.you == *from {
240 ("hud-chat-tell-to-npc", to)
241 } else {
242 ("hud-chat-tell-from-npc", from)
243 };
244
245 localization
246 .get_msg_ctx(key, &i18n::fluent_args! {
247 "alias" => name_format(person_to_show),
248 "msg" => localization.get_content(msg.content()),
249 })
250 .into_owned()
251 },
252 ChatType::Kill(kill_source, victim) => {
253 localize_kill_message(kill_source, victim, name_format, gender_str, localization)
254 },
255 };
256
257 (msg.chat_type.clone(), new_msg)
258}
259
260fn localize_kill_message(
261 kill_source: &KillSource,
262 victim: &Uid,
263 name_format: impl Fn(&Uid) -> String,
264 gender_str: impl Fn(&Uid) -> String,
265 localization: &Localization,
266) -> String {
267 match kill_source {
268 KillSource::Player(attacker, kill_type) => {
270 let key = match kill_type {
271 KillType::Melee => "hud-chat-pvp_melee_kill_msg",
272 KillType::Projectile => "hud-chat-pvp_ranged_kill_msg",
273 KillType::Explosion => "hud-chat-pvp_explosion_kill_msg",
274 KillType::Energy => "hud-chat-pvp_energy_kill_msg",
275 KillType::Other => "hud-chat-pvp_other_kill_msg",
276 KillType::Buff(buff_kind) => {
277 let buff_ident = get_buff_ident(*buff_kind);
278
279 return localization
280 .get_attr_ctx(
281 "hud-chat-died_of_pvp_buff_msg",
282 buff_ident,
283 &i18n::fluent_args! {
284 "victim" => name_format(victim),
285 "victim_gender" => gender_str(victim),
286 "attacker" => name_format(attacker),
287 "attacker_gender" => gender_str(attacker),
288 },
289 )
290 .into_owned();
291 },
292 };
293 localization.get_msg_ctx(key, &i18n::fluent_args! {
294 "victim" => name_format(victim),
295 "victim_gender" => gender_str(victim),
296 "attacker" => name_format(attacker),
297 "attacker_gender" => gender_str(attacker),
298 })
299 },
300 KillSource::NonPlayer(attacker_name, kill_type) => {
302 let key = match kill_type {
303 KillType::Melee => "hud-chat-npc_melee_kill_msg",
304 KillType::Projectile => "hud-chat-npc_ranged_kill_msg",
305 KillType::Explosion => "hud-chat-npc_explosion_kill_msg",
306 KillType::Energy => "hud-chat-npc_energy_kill_msg",
307 KillType::Other => "hud-chat-npc_other_kill_msg",
308 KillType::Buff(buff_kind) => {
309 let buff_ident = get_buff_ident(*buff_kind);
310
311 return localization
312 .get_attr_ctx(
313 "hud-chat-died_of_npc_buff_msg",
314 buff_ident,
315 &i18n::fluent_args! {
316 "victim" => name_format(victim),
317 "victim_gender" => gender_str(victim),
318 "attacker" => localization.get_content(attacker_name),
319 },
320 )
321 .into_owned();
322 },
323 };
324 localization.get_msg_ctx(key, &i18n::fluent_args! {
325 "victim" => name_format(victim),
326 "victim_gender" => gender_str(victim),
327 "attacker" => localization.get_content(attacker_name),
328 })
329 },
330 KillSource::FallDamage => {
332 localization.get_msg_ctx("hud-chat-fall_kill_msg", &i18n::fluent_args! {
333 "name" => name_format(victim),
334 "victim_gender" => gender_str(victim),
335 })
336 },
337 KillSource::Suicide => {
338 localization.get_msg_ctx("hud-chat-suicide_msg", &i18n::fluent_args! {
339 "name" => name_format(victim),
340 "victim_gender" => gender_str(victim),
341 })
342 },
343 KillSource::NonExistent(KillType::Buff(buff_kind)) => {
344 let buff_ident = get_buff_ident(*buff_kind);
345
346 let s = localization
347 .get_attr_ctx(
348 "hud-chat-died_of_buff_nonexistent_msg",
349 buff_ident,
350 &i18n::fluent_args! {
351 "victim" => name_format(victim),
352 "victim_gender" => gender_str(victim),
353 },
354 )
355 .into_owned();
356 Cow::Owned(s)
357 },
358 KillSource::NonExistent(_) | KillSource::Other => {
359 localization.get_msg_ctx("hud-chat-default_death_msg", &i18n::fluent_args! {
360 "name" => name_format(victim),
361 "victim_gender" => gender_str(victim),
362 })
363 },
364 }
365 .into_owned()
366}
367
368fn get_buff_ident(buff: BuffKind) -> &'static str {
370 match buff {
371 BuffKind::Burning => "burning",
372 BuffKind::Bleeding => "bleeding",
373 BuffKind::Cursed => "curse",
374 BuffKind::Crippled => "crippled",
375 BuffKind::Frozen => "frozen",
376 BuffKind::Regeneration
377 | BuffKind::Saturation
378 | BuffKind::Potion
379 | BuffKind::Agility
380 | BuffKind::RestingHeal
381 | BuffKind::EnergyRegen
382 | BuffKind::ComboGeneration
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::Amnesia
417 | BuffKind::OffBalance => {
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, Ron},
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::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 = Ron::<EntityConfig>::load_expect_cloned(&config_asset).into_inner();
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}