an http based roguelike rpg about jelqing
Commit ef7d6dee5976
tarball6 files changed+316-13
@@ -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 = request79
- .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> = parts90
+ .headers91
+ .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 = parts104
+ .headers105
+ .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
+ request128
+ .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
headers94155
.entry(header::CACHE_CONTROL)95156
.or_insert(HeaderValue::from_static("no-store, private"));@@ -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 = ctx84
+ .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
}@@ -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
@@ -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
+ out77
+}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
+ header103
+ .split(',')104
+ .filter_map(|range| {105
+ let mut parts = range.split(';');106
+ let locale = Self::parse(parts.next()?)?;107
+ let quality = parts108
+ .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::Australian170
+ );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
+}@@ -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
+}@@ -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");