veloren_voxygen_i18n_helpers/
lib.rs

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    // Some messages do suffer from complicated logic of insert_alias.
37    // This includes every notification-like message, like death.
38    let name_format = |uid: &Uid| name_format_or_complex(false, uid);
39
40    // This is a hack, kind of.
41    //
42    // Current implementation just checks if our player is humanoid, and if so,
43    // we take the body_type of its character and assume it as grammatical gender.
44    //
45    // In short,
46    //  body_type of character
47    //  -> sex of character
48    //  -> gender of player.
49    //  -> grammatical gender for use in messages.
50    //
51    // This is obviously, wrong, but it's good enough approximation, after all,
52    // players do choose their characters.
53    //
54    // In the future, we will want special GUI where players can specify their
55    // gender (and change it!), and we do want to handle more genders than just
56    // male and female.
57    //
58    // Ideally, the system should handle following (if we exclude plurals):
59    // - Female
60    // - Male
61    // - Neuter (or fallback Female)
62    // - Neuter (or fallback Male)
63    // - Intermediate (or fallback Female)
64    // - Intermediate (or fallback Male)
65    // and maybe more, not sure.
66    //
67    // What is supported by language and what is not, as well as maybe how to
68    // convert genders into strings to match, should go into _manifest.ron file
69    //
70    // So let's say language only supports male and female, we will convert all
71    // genders to these, using some fallbacks, and pass it.
72    //
73    // If the language can represent Female, Male and Neuter, we can pass these.
74    //
75    // Exact design of such a complex system is honestly up to discussion.
76    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                // TODO: humanoids and hence players can't be neuter, until
82                // we address the comment above at least.
83                Some(Gender::Neuter) | None => "??".to_owned(),
84            }
85        } else {
86            "??".to_owned()
87        }
88    };
89
90    // This is where the most fun begings.
91    //
92    // Unlike people, "items" can have their own gender, which is completely
93    // independent of everything, including common sense.
94    //
95    // For example, word "masculinity" can be feminine in some languages,
96    // as well as word "boy", and vice versa.
97    //
98    // So we can't rely on body_type, at all. And even if we did try, our
99    // body_type isn't even always represents animal sex, there are some
100    // animals that use body_type to represent their kind, like different
101    // types of Fox ("male" fox is forest, "female" is arctic one).
102    // And what about Mindflayer? They do have varied body_type, but do they
103    // even have concept of gender?
104    //
105    // Our use case is probably less cryptic, after all we are limited by
106    // mostly sentient things, but that doesn't help at all.
107    //
108    // Common example is word "spider", which can be feminine in one languages
109    // and masculine in other, and sometimes even neuter.
110    //
111    // Oh, and I want to add that we are talking about grammatical genders, and
112    // languages define their own grammar. There are languages that have more
113    // than three grammatical genders, there are languages that don't have
114    // male/female distinction and instead realy on animacy/non-animacy.
115    // What has an animacy and what doesn't is for language to decide.
116    // There are languages as well that mix these concepts and may have neuter,
117    // female, masculine with animacy, masculine with animacy. Some languages
118    // have their own scheme of things that arbitrarily picks noun-class per
119    // noun.
120    // Don't get me wrong. *All* languages do pick the gender for the word
121    // arbitrary as I showed at the beginning, it's just some languages have
122    // not just different mapping, but different gender set as well.
123    //
124    // The *only* option we have is fetch the gender per each name entry from
125    // localization files.
126    //
127    // I'm not 100% sure what should be the implementation of it, but I imagine
128    // that Stats::name() should be changed to include a way to reference where
129    // to grab the gender associated with this name, so translation then can
130    // pick right article or use right adjective/verb connected with NPC in the
131    // context of the message.
132    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            // If `from` is you, it means you're writing to someone
206            // and you want to see who you're writing to.
207            //
208            // Otherwise, someone writes to you, and you want to see
209            // who is that person that's writing to you.
210            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            // If `from` is you, it means you're writing to someone
233            // and you want to see who you're writing to.
234            //
235            // Otherwise, someone writes to you, and you want to see
236            // who is that person that's writing to you.
237            //
238            // Hopefully, no gendering needed, because for npc, we
239            // simply don't know.
240            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        // PvP deaths
270        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        // PvE deaths
302        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        // Other deaths
332        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
369/// Determines .attr for `hud-chat-died-of-buff` messages
370fn 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::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::Concussion
417        | BuffKind::Staggered => {
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,
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::thread_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 = EntityConfig::load_expect_cloned(&config_asset);
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}