atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 6619c458c23e

tarball

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

11 files changed+20-220
game: drop PATCH

A JSON Patch test only revealed numbers the player can already see. X-Self-Reported-Length is always the truth plus SELF_REPORT_INFLATION, and X-Girth and X-Foreskin are exact, so the method cost EQ and a UI slot for nothing. It was the only request with a body, so Ctx stops buffering one and the /ui/send envelope loses its body field.
Modified file: src/game/balance/eq.rs−1 deletions
@@ -23,7 +23,6 @@ pub const HOLD_EQ: i32 = 15;
2323 pub const ARRIVAL_BONUS: i32 = 1;
2424 pub const GREED_WARM: f64 = 1.5;
2525 pub const GREED_HOT: f64 = 2.0;
26-pub const PATCH_EQ: i32 = 3;
2726 pub const STROKE_EQ: i32 = 2;
2827 pub const STROKE_EQ_COST: i32 = 6;
2928 pub const SOFT_INTENSITY: i32 = 2;
Modified file: src/game/measure.rs+2 additions−87 deletions
@@ -1,10 +1,9 @@
11 use axum::http::StatusCode;
2-use serde::Deserialize;
3-use serde_json::{Value, json};
2+use serde_json::json;
43
54 use super::{
65 Outcome, Run,
7- balance::{EXAGGERATION, HONEST_TOLERANCE, PATCH_EQ, PUT_GAIN},
6+ balance::{HONEST_TOLERANCE, PUT_GAIN},
87 };
98 use crate::problem::Problem;
109
@@ -37,61 +36,6 @@ pub(super) fn exaggeration() -> Problem {
3736 )
3837 }
3938
40-/// One JSON Patch operation as sent, before the server decides what it will honour.
41-#[derive(Deserialize)]
42-pub struct PatchOp {
43- op: String,
44- path: String,
45- #[serde(default)]
46- value: Value,
47-}
48-
49-#[derive(Clone, Copy, PartialEq, Eq)]
50-enum Measurement {
51- Length,
52- Girth,
53- Foreskin,
54-}
55-
56-struct Claim {
57- measurement: Measurement,
58- value: f64,
59-}
60-
61-impl PatchOp {
62- fn claim(&self) -> Result<Claim, Problem> {
63- if self.op != "test" {
64- return Err(Problem::new(
65- StatusCode::UNPROCESSABLE_ENTITY,
66- "wishful-thinking",
67- "The server accepts only the truth.",
68- )
69- .with("op", self.op.clone()));
70- }
71- let measurement = match self.path.as_str() {
72- "/length" => Measurement::Length,
73- "/girth" => Measurement::Girth,
74- "/foreskin" => Measurement::Foreskin,
75- _ => {
76- return Err(Problem::new(
77- StatusCode::UNPROCESSABLE_ENTITY,
78- "no-such-measurement",
79- "There is no such measurement.",
80- )
81- .with("path", self.path.clone()));
82- }
83- };
84- let value = self.value.as_f64().ok_or_else(|| {
85- Problem::new(
86- StatusCode::BAD_REQUEST,
87- "malformed-patch",
88- "A test requires a numeric value.",
89- )
90- })?;
91- Ok(Claim { measurement, value })
92- }
93-}
94-
9539 impl Run {
9640 pub fn put(&mut self) -> Outcome {
9741 if self.body.put_applied {
@@ -102,33 +46,4 @@ impl Run {
10246 self.touch();
10347 Outcome::json(StatusCode::OK, json!({ "detail": "Applied." }))
10448 }
105-
106- /// Being measured is not arousing, so every PATCH costs a little EQ whatever it finds.
107- pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {
108- self.reset_eq(self.body.eq - PATCH_EQ);
109- self.touch();
110- for op in ops {
111- let claim = op.claim()?;
112- let actual = match claim.measurement {
113- Measurement::Length => self.current_length(),
114- Measurement::Girth => self.body.girth,
115- Measurement::Foreskin => self.body.foreskin.unwrap_or(0.0),
116- };
117- if claim.measurement == Measurement::Length && claim.value >= actual + EXAGGERATION {
118- self.standing.shame += 2;
119- return Err(exaggeration());
120- }
121- if !honest(claim.value, actual) {
122- self.standing.shame += 1;
123- return Err(
124- Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")
125- .with("path", op.path.clone()),
126- );
127- }
128- }
129- Ok(Outcome::json(
130- StatusCode::OK,
131- json!({ "detail": "Correct." }),
132- ))
133- }
13449 }
Modified file: src/game/mod.rs+1 additions−1 deletions
@@ -43,7 +43,7 @@ pub use household::{Excuse, Household, MumVisit, vocabulary as excuse_vocabulary
4343 pub use inventory::Items;
4444 pub use items::{Item, SHOP};
4545 pub use lube::{LUBRICANTS, Lube};
46-pub use measure::{PatchOp, shaft, volume};
46+pub use measure::{shaft, volume};
4747 pub use notice::Notice;
4848 pub use outcome::{Outcome, Payload};
4949 pub use run::Run;
Modified file: src/headers.rs−1 deletions
@@ -1,6 +1,5 @@
11 use axum::http::HeaderName;
22
3-pub const ACCEPT_PATCH: HeaderName = HeaderName::from_static("accept-patch");
43 pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");
54 pub const X_CASH: HeaderName = HeaderName::from_static("x-cash");
65 pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");
Modified file: src/http/ctx.rs+3 additions−22 deletions
@@ -1,17 +1,12 @@
11 use std::{collections::HashMap, fmt::Write as _, time::Instant};
22
33 use axum::{
4- body::Bytes,
54 extract::{FromRequest, FromRequestParts, Query, RawPathParams, Request},
6- http::{HeaderMap, HeaderName, Method, StatusCode, Uri, Version, header},
7- response::{IntoResponse, Response},
5+ http::{HeaderMap, HeaderName, Method, Uri, Version, header},
6+ response::Response,
87 };
98 use hyper::upgrade::OnUpgrade;
109
11-use crate::problem::Problem;
12-
13-pub const BODY_LIMIT: usize = 64 * 1024;
14-
1510 /// Everything a handler may look at, gathered once per request.
1611 pub struct Ctx {
1712 method: Method,
@@ -20,7 +15,6 @@ pub struct Ctx {
2015 headers: HeaderMap,
2116 query: HashMap<String, String>,
2217 params: HashMap<String, String>,
23- body: Bytes,
2418 now: Instant,
2519 upgrade: Option<OnUpgrade>,
2620 }
@@ -53,10 +47,6 @@ impl Ctx {
5347 &self.method
5448 }
5549
56- pub fn body(&self) -> &[u8] {
57- &self.body
58- }
59-
6050 pub fn now(&self) -> Instant {
6151 self.now
6252 }
@@ -85,7 +75,7 @@ impl<S: Send + Sync> FromRequest<S> for Ctx {
8575
8676 async fn from_request(mut request: Request, state: &S) -> Result<Self, Self::Rejection> {
8777 let upgrade = request.extensions_mut().remove::<OnUpgrade>();
88- let (mut parts, body) = request.into_parts();
78+ let (mut parts, _) = request.into_parts();
8979 let params = RawPathParams::from_request_parts(&mut parts, state)
9080 .await
9181 .map(|raw| {
@@ -97,14 +87,6 @@ impl<S: Send + Sync> FromRequest<S> for Ctx {
9787 let query = Query::<HashMap<String, String>>::try_from_uri(&parts.uri)
9888 .map(|q| q.0)
9989 .unwrap_or_default();
100- let body = axum::body::to_bytes(body, BODY_LIMIT).await.map_err(|_| {
101- Problem::new(
102- StatusCode::PAYLOAD_TOO_LARGE,
103- "body-too-large",
104- "The request body is larger than anything the server is prepared to receive.",
105- )
106- .into_response()
107- })?;
10890 Ok(Self {
10991 method: parts.method,
11092 uri: parts.uri,
@@ -112,7 +94,6 @@ impl<S: Send + Sync> FromRequest<S> for Ctx {
11294 headers: parts.headers,
11395 query,
11496 params,
115- body,
11697 now: Instant::now(),
11798 upgrade,
11899 })
Modified file: src/http/resource.rs+7 additions−25 deletions
@@ -1,12 +1,12 @@
11 use axum::{
22 Json,
3- http::{HeaderValue, Method, StatusCode, header},
3+ http::{Method, StatusCode, header},
44 response::{IntoResponse, Response},
55 };
66 use serde_json::json;
77
88 use super::ctx::Ctx;
9-use crate::{headers::ACCEPT_PATCH, problem::Problem};
9+use crate::problem::Problem;
1010
1111 const THERAPY: &str = "Your options are listed in the Allow header. There are more of them than you think. You could also try being honest. It is not in the Allow header because it is not a method.";
1212
@@ -62,22 +62,11 @@ impl<T> Verb<T> {
6262 /// The methods a resource supports. `Allow`, `OPTIONS` and `405` are all derived from this list.
6363 pub struct Resource<T: 'static> {
6464 verbs: &'static [Verb<T>],
65- accept_patch: bool,
6665 }
6766
6867 impl<T> Resource<T> {
6968 pub const fn new(verbs: &'static [Verb<T>]) -> Self {
70- Self {
71- verbs,
72- accept_patch: false,
73- }
74- }
75-
76- pub const fn patchable(verbs: &'static [Verb<T>]) -> Self {
77- Self {
78- verbs,
79- accept_patch: true,
80- }
69+ Self { verbs }
8170 }
8271
8372 pub fn allow(&self, target: &T) -> String {
@@ -109,7 +98,7 @@ impl<T> Resource<T> {
10998 let refusal = verb.guard.map_or("", |(_, refusal)| refusal);
11099 method_not_allowed(refusal.to_owned(), &self.allow(target))
111100 }
112- None if requested == "OPTIONS" => options(&self.allow(target), self.accept_patch),
101+ None if requested == "OPTIONS" => options(&self.allow(target)),
113102 None => unsupported(ctx.method(), &self.allow(target)),
114103 }
115104 }
@@ -150,18 +139,11 @@ pub fn unsupported(method: &Method, allow: &str) -> Response {
150139 }
151140 }
152141
153-fn options(allow: &str, accept_patch: bool) -> Response {
154- let mut response = (
142+fn options(allow: &str) -> Response {
143+ (
155144 StatusCode::OK,
156145 [(header::ALLOW, allow)],
157146 Json(json!({ "detail": THERAPY })),
158147 )
159- .into_response();
160- if accept_patch {
161- response.headers_mut().insert(
162- ACCEPT_PATCH,
163- HeaderValue::from_static("application/json-patch+json"),
164- );
165- }
166- response
148+ .into_response()
167149 }
Modified file: src/http/runs.rs+2 additions−28 deletions
@@ -18,10 +18,9 @@ use super::{
1818 };
1919 use crate::{
2020 game::{
21- Branch, Cause, Floor, Grip, Item, LUBRICANTS, Lube, PatchOp, Run, Technique,
21+ Branch, Cause, Floor, Grip, Item, LUBRICANTS, Lube, Run, Technique,
2222 balance::{EQ_BAND, VIEWS_PER_SECOND},
2323 },
24- headers::ACCEPT_PATCH,
2524 problem::Problem,
2625 };
2726
@@ -45,7 +44,7 @@ fn not_in_clinic(run: &Run) -> bool {
4544
4645 pub static RUN: RunResource = RunResource {
4746 scope: Scope::Home,
48- resource: Resource::patchable(&[
47+ resource: Resource::new(&[
4948 Verb::new("GET", get_run),
5049 Verb::new("JELQ", jelq).only_when(not_in_clinic, "JELQ is not performed in the clinic."),
5150 Verb::new("HOLD", hold_run).only_when(
@@ -53,7 +52,6 @@ pub static RUN: RunResource = RunResource {
5352 "HOLD is only available above 70 EQ or at 2 stamina or less.",
5453 ),
5554 Verb::new("PUT", put_run),
56- Verb::new("PATCH", patch_run),
5755 Verb::new("DELETE", delete_run),
5856 Verb::new("TRACE", trace_run),
5957 ]),
@@ -131,30 +129,6 @@ fn hold_run(run: &mut Run, _: &Ctx) -> Response {
131129 respond(run.hold())
132130 }
133131
134-fn patch_run(run: &mut Run, ctx: &Ctx) -> Response {
135- let is_patch = ctx
136- .header(header::CONTENT_TYPE)
137- .is_some_and(|ct| ct.starts_with("application/json-patch+json"));
138- if !is_patch {
139- return Problem::new(
140- StatusCode::UNSUPPORTED_MEDIA_TYPE,
141- "patch-format",
142- "Patches are accepted as application/json-patch+json.",
143- )
144- .header(ACCEPT_PATCH, "application/json-patch+json")
145- .into_response();
146- }
147- match serde_json::from_slice::<Vec<PatchOp>>(ctx.body()) {
148- Ok(ops) => respond(run.patch(&ops)),
149- Err(_) => Problem::new(
150- StatusCode::BAD_REQUEST,
151- "malformed-patch",
152- "The patch could not be parsed.",
153- )
154- .into_response(),
155- }
156-}
157-
158132 fn delete_run(run: &mut Run, _: &Ctx) -> Response {
159133 run.end(Cause::Abandoned);
160134 StatusCode::NO_CONTENT.into_response()
Modified file: src/http/ui.rs+3 additions−4 deletions
@@ -12,9 +12,10 @@ use serde::Deserialize;
1212 use serde_json::json;
1313 use tower::ServiceExt;
1414
15-use super::{ctx::BODY_LIMIT, resource::unsupported};
15+use super::resource::unsupported;
1616 use crate::problem::{Problem, reason};
1717
18+const BODY_LIMIT: usize = 64 * 1024;
1819 const RESPONSE_LIMIT: usize = 1 << 20;
1920
2021 #[derive(Deserialize)]
@@ -23,8 +24,6 @@ struct Envelope {
2324 path: String,
2425 #[serde(default)]
2526 headers: Vec<(String, String)>,
26- #[serde(default)]
27- body: String,
2827 }
2928
3029 pub async fn page(method: Method) -> Response {
@@ -73,7 +72,7 @@ pub async fn send(State(api): State<Router>, headers: HeaderMap, body: Bytes) ->
7372 for cookie in headers.get_all(header::COOKIE) {
7473 request = request.header(header::COOKIE, cookie);
7574 }
76- let Ok(request) = request.body(Body::from(envelope.body)) else {
75+ let Ok(request) = request.body(Body::empty()) else {
7776 return bad_envelope("The request could not be built. A header may be malformed.");
7877 };
7978 let Ok(response) = api.oneshot(request).await;
Modified file: src/tests/body.rs−15 deletions
@@ -357,21 +357,6 @@ async fn lubes_run_out_but_spit_never_does() {
357357 }
358358
359359 #[tokio::test]
360-async fn patching_costs_a_little_eq() {
361- let app = seeded_with(Floor::Bedroom, |run| run.body.eq = 50);
362- let patch = send(
363- &app,
364- "PATCH",
365- "/runs/cafebabe",
366- &[("content-type", "application/json-patch+json")],
367- r#"[{"op": "test", "path": "/girth", "value": 1.0}]"#,
368- )
369- .await;
370- assert_eq!(patch.status(), StatusCode::CONFLICT);
371- assert_eq!(header(&patch, "x-eq"), "47");
372-}
373-
374-#[tokio::test]
375360 async fn a_combo_chafes_twice_and_costs_two_stamina() {
376361 let app = seeded_with(Floor::Hotel, |run| {
377362 run.here.wifi_accepted = true;
Modified file: src/tests/methods.rs−27 deletions
@@ -83,33 +83,6 @@ async fn the_clinic_does_not_advertise_jelq() {
8383 }
8484
8585 #[tokio::test]
86-async fn patch_only_accepts_the_truth() {
87- let Harness { app, .. } = harness(Floor::Bedroom, SEED, |_| {});
88- let patch = [("content-type", "application/json-patch+json")];
89- let replace = send(
90- &app,
91- "PATCH",
92- "/runs/cafebabe",
93- &patch,
94- r#"[{"op":"replace","path":"/length","value":12}]"#,
95- )
96- .await;
97- assert_eq!(replace.status(), StatusCode::UNPROCESSABLE_ENTITY);
98- let lie = send(
99- &app,
100- "PATCH",
101- "/runs/cafebabe",
102- &patch,
103- r#"[{"op":"test","path":"/length","value":40}]"#,
104- )
105- .await;
106- assert_eq!(
107- json(lie).await["type"],
108- "https://jelq.dev/problems/exaggeration"
109- );
110-}
111-
112-#[tokio::test]
11386 async fn http_1_0_gets_the_pdf_treatment() {
11487 let request = Request::builder()
11588 .uri("/graveyard")
Modified file: src/ui.html+2 additions−9 deletions
@@ -51,7 +51,7 @@ pre, li, p, td, label { overflow-wrap: anywhere; }
5151
5252 <script>
5353 const REL = "https://jelq.dev/rels/";
54-const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];
54+const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"];
5555 const ALL_LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];
5656 const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "watermelon", "overstayed", "run-ended"];
5757 let stocked = null;
@@ -79,7 +79,6 @@ const FIELDS = {
7979 also: { label: "combo with", kind: "select" },
8080 intensity: { label: "intensity, 1 to 10", kind: "number", value: "5" },
8181 lube: { label: "Content-Type", header: "Content-Type", kind: "select" },
82- patch: { label: "body", kind: "textarea", value: '[{"op": "test", "path": "/length", "value": 6}]' },
8382 range: { label: "Range", header: "Range", kind: "text", placeholder: "inches=0-2" },
8483 ifMatch: { label: "If-Match", header: "If-Match", kind: "text" },
8584 ifNoneMatch: { label: "If-None-Match", header: "If-None-Match", kind: "text" },
@@ -91,7 +90,6 @@ const FIELDS = {
9190 };
9291 const BY_TARGET = {
9392 "runs POST": ["seed"],
94- "run PATCH": ["patch"],
9593 "length GET": ["range"],
9694 "length HEAD": ["range"],
9795 "foreskin DELETE": ["ifMatch"],
@@ -443,7 +441,6 @@ async function go(event) {
443441 const value = name => fieldValue(name).trim();
444442 const headers = [];
445443 let path = t.path;
446- let body = "";
447444 for (const name of fieldNames()) {
448445 const f = FIELDS[name];
449446 if (f.header && value(name)) headers.push([f.header, value(name)]);
@@ -460,10 +457,6 @@ async function go(event) {
460457 if (names.includes("intensity")) path += "&intensity=" + encodeURIComponent(value("intensity"));
461458 }
462459 if (key === "runs POST" && value("seed")) path += "?seed=" + encodeURIComponent(value("seed"));
463- if (method === "PATCH") {
464- headers.push(["Content-Type", "application/json-patch+json"]);
465- body = value("patch");
466- }
467460
468461 const sent = ["> " + method + " " + short(path)].concat(headers.map(([name, v]) => "> " + name + ": " + v)).join("\n");
469462 delete values.authorization;
@@ -473,7 +466,7 @@ async function go(event) {
473466 const response = await fetch("/ui/send", {
474467 method: "POST",
475468 headers: { "Content-Type": "application/json" },
476- body: JSON.stringify({ method, path, headers, body }),
469+ body: JSON.stringify({ method, path, headers }),
477470 });
478471 r = await response.json();
479472 } catch (error) {