atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 98432b6057aa

tarball

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

4 files changed+115-21
game: tell each technique of a combo in full

The 207 kept only each technique's status line, so a successful combo applied gains the player never saw. Each D:response now carries the JSON body that technique would have answered alone, plus its D:location for a 308.
Modified file: src/game/dispatch.rs+55 additions−11 deletions
@@ -4,7 +4,7 @@ use super::{
44 };
55 use crate::problem::Problem;
66 use axum::http::{StatusCode, header};
7-use serde_json::json;
7+use serde_json::{Value, json};
88 use std::{fmt::Write as _, ops::ControlFlow};
99
1010 impl Run {
@@ -133,31 +133,45 @@ impl Run {
133133 return Ok(interrupted);
134134 }
135135
136- let mut statuses = Vec::new();
136+ let mut reports = Vec::new();
137137 for training in [a, b] {
138138 let result = self.roll(training, intensity, lube);
139139 if self.ended.is_some() {
140140 return result;
141141 }
142- let status = match result {
143- Ok(outcome) => self.gossip(outcome).status(),
144- Err(problem) => problem.status(),
145- };
146- statuses.push((training, status));
142+ reports.push((
143+ training,
144+ Report::of(result.map(|outcome| self.gossip(outcome))),
145+ ));
147146 }
148147
149148 let mut xml = String::from(
150149 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n",
151150 );
152- for (training, status) in statuses {
151+ for (training, report) in reports {
153152 let _ = write!(
154153 xml,
155- " <D:response>\n <D:href>{}?technique={}</D:href>\n <D:status>HTTP/1.1 {} {}</D:status>\n </D:response>\n",
154+ " <D:response>\n <D:href>{}?technique={}</D:href>\n <D:status>HTTP/1.1 {} {}</D:status>\n",
156155 self.path(""),
157156 training.name(),
158- status.as_u16(),
159- crate::problem::reason(status),
157+ report.status.as_u16(),
158+ crate::problem::reason(report.status),
160159 );
160+ if let Some(location) = report.location {
161+ let _ = writeln!(
162+ xml,
163+ " <D:location><D:href>{}</D:href></D:location>",
164+ escape(&location)
165+ );
166+ }
167+ if let Some(text) = report.text {
168+ let _ = writeln!(
169+ xml,
170+ " <D:responsedescription>{}</D:responsedescription>",
171+ escape(&text)
172+ );
173+ }
174+ xml.push_str(" </D:response>\n");
161175 }
162176 xml.push_str(
163177 " <D:responsedescription>Each technique has been reported separately.</D:responsedescription>\n</D:multistatus>\n",
@@ -169,3 +183,33 @@ impl Run {
169183 ))
170184 }
171185 }
186+
187+/// What one technique of a combo would have answered on its own.
188+struct Report {
189+ status: StatusCode,
190+ location: Option<String>,
191+ text: Option<String>,
192+}
193+
194+impl Report {
195+ fn of(result: Result<Outcome, Problem>) -> Self {
196+ match result {
197+ Ok(outcome) => Self {
198+ status: outcome.status(),
199+ location: outcome.header_value(&header::LOCATION).map(str::to_owned),
200+ text: outcome.body().map(Value::to_string),
201+ },
202+ Err(problem) => Self {
203+ status: problem.status(),
204+ location: None,
205+ text: Some(Value::Object(problem.document()).to_string()),
206+ },
207+ }
208+ }
209+}
210+
211+fn escape(text: &str) -> String {
212+ text.replace('&', "&amp;")
213+ .replace('<', "&lt;")
214+ .replace('>', "&gt;")
215+}
Modified file: src/game/outcome.rs+14 additions
@@ -46,6 +46,20 @@ impl Outcome {
4646 self.status
4747 }
4848
49+ pub fn body(&self) -> Option<&Value> {
50+ match &self.payload {
51+ Payload::Json(body) => Some(body),
52+ _ => None,
53+ }
54+ }
55+
56+ pub fn header_value(&self, name: &HeaderName) -> Option<&str> {
57+ self.headers
58+ .iter()
59+ .find(|(n, _)| n == name)
60+ .map(|(_, v)| v.as_str())
61+ }
62+
4963 pub(super) fn retold(mut self, status: StatusCode, detail: &str) -> Self {
5064 self.status = status;
5165 if let Payload::Json(body) = &mut self.payload {
Modified file: src/problem.rs+15 additions−10 deletions
@@ -38,6 +38,20 @@ impl Problem {
3838 self
3939 }
4040
41+ pub fn document(&self) -> Map<String, Value> {
42+ let problem = &self.0;
43+ let mut body = Map::new();
44+ body.insert(
45+ "type".into(),
46+ json!(format!("{PROBLEM_BASE}{}", problem.slug)),
47+ );
48+ body.insert("title".into(), json!(reason(problem.status)));
49+ body.insert("status".into(), json!(problem.status.as_u16()));
50+ body.insert("detail".into(), json!(problem.detail));
51+ body.extend(problem.extensions.clone());
52+ body
53+ }
54+
4155 pub fn header(mut self, name: HeaderName, value: impl AsRef<str>) -> Self {
4256 if let Ok(value) = HeaderValue::from_str(value.as_ref()) {
4357 self.0.headers.append(name, value);
@@ -57,17 +71,8 @@ pub fn reason(status: StatusCode) -> &'static str {
5771
5872 impl IntoResponse for Problem {
5973 fn into_response(self) -> Response {
74+ let body = self.document();
6075 let problem = *self.0;
61- let mut body = Map::new();
62- body.insert(
63- "type".into(),
64- json!(format!("{PROBLEM_BASE}{}", problem.slug)),
65- );
66- body.insert("title".into(), json!(reason(problem.status)));
67- body.insert("status".into(), json!(problem.status.as_u16()));
68- body.insert("detail".into(), json!(problem.detail));
69- body.extend(problem.extensions);
70-
7176 let mut response = (problem.status, Json(body)).into_response();
7277 let headers = response.headers_mut();
7378 headers.insert(
Modified file: src/tests/body.rs+31 additions
@@ -377,6 +377,37 @@ async fn a_combo_chafes_twice_and_costs_two_stamina() {
377377 assert_eq!(header(&combo, "x-stamina"), "8/10");
378378 }
379379
380+#[tokio::test]
381+async fn a_combo_tells_each_technique_in_full() {
382+ let mut told = 0;
383+ for seed in 0..40 {
384+ let app = app_with(Floor::Bedroom, seed, |run| run.body.eq = 60);
385+ let lube = [("content-type", "application/baby-oil")];
386+ let combo = send(
387+ &app,
388+ "JELQ",
389+ "/runs/cafebabe?technique=wet,stretch",
390+ &lube,
391+ "",
392+ )
393+ .await;
394+ if combo.status() != StatusCode::MULTI_STATUS {
395+ continue;
396+ }
397+ for report in text(combo).await.split("<D:response>").skip(1) {
398+ if report.contains("HTTP/1.1 204") {
399+ continue;
400+ }
401+ assert!(
402+ report.contains("<D:responsedescription>{\"") && report.contains("\"detail\""),
403+ "{report}"
404+ );
405+ told += 1;
406+ }
407+ }
408+ assert!(told > 0, "no combo told anything in forty runs");
409+}
410+
380411 #[test]
381412 fn the_drawer_starts_with_at_most_one_bottle() {
382413 let mut stocked = 0;