atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit c4d70621d377

tarball

atagen <boss@atagen.co>2026-10-12 11:59 UTC

11 files changed+159-81
game: tie up the loose ends the last review found

A prescription left the run alive and unrecorded forever, so it now
finishes the run as prescribed while the original ETag can still reach the
true ending. Raids make noise and the telling and the melon both scale
with suspicion, so a lucky streak cannot outrun the household. American
players were refused excuses the server itself had localised, because
only two phrases were mapped back and the comparison was case-sensitive,
which also meant "helping Nan" could never be accepted anywhere.
Modified file: src/game/balance.rs+6 additions−2 deletions
@@ -81,10 +81,14 @@ pub const KITCHEN_ICE_ODDS: u32 = 8;
8181 pub const KETTLE_ODDS: u32 = 6;
8282 pub const ULI_READS: u8 = 2;
8383 pub const DRAWER_JAMS: u32 = 4;
84-pub const KITCHEN_TOLD: u32 = 4;
84+/// A telling is suspicion + 1 in this many, so a quiet house tolerates a raid and a noisy one does not.
85+pub const KITCHEN_TOLD: u32 = 8;
86+pub const RAID_NOISE: i32 = 1;
87+pub const PATCH_EQ: i32 = 3;
8588 pub const KITCHEN_TELLINGS: u8 = 2;
8689 pub const WATERMELON: (u32, u32) = (2, 5);
87-pub const CAUGHT_MELONED: u32 = 3;
90+/// Caught with the melon is suspicion + 2 in this many.
91+pub const CAUGHT_MELONED: u32 = 9;
8892 pub const MELON_SHAME: u32 = 5;
8993 pub const ENERGY_STAMINA: i32 = 3;
9094 pub const FRICTION_MAX: i32 = 10;
Modified file: src/game/body.rs+4 additions−1 deletions
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
44
55 use super::{
66 Grip, Outcome, Run,
7- balance::{EXAGGERATION, GIRTH_RESPECTED, GIRTH_THICK, LOCKER_ROOM_BAR, PUT_GAIN},
7+ balance::{EXAGGERATION, GIRTH_RESPECTED, GIRTH_THICK, LOCKER_ROOM_BAR, PATCH_EQ, PUT_GAIN},
88 etag_matches, honest,
99 };
1010 use crate::problem::Problem;
@@ -36,7 +36,10 @@ impl Run {
3636 Outcome::json(StatusCode::OK, json!({ "detail": "Applied." }))
3737 }
3838
39+ /// Being measured is not arousing, so every PATCH costs a little EQ whatever it finds.
3940 pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {
41+ self.reset_eq(self.eq - PATCH_EQ);
42+ self.touch();
4043 for op in ops {
4144 if op.op != "test" {
4245 return Err(Problem::new(
Modified file: src/game/clinic.rs+3 additions
@@ -69,10 +69,13 @@ impl Run {
6969 "length;dur={:.2}, girth;dur={:.2}, foreskin;dur={foreskin:.2}, volume;desc=\"cubic inches\";dur={volume:.2}",
7070 self.length, self.girth
7171 );
72+ self.clinic.prescribed = true;
73+ self.touch();
7274 Ok(Outcome::json(
7375 StatusCode::OK,
7476 json!({
7577 "diagnosis": "within normal range",
78+ "detail": "Congratulations. You have been cured of a condition you never had. The run is over.",
7679 "measured_length": round2(self.length),
7780 "measured_girth": round2(self.girth),
7881 "volume": round2(volume),
Modified file: src/game/household.rs+12 additions−2 deletions
@@ -26,6 +26,11 @@ const CHRISTMAS_EXCUSES: [&str; 6] = [
2626 "helping Nan",
2727 "peeling sprouts",
2828 ];
29+/// Every excuse Mum might hear, so a localised one can be traced back to the phrase she checks.
30+pub fn vocabulary() -> impl Iterator<Item = &'static str> {
31+ EXCUSES.into_iter().chain(CHRISTMAS_EXCUSES)
32+}
33+
2934 const TRUTHS: [&str; 3] = ["the truth", "truth", "honesty"];
3035 const TRUE_WHEN_PULLED: &str = "pulled a muscle";
3136
@@ -75,7 +80,7 @@ impl Run {
7580 EXCUSES
7681 .into_iter()
7782 .chain(CHRISTMAS_EXCUSES)
78- .filter(|e| !self.heard.iter().any(|h| h == e))
83+ .filter(|e| !self.heard.iter().any(|h| h.eq_ignore_ascii_case(e)))
7984 .collect()
8085 }
8186
@@ -194,7 +199,12 @@ impl Run {
194199 "Mum has heard that one before. She would have preferred the truth.",
195200 ))));
196201 }
197- if true_now || self.excuses().contains(&excuse.as_str()) {
202+ if true_now
203+ || self
204+ .excuses()
205+ .iter()
206+ .any(|e| e.eq_ignore_ascii_case(&excuse))
207+ {
198208 self.heard.push(excuse);
199209 self.leave_door();
200210 return Ok(());
Modified file: src/game/kitchen.rs+42 additions−41 deletions
@@ -5,13 +5,13 @@ use super::{
55 Cause, Floor, Item, Outcome, Run, Story,
66 balance::{
77 CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KETTLE_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TELLINGS,
8- KITCHEN_TOLD, MELON_SHAME, RUPTURE_LINE, SUSPICION_LIMIT, WATERMELON,
8+ KITCHEN_TOLD, MELON_SHAME, RAID_NOISE, RUPTURE_LINE, SUSPICION_LIMIT, WATERMELON,
99 },
1010 };
1111 use crate::problem::Problem;
1212
1313 impl Run {
14- /// Raid the fridge. It costs the floor turn like anything else, and the household keeps count.
14+ /// The household keeps count, so every raid makes noise and the noise makes the next telling likelier.
1515 pub fn raid_kitchen(&mut self) -> Result<Outcome, Problem> {
1616 if !self.floor.has_kitchen() {
1717 return Err(Problem::new(
@@ -41,6 +41,7 @@ impl Run {
4141 return Err(self.rupture());
4242 }
4343 let was_below = self.suspicion < SUSPICION_LIMIT;
44+ self.make_noise(RAID_NOISE);
4445 // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5, 1 in 8 and 1 in 6.
4546 let loot = self.oracle.pick(
4647 self.decision("loot", "What does the fridge give up on this raid?"),
@@ -62,7 +63,7 @@ impl Run {
6263 "Nothing. The fridge is ordinary.",
6364 ],
6465 );
65- let outcome = if self.eq > RUPTURE_LINE
66+ let melon = self.eq > RUPTURE_LINE
6667 && self.oracle.ratio(
6768 self.decision(
6869 "watermelon",
@@ -70,43 +71,14 @@ impl Run {
7071 ),
7172 WATERMELON.0,
7273 WATERMELON.1,
73- ) {
74- self.here.melon_offered = true;
75- self.melon_offer()
76- } else if loot == 0 {
77- self.items.grant(Item::CoconutOil);
78- Ok(Outcome::json(
79- StatusCode::OK,
80- json!({ "detail": "A jar of coconut oil, half of it already gone." }),
81- ))
82- } else if loot == 1 {
83- self.items.grant(Item::EnergyDrink);
84- Ok(Outcome::json(
85- StatusCode::OK,
86- json!({ "detail": "An energy drink, cold and faintly alarming." }),
87- ))
88- } else if loot == 2 {
89- self.items.grant(Item::IcePack);
90- Ok(Outcome::json(
91- StatusCode::OK,
92- json!({ "detail": "An ice pack, buried under the peas." }),
93- ))
94- } else if loot == 3 {
95- self.here.kettle_boiled = true;
96- Ok(Outcome::json(
97- StatusCode::OK,
98- json!({ "detail": "The kettle has just boiled. The towel can be warmed once more." }),
99- ))
100- } else {
101- Ok(Outcome::empty(StatusCode::NO_CONTENT))
102- };
74+ );
10375 self.settle(was_below);
104- let outcome = outcome?;
105- if self.chance(
76+ if self.oracle.ratio(
10677 self.decision(
10778 "kitchen-told",
10879 "Has the household had enough of the raiding?",
10980 ),
81+ u32::from(self.suspicion) + 1,
11082 KITCHEN_TOLD,
11183 ) {
11284 self.kitchen_tellings += 1;
@@ -124,12 +96,40 @@ impl Run {
12496 "Stop raiding the kitchen."
12597 });
12698 }
127- Ok(outcome)
99+ if melon {
100+ self.here.melon_offered = true;
101+ return Ok(self.melon_offer());
102+ }
103+ Ok(self.unpack(loot))
128104 }
129105
130- fn melon_offer(&self) -> Result<Outcome, Problem> {
106+ fn unpack(&mut self, loot: usize) -> Outcome {
107+ let (item, detail) = match loot {
108+ 0 => (
109+ Item::CoconutOil,
110+ "A jar of coconut oil, half of it already gone.",
111+ ),
112+ 1 => (
113+ Item::EnergyDrink,
114+ "An energy drink, cold and faintly alarming.",
115+ ),
116+ 2 => (Item::IcePack, "An ice pack, buried under the peas."),
117+ 3 => {
118+ self.here.kettle_boiled = true;
119+ return Outcome::json(
120+ StatusCode::OK,
121+ json!({ "detail": "The kettle has just boiled. The towel can be warmed once more." }),
122+ );
123+ }
124+ _ => return Outcome::empty(StatusCode::NO_CONTENT),
125+ };
126+ self.items.grant(item);
127+ Outcome::json(StatusCode::OK, json!({ "detail": detail }))
128+ }
129+
130+ fn melon_offer(&self) -> Outcome {
131131 let commit = self.path("/kitchen/watermelon");
132- Ok(Outcome::json(
132+ Outcome::json(
133133 StatusCode::MULTIPLE_CHOICES,
134134 json!({
135135 "detail": "The fridge holds a watermelon. It is exactly the right shape.",
@@ -144,10 +144,10 @@ impl Run {
144144 format!(
145145 "<{commit}>; rel=\"alternate\"; title=\"Commit to the watermelon\"; allow=\"POST\""
146146 ),
147- ))
147+ )
148148 }
149149
150- /// The watermelon. It empties EQ, keeps the gains, costs shame, and the household may notice.
150+ /// A bail-out that costs no turn. The more the household has already heard, the likelier Mum is in the doorway.
151151 pub fn commit_melon(&mut self) -> Result<Outcome, Problem> {
152152 if !self.here.melon_offered {
153153 return Err(Problem::new(
@@ -160,11 +160,12 @@ impl Run {
160160 self.reset_eq(0);
161161 self.shame += MELON_SHAME;
162162 self.touch();
163- if self.chance(
163+ if self.oracle.ratio(
164164 self.decision(
165165 "caught-meloned",
166166 "Is Mum standing in the doorway as you look up?",
167167 ),
168+ u32::from(self.suspicion) + 2,
168169 CAUGHT_MELONED,
169170 ) {
170171 return Err(self.end(Cause::Meloned));
Modified file: src/game/mod.rs+13 additions−7 deletions
@@ -17,10 +17,12 @@ mod utility;
1717
1818 pub use body::{PatchOp, volume};
1919 pub use forum::{ELDER_LAST_MODIFIED, FORUM_THREAD, forum_thread};
20+pub use household::vocabulary as excuse_vocabulary;
2021 pub use items::{Item, Items, SHOP};
2122 pub use outcome::{Outcome, Payload};
2223 pub use state::{
23- Cause, Clinic, Conditions, Elder, Ending, Floor, FloorState, Grip, MumVisit, Observer, Story,
24+ Cause, Clinic, Conditions, Elder, Ending, Floor, FloorState, Grip, MumVisit, Observer,
25+ RunStatus, Story,
2426 };
2527 pub use techniques::{LUBRICANTS, Lube, Technique};
2628
@@ -351,11 +353,15 @@ impl Run {
351353 Some((filmed, age))
352354 }
353355
354- pub fn status(&self) -> &'static str {
355- match (&self.ended, self.clinic.cured) {
356- (Some(_), _) => "gone",
357- (None, true) => "normal",
358- (None, false) => "alive",
356+ pub fn status(&self) -> RunStatus {
357+ if self.ended.is_some() {
358+ RunStatus::Gone
359+ } else if self.clinic.cured {
360+ RunStatus::Normal
361+ } else if self.clinic.prescribed {
362+ RunStatus::Prescribed
363+ } else {
364+ RunStatus::Alive
359365 }
360366 }
361367
@@ -374,7 +380,7 @@ impl Run {
374380 .collect();
375381 json!({
376382 "public_id": self.public_id,
377- "status": self.status(),
383+ "status": self.status().name(),
378384 "turn": self.turn,
379385 "run_shame": self.shame,
380386 "grip": self.grip.name(),
Modified file: src/game/state.rs+25 additions
@@ -177,6 +177,7 @@ pub enum Elder {
177177 pub struct Clinic {
178178 pub intake_attempts: usize,
179179 pub measured: bool,
180+ pub prescribed: bool,
180181 pub cured: bool,
181182 }
182183
@@ -257,6 +258,30 @@ impl Cause {
257258 }
258259 }
259260
261+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
262+pub enum RunStatus {
263+ Alive,
264+ Prescribed,
265+ /// The true ending, the clinic's `304`.
266+ Normal,
267+ Gone,
268+}
269+
270+impl RunStatus {
271+ pub fn name(self) -> &'static str {
272+ match self {
273+ Self::Alive => "alive",
274+ Self::Prescribed => "prescribed",
275+ Self::Normal => "normal",
276+ Self::Gone => "gone",
277+ }
278+ }
279+
280+ pub fn finished(self) -> bool {
281+ self != Self::Alive
282+ }
283+}
284+
260285 pub struct Ending {
261286 pub cause: Cause,
262287 pub turn: u32,
Modified file: src/game/turn.rs+27 additions−21 deletions
@@ -124,6 +124,7 @@ impl Run {
124124 self.floor_enemy()?;
125125 self.take_lube(lube)?;
126126 self.chafe(lube, intensity);
127+ self.chafe(lube, intensity);
127128 self.commit(intensity, lube, 2);
128129 for training in [a, b] {
129130 if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
@@ -397,22 +398,11 @@ impl Run {
397398 );
398399 self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);
399400
400- let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
401- let doubled = match self.floor {
402- Floor::Nans => true,
403- Floor::ChangingRoom => !self.holds_own_in_the_locker_room(),
404- _ => false,
405- };
406- if doubled {
407- suspicion *= 2;
408- }
401+ let was_below = self.suspicion < SUSPICION_LIMIT;
402+ self.make_noise(intensity / 5 + i32::from(lube == Some(Lube::Vaseline)));
409403 if self.floor == Floor::Nans && lube == Some(Lube::CoconutOil) {
410- suspicion += 2;
404+ self.add_suspicion(2);
411405 }
412- let was_below = self.suspicion < SUSPICION_LIMIT;
413- self.suspicion =
414- u8::try_from((i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)))
415- .unwrap_or(SUSPICION_LIMIT);
416406
417407 if intensity >= PULLED_MUSCLE_INTENSITY
418408 && !self.conditions.pulled_muscle
@@ -427,6 +417,21 @@ impl Run {
427417 self.settle(was_below);
428418 }
429419
420+ pub(super) fn make_noise(&mut self, noise: i32) {
421+ let doubled = match self.floor {
422+ Floor::Nans => true,
423+ Floor::ChangingRoom => !self.holds_own_in_the_locker_room(),
424+ _ => false,
425+ };
426+ self.add_suspicion(if doubled { noise * 2 } else { noise });
427+ }
428+
429+ fn add_suspicion(&mut self, amount: i32) {
430+ self.suspicion =
431+ u8::try_from((i32::from(self.suspicion) + amount).clamp(0, i32::from(SUSPICION_LIMIT)))
432+ .unwrap_or(SUSPICION_LIMIT);
433+ }
434+
430435 /// The part of a turn the house takes part in, whatever the action was.
431436 pub(super) fn settle(&mut self, was_below: bool) {
432437 self.maybe_summon_mum(was_below);
@@ -493,7 +498,6 @@ impl Run {
493498 self.touch();
494499 self.turn += 1;
495500 self.here.turns += 1;
496- self.step_away();
497501 self.realise();
498502 if self.oracle.chance_not(
499503 self.decision("rupture", "The pause is uneventful"),
@@ -506,10 +510,12 @@ impl Run {
506510 self.recover();
507511 self.shed(FRICTION_HOLD);
508512 self.settle(was_below);
509- return Ok(Outcome::json(
510- StatusCode::OK,
511- json!({ "detail": "The count went well past ten. Stamina is back. Everything else has gone quiet, and you are cold again." }),
512- ));
513+ let detail = if self.conditions.priapism_turns > 0 {
514+ "The count went well past ten. Stamina is back. It went down a little. Maybe. You hope."
515+ } else {
516+ "The count went well past ten. Stamina is back. Everything else has gone quiet, and you are cold again."
517+ };
518+ return Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })));
513519 }
514520 if self.chance(
515521 self.decision(
@@ -610,7 +616,6 @@ impl Run {
610616 _ => 0,
611617 };
612618 let arrival = if self.here.warm_arrival {
613- self.here.warm_arrival = false;
614619 ARRIVAL_BONUS
615620 } else {
616621 0
@@ -632,6 +637,7 @@ impl Run {
632637 if natural == 1 {
633638 return Err(timed(self.critical_failure(training, &profile)));
634639 }
640+ self.here.warm_arrival = false;
635641 if natural == 2 && self.eq > DANGER_EQ - burn {
636642 return Err(timed(self.rupture()));
637643 }
@@ -765,7 +771,7 @@ impl Run {
765771 }
766772
767773 fn gossip(&mut self, mut outcome: Outcome) -> Outcome {
768- let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206);
774+ let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206 | 308);
769775 if !self.here.brother_told || !reportable {
770776 return outcome;
771777 }
Modified file: src/http/run.rs+1 additions−1 deletions
@@ -70,7 +70,7 @@ pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {
7070 let response =
7171 gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx));
7272 let response = finish(run, &ctx, &state.config.secret, response);
73- if run.ended.is_some() || run.clinic.cured {
73+ if run.status().finished() {
7474 state.records.save(&Record::of(run));
7575 }
7676 let ended = run.ended.is_some();
Modified file: src/locale.rs+25 additions−5 deletions
@@ -52,8 +52,6 @@ const EN_AU: &[(&str, &str)] = &[
5252 ("£", "$"),
5353 ];
5454
55-const EXCUSES: &[&str] = &["wrapping presents", "folding the washing"];
56-
5755 fn bounded(text: &str, at: usize, len: usize) -> bool {
5856 let before = text[..at].chars().next_back();
5957 let after = text[at + len..].chars().next();
@@ -62,9 +60,19 @@ fn bounded(text: &str, at: usize, len: usize) -> bool {
6260 }
6361
6462 fn replace_words(text: &str, from: &str, to: &str) -> String {
63+ replace_matching(text, text, from, to)
64+}
65+
66+/// Players type excuses in whatever case they like. ASCII lowering keeps byte offsets intact.
67+fn replace_words_ignoring_case(text: &str, from: &str, to: &str) -> String {
68+ let lowered = text.to_ascii_lowercase();
69+ replace_matching(text, &lowered, &from.to_ascii_lowercase(), to)
70+}
71+
72+fn replace_matching(text: &str, haystack: &str, from: &str, to: &str) -> String {
6573 let mut out = String::with_capacity(text.len());
6674 let mut rest = 0;
67- for (at, _) in text.match_indices(from) {
75+ for (at, _) in haystack.match_indices(from) {
6876 if at < rest || !bounded(text, at, from.len()) {
6977 continue;
7078 }
@@ -133,8 +141,12 @@ impl Locale {
133141 /// Translates the excuses a player types back into the en-GB phrases the game checks against.
134142 pub fn canonicalise(self, text: &str) -> String {
135143 let local = |excuse: &str| self.localise(excuse);
136- EXCUSES.iter().fold(text.to_owned(), |text, excuse| {
137- replace_words(&text, &local(excuse), excuse)
144+ crate::game::excuse_vocabulary().fold(text.to_owned(), |text, excuse| {
145+ let localised = local(excuse);
146+ if localised == excuse {
147+ return text;
148+ }
149+ replace_words_ignoring_case(&text, &localised, excuse)
138150 })
139151 }
140152
@@ -191,5 +203,13 @@ mod tests {
191203 Locale::American.canonicalise("Digest username=\"carving the turkey\""),
192204 "Digest username=\"wrapping presents\""
193205 );
206+ assert_eq!(
207+ Locale::American.canonicalise("Excuse \"Studying\""),
208+ "Excuse \"revising\""
209+ );
210+ assert_eq!(
211+ Locale::American.canonicalise("Excuse \"helping grandma\""),
212+ "Excuse \"helping Nan\""
213+ );
194214 }
195215 }
Modified file: src/records.rs+1 additions−1 deletions
@@ -55,7 +55,7 @@ impl Record {
5555 id: run.id.clone(),
5656 public_id: run.public_id.clone(),
5757 seeded: run.seeded,
58- status: run.status().to_owned(),
58+ status: run.status().name().to_owned(),
5959 ending: run.ended.as_ref().map(|ending| Ending {
6060 cause: ending.cause.slug().to_owned(),
6161 detail: ending.cause.detail().to_owned(),