veloren_voxygen_i18n_helpers/
lib.rs

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    // Some messages do suffer from complicated logic of insert_alias.
36    // This includes every notification-like message, like death.
37    let name_format = |uid: &Uid| name_format_or_complex(false, uid);
38
39    // This is a hack, kind of.
40    //
41    // Current implementation just checks if our player is humanoid, and if so,
42    // we take the body_type of its character and assume it as grammatical gender.
43    //
44    // In short,
45    //  body_type of character
46    //  -> sex of character
47    //  -> gender of player.
48    //  -> grammatical gender for use in messages.
49    //
50    // This is obviously, wrong, but it's good enough approximation, after all,
51    // players do choose their characters.
52    //
53    // In the future, we will want special GUI where players can specify their
54    // gender (and change it!), and we do want to handle more genders than just
55    // male and female.
56    //
57    // Ideally, the system should handle following (if we exclude plurals):
58    // - Female
59    // - Male
60    // - Neuter (or fallback Female)
61    // - Neuter (or fallback Male)
62    // - Intermediate (or fallback Female)
63    // - Intermediate (or fallback Male)
64    // and maybe more, not sure.
65    //
66    // What is supported by language and what is not, as well as maybe how to
67    // convert genders into strings to match, should go into _manifest.ron file
68    //
69    // So let's say language only supports male and female, we will convert all
70    // genders to these, using some fallbacks, and pass it.
71    //
72    // If the language can represent Female, Male and Neuter, we can pass these.
73    //
74    // Exact design of such a complex system is honestly up to discussion.
75    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                // TODO: humanoids and hence players can't be neuter, until
81                // we address the comment above at least.
82                Some(Gender::Neuter) | None => "??".to_owned(),
83            }
84        } else {
85            "??".to_owned()
86        }
87    };
88
89    // This is where the most fun begings.
90    //
91    // Unlike people, "items" can have their own gender, which is completely
92    // independent of everything, including common sense.
93    //
94    // For example, word "masculinity" can be feminine in some languages,
95    // as well as word "boy", and vice versa.
96    //
97    // So we can't rely on body_type, at all. And even if we did try, our
98    // body_type isn't even always represents animal sex, there are some
99    // animals that use body_type to represent their kind, like different
100    // types of Fox ("male" fox is forest, "female" is arctic one).
101    // And what about Mindflayer? They do have varied body_type, but do they
102    // even have concept of gender?
103    //
104    // Our use case is probably less cryptic, after all we are limited by
105    // mostly sentient things, but that doesn't help at all.
106    //
107    // Common example is word "spider", which can be feminine in one languages
108    // and masculine in other, and sometimes even neuter.
109    //
110    // Oh, and I want to add that we are talking about grammatical genders, and
111    // languages define their own grammar. There are languages that have more
112    // than three grammatical genders, there are languages that don't have
113    // male/female distinction and instead realy on animacy/non-animacy.
114    // What has an animacy and what doesn't is for language to decide.
115    // There are languages as well that mix these concepts and may have neuter,
116    // female, masculine with animacy, masculine with animacy. Some languages
117    // have their own scheme of things that arbitrarily picks noun-class per
118    // noun.
119    // Don't get me wrong. *All* languages do pick the gender for the word
120    // arbitrary as I showed at the beginning, it's just some languages have
121    // not just different mapping, but different gender set as well.
122    //
123    // The *only* option we have is fetch the gender per each name entry from
124    // localization files.
125    //
126    // I'm not 100% sure what should be the implementation of it, but I imagine
127    // that Stats::name() should be changed to include a way to reference where
128    // to grab the gender associated with this name, so translation then can
129    // pick right article or use right adjective/verb connected with NPC in the
130    // context of the message.
131    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            // If `from` is you, it means you're writing to someone
205            // and you want to see who you're writing to.
206            //
207            // Otherwise, someone writes to you, and you want to see
208            // who is that person that's writing to you.
209            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            // If `from` is you, it means you're writing to someone
232            // and you want to see who you're writing to.
233            //
234            // Otherwise, someone writes to you, and you want to see
235            // who is that person that's writing to you.
236            //
237            // Hopefully, no gendering needed, because for npc, we
238            // simply don't know.
239            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        // PvP deaths
269        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        // PvE deaths
301        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        // Other deaths
331        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
368/// Determines .attr for `hud-chat-died-of-buff` messages
369fn 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::SalamanderAspect
395        | 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
432/// Used for inserting spacing for mod badge icon next to alias
433// TODO: consider passing '$is_you' to hud-chat-message strings along with
434// $spacing variable, for more flexible translations.
435fn insert_alias(_replace_you: bool, info: PlayerInfo, _localization: &Localization) -> String {
436    // Leave space for a mod badge icon.
437    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    // item::tests::ensure_item_localization tests that we have Content for
463    // each item. This tests that we actually have at least English translation
464    // for this Content.
465    #[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            // check i18n for item name
475            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            // check i18n for item desc
483            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        // ignore things that don't have names anyway
500        //
501        // also plugins, sommry, plugins don't have i18n yet
502        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        // collect to then clone over and over
547        let all_bodies = Body::iter().collect::<Vec<_>>();
548        for config_asset in entity_configs {
549            // TODO: figure out way to test events?
550            let event = None;
551
552            // evaluate to get template to modify later
553            let entity_config = Ron::<EntityConfig>::load_expect_cloned(&config_asset).into_inner();
554            // evaluate to get random body
555            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            // loop over all possible bodies for the same species
563            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                // real entity config we plan to test with
569                let entity_config = entity_config.clone().with_body(BodyBuilder::Exact(*body));
570                // evaluate to get the resulting name
571                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}