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)] = &[ |
0a0bfee8329e | atagen | 2026-10-11 05:11 UTC | 15 | ("Stop raiding the kitchen.", "Stop raiding the fridge."), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 16 | ( |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 17 | "Nan's house at Christmas", |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 18 | "Grandma's house for Thanksgiving", |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 19 | ), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 20 | ("Christmas dinner", "Thanksgiving dinner"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 21 | ( |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 22 | "The King's speech is about to start.", |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 23 | "The game is about to start.", |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 24 | ), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 25 | ("wrapping presents", "carving the turkey"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 26 | ("folding the washing", "folding laundry"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 27 | ("your washing", "your laundry"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 28 | ("changing room", "locker room"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 29 | ("authorisation", "authorization"), |
7d3fb63208bf | atagen | 2026-10-11 04:38 UTC | 30 | ("revising", "studying"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 31 | ("neighbour", "neighbor"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 32 | ("cupboard", "pantry"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 33 | ("Christmas", "Thanksgiving"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 34 | ("Mum", "Mom"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 35 | ("mum", "mom"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 36 | ("Nan", "Grandma"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 37 | ("nan", "grandma"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 38 | ("zip", "zipper"), |
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 | |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 42 | const EN_AU: &[(&str, &str)] = &[ |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 43 | ("Christmas dinner", "Christmas lunch"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 44 | ( |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 45 | "The King's speech is about to start.", |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 46 | "The cricket is about to start.", |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 47 | ), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 48 | ("wrapping presents", "getting ice from the servo"), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 49 | ("changing room", "change rooms"), |
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 | fn bounded(text: &str, at: usize, len: usize) -> bool { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 56 | let before = text[..at].chars().next_back(); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 57 | let after = text[at + len..].chars().next(); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 58 | let edge = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric()); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 59 | edge(before) && edge(after) |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 60 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 61 | |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 62 | fn replace_words(text: &str, from: &str, to: &str) -> String { |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 63 | replace_matching(text, text, from, to) |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 64 | } |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 65 | |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 66 | /// Players type excuses in whatever case they like. ASCII lowering keeps byte offsets intact. |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 67 | fn replace_words_ignoring_case(text: &str, from: &str, to: &str) -> String { |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 68 | let lowered = text.to_ascii_lowercase(); |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 69 | replace_matching(text, &lowered, &from.to_ascii_lowercase(), to) |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 70 | } |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 71 | |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 72 | fn replace_matching(text: &str, haystack: &str, from: &str, to: &str) -> String { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 73 | let mut out = String::with_capacity(text.len()); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 74 | let mut rest = 0; |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 75 | for (at, _) in haystack.match_indices(from) { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 76 | if at < rest || !bounded(text, at, from.len()) { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 77 | continue; |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 78 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 79 | out.push_str(&text[rest..at]); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 80 | out.push_str(to); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 81 | rest = at + from.len(); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 82 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 83 | out.push_str(&text[rest..]); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 84 | out |
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 | impl Locale { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 88 | pub fn tag(self) -> &'static str { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 89 | match self { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 90 | Self::British => "en-GB", |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 91 | Self::American => "en-US", |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 92 | Self::Australian => "en-AU", |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 93 | } |
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 | fn parse(tag: &str) -> Option<Self> { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 97 | match tag.trim().to_ascii_lowercase().as_str() { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 98 | "en-gb" | "en" => Some(Self::British), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 99 | "en-us" => Some(Self::American), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 100 | "en-au" => Some(Self::Australian), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 101 | _ => None, |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 102 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 103 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 104 | |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 105 | /// Picks the most preferred supported locale from an `Accept-Language` header (RFC 9110 §12.5.4). |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 106 | pub fn negotiate(accept_language: Option<&str>) -> Self { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 107 | let Some(header) = accept_language else { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 108 | return Self::default(); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 109 | }; |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 110 | header |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 111 | .split(',') |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 112 | .filter_map(|range| { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 113 | let mut parts = range.split(';'); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 114 | let locale = Self::parse(parts.next()?)?; |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 115 | let quality = parts |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 116 | .find_map(|p| p.trim().strip_prefix("q=")) |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 117 | .and_then(|q| q.parse::<f32>().ok()) |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 118 | .unwrap_or(1.0); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 119 | (quality > 0.0).then_some((locale, quality)) |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 120 | }) |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 121 | .max_by(|a, b| a.1.total_cmp(&b.1)) |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 122 | .map_or_else(Self::default, |(locale, _)| locale) |
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 | fn phrases(self) -> &'static [(&'static str, &'static str)] { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 126 | match self { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 127 | Self::British => &[], |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 128 | Self::American => EN_US, |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 129 | Self::Australian => EN_AU, |
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 | pub fn localise(self, text: &str) -> String { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 134 | self.phrases() |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 135 | .iter() |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 136 | .fold(text.to_owned(), |text, (from, to)| { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 137 | replace_words(&text, from, to) |
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 | /// Translates the excuses a player types back into the en-GB phrases the game checks against. |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 142 | pub fn canonicalise(self, text: &str) -> String { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 143 | let local = |excuse: &str| self.localise(excuse); |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 144 | crate::game::excuse_vocabulary().fold(text.to_owned(), |text, excuse| { |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 145 | let localised = local(excuse); |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 146 | if localised == excuse { |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 147 | return text; |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 148 | } |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 149 | replace_words_ignoring_case(&text, &localised, excuse) |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 150 | }) |
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 | pub fn localise_json(self, value: &mut Value) { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 154 | match value { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 155 | Value::String(text) if !text.starts_with('/') && !text.starts_with("http") => { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 156 | *text = self.localise(text); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 157 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 158 | Value::Array(items) => items.iter_mut().for_each(|item| self.localise_json(item)), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 159 | Value::Object(fields) => { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 160 | for (key, field) in fields.iter_mut() { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 161 | if !matches!(key.as_str(), "href" | "type" | "instance") { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 162 | self.localise_json(field); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 163 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 164 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 165 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 166 | _ => {} |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 167 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 168 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 169 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 170 | |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 171 | #[cfg(test)] |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 172 | mod tests { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 173 | use super::*; |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 174 | |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 175 | #[test] |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 176 | fn negotiates_by_quality() { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 177 | assert_eq!(Locale::negotiate(None), Locale::British); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 178 | assert_eq!(Locale::negotiate(Some("en-US")), Locale::American); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 179 | assert_eq!( |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 180 | Locale::negotiate(Some("fr, en-AU;q=0.8, en-US;q=0.5")), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 181 | Locale::Australian |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 182 | ); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 183 | assert_eq!(Locale::negotiate(Some("de")), Locale::British); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 184 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 185 | |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 186 | #[test] |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 187 | fn rewrites_whole_words_only() { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 188 | let us = Locale::American; |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 189 | assert_eq!( |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 190 | us.localise("Mum's zip, not gzip."), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 191 | "Mom's zipper, not gzip." |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 192 | ); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 193 | assert_eq!(us.localise("1.1 mum"), "1.1 mom"); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 194 | assert_eq!( |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 195 | Locale::Australian.localise("The request conflicts with the current state of the zip."), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 196 | "The request conflicts with the current state of the fly." |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 197 | ); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 198 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 199 | |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 200 | #[test] |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 201 | fn excuses_come_back_to_en_gb() { |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 202 | assert_eq!( |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 203 | Locale::American.canonicalise("Digest username=\"carving the turkey\""), |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 204 | "Digest username=\"wrapping presents\"" |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 205 | ); |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 206 | assert_eq!( |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 207 | Locale::American.canonicalise("Excuse \"Studying\""), |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 208 | "Excuse \"revising\"" |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 209 | ); |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 210 | assert_eq!( |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 211 | Locale::American.canonicalise("Excuse \"helping grandma\""), |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 212 | "Excuse \"helping Nan\"" |
c4d70621d377 | atagen | 2026-10-12 11:56 UTC | 213 | ); |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 214 | } |
ef7d6dee5976 | atagen | 2026-10-10 13:23 UTC | 215 | } |