atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
fa21a42ca0856e336f8217a763a57c0af40afc6d
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git

Blame: src/locale.rs

ef7d6dee5976atagen2026-10-10 13:23 UTC1
//! The game is written in en-GB. Other locales are produced by rewriting regional phrases.
ef7d6dee5976atagen2026-10-10 13:23 UTC2
ef7d6dee5976atagen2026-10-10 13:23 UTC3
use serde_json::Value;
ef7d6dee5976atagen2026-10-10 13:23 UTC4
ef7d6dee5976atagen2026-10-10 13:23 UTC5
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
ef7d6dee5976atagen2026-10-10 13:23 UTC6
pub enum Locale {
ef7d6dee5976atagen2026-10-10 13:23 UTC7
    #[default]
ef7d6dee5976atagen2026-10-10 13:23 UTC8
    British,
ef7d6dee5976atagen2026-10-10 13:23 UTC9
    American,
ef7d6dee5976atagen2026-10-10 13:23 UTC10
    Australian,
ef7d6dee5976atagen2026-10-10 13:23 UTC11
}
ef7d6dee5976atagen2026-10-10 13:23 UTC12
ef7d6dee5976atagen2026-10-10 13:23 UTC13
/// Ordered so that longer phrases are rewritten before the words inside them.
ef7d6dee5976atagen2026-10-10 13:23 UTC14
const EN_US: &[(&str, &str)] = &[
0a0bfee8329eatagen2026-10-11 05:11 UTC15
    ("Stop raiding the kitchen.", "Stop raiding the fridge."),
ef7d6dee5976atagen2026-10-10 13:23 UTC16
    (
ef7d6dee5976atagen2026-10-10 13:23 UTC17
        "Nan's house at Christmas",
ef7d6dee5976atagen2026-10-10 13:23 UTC18
        "Grandma's house for Thanksgiving",
ef7d6dee5976atagen2026-10-10 13:23 UTC19
    ),
ef7d6dee5976atagen2026-10-10 13:23 UTC20
    ("Christmas dinner", "Thanksgiving dinner"),
ef7d6dee5976atagen2026-10-10 13:23 UTC21
    (
ef7d6dee5976atagen2026-10-10 13:23 UTC22
        "The King's speech is about to start.",
ef7d6dee5976atagen2026-10-10 13:23 UTC23
        "The game is about to start.",
ef7d6dee5976atagen2026-10-10 13:23 UTC24
    ),
ef7d6dee5976atagen2026-10-10 13:23 UTC25
    ("wrapping presents", "carving the turkey"),
ef7d6dee5976atagen2026-10-10 13:23 UTC26
    ("folding the washing", "folding laundry"),
ef7d6dee5976atagen2026-10-10 13:23 UTC27
    ("your washing", "your laundry"),
ef7d6dee5976atagen2026-10-10 13:23 UTC28
    ("changing room", "locker room"),
ef7d6dee5976atagen2026-10-10 13:23 UTC29
    ("authorisation", "authorization"),
7d3fb63208bfatagen2026-10-11 04:38 UTC30
    ("revising", "studying"),
ef7d6dee5976atagen2026-10-10 13:23 UTC31
    ("neighbour", "neighbor"),
ef7d6dee5976atagen2026-10-10 13:23 UTC32
    ("cupboard", "pantry"),
ef7d6dee5976atagen2026-10-10 13:23 UTC33
    ("Christmas", "Thanksgiving"),
ef7d6dee5976atagen2026-10-10 13:23 UTC34
    ("Mum", "Mom"),
ef7d6dee5976atagen2026-10-10 13:23 UTC35
    ("mum", "mom"),
ef7d6dee5976atagen2026-10-10 13:23 UTC36
    ("Nan", "Grandma"),
ef7d6dee5976atagen2026-10-10 13:23 UTC37
    ("nan", "grandma"),
ef7d6dee5976atagen2026-10-10 13:23 UTC38
    ("zip", "zipper"),
ef7d6dee5976atagen2026-10-10 13:23 UTC39
    ("£", "$"),
ef7d6dee5976atagen2026-10-10 13:23 UTC40
];
ef7d6dee5976atagen2026-10-10 13:23 UTC41
ef7d6dee5976atagen2026-10-10 13:23 UTC42
const EN_AU: &[(&str, &str)] = &[
ef7d6dee5976atagen2026-10-10 13:23 UTC43
    ("Christmas dinner", "Christmas lunch"),
ef7d6dee5976atagen2026-10-10 13:23 UTC44
    (
ef7d6dee5976atagen2026-10-10 13:23 UTC45
        "The King's speech is about to start.",
ef7d6dee5976atagen2026-10-10 13:23 UTC46
        "The cricket is about to start.",
ef7d6dee5976atagen2026-10-10 13:23 UTC47
    ),
ef7d6dee5976atagen2026-10-10 13:23 UTC48
    ("wrapping presents", "getting ice from the servo"),
ef7d6dee5976atagen2026-10-10 13:23 UTC49
    ("changing room", "change rooms"),
ef7d6dee5976atagen2026-10-10 13:23 UTC50
    ("cupboard", "pantry"),
ef7d6dee5976atagen2026-10-10 13:23 UTC51
    ("zip", "fly"),
ef7d6dee5976atagen2026-10-10 13:23 UTC52
    ("£", "$"),
ef7d6dee5976atagen2026-10-10 13:23 UTC53
];
ef7d6dee5976atagen2026-10-10 13:23 UTC54
ef7d6dee5976atagen2026-10-10 13:23 UTC55
fn bounded(text: &str, at: usize, len: usize) -> bool {
ef7d6dee5976atagen2026-10-10 13:23 UTC56
    let before = text[..at].chars().next_back();
ef7d6dee5976atagen2026-10-10 13:23 UTC57
    let after = text[at + len..].chars().next();
ef7d6dee5976atagen2026-10-10 13:23 UTC58
    let edge = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
ef7d6dee5976atagen2026-10-10 13:23 UTC59
    edge(before) && edge(after)
ef7d6dee5976atagen2026-10-10 13:23 UTC60
}
ef7d6dee5976atagen2026-10-10 13:23 UTC61
ef7d6dee5976atagen2026-10-10 13:23 UTC62
fn replace_words(text: &str, from: &str, to: &str) -> String {
c4d70621d377atagen2026-10-12 11:56 UTC63
    replace_matching(text, text, from, to)
c4d70621d377atagen2026-10-12 11:56 UTC64
}
c4d70621d377atagen2026-10-12 11:56 UTC65
c4d70621d377atagen2026-10-12 11:56 UTC66
/// Players type excuses in whatever case they like. ASCII lowering keeps byte offsets intact.
c4d70621d377atagen2026-10-12 11:56 UTC67
fn replace_words_ignoring_case(text: &str, from: &str, to: &str) -> String {
c4d70621d377atagen2026-10-12 11:56 UTC68
    let lowered = text.to_ascii_lowercase();
c4d70621d377atagen2026-10-12 11:56 UTC69
    replace_matching(text, &lowered, &from.to_ascii_lowercase(), to)
c4d70621d377atagen2026-10-12 11:56 UTC70
}
c4d70621d377atagen2026-10-12 11:56 UTC71
c4d70621d377atagen2026-10-12 11:56 UTC72
fn replace_matching(text: &str, haystack: &str, from: &str, to: &str) -> String {
ef7d6dee5976atagen2026-10-10 13:23 UTC73
    let mut out = String::with_capacity(text.len());
ef7d6dee5976atagen2026-10-10 13:23 UTC74
    let mut rest = 0;
c4d70621d377atagen2026-10-12 11:56 UTC75
    for (at, _) in haystack.match_indices(from) {
ef7d6dee5976atagen2026-10-10 13:23 UTC76
        if at < rest || !bounded(text, at, from.len()) {
ef7d6dee5976atagen2026-10-10 13:23 UTC77
            continue;
ef7d6dee5976atagen2026-10-10 13:23 UTC78
        }
ef7d6dee5976atagen2026-10-10 13:23 UTC79
        out.push_str(&text[rest..at]);
ef7d6dee5976atagen2026-10-10 13:23 UTC80
        out.push_str(to);
ef7d6dee5976atagen2026-10-10 13:23 UTC81
        rest = at + from.len();
ef7d6dee5976atagen2026-10-10 13:23 UTC82
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC83
    out.push_str(&text[rest..]);
ef7d6dee5976atagen2026-10-10 13:23 UTC84
    out
ef7d6dee5976atagen2026-10-10 13:23 UTC85
}
ef7d6dee5976atagen2026-10-10 13:23 UTC86
ef7d6dee5976atagen2026-10-10 13:23 UTC87
impl Locale {
ef7d6dee5976atagen2026-10-10 13:23 UTC88
    pub fn tag(self) -> &'static str {
ef7d6dee5976atagen2026-10-10 13:23 UTC89
        match self {
ef7d6dee5976atagen2026-10-10 13:23 UTC90
            Self::British => "en-GB",
ef7d6dee5976atagen2026-10-10 13:23 UTC91
            Self::American => "en-US",
ef7d6dee5976atagen2026-10-10 13:23 UTC92
            Self::Australian => "en-AU",
ef7d6dee5976atagen2026-10-10 13:23 UTC93
        }
ef7d6dee5976atagen2026-10-10 13:23 UTC94
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC95
ef7d6dee5976atagen2026-10-10 13:23 UTC96
    fn parse(tag: &str) -> Option<Self> {
ef7d6dee5976atagen2026-10-10 13:23 UTC97
        match tag.trim().to_ascii_lowercase().as_str() {
ef7d6dee5976atagen2026-10-10 13:23 UTC98
            "en-gb" | "en" => Some(Self::British),
ef7d6dee5976atagen2026-10-10 13:23 UTC99
            "en-us" => Some(Self::American),
ef7d6dee5976atagen2026-10-10 13:23 UTC100
            "en-au" => Some(Self::Australian),
ef7d6dee5976atagen2026-10-10 13:23 UTC101
            _ => None,
ef7d6dee5976atagen2026-10-10 13:23 UTC102
        }
ef7d6dee5976atagen2026-10-10 13:23 UTC103
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC104
ef7d6dee5976atagen2026-10-10 13:23 UTC105
    /// Picks the most preferred supported locale from an `Accept-Language` header (RFC 9110 §12.5.4).
ef7d6dee5976atagen2026-10-10 13:23 UTC106
    pub fn negotiate(accept_language: Option<&str>) -> Self {
ef7d6dee5976atagen2026-10-10 13:23 UTC107
        let Some(header) = accept_language else {
ef7d6dee5976atagen2026-10-10 13:23 UTC108
            return Self::default();
ef7d6dee5976atagen2026-10-10 13:23 UTC109
        };
ef7d6dee5976atagen2026-10-10 13:23 UTC110
        header
ef7d6dee5976atagen2026-10-10 13:23 UTC111
            .split(',')
ef7d6dee5976atagen2026-10-10 13:23 UTC112
            .filter_map(|range| {
ef7d6dee5976atagen2026-10-10 13:23 UTC113
                let mut parts = range.split(';');
ef7d6dee5976atagen2026-10-10 13:23 UTC114
                let locale = Self::parse(parts.next()?)?;
ef7d6dee5976atagen2026-10-10 13:23 UTC115
                let quality = parts
ef7d6dee5976atagen2026-10-10 13:23 UTC116
                    .find_map(|p| p.trim().strip_prefix("q="))
ef7d6dee5976atagen2026-10-10 13:23 UTC117
                    .and_then(|q| q.parse::<f32>().ok())
ef7d6dee5976atagen2026-10-10 13:23 UTC118
                    .unwrap_or(1.0);
ef7d6dee5976atagen2026-10-10 13:23 UTC119
                (quality > 0.0).then_some((locale, quality))
ef7d6dee5976atagen2026-10-10 13:23 UTC120
            })
ef7d6dee5976atagen2026-10-10 13:23 UTC121
            .max_by(|a, b| a.1.total_cmp(&b.1))
ef7d6dee5976atagen2026-10-10 13:23 UTC122
            .map_or_else(Self::default, |(locale, _)| locale)
ef7d6dee5976atagen2026-10-10 13:23 UTC123
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC124
ef7d6dee5976atagen2026-10-10 13:23 UTC125
    fn phrases(self) -> &'static [(&'static str, &'static str)] {
ef7d6dee5976atagen2026-10-10 13:23 UTC126
        match self {
ef7d6dee5976atagen2026-10-10 13:23 UTC127
            Self::British => &[],
ef7d6dee5976atagen2026-10-10 13:23 UTC128
            Self::American => EN_US,
ef7d6dee5976atagen2026-10-10 13:23 UTC129
            Self::Australian => EN_AU,
ef7d6dee5976atagen2026-10-10 13:23 UTC130
        }
ef7d6dee5976atagen2026-10-10 13:23 UTC131
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC132
ef7d6dee5976atagen2026-10-10 13:23 UTC133
    pub fn localise(self, text: &str) -> String {
ef7d6dee5976atagen2026-10-10 13:23 UTC134
        self.phrases()
ef7d6dee5976atagen2026-10-10 13:23 UTC135
            .iter()
ef7d6dee5976atagen2026-10-10 13:23 UTC136
            .fold(text.to_owned(), |text, (from, to)| {
ef7d6dee5976atagen2026-10-10 13:23 UTC137
                replace_words(&text, from, to)
ef7d6dee5976atagen2026-10-10 13:23 UTC138
            })
ef7d6dee5976atagen2026-10-10 13:23 UTC139
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC140
ef7d6dee5976atagen2026-10-10 13:23 UTC141
    /// Translates the excuses a player types back into the en-GB phrases the game checks against.
ef7d6dee5976atagen2026-10-10 13:23 UTC142
    pub fn canonicalise(self, text: &str) -> String {
ef7d6dee5976atagen2026-10-10 13:23 UTC143
        let local = |excuse: &str| self.localise(excuse);
c4d70621d377atagen2026-10-12 11:56 UTC144
        crate::game::excuse_vocabulary().fold(text.to_owned(), |text, excuse| {
c4d70621d377atagen2026-10-12 11:56 UTC145
            let localised = local(excuse);
c4d70621d377atagen2026-10-12 11:56 UTC146
            if localised == excuse {
c4d70621d377atagen2026-10-12 11:56 UTC147
                return text;
c4d70621d377atagen2026-10-12 11:56 UTC148
            }
c4d70621d377atagen2026-10-12 11:56 UTC149
            replace_words_ignoring_case(&text, &localised, excuse)
ef7d6dee5976atagen2026-10-10 13:23 UTC150
        })
ef7d6dee5976atagen2026-10-10 13:23 UTC151
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC152
ef7d6dee5976atagen2026-10-10 13:23 UTC153
    pub fn localise_json(self, value: &mut Value) {
ef7d6dee5976atagen2026-10-10 13:23 UTC154
        match value {
ef7d6dee5976atagen2026-10-10 13:23 UTC155
            Value::String(text) if !text.starts_with('/') && !text.starts_with("http") => {
ef7d6dee5976atagen2026-10-10 13:23 UTC156
                *text = self.localise(text);
ef7d6dee5976atagen2026-10-10 13:23 UTC157
            }
ef7d6dee5976atagen2026-10-10 13:23 UTC158
            Value::Array(items) => items.iter_mut().for_each(|item| self.localise_json(item)),
ef7d6dee5976atagen2026-10-10 13:23 UTC159
            Value::Object(fields) => {
ef7d6dee5976atagen2026-10-10 13:23 UTC160
                for (key, field) in fields.iter_mut() {
ef7d6dee5976atagen2026-10-10 13:23 UTC161
                    if !matches!(key.as_str(), "href" | "type" | "instance") {
ef7d6dee5976atagen2026-10-10 13:23 UTC162
                        self.localise_json(field);
ef7d6dee5976atagen2026-10-10 13:23 UTC163
                    }
ef7d6dee5976atagen2026-10-10 13:23 UTC164
                }
ef7d6dee5976atagen2026-10-10 13:23 UTC165
            }
ef7d6dee5976atagen2026-10-10 13:23 UTC166
            _ => {}
ef7d6dee5976atagen2026-10-10 13:23 UTC167
        }
ef7d6dee5976atagen2026-10-10 13:23 UTC168
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC169
}
ef7d6dee5976atagen2026-10-10 13:23 UTC170
ef7d6dee5976atagen2026-10-10 13:23 UTC171
#[cfg(test)]
ef7d6dee5976atagen2026-10-10 13:23 UTC172
mod tests {
ef7d6dee5976atagen2026-10-10 13:23 UTC173
    use super::*;
ef7d6dee5976atagen2026-10-10 13:23 UTC174
ef7d6dee5976atagen2026-10-10 13:23 UTC175
    #[test]
ef7d6dee5976atagen2026-10-10 13:23 UTC176
    fn negotiates_by_quality() {
ef7d6dee5976atagen2026-10-10 13:23 UTC177
        assert_eq!(Locale::negotiate(None), Locale::British);
ef7d6dee5976atagen2026-10-10 13:23 UTC178
        assert_eq!(Locale::negotiate(Some("en-US")), Locale::American);
ef7d6dee5976atagen2026-10-10 13:23 UTC179
        assert_eq!(
ef7d6dee5976atagen2026-10-10 13:23 UTC180
            Locale::negotiate(Some("fr, en-AU;q=0.8, en-US;q=0.5")),
ef7d6dee5976atagen2026-10-10 13:23 UTC181
            Locale::Australian
ef7d6dee5976atagen2026-10-10 13:23 UTC182
        );
ef7d6dee5976atagen2026-10-10 13:23 UTC183
        assert_eq!(Locale::negotiate(Some("de")), Locale::British);
ef7d6dee5976atagen2026-10-10 13:23 UTC184
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC185
ef7d6dee5976atagen2026-10-10 13:23 UTC186
    #[test]
ef7d6dee5976atagen2026-10-10 13:23 UTC187
    fn rewrites_whole_words_only() {
ef7d6dee5976atagen2026-10-10 13:23 UTC188
        let us = Locale::American;
ef7d6dee5976atagen2026-10-10 13:23 UTC189
        assert_eq!(
ef7d6dee5976atagen2026-10-10 13:23 UTC190
            us.localise("Mum's zip, not gzip."),
ef7d6dee5976atagen2026-10-10 13:23 UTC191
            "Mom's zipper, not gzip."
ef7d6dee5976atagen2026-10-10 13:23 UTC192
        );
ef7d6dee5976atagen2026-10-10 13:23 UTC193
        assert_eq!(us.localise("1.1 mum"), "1.1 mom");
ef7d6dee5976atagen2026-10-10 13:23 UTC194
        assert_eq!(
ef7d6dee5976atagen2026-10-10 13:23 UTC195
            Locale::Australian.localise("The request conflicts with the current state of the zip."),
ef7d6dee5976atagen2026-10-10 13:23 UTC196
            "The request conflicts with the current state of the fly."
ef7d6dee5976atagen2026-10-10 13:23 UTC197
        );
ef7d6dee5976atagen2026-10-10 13:23 UTC198
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC199
ef7d6dee5976atagen2026-10-10 13:23 UTC200
    #[test]
ef7d6dee5976atagen2026-10-10 13:23 UTC201
    fn excuses_come_back_to_en_gb() {
ef7d6dee5976atagen2026-10-10 13:23 UTC202
        assert_eq!(
ef7d6dee5976atagen2026-10-10 13:23 UTC203
            Locale::American.canonicalise("Digest username=\"carving the turkey\""),
ef7d6dee5976atagen2026-10-10 13:23 UTC204
            "Digest username=\"wrapping presents\""
ef7d6dee5976atagen2026-10-10 13:23 UTC205
        );
c4d70621d377atagen2026-10-12 11:56 UTC206
        assert_eq!(
c4d70621d377atagen2026-10-12 11:56 UTC207
            Locale::American.canonicalise("Excuse \"Studying\""),
c4d70621d377atagen2026-10-12 11:56 UTC208
            "Excuse \"revising\""
c4d70621d377atagen2026-10-12 11:56 UTC209
        );
c4d70621d377atagen2026-10-12 11:56 UTC210
        assert_eq!(
c4d70621d377atagen2026-10-12 11:56 UTC211
            Locale::American.canonicalise("Excuse \"helping grandma\""),
c4d70621d377atagen2026-10-12 11:56 UTC212
            "Excuse \"helping Nan\""
c4d70621d377atagen2026-10-12 11:56 UTC213
        );
ef7d6dee5976atagen2026-10-10 13:23 UTC214
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC215
}