atagen

jelq

public

an http based roguelike rpg about jelqing

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