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