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 => info
30            .entity_name
31            .get(uid)
32            .cloned()
33            .expect("client didn't provided enough info"),
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                None => "??".to_owned(),
82            }
83        } else {
84            "??".to_owned()
85        }
86    };
87
88    // This is where the most fun begings.
89    //
90    // Unlike people, "items" can have their own gender, which is completely
91    // independent of everything, including common sense.
92    //
93    // For example, word "masculinity" can be feminine in some languages,
94    // as well as word "boy", and vice versa.
95    //
96    // So we can't rely on body_type, at all. And even if we did try, our
97    // body_type isn't even always represents animal sex, there are some
98    // animals that use body_type to represent their kind, like different
99    // types of Fox ("male" fox is forest, "female" is arctic one).
100    // And what about Mindflayer? They do have varied body_type, but do they
101    // even have concept of gender?
102    //
103    // Our use case is probably less cryptic, after all we are limited by
104    // mostly sentient things, but that doesn't help at all.
105    //
106    // Common example is word "spider", which can be feminine in one languages
107    // and masculine in other, and sometimes even neuter.
108    //
109    // Oh, and I want to add that we are talking about grammatical genders, and
110    // languages define their own grammar. There are languages that have more
111    // than three grammatical genders, there are languages that don't have
112    // male/female distinction and instead realy on animacy/non-animacy.
113    // What has an animacy and what doesn't is for language to decide.
114    // There are languages as well that mix these concepts and may have neuter,
115    // female, masculine with animacy, masculine with animacy. Some languages
116    // have their own scheme of things that arbitrarily picks noun-class per
117    // noun.
118    // Don't get me wrong. *All* languages do pick the gender for the word
119    // arbitrary as I showed at the beginning, it's just some languages have
120    // not just different mapping, but different gender set as well.
121    //
122    // The *only* option we have is fetch the gender per each name entry from
123    // localization files.
124    //
125    // I'm not 100% sure what should be the implementation of it, but I imagine
126    // that Stats::name() should be changed to include a way to reference where
127    // to grab the gender associated with this name, so translation then can
128    // pick right article or use right adjective/verb connected with NPC in the
129    // context of the message.
130    let _gender_str_npc = || "idk".to_owned();
131
132    let message_format = |from: &Uid, content: &Content, group: Option<&String>| {
133        let alias = name_format_or_complex(true, from);
134
135        let name = if let Some(pi) = info.player_info.get(from).cloned()
136            && show_char_name
137        {
138            pi.character.map(|c| c.name)
139        } else {
140            None
141        };
142
143        let message = localization.get_content(content);
144
145        let line = match group {
146            Some(group) => match name {
147                Some(name) => localization.get_msg_ctx(
148                    "hud-chat-message-in-group-with-name",
149                    &i18n::fluent_args! {
150                        "group" => group,
151                        "alias" => alias,
152                        "name" => name,
153                        "msg" => message,
154                    },
155                ),
156                None => {
157                    localization.get_msg_ctx("hud-chat-message-in-group", &i18n::fluent_args! {
158                        "group" => group,
159                        "alias" => alias,
160                        "msg" => message,
161                    })
162                },
163            },
164            None => match name {
165                Some(name) => {
166                    localization.get_msg_ctx("hud-chat-message-with-name", &i18n::fluent_args! {
167                        "alias" => alias,
168                        "name" => name,
169                        "msg" => message,
170                    })
171                },
172                None => localization.get_msg_ctx("hud-chat-message", &i18n::fluent_args! {
173                    "alias" => alias,
174                    "msg" => message,
175                }),
176            },
177        };
178
179        line.into_owned()
180    };
181
182    let new_msg = match &msg.chat_type {
183        ChatType::Online(uid) => localization
184            .get_msg_ctx("hud-chat-online_msg", &i18n::fluent_args! {
185                "user_gender" => gender_str(uid),
186                "name" => name_format(uid),
187            })
188            .into_owned(),
189        ChatType::Offline(uid) => localization
190            .get_msg_ctx("hud-chat-offline_msg", &i18n::fluent_args! {
191                "user_gender" => gender_str(uid),
192                "name" => name_format(uid),
193            })
194            .into_owned(),
195        ChatType::CommandError
196        | ChatType::CommandInfo
197        | ChatType::Meta
198        | ChatType::FactionMeta(_)
199        | ChatType::GroupMeta(_) => localization.get_content(msg.content()),
200        ChatType::Tell(from, to) => {
201            // If `from` is you, it means you're writing to someone
202            // and you want to see who you're writing to.
203            //
204            // Otherwise, someone writes to you, and you want to see
205            // who is that person that's writing to you.
206            let (key, person_to_show) = if info.you == *from {
207                ("hud-chat-tell-to", to)
208            } else {
209                ("hud-chat-tell-from", from)
210            };
211
212            localization
213                .get_msg_ctx(key, &i18n::fluent_args! {
214                    "alias" => name_format(person_to_show),
215                    "user_gender" => gender_str(person_to_show),
216                    "msg" => localization.get_content(msg.content()),
217                })
218                .into_owned()
219        },
220        ChatType::Say(uid) | ChatType::Region(uid) | ChatType::World(uid) => {
221            message_format(uid, msg.content(), None)
222        },
223        ChatType::Group(uid, descriptor) | ChatType::Faction(uid, descriptor) => {
224            message_format(uid, msg.content(), Some(descriptor))
225        },
226        ChatType::Npc(uid) | ChatType::NpcSay(uid) => message_format(uid, msg.content(), None),
227        ChatType::NpcTell(from, to) => {
228            // If `from` is you, it means you're writing to someone
229            // and you want to see who you're writing to.
230            //
231            // Otherwise, someone writes to you, and you want to see
232            // who is that person that's writing to you.
233            //
234            // Hopefully, no gendering needed, because for npc, we
235            // simply don't know.
236            let (key, person_to_show) = if info.you == *from {
237                ("hud-chat-tell-to-npc", to)
238            } else {
239                ("hud-chat-tell-from-npc", from)
240            };
241
242            localization
243                .get_msg_ctx(key, &i18n::fluent_args! {
244                    "alias" => name_format(person_to_show),
245                    "msg" => localization.get_content(msg.content()),
246                })
247                .into_owned()
248        },
249        ChatType::Kill(kill_source, victim) => {
250            localize_kill_message(kill_source, victim, name_format, gender_str, localization)
251        },
252    };
253
254    (msg.chat_type.clone(), new_msg)
255}
256
257fn localize_kill_message(
258    kill_source: &KillSource,
259    victim: &Uid,
260    name_format: impl Fn(&Uid) -> String,
261    gender_str: impl Fn(&Uid) -> String,
262    localization: &Localization,
263) -> String {
264    match kill_source {
265        // PvP deaths
266        KillSource::Player(attacker, kill_type) => {
267            let key = match kill_type {
268                KillType::Melee => "hud-chat-pvp_melee_kill_msg",
269                KillType::Projectile => "hud-chat-pvp_ranged_kill_msg",
270                KillType::Explosion => "hud-chat-pvp_explosion_kill_msg",
271                KillType::Energy => "hud-chat-pvp_energy_kill_msg",
272                KillType::Other => "hud-chat-pvp_other_kill_msg",
273                KillType::Buff(buff_kind) => {
274                    let buff_ident = get_buff_ident(*buff_kind);
275
276                    return localization
277                        .get_attr_ctx(
278                            "hud-chat-died_of_pvp_buff_msg",
279                            buff_ident,
280                            &i18n::fluent_args! {
281                                "victim" => name_format(victim),
282                                "victim_gender" => gender_str(victim),
283                                "attacker" => name_format(attacker),
284                                "attacker_gender" => gender_str(attacker),
285                            },
286                        )
287                        .into_owned();
288                },
289            };
290            localization.get_msg_ctx(key, &i18n::fluent_args! {
291                "victim" => name_format(victim),
292                "victim_gender" => gender_str(victim),
293                "attacker" => name_format(attacker),
294                "attacker_gender" => gender_str(attacker),
295            })
296        },
297        // PvE deaths
298        KillSource::NonPlayer(attacker_name, kill_type) => {
299            let key = match kill_type {
300                KillType::Melee => "hud-chat-npc_melee_kill_msg",
301                KillType::Projectile => "hud-chat-npc_ranged_kill_msg",
302                KillType::Explosion => "hud-chat-npc_explosion_kill_msg",
303                KillType::Energy => "hud-chat-npc_energy_kill_msg",
304                KillType::Other => "hud-chat-npc_other_kill_msg",
305                KillType::Buff(buff_kind) => {
306                    let buff_ident = get_buff_ident(*buff_kind);
307
308                    return localization
309                        .get_attr_ctx(
310                            "hud-chat-died_of_npc_buff_msg",
311                            buff_ident,
312                            &i18n::fluent_args! {
313                                "victim" => name_format(victim),
314                                "victim_gender" => gender_str(victim),
315                                "attacker" => attacker_name,
316                            },
317                        )
318                        .into_owned();
319                },
320            };
321            localization.get_msg_ctx(key, &i18n::fluent_args! {
322                "victim" => name_format(victim),
323                "victim_gender" => gender_str(victim),
324                "attacker" => attacker_name,
325            })
326        },
327        // Other deaths
328        KillSource::FallDamage => {
329            localization.get_msg_ctx("hud-chat-fall_kill_msg", &i18n::fluent_args! {
330                "name" => name_format(victim),
331                "victim_gender" => gender_str(victim),
332            })
333        },
334        KillSource::Suicide => {
335            localization.get_msg_ctx("hud-chat-suicide_msg", &i18n::fluent_args! {
336                "name" => name_format(victim),
337                "victim_gender" => gender_str(victim),
338            })
339        },
340        KillSource::NonExistent(KillType::Buff(buff_kind)) => {
341            let buff_ident = get_buff_ident(*buff_kind);
342
343            let s = localization
344                .get_attr_ctx(
345                    "hud-chat-died_of_buff_nonexistent_msg",
346                    buff_ident,
347                    &i18n::fluent_args! {
348                        "victim" => name_format(victim),
349                        "victim_gender" => gender_str(victim),
350                    },
351                )
352                .into_owned();
353            Cow::Owned(s)
354        },
355        KillSource::NonExistent(_) | KillSource::Other => {
356            localization.get_msg_ctx("hud-chat-default_death_msg", &i18n::fluent_args! {
357                "name" => name_format(victim),
358                "victim_gender" => gender_str(victim),
359            })
360        },
361    }
362    .into_owned()
363}
364
365/// Determines .attr for `hud-chat-died-of-buff` messages
366fn get_buff_ident(buff: BuffKind) -> &'static str {
367    match buff {
368        BuffKind::Burning => "burning",
369        BuffKind::Bleeding => "bleeding",
370        BuffKind::Cursed => "curse",
371        BuffKind::Crippled => "crippled",
372        BuffKind::Frozen => "frozen",
373        BuffKind::Regeneration
374        | BuffKind::Saturation
375        | BuffKind::Potion
376        | BuffKind::Agility
377        | BuffKind::CampfireHeal
378        | BuffKind::EnergyRegen
379        | BuffKind::IncreaseMaxEnergy
380        | BuffKind::IncreaseMaxHealth
381        | BuffKind::Invulnerability
382        | BuffKind::ProtectingWard
383        | BuffKind::Frenzied
384        | BuffKind::Hastened
385        | BuffKind::Fortitude
386        | BuffKind::Reckless
387        | BuffKind::Flame
388        | BuffKind::Frigid
389        | BuffKind::Lifesteal
390        // | BuffKind::SalamanderAspect
391        | BuffKind::ImminentCritical
392        | BuffKind::Fury
393        | BuffKind::Sunderer
394        | BuffKind::Defiance
395        | BuffKind::Bloodfeast
396        | BuffKind::Berserk
397        | BuffKind::ScornfulTaunt
398        | BuffKind::Tenacity
399        | BuffKind::Resilience => {
400            tracing::error!("Player was killed by a positive buff!");
401            "mysterious"
402        },
403        BuffKind::Wet
404        | BuffKind::Ensnared
405        | BuffKind::Poisoned
406        | BuffKind::Parried
407        | BuffKind::PotionSickness
408        | BuffKind::Polymorphed
409        | BuffKind::Heatstroke
410        | BuffKind::Rooted
411        | BuffKind::Winded
412        | BuffKind::Concussion
413        | BuffKind::Staggered => {
414            tracing::error!("Player was killed by a debuff that doesn't do damage!");
415            "mysterious"
416        },
417    }
418}
419
420/// Used for inserting spacing for mod badge icon next to alias
421// TODO: consider passing '$is_you' to hud-chat-message strings along with
422// $spacing variable, for more flexible translations.
423fn insert_alias(_replace_you: bool, info: PlayerInfo, _localization: &Localization) -> String {
424    // Leave space for a mod badge icon.
425    const MOD_SPACING: &str = "      ";
426
427    if info.is_moderator {
428        format!("{}{}", MOD_SPACING, info.player_alias)
429    } else {
430        info.player_alias
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    #[expect(unused)] use super::*;
437    use common::comp::{
438        Content,
439        inventory::item::{ItemDesc, ItemI18n, all_items_expect},
440    };
441    use i18n::LocalizationHandle;
442
443    // item::tests::ensure_item_localization tests that we have Content for
444    // each item. This tests that we actually have at least English translation
445    // for this Content.
446    #[test]
447    fn test_item_text() {
448        let manifest = ItemI18n::new_expect();
449        let localization = LocalizationHandle::load_expect("en").read();
450        let items = all_items_expect();
451
452        for item in items {
453            let (name, desc) = item.i18n(&manifest);
454
455            // check i18n for item name
456            let Content::Key(key) = name else {
457                panic!("name is expected to be Key, please fix the test");
458            };
459            localization.try_msg(&key).unwrap_or_else(|| {
460                panic!("'{key}' name doesn't have i18n");
461            });
462
463            // check i18n for item desc
464            let Content::Attr(key, attr) = desc else {
465                panic!("desc is expected to be Attr, please fix the test");
466            };
467            localization.try_attr(&key, &attr).unwrap_or_else(|| {
468                panic!("'{key}' description doesn't have i18n");
469            });
470        }
471    }
472}