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