atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
72cdd1267600b80b912d1e1225c422211b490326
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/locale.rsRust215 lines6.7 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
fn bounded(text: &str, at: usize, len: usize) -> bool {
56
    let before = text[..at].chars().next_back();
57
    let after = text[at + len..].chars().next();
58
    let edge = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
59
    edge(before) && edge(after)
60
}
61
62
fn replace_words(text: &str, from: &str, to: &str) -> String {
63
    replace_matching(text, text, from, to)
64
}
65
66
/// Players type excuses in whatever case they like. ASCII lowering keeps byte offsets intact.
67
fn replace_words_ignoring_case(text: &str, from: &str, to: &str) -> String {
68
    let lowered = text.to_ascii_lowercase();
69
    replace_matching(text, &lowered, &from.to_ascii_lowercase(), to)
70
}
71
72
fn replace_matching(text: &str, haystack: &str, from: &str, to: &str) -> String {
73
    let mut out = String::with_capacity(text.len());
74
    let mut rest = 0;
75
    for (at, _) in haystack.match_indices(from) {
76
        if at < rest || !bounded(text, at, from.len()) {
77
            continue;
78
        }
79
        out.push_str(&text[rest..at]);
80
        out.push_str(to);
81
        rest = at + from.len();
82
    }
83
    out.push_str(&text[rest..]);
84
    out
85
}
86
87
impl Locale {
88
    pub fn tag(self) -> &'static str {
89
        match self {
90
            Self::British => "en-GB",
91
            Self::American => "en-US",
92
            Self::Australian => "en-AU",
93
        }
94
    }
95
96
    fn parse(tag: &str) -> Option<Self> {
97
        match tag.trim().to_ascii_lowercase().as_str() {
98
            "en-gb" | "en" => Some(Self::British),
99
            "en-us" => Some(Self::American),
100
            "en-au" => Some(Self::Australian),
101
            _ => None,
102
        }
103
    }
104
105
    /// Picks the most preferred supported locale from an `Accept-Language` header (RFC 9110 §12.5.4).
106
    pub fn negotiate(accept_language: Option<&str>) -> Self {
107
        let Some(header) = accept_language else {
108
            return Self::default();
109
        };
110
        header
111
            .split(',')
112
            .filter_map(|range| {
113
                let mut parts = range.split(';');
114
                let locale = Self::parse(parts.next()?)?;
115
                let quality = parts
116
                    .find_map(|p| p.trim().strip_prefix("q="))
117
                    .and_then(|q| q.parse::<f32>().ok())
118
                    .unwrap_or(1.0);
119
                (quality > 0.0).then_some((locale, quality))
120
            })
121
            .max_by(|a, b| a.1.total_cmp(&b.1))
122
            .map_or_else(Self::default, |(locale, _)| locale)
123
    }
124
125
    fn phrases(self) -> &'static [(&'static str, &'static str)] {
126
        match self {
127
            Self::British => &[],
128
            Self::American => EN_US,
129
            Self::Australian => EN_AU,
130
        }
131
    }
132
133
    pub fn localise(self, text: &str) -> String {
134
        self.phrases()
135
            .iter()
136
            .fold(text.to_owned(), |text, (from, to)| {
137
                replace_words(&text, from, to)
138
            })
139
    }
140
141
    /// Translates the excuses a player types back into the en-GB phrases the game checks against.
142
    pub fn canonicalise(self, text: &str) -> String {
143
        let local = |excuse: &str| self.localise(excuse);
144
        crate::game::excuse_vocabulary().fold(text.to_owned(), |text, excuse| {
145
            let localised = local(excuse);
146
            if localised == excuse {
147
                return text;
148
            }
149
            replace_words_ignoring_case(&text, &localised, excuse)
150
        })
151
    }
152
153
    pub fn localise_json(self, value: &mut Value) {
154
        match value {
155
            Value::String(text) if !text.starts_with('/') && !text.starts_with("http") => {
156
                *text = self.localise(text);
157
            }
158
            Value::Array(items) => items.iter_mut().for_each(|item| self.localise_json(item)),
159
            Value::Object(fields) => {
160
                for (key, field) in fields.iter_mut() {
161
                    if !matches!(key.as_str(), "href" | "type" | "instance") {
162
                        self.localise_json(field);
163
                    }
164
                }
165
            }
166
            _ => {}
167
        }
168
    }
169
}
170
171
#[cfg(test)]
172
mod tests {
173
    use super::*;
174
175
    #[test]
176
    fn negotiates_by_quality() {
177
        assert_eq!(Locale::negotiate(None), Locale::British);
178
        assert_eq!(Locale::negotiate(Some("en-US")), Locale::American);
179
        assert_eq!(
180
            Locale::negotiate(Some("fr, en-AU;q=0.8, en-US;q=0.5")),
181
            Locale::Australian
182
        );
183
        assert_eq!(Locale::negotiate(Some("de")), Locale::British);
184
    }
185
186
    #[test]
187
    fn rewrites_whole_words_only() {
188
        let us = Locale::American;
189
        assert_eq!(
190
            us.localise("Mum's zip, not gzip."),
191
            "Mom's zipper, not gzip."
192
        );
193
        assert_eq!(us.localise("1.1 mum"), "1.1 mom");
194
        assert_eq!(
195
            Locale::Australian.localise("The request conflicts with the current state of the zip."),
196
            "The request conflicts with the current state of the fly."
197
        );
198
    }
199
200
    #[test]
201
    fn excuses_come_back_to_en_gb() {
202
        assert_eq!(
203
            Locale::American.canonicalise("Digest username=\"carving the turkey\""),
204
            "Digest username=\"wrapping presents\""
205
        );
206
        assert_eq!(
207
            Locale::American.canonicalise("Excuse \"Studying\""),
208
            "Excuse \"revising\""
209
        );
210
        assert_eq!(
211
            Locale::American.canonicalise("Excuse \"helping grandma\""),
212
            "Excuse \"helping Nan\""
213
        );
214
    }
215
}