atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit ef7d6dee5976

tarball

atagen <boss@atagen.co>2026-10-10 15:15 UTC

6 files changed+316-13
jelqd: localise into en-GB, en-US and en-AU by Accept-Language

The game is written once in en-GB and other locales rewrite regional phrases on the way out, skipping hrefs and problem type URIs so identifiers stay stable. Excuses typed in the player's locale are mapped back before Mum checks them, so Americans can carve the turkey.
Modified file: src/http/middleware.rs+68 additions−7 deletions
@@ -9,7 +9,13 @@ use axum::{
99 };
1010 use flate2::{Compression, write::GzEncoder};
1111
12-use crate::{headers::X_POWERED_BY, problem::Problem};
12+use serde_json::Value;
13+
14+use crate::{
15+ headers::{X_OBSERVED_BY, X_POWERED_BY},
16+ locale::Locale,
17+ problem::Problem,
18+};
1319
1420 const MAX_URI: usize = 512;
1521 const MAX_HEADER: usize = 1024;
@@ -74,22 +80,77 @@ async fn compress(response: Response) -> Response {
7480 Response::from_parts(parts, Body::from(gzipped))
7581 }
7682
77-pub async fn chrome(request: Request, next: Next) -> Response {
78- let gzip = request
79- .headers()
80- .get(header::ACCEPT_ENCODING)
83+async fn localise(response: Response, locale: Locale) -> Response {
84+ if locale == Locale::British {
85+ return response;
86+ }
87+ let (mut parts, body) = response.into_parts();
88+ for name in [header::VIA, header::VARY, X_OBSERVED_BY] {
89+ let localised: Vec<HeaderValue> = parts
90+ .headers
91+ .get_all(&name)
92+ .iter()
93+ .filter_map(|v| v.to_str().ok())
94+ .filter_map(|v| HeaderValue::from_str(&locale.localise(v)).ok())
95+ .collect();
96+ if !localised.is_empty() {
97+ parts.headers.remove(&name);
98+ for value in localised {
99+ parts.headers.append(name.clone(), value);
100+ }
101+ }
102+ }
103+ let json = parts
104+ .headers
105+ .get(header::CONTENT_TYPE)
81106 .and_then(|v| v.to_str().ok())
82- .is_some_and(|e| e.to_ascii_lowercase().contains("gzip"));
83- let mut response = match reject(&request) {
107+ .is_some_and(|ct| {
108+ ct.starts_with("application/json") || ct.starts_with("application/problem+json")
109+ });
110+ if !json || parts.status == StatusCode::PARTIAL_CONTENT {
111+ return Response::from_parts(parts, body);
112+ }
113+ let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
114+ return Response::from_parts(parts, Body::empty());
115+ };
116+ let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
117+ return Response::from_parts(parts, Body::from(bytes));
118+ };
119+ locale.localise_json(&mut value);
120+ parts.headers.remove(header::CONTENT_LENGTH);
121+ Response::from_parts(parts, Body::from(value.to_string()))
122+}
123+
124+pub async fn chrome(request: Request, next: Next) -> Response {
125+ let (gzip, locale) = {
126+ let header_text = |name| {
127+ request
128+ .headers()
129+ .get(name)
130+ .and_then(|v| v.to_str().ok())
131+ .map(str::to_owned)
132+ };
133+ let gzip = header_text(header::ACCEPT_ENCODING)
134+ .is_some_and(|e| e.to_ascii_lowercase().contains("gzip"));
135+ let locale = Locale::negotiate(header_text(header::ACCEPT_LANGUAGE).as_deref());
136+ (gzip, locale)
137+ };
138+ let response = match reject(&request) {
84139 Some(problem) => problem.into_response(),
85140 None => next.run(request).await,
86141 };
142+ let mut response = localise(response, locale).await;
87143 if gzip && response.extensions().get::<Compress>().is_some() {
88144 response = compress(response).await;
89145 }
90146 let headers = response.headers_mut();
91147 headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9"));
92148 headers.insert(X_POWERED_BY, HeaderValue::from_static("blood"));
149+ headers.insert(
150+ header::CONTENT_LANGUAGE,
151+ HeaderValue::from_static(locale.tag()),
152+ );
153+ headers.append(header::VARY, HeaderValue::from_static("Accept-Language"));
93154 headers
94155 .entry(header::CACHE_CONTROL)
95156 .or_insert(HeaderValue::from_static("no-store, private"));
Modified file: src/http/run.rs+5 additions−1 deletions
@@ -14,6 +14,7 @@ use crate::{
1414 X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_OBSERVED_BY, X_SELF_REPORTED_LENGTH, X_SHAME,
1515 X_STAMINA, X_SUSPICION,
1616 },
17+ locale::Locale,
1718 problem::{PROBLEM_BASE, Problem},
1819 secret::Secret,
1920 };
@@ -79,7 +80,10 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
7980 .into_response(),
8081 );
8182 }
82- run.answer_door(ctx.header(header::AUTHORIZATION))
83+ let authorization = ctx
84+ .header(header::AUTHORIZATION)
85+ .map(|text| Locale::negotiate(ctx.header(header::ACCEPT_LANGUAGE)).canonicalise(text));
86+ run.answer_door(authorization.as_deref())
8387 .err()
8488 .map(IntoResponse::into_response)
8589 }
Modified file: src/lib.rs+1 additions
@@ -2,6 +2,7 @@ mod config;
22 mod game;
33 mod headers;
44 mod http;
5+mod locale;
56 mod problem;
67 mod secret;
78
Added file: src/locale.rs+195 additions
@@ -0,0 +1,195 @@
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+ ("flatmate", "roommate"),
29+ ("authorisation", "authorization"),
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+ ("flatmate", "housemate"),
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+}
Modified file: src/tests.rs+35 additions
@@ -680,3 +680,38 @@ async fn http_1_0_gets_the_pdf_treatment() {
680680 let response = app().oneshot(request).await.unwrap();
681681 assert_eq!(response.status(), StatusCode::HTTP_VERSION_NOT_SUPPORTED);
682682 }
683+
684+#[tokio::test]
685+async fn mom_speaks_american() {
686+ let app = seeded_with(Floor::Nans, mum_at_the_door);
687+ let us = [("accept-language", "en-US")];
688+ let knock = send(&app, "GET", "/runs/cafebabe", &us, "").await;
689+ assert_eq!(header(&knock, "content-language"), "en-US");
690+ assert_eq!(header(&knock, "via"), "1.1 mom");
691+ assert!(
692+ json(knock).await["detail"]
693+ .as_str()
694+ .unwrap()
695+ .contains("Someone is at the door.")
696+ );
697+ let excuse = [
698+ ("accept-language", "en-US"),
699+ (
700+ "authorization",
701+ "Digest username=\"carving the turkey\", nonce=\"abc\"",
702+ ),
703+ ];
704+ let answered = send(&app, "GET", "/runs/cafebabe", &excuse, "").await;
705+ assert_eq!(answered.status(), StatusCode::OK);
706+ let floor = json(send(&app, "GET", "/runs/cafebabe/floor", &us, "").await).await;
707+ assert_eq!(floor["name"], "Grandma's house for Thanksgiving");
708+}
709+
710+#[tokio::test]
711+async fn the_default_locale_is_british() {
712+ let (app, _) = seeded(Floor::Nans);
713+ let floor = send(&app, "GET", "/runs/cafebabe/floor", &[], "").await;
714+ assert_eq!(header(&floor, "content-language"), "en-GB");
715+ assert!(header(&floor, "vary").contains("Accept-Language"));
716+ assert_eq!(json(floor).await["name"], "Nan's house at Christmas");
717+}
Modified file: src/ui.html+12 additions−5 deletions
@@ -12,7 +12,8 @@ pre, li, p, td, label { overflow-wrap: anywhere; }
1212 <body>
1313
1414 <h1>jelqd</h1>
15-<p>A roguelike played over HTTP. Pick a resource, pick a method, send it. The log on the right is everything the server has said to you.</p>
15+<p>A roguelike played over HTTP. Pick a resource, pick a method, send it. The log on the right is everything the server has said to you.
16+Language <select id="locale"><option>en-GB</option><option>en-US</option><option>en-AU</option></select></p>
1617
1718 <table width="100%" cellspacing="6">
1819 <tr>
@@ -62,7 +63,6 @@ const STATS = [
6263 ["x-shame", "Shame"],
6364 ["x-cash", "Cash"],
6465 ];
65-const OBSERVERS = { cat: "The cat is watching.", greg: "Greg is watching.", nan: "Nan is watching." };
6666 const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-control", "content-length", "content-type", "date"].concat(STATS.map(s => s[0])));
6767
6868 const FIELDS = {
@@ -149,16 +149,20 @@ function renderStats(headers) {
149149 }
150150 }
151151
152+function titled(text) {
153+ return text.charAt(0).toUpperCase() + text.slice(1);
154+}
155+
152156 function renderAlerts(list) {
153157 const box = $("alerts");
154158 box.replaceChildren();
155159 const alerts = [];
156160 for (const [name, value] of list || []) {
157- if (name === "via" && value.includes("mum")) alerts.push("Mum is at the door.");
158- if (name === "x-observed-by") alerts.push(OBSERVERS[value] || "Someone is watching.");
161+ if (name === "via") alerts.push(titled(value.split(" ").pop()) + " is at the door.");
162+ if (name === "x-observed-by") alerts.push((value === "cat" ? "The cat" : titled(value)) + " is watching.");
159163 if (name === "warning") {
160164 const text = (value.match(/"([^"]*)"/) || [, value])[1];
161- alerts.push(text.charAt(0).toUpperCase() + text.slice(1) + ".");
165+ alerts.push(titled(text) + ".");
162166 }
163167 }
164168 for (const alert of alerts) box.append(el("h2", { textContent: alert }));
@@ -328,6 +332,7 @@ async function go(event) {
328332
329333 log("> " + method + " " + short(path));
330334 for (const [name, v] of headers) log("> " + name + ": " + v);
335+ headers.push(["Accept-Language", $("locale").value]);
331336 const response = await fetch("/ui/send", {
332337 method: "POST",
333338 headers: { "Content-Type": "application/json" },
@@ -339,6 +344,8 @@ async function go(event) {
339344 }
340345
341346 $("form").onsubmit = go;
347+$("locale").value = localStorage.getItem("locale") || "en-GB";
348+$("locale").onchange = () => localStorage.setItem("locale", $("locale").value);
342349 renderStats(null);
343350 render();
344351 log("Welcome. Choose \"start a new run\" and send POST to begin.\n");