atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 7d3fb63208bf

tarball

atagen <boss@atagen.co>2026-10-11 04:40 UTC

13 files changed+82-22
game: your brother tells Mum and the shop opens a session

The flatmate was narratively incoherent in a house with Mum in it, so floor 2 now has a brother whose 208 doubles the odds Mum visits for the rest of the floor. Shop stock only advertised itself in the body, so the UI lost it every turn. A GET opens a shopping session that survives browsing and buying and ends at the next JELQ, with stock linked as shop-item rels. Mum dismisses herself with a Warning now that she believes you, the 303 says goodbye to the floor you left, and the excuse vocabulary has room to breathe.
Modified file: src/game/floors.rs+2 additions−1 deletions
@@ -60,10 +60,11 @@ impl Run {
6060 if self.chance(ZIP) {
6161 return Err(self.end(Cause::Zip));
6262 }
63+ let left = self.floor.name();
6364 let reverted = self.here.temp_length;
6465 self.next_floor(true);
6566 let mut body = json!({
66- "detail": format!("You have arrived at {}.", self.floor.name()),
67+ "detail": format!("You leave {left}. You have arrived at {}.", self.floor.name()),
6768 "note": self.floor.note(),
6869 });
6970 if reverted > 0.0 {
Modified file: src/game/hazards.rs+4 additions−4 deletions
@@ -109,14 +109,14 @@ impl Run {
109109 self.touch();
110110 if self.items.door_locks > 0 {
111111 self.items.door_locks -= 1;
112- self.notices.push("the flatmate tried the door");
112+ self.notices.push("your brother tried the door");
113113 return Ok(());
114114 }
115- self.here.flatmate_told = true;
115+ self.here.brother_told = true;
116116 Err(Problem::new(
117117 StatusCode::CONFLICT,
118- "flatmate",
119- "The flatmate needs the bathroom. He will mention this to people.",
118+ "brother",
119+ "Your brother needs the bathroom. He will tell Mum.",
120120 ))
121121 }
122122 Floor::ChangingRoom => {
Modified file: src/game/household.rs+19 additions−2 deletions
@@ -10,8 +10,23 @@ use super::{
1010 };
1111 use crate::problem::Problem;
1212
13-const EXCUSES: [&str; 3] = ["changing", "homework", "pulled a muscle"];
14-const CHRISTMAS_EXCUSES: [&str; 3] = ["on a call", "wrapping presents", "folding the washing"];
13+const EXCUSES: [&str; 7] = [
14+ "changing",
15+ "homework",
16+ "pulled a muscle",
17+ "tidying",
18+ "the washing up",
19+ "fed the cat",
20+ "revising",
21+];
22+const CHRISTMAS_EXCUSES: [&str; 6] = [
23+ "on a call",
24+ "wrapping presents",
25+ "folding the washing",
26+ "hanging the lights",
27+ "helping Nan",
28+ "peeling sprouts",
29+];
1530 const TRUTHS: [&str; 3] = ["the truth", "truth", "honesty"];
1631 const TRUE_WHEN_PULLED: &str = "pulled a muscle";
1732
@@ -206,6 +221,8 @@ impl Run {
206221 fn leave_door(&mut self) {
207222 self.mum = None;
208223 self.suspicion = 0;
224+ self.notices
225+ .push("Mum seems satisfied. She takes the washing with her.");
209226 self.touch();
210227 }
211228 }
Modified file: src/game/items.rs+1 additions−1 deletions
@@ -83,7 +83,7 @@ impl Item {
8383 }
8484 Self::IcePack => "Lowers EQ by 40. Use technique=ice.",
8585 Self::Ruler => "Measures honestly. The length resource now carries a Content-Length.",
86- Self::DoorLock => "Keeps the flatmate out once.",
86+ Self::DoorLock => "Keeps your brother out once.",
8787 Self::DndSign => "Works on housekeeping. Mum does not read signs.",
8888 Self::Blinds => "The neighbour has a good camera. The blinds are better.",
8989 Self::Pump => "A pump.",
Modified file: src/game/mod.rs+1 additions−1 deletions
@@ -15,7 +15,7 @@ mod utility;
1515
1616 pub use body::PatchOp;
1717 pub use forum::{ELDER_LAST_MODIFIED, FORUM_THREAD, forum_thread};
18-pub use items::{Item, Items};
18+pub use items::{Item, Items, SHOP};
1919 pub use outcome::{Outcome, Payload};
2020 pub use state::{
2121 Cause, Clinic, Conditions, Elder, Ending, Floor, FloorState, Grip, MumVisit, Observer, Story,
Modified file: src/game/state.rs+3 additions−2 deletions
@@ -46,7 +46,7 @@ impl Floor {
4646 pub fn note(self) -> &'static str {
4747 match self {
4848 Self::Bedroom => "The door does not lock. The cat is present.",
49- Self::Bathroom => "The water is hot. The flatmate showers at eight.",
49+ Self::Bathroom => "The water is hot. Your brother showers at eight.",
5050 Self::ChangingRoom => {
5151 "It is cold. Discretion is advised. Everyone here is lying. Pump-Bro is by the lockers."
5252 }
@@ -136,7 +136,8 @@ pub struct FloorState {
136136 pub temp_length: f64,
137137 pub teapot: bool,
138138 pub towel_stolen: bool,
139- pub flatmate_told: bool,
139+ pub brother_told: bool,
140+ pub shopping: bool,
140141 pub wifi_accepted: bool,
141142 }
142143
Modified file: src/game/turn.rs+8 additions−4 deletions
@@ -307,6 +307,7 @@ impl Run {
307307 self.touch();
308308 self.turn += 1;
309309 self.here.turns += 1;
310+ self.here.shopping = false;
310311 self.realise();
311312 if self.items.extender {
312313 self.length += EXTENDER_GAIN;
@@ -369,7 +370,10 @@ impl Run {
369370 return;
370371 }
371372 let summoned = was_below && self.suspicion == SUSPICION_LIMIT;
372- let visits = u32::from(self.suspicion) + 1;
373+ let mut visits = u32::from(self.suspicion) + 1;
374+ if self.here.brother_told {
375+ visits *= 2;
376+ }
373377 if summoned || self.rng.random_ratio(visits, super::balance::MUM_VISIT) {
374378 let errand = self.errand(summoned);
375379 let nonce = self.fresh_nonce();
@@ -533,13 +537,13 @@ impl Run {
533537
534538 fn gossip(&mut self, mut outcome: Outcome) -> Outcome {
535539 let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206);
536- if !self.here.flatmate_told || !reportable {
540+ if !self.here.brother_told || !reportable {
537541 return outcome;
538542 }
539- self.here.flatmate_told = false;
543+ self.here.brother_told = false;
540544 outcome.status = StatusCode::ALREADY_REPORTED;
541545 if let Payload::Json(body) = &mut outcome.payload {
542- body["detail"] = json!("The flatmate has already told everyone.");
546+ body["detail"] = json!("Your brother has already told Mum.");
543547 }
544548 outcome
545549 }
Modified file: src/game/utility.rs+1 additions−1 deletions
@@ -33,7 +33,7 @@ impl Run {
3333 let (boost, detail) = if self.floor == Floor::Bathroom {
3434 (
3535 SHOWER_EQ,
36- "Warmed up. The shower is hot. The flatmate's will not be.",
36+ "Warmed up. The shower is hot. Your brother's will not be.",
3737 )
3838 } else if self.here.towel_stolen {
3939 (TOWELLESS_EQ, "Warmed up, poorly. The cat has the towel.")
Modified file: src/http/run.rs+14 additions−1 deletions
@@ -11,7 +11,7 @@ use tokio::io::AsyncWriteExt;
1111
1212 use super::{AppState, ctx::Ctx, meta, middleware::Compress, resource::Resource, runs};
1313 use crate::{
14- game::{Elder, FORUM_THREAD, Floor, Run, Story, balance::SUSPICION_LIMIT},
14+ game::{Elder, FORUM_THREAD, Floor, Run, SHOP, Story, balance::SUSPICION_LIMIT},
1515 headers::{
1616 X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_LENGTH, X_OBSERVED_BY,
1717 X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA, X_SUSPICION,
@@ -156,6 +156,19 @@ fn links(run: &Run, now: Instant) -> String {
156156 run.path("")
157157 )
158158 }))
159+ .chain(run.here.shopping.then(|| {
160+ SHOP.iter()
161+ .filter_map(|item| {
162+ let price = item.price()?;
163+ Some(format!(
164+ "<{}>; rel=\"{REL_BASE}shop-item\"; title=\"{} for {price}\"; allow=\"POST\"",
165+ run.path(&format!("/shop/{}", item.slug())),
166+ item.slug()
167+ ))
168+ })
169+ .collect::<Vec<_>>()
170+ .join(", ")
171+ }))
159172 .collect::<Vec<_>>()
160173 .join(", ")
161174 }
Modified file: src/http/runs.rs+5 additions−1 deletions
@@ -450,7 +450,11 @@ fn leave_floor(run: &mut Run, _: &Ctx) -> Response {
450450 }
451451
452452 fn get_shop(run: &mut Run, _: &Ctx) -> Response {
453- respond(run.shop_view().map(Json))
453+ let view = run.shop_view();
454+ if view.is_ok() {
455+ run.here.shopping = true;
456+ }
457+ respond(view.map(Json))
454458 }
455459
456460 fn buy(run: &mut Run, ctx: &Ctx) -> Response {
Modified file: src/locale.rs+1 additions−2 deletions
@@ -25,8 +25,8 @@ const EN_US: &[(&str, &str)] = &[
2525 ("folding the washing", "folding laundry"),
2626 ("your washing", "your laundry"),
2727 ("changing room", "locker room"),
28- ("flatmate", "roommate"),
2928 ("authorisation", "authorization"),
29+ ("revising", "studying"),
3030 ("neighbour", "neighbor"),
3131 ("cupboard", "pantry"),
3232 ("Christmas", "Thanksgiving"),
@@ -46,7 +46,6 @@ const EN_AU: &[(&str, &str)] = &[
4646 ),
4747 ("wrapping presents", "getting ice from the servo"),
4848 ("changing room", "change rooms"),
49- ("flatmate", "housemate"),
5049 ("cupboard", "pantry"),
5150 ("zip", "fly"),
5251 ("£", "$"),
Modified file: src/tests.rs+13 additions−1 deletions
@@ -931,6 +931,18 @@ async fn a_door_lock_says_when_it_was_used() {
931931 .headers()
932932 .get_all("warning")
933933 .iter()
934- .any(|w| w.to_str().unwrap().contains("the flatmate tried the door"))
934+ .any(|w| w.to_str().unwrap().contains("your brother tried the door"))
935935 );
936936 }
937+
938+#[tokio::test]
939+async fn shopping_survives_browsing_but_not_jelqing() {
940+ let app = seeded_with(Floor::ChangingRoom, |run| run.cash = 50);
941+ send(&app, "GET", "/runs/cafebabe/shop", &[], "").await;
942+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
943+ assert!(header(&view, "link").contains("shop-item"));
944+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
945+ assert_ne!(jelq.status(), StatusCode::METHOD_NOT_ALLOWED);
946+ let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
947+ assert!(!header(&after, "link").contains("shop-item"));
948+}
Modified file: src/ui.html+10 additions−1 deletions
@@ -25,6 +25,13 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
2525 <form id="form">
2626 <h3>Method</h3>
2727 <div id="methods"></div>
28+<datalist id="excuses">
29+<option value="changing"><option value="homework"><option value="pulled a muscle">
30+<option value="tidying"><option value="the washing up"><option value="fed the cat">
31+<option value="revising"><option value="on a call"><option value="wrapping presents">
32+<option value="folding the washing"><option value="hanging the lights">
33+<option value="helping Nan"><option value="peeling sprouts"><option value="the truth">
34+</datalist>
2835
2936 <h3>Request</h3>
3037 <table id="fields" cellpadding="2"></table>
@@ -94,12 +101,13 @@ const BY_TARGET = {
94101 };
95102 const GROUPS = [
96103 ["play", "Play"],
104+ ["shop", "Shop"],
97105 ["you", "You"],
98106 ["said", "From the last response"],
99107 ["reading", "Reading"],
100108 ["meta", "Meta"],
101109 ];
102-const GROUP_OF = { run: "play", floor: "play", drawer: "play", shop: "play", portal: "play", elder: "play", diagnosis: "play", length: "you", foreskin: "you", grip: "you", inventory: "you", footage: "you", techniques: "reading", thread: "reading" };
110+const GROUP_OF = { run: "play", floor: "play", drawer: "play", shop: "play", "shop-item": "shop", kitchen: "play", portal: "play", elder: "play", diagnosis: "play", length: "you", foreskin: "you", grip: "you", inventory: "you", footage: "you", techniques: "reading", thread: "reading" };
103111 const NAMES = { run: "the run", thread: "the 2009 forum thread" };
104112 const GLOBALS = [
105113 { key: "runs", group: "play", name: "start a new run", path: "/runs", methods: ["POST"] },
@@ -289,6 +297,7 @@ function renderFields() {
289297 }
290298 input.oninput = () => { values[name] = input.value; };
291299 input.onchange = () => { values[name] = input.value; if (choices) renderFields(); };
300+ if (name === "authorization") input.setAttribute("list", "excuses");
292301 table.append(el("tr", {}, el("td", { align: "right", valign: "top", textContent: f.label }), el("td", {}, input)));
293302 }
294303 const extra = el("textarea", { rows: 3, cols: 40, value: raw("extra"), placeholder: FIELDS.extra.placeholder });