an http based roguelike rpg about jelqing
| 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 | } |