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::ComboGeneration
384 | BuffKind::IncreaseMaxEnergy
385 | BuffKind::IncreaseMaxHealth
386 | BuffKind::Invulnerability
387 | BuffKind::ProtectingWard
388 | BuffKind::Frenzied
389 | BuffKind::Hastened
390 | BuffKind::Fortitude
391 | BuffKind::Reckless
392 | BuffKind::Flame
393 | BuffKind::Frigid
394 | BuffKind::Lifesteal
395 | BuffKind::ImminentCritical
397 | BuffKind::Fury
398 | BuffKind::Sunderer
399 | BuffKind::Defiance
400 | BuffKind::Bloodfeast
401 | BuffKind::Berserk
402 | BuffKind::ScornfulTaunt
403 | BuffKind::Tenacity
404 | BuffKind::Resilience => {
405 tracing::error!("Player was killed by a positive buff!");
406 "mysterious"
407 },
408 BuffKind::Wet
409 | BuffKind::Ensnared
410 | BuffKind::Poisoned
411 | BuffKind::Parried
412 | BuffKind::PotionSickness
413 | BuffKind::Polymorphed
414 | BuffKind::Heatstroke
415 | BuffKind::Rooted
416 | BuffKind::Winded
417 | BuffKind::Concussion
418 | BuffKind::Staggered => {
419 tracing::error!("Player was killed by a debuff that doesn't do damage!");
420 "mysterious"
421 },
422 }
423}
424
425fn insert_alias(_replace_you: bool, info: PlayerInfo, _localization: &Localization) -> String {
429 const MOD_SPACING: &str = " ";
431
432 if info.is_moderator {
433 format!("{}{}", MOD_SPACING, info.player_alias)
434 } else {
435 info.player_alias
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 #[expect(unused)] use super::*;
442 use common::{
443 assets::AssetExt,
444 comp::{
445 Body, Content,
446 inventory::item::{ItemDesc, ItemI18n, all_items_expect},
447 },
448 generation::{BodyBuilder, EntityConfig, EntityInfo, try_all_entity_configs},
449 npc::NPC_NAMES,
450 };
451 use i18n::LocalizationHandle;
452 use std::collections::HashSet;
453 use vek::Vec3;
454
455 #[test]
459 fn test_item_text() {
460 let manifest = ItemI18n::new_expect();
461 let localization = LocalizationHandle::load_expect("en").read();
462 let items = all_items_expect();
463
464 for item in items {
465 let (name, desc) = item.i18n(&manifest);
466
467 let Content::Key(key) = name else {
469 panic!("name is expected to be Key, please fix the test");
470 };
471 localization.try_msg(&key).unwrap_or_else(|| {
472 panic!("'{key}' name doesn't have i18n");
473 });
474
475 let Content::Attr(key, attr) = desc else {
477 panic!("desc is expected to be Attr, please fix the test");
478 };
479 localization.try_attr(&key, &attr).unwrap_or_else(|| {
480 panic!("'{key}' description doesn't have i18n");
481 });
482 }
483 }
484
485 #[test]
486 fn test_body_names() {
487 let mut no_names = HashSet::new();
488 let mut no_i18n = HashSet::new();
489
490 let localization = LocalizationHandle::load_expect("en").read();
491 let npc_names = NPC_NAMES.read();
492 for body in Body::iter().filter(|b| {
496 !matches!(
497 b,
498 Body::Item(_) | Body::Object(_) | Body::Ship(_) | Body::Plugin(_)
499 )
500 }) {
501 let Some(name) = npc_names.get_default_name(&body) else {
502 no_names.insert(body);
503 continue;
504 };
505 let Content::Attr(key, attr) = name else {
506 panic!("name is expected to be Attr, please fix the test");
507 };
508 if localization.try_attr(&key, &attr).is_none() {
509 no_i18n.insert((key, attr));
510 };
511 }
512 if !no_names.is_empty() {
513 let mut no_names = no_names.iter().collect::<Vec<_>>();
514 no_names.sort();
515 panic!(
516 "Following configs have neither custom name nor default: {:#?}",
517 no_names
518 );
519 }
520
521 if !no_i18n.is_empty() {
522 let mut no_i18n = no_i18n.iter().collect::<Vec<_>>();
523 no_i18n.sort();
524 panic!("Following entities don't have proper i18n: {:#?}", no_i18n);
525 }
526 }
527
528 #[test]
529 fn test_npc_names() {
530 let mut no_names = HashSet::new();
531 let mut no_i18n = HashSet::new();
532
533 let mut rng = rand::thread_rng();
534 let localization = LocalizationHandle::load_expect("en").read();
535
536 let entity_configs =
537 try_all_entity_configs().expect("Failed to access entity configs directory");
538
539 let all_bodies = Body::iter().collect::<Vec<_>>();
541 for config_asset in entity_configs {
542 let event = None;
544
545 let entity_config = EntityConfig::load_expect_cloned(&config_asset);
547 let template_entity = EntityInfo::at(Vec3::zero()).with_entity_config(
549 entity_config.clone(),
550 Some(&config_asset),
551 &mut rng,
552 event,
553 );
554
555 let entity_body = template_entity.body;
557 for body in all_bodies
558 .iter()
559 .filter(|b| b.is_same_species_as(&entity_body))
560 {
561 let entity_config = entity_config.clone().with_body(BodyBuilder::Exact(*body));
563 let entity = EntityInfo::at(Vec3::zero()).with_entity_config(
565 entity_config,
566 Some(&config_asset),
567 &mut rng,
568 event,
569 );
570
571 let name = entity.name;
572 let Some(name) = name else {
573 no_names.insert(config_asset.to_owned());
574 continue;
575 };
576
577 let Content::Attr(key, attr) = name else {
578 panic!("name is expected to be Attr, please fix the test");
579 };
580 if localization.try_attr(&key, &attr).is_none() {
581 no_i18n.insert((key, attr));
582 };
583 }
584 }
585
586 if !no_names.is_empty() {
587 let mut no_names = no_names.iter().collect::<Vec<_>>();
588 no_names.sort();
589 panic!(
590 "Following configs have neither custom name nor default: {:#?}",
591 no_names
592 );
593 }
594
595 if !no_i18n.is_empty() {
596 let mut no_i18n = no_i18n.iter().collect::<Vec<_>>();
597 no_i18n.sort();
598 panic!("Following entities don't have proper i18n: {:#?}", no_i18n);
599 }
600 }
601}