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 | BuffKind::OwlTalon
405 | BuffKind::HeavyNock
406 | BuffKind::Heartseeker
407 | BuffKind::EagleEye
408 | BuffKind::ArdentHunter
409 | BuffKind::SepticShot => {
410 tracing::error!("Player was killed by a positive buff!");
411 "mysterious"
412 },
413 BuffKind::Wet
414 | BuffKind::Ensnared
415 | BuffKind::Poisoned
416 | BuffKind::Parried
417 | BuffKind::PotionSickness
418 | BuffKind::Polymorphed
419 | BuffKind::Heatstroke
420 | BuffKind::Rooted
421 | BuffKind::Winded
422 | BuffKind::Amnesia
423 | BuffKind::OffBalance
424 | BuffKind::Chilled
425 | BuffKind::ArdentHunted => {
426 tracing::error!("Player was killed by a debuff that doesn't do damage!");
427 "mysterious"
428 },
429 }
430}
431
432fn insert_alias(_replace_you: bool, info: PlayerInfo, _localization: &Localization) -> String {
436 const MOD_SPACING: &str = " ";
438
439 if info.is_moderator {
440 format!("{}{}", MOD_SPACING, info.player_alias)
441 } else {
442 info.player_alias
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 #[expect(unused)] use super::*;
449 use common::{
450 assets::{AssetExt, Ron},
451 comp::{
452 Body, Content,
453 inventory::item::{ItemDesc, ItemI18n, all_items_expect},
454 },
455 generation::{BodyBuilder, EntityConfig, EntityInfo, try_all_entity_configs},
456 npc::NPC_NAMES,
457 };
458 use i18n::LocalizationHandle;
459 use std::collections::HashSet;
460 use vek::Vec3;
461
462 #[test]
466 fn test_item_text() {
467 let manifest = ItemI18n::new_expect();
468 let localization = LocalizationHandle::load_expect("en").read();
469 let items = all_items_expect();
470
471 for item in items {
472 let (name, desc) = item.i18n(&manifest);
473
474 let Content::Key(key) = name else {
476 panic!("name is expected to be Key, please fix the test");
477 };
478 localization.try_msg(&key).unwrap_or_else(|| {
479 panic!("'{key}' name doesn't have i18n");
480 });
481
482 let Content::Attr(key, attr) = desc else {
484 panic!("desc is expected to be Attr, please fix the test");
485 };
486 localization.try_attr(&key, &attr).unwrap_or_else(|| {
487 panic!("'{key}' description doesn't have i18n");
488 });
489 }
490 }
491
492 #[test]
493 fn test_body_names() {
494 let mut no_names = HashSet::new();
495 let mut no_i18n = HashSet::new();
496
497 let localization = LocalizationHandle::load_expect("en").read();
498 let npc_names = NPC_NAMES.read();
499 for body in Body::iter().filter(|b| {
503 !matches!(
504 b,
505 Body::Item(_) | Body::Object(_) | Body::Ship(_) | Body::Plugin(_)
506 )
507 }) {
508 let Some(name) = npc_names.get_default_name(&body) else {
509 no_names.insert(body);
510 continue;
511 };
512 let Content::Attr(key, attr) = name else {
513 panic!("name is expected to be Attr, please fix the test");
514 };
515 if localization.try_attr(&key, &attr).is_none() {
516 no_i18n.insert((key, attr));
517 };
518 }
519 if !no_names.is_empty() {
520 let mut no_names = no_names.iter().collect::<Vec<_>>();
521 no_names.sort();
522 panic!(
523 "Following configs have neither custom name nor default: {:#?}",
524 no_names
525 );
526 }
527
528 if !no_i18n.is_empty() {
529 let mut no_i18n = no_i18n.iter().collect::<Vec<_>>();
530 no_i18n.sort();
531 panic!("Following entities don't have proper i18n: {:#?}", no_i18n);
532 }
533 }
534
535 #[test]
536 fn test_npc_names() {
537 let mut no_names = HashSet::new();
538 let mut no_i18n = HashSet::new();
539
540 let mut rng = rand::rng();
541 let localization = LocalizationHandle::load_expect("en").read();
542
543 let entity_configs =
544 try_all_entity_configs().expect("Failed to access entity configs directory");
545
546 let all_bodies = Body::iter().collect::<Vec<_>>();
548 for config_asset in entity_configs {
549 let event = None;
551
552 let entity_config = Ron::<EntityConfig>::load_expect_cloned(&config_asset).into_inner();
554 let template_entity = EntityInfo::at(Vec3::zero()).with_entity_config(
556 entity_config.clone(),
557 Some(&config_asset),
558 &mut rng,
559 event,
560 );
561
562 let entity_body = template_entity.body;
564 for body in all_bodies
565 .iter()
566 .filter(|b| b.is_same_species_as(&entity_body))
567 {
568 let entity_config = entity_config.clone().with_body(BodyBuilder::Exact(*body));
570 let entity = EntityInfo::at(Vec3::zero()).with_entity_config(
572 entity_config,
573 Some(&config_asset),
574 &mut rng,
575 event,
576 );
577
578 let name = entity.name;
579 let Some(name) = name else {
580 no_names.insert(config_asset.to_owned());
581 continue;
582 };
583
584 let Content::Attr(key, attr) = name else {
585 panic!("name is expected to be Attr, please fix the test");
586 };
587 if localization.try_attr(&key, &attr).is_none() {
588 no_i18n.insert((key, attr));
589 };
590 }
591 }
592
593 if !no_names.is_empty() {
594 let mut no_names = no_names.iter().collect::<Vec<_>>();
595 no_names.sort();
596 panic!(
597 "Following configs have neither custom name nor default: {:#?}",
598 no_names
599 );
600 }
601
602 if !no_i18n.is_empty() {
603 let mut no_i18n = no_i18n.iter().collect::<Vec<_>>();
604 no_i18n.sort();
605 panic!("Following entities don't have proper i18n: {:#?}", no_i18n);
606 }
607 }
608}