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            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
424/// Used for inserting spacing for mod badge icon next to alias
425// TODO: consider passing '$is_you' to hud-chat-message strings along with
426// $spacing variable, for more flexible translations.
427fn insert_alias(_replace_you: bool, info: PlayerInfo, _localization: &Localization) -> String {
428    // Leave space for a mod badge icon.
429    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    // item::tests::ensure_item_localization tests that we have Content for
455    // each item. This tests that we actually have at least English translation
456    // for this Content.
457    #[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            // check i18n for item name
467            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            // check i18n for item desc
475            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        // ignore things that don't have names anyway
492        //
493        // also plugins, sommry, plugins don't have i18n yet
494        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        // collect to then clone over and over
539        let all_bodies = Body::iter().collect::<Vec<_>>();
540        for config_asset in entity_configs {
541            // TODO: figure out way to test events?
542            let event = None;
543
544            // evaluate to get template to modify later
545            let entity_config = Ron::<EntityConfig>::load_expect_cloned(&config_asset).into_inner();
546            // evaluate to get random body
547            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            // loop over all possible bodies for the same species
555            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                // real entity config we plan to test with
561                let entity_config = entity_config.clone().with_body(BodyBuilder::Exact(*body));
562                // evaluate to get the resulting name
563                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}