atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
9374b3933189ddeefe1c4e1c10256e0d8285b2c7
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/locale.rsRust195 lines6.0 KB
1
//! The game is written in en-GB. Other locales are produced by rewriting regional phrases.
2
3
use serde_json::Value;
4
5
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
6
pub enum Locale {
7
    #[default]
8
    British,
9
    American,
10
    Australian,
11
}
12
13
/// Ordered so that longer phrases are rewritten before the words inside them.
14
const EN_US: &[(&str, &str)] = &[
15
    ("Stop raiding the kitchen.", "Stop raiding the fridge."),
16
    (
17
        "Nan's house at Christmas",
18
        "Grandma's house for Thanksgiving",
19
    ),
20
    ("Christmas dinner", "Thanksgiving dinner"),
21
    (
22
        "The King's speech is about to start.",
23
        "The game is about to start.",
24
    ),
25
    ("wrapping presents", "carving the turkey"),
26
    ("folding the washing", "folding laundry"),
27
    ("your washing", "your laundry"),
28
    ("changing room", "locker room"),
29
    ("authorisation", "authorization"),
30
    ("revising", "studying"),
31
    ("neighbour", "neighbor"),
32
    ("cupboard", "pantry"),
33
    ("Christmas", "Thanksgiving"),
34
    ("Mum", "Mom"),
35
    ("mum", "mom"),
36
    ("Nan", "Grandma"),
37
    ("nan", "grandma"),
38
    ("zip", "zipper"),
39
    ("£", "$"),
40
];
41
42
const EN_AU: &[(&str, &str)] = &[
43
    ("Christmas dinner", "Christmas lunch"),
44
    (
45
        "The King's speech is about to start.",
46
        "The cricket is about to start.",
47
    ),
48
    ("wrapping presents", "getting ice from the servo"),
49
    ("changing room", "change rooms"),
50
    ("cupboard", "pantry"),
51
    ("zip", "fly"),
52
    ("£", "$"),
53
];
54
55
const EXCUSES: &[&str] = &["wrapping presents", "folding the washing"];
56
57
fn bounded(text: &str, at: usize, len: usize) -> bool {
58
    let before = text[..at].chars().next_back();
59
    let after = text[at + len..].chars().next();
60
    let edge = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
61
    edge(before) && edge(after)
62
}
63
64
fn replace_words(text: &str, from: &str, to: &str) -> String {
65
    let mut out = String::with_capacity(text.len());
66
    let mut rest = 0;
67
    for (at, _) in text.match_indices(from) {
68
        if at < rest || !bounded(text, at, from.len()) {
69
            continue;
70
        }
71
        out.push_str(&text[rest..at]);
72
        out.push_str(to);
73
        rest = at + from.len();
74
    }
75
    out.push_str(&text[rest..]);
76
    out
77
}
78
79
impl Locale {
80
    pub fn tag(self) -> &'static str {
81
        match self {
82
            Self::British => "en-GB",
83
            Self::American => "en-US",
84
            Self::Australian => "en-AU",
85
        }
86
    }
87
88
    fn parse(tag: &str) -> Option<Self> {
89
        match tag.trim().to_ascii_lowercase().as_str() {
90
            "en-gb" | "en" => Some(Self::British),
91
            "en-us" => Some(Self::American),
92
            "en-au" => Some(Self::Australian),
93
            _ => None,
94
        }
95
    }
96
97
    /// Picks the most preferred supported locale from an `Accept-Language` header (RFC 9110 §12.5.4).
98
    pub fn negotiate(accept_language: Option<&str>) -> Self {
99
        let Some(header) = accept_language else {
100
            return Self::default();
101
        };
102
        header
103
            .split(',')
104
            .filter_map(|range| {
105
                let mut parts = range.split(';');
106
                let locale = Self::parse(parts.next()?)?;
107
                let quality = parts
108
                    .find_map(|p| p.trim().strip_prefix("q="))
109
                    .and_then(|q| q.parse::<f32>().ok())
110
                    .unwrap_or(1.0);
111
                (quality > 0.0).then_some((locale, quality))
112
            })
113
            .max_by(|a, b| a.1.total_cmp(&b.1))
114
            .map_or_else(Self::default, |(locale, _)| locale)
115
    }
116
117
    fn phrases(self) -> &'static [(&'static str, &'static str)] {
118
        match self {
119
            Self::British => &[],
120
            Self::American => EN_US,
121
            Self::Australian => EN_AU,
122
        }
123
    }
124
125
    pub fn localise(self, text: &str) -> String {
126
        self.phrases()
127
            .iter()
128
            .fold(text.to_owned(), |text, (from, to)| {
129
                replace_words(&text, from, to)
130
            })
131
    }
132
133
    /// Translates the excuses a player types back into the en-GB phrases the game checks against.
134
    pub fn canonicalise(self, text: &str) -> String {
135
        let local = |excuse: &str| self.localise(excuse);
136
        EXCUSES.iter().fold(text.to_owned(), |text, excuse| {
137
            replace_words(&text, &local(excuse), excuse)
138
        })
139
    }
140
141
    pub fn localise_json(self, value: &mut Value) {
142
        match value {
143
            Value::String(text) if !text.starts_with('/') && !text.starts_with("http") => {
144
                *text = self.localise(text);
145
            }
146
            Value::Array(items) => items.iter_mut().for_each(|item| self.localise_json(item)),
147
            Value::Object(fields) => {
148
                for (key, field) in fields.iter_mut() {
149
                    if !matches!(key.as_str(), "href" | "type" | "instance") {
150
                        self.localise_json(field);
151
                    }
152
                }
153
            }
154
            _ => {}
155
        }
156
    }
157
}
158
159
#[cfg(test)]
160
mod tests {
161
    use super::*;
162
163
    #[test]
164
    fn negotiates_by_quality() {
165
        assert_eq!(Locale::negotiate(None), Locale::British);
166
        assert_eq!(Locale::negotiate(Some("en-US")), Locale::American);
167
        assert_eq!(
168
            Locale::negotiate(Some("fr, en-AU;q=0.8, en-US;q=0.5")),
169
            Locale::Australian
170
        );
171
        assert_eq!(Locale::negotiate(Some("de")), Locale::British);
172
    }
173
174
    #[test]
175
    fn rewrites_whole_words_only() {
176
        let us = Locale::American;
177
        assert_eq!(
178
            us.localise("Mum's zip, not gzip."),
179
            "Mom's zipper, not gzip."
180
        );
181
        assert_eq!(us.localise("1.1 mum"), "1.1 mom");
182
        assert_eq!(
183
            Locale::Australian.localise("The request conflicts with the current state of the zip."),
184
            "The request conflicts with the current state of the fly."
185
        );
186
    }
187
188
    #[test]
189
    fn excuses_come_back_to_en_gb() {
190
        assert_eq!(
191
            Locale::American.canonicalise("Digest username=\"carving the turkey\""),
192
            "Digest username=\"wrapping presents\""
193
        );
194
    }
195
}