atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 880b29a0c11e

tarball

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

15 files changed+158-44
game: make the forum teach breathing and leave the drawer to chance

Meditation was a starting technique since b85a0d45, which left the forum branch nothing to teach before uli and made every run's cheapest EQ dump free from turn one. It is now learned at 1 in 2 on the forum choice and each live re-read, ahead of uli. Every run used to start with baby oil and lotion, now half start with a single bottle of either.
Modified file: src/game/balance/economy.rs+2 additions
@@ -1,5 +1,7 @@
11 pub const STARTING_CASH: u32 = 20;
22 pub const STARTING_ICE_PACKS: u32 = 1;
3+pub const STARTING_LUBE: u32 = 2;
4+pub const STARTING_BABY_OIL: u32 = 2;
35 pub const FLOOR_ALLOWANCE: u32 = 10;
46 pub const THIN_MARKUP: u32 = 3;
57 /// The footage gains this many views a second, starting from one.
Modified file: src/game/balance/floors.rs+2 additions−1 deletions
@@ -4,8 +4,9 @@ pub const ZIP: u32 = 200;
44 pub const RULER_IN_DRAWER: u32 = 2;
55 pub const KEY_IN_DRAWER: u32 = 3;
66 pub const GREGS_LOCKER: u32 = 20;
7+pub const MEDITATION_ODDS: u32 = 2;
78 pub const ULI_ODDS: u32 = 4;
8-pub const ULI_READS: u8 = 2;
9+pub const THREAD_READS: u8 = 2;
910 pub const DRAWER_JAMS: u32 = 4;
1011 pub const OBSERVED: u32 = 6;
1112 pub const CAT_TAKES_TOWEL: u32 = 5;
Modified file: src/game/checks.rs+3 additions−1 deletions
@@ -107,7 +107,9 @@ impl Run {
107107 }
108108 Technique::Utility(Utility::Ice) => self.items.stores.ice_packs > 0,
109109 Technique::Utility(Utility::Supplement) => self.items.stores.supplements > 0,
110- Technique::Utility(Utility::Meditate) => !self.here.meditated,
110+ Technique::Utility(Utility::Meditate) => {
111+ self.online.meditation_taught && !self.here.meditated
112+ }
111113 Technique::Utility(Utility::Energy) => self.items.stores.energy_drinks > 0,
112114 _ => self.access(technique).is_ok() && self.check_storage().is_ok(),
113115 })
Modified file: src/game/forum.rs+39 additions−17 deletions
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
55
66 use super::{
77 Cause, Floor, Outcome, Run,
8- balance::{EXAGGERATION, PATIENCE, ULI_ODDS, ULI_READS},
8+ balance::{EXAGGERATION, MEDITATION_ODDS, PATIENCE, THREAD_READS, ULI_ODDS},
99 credentials::{Scheme, parse_credentials},
1010 measure::{exaggeration, honest},
1111 };
@@ -105,6 +105,7 @@ pub enum Elder {
105105 /// The run's life online, from the tab on turn 10 to the footage the neighbour took.
106106 pub struct Online {
107107 pub(crate) story: Story,
108+ pub(crate) meditation_taught: bool,
108109 pub(crate) uli_taught: bool,
109110 pub(crate) elder: Elder,
110111 pub(crate) filmed_at: Option<SystemTime>,
@@ -114,6 +115,7 @@ impl Default for Online {
114115 fn default() -> Self {
115116 Self {
116117 story: Story::Unopened,
118+ meditation_taught: false,
117119 uli_taught: false,
118120 elder: Elder::Unverified { strikes: 0 },
119121 filmed_at: None,
@@ -154,8 +156,11 @@ impl Run {
154156 let (story, location, detail) = match choice {
155157 Branch::Forum => {
156158 self.standing.shame += 3;
157- let detail =
158- "The thread is from 2009. A technique called uli is discussed at length.";
159+ let detail = if self.learn_meditation() {
160+ "The thread is from 2009. A technique called uli is discussed at length. Further down, someone explains how to breathe, and you write it down."
161+ } else {
162+ "The thread is from 2009. A technique called uli is discussed at length."
163+ };
159164 (Story::ReadForum, self.path("/techniques"), detail)
160165 }
161166 Branch::CloseTab => {
@@ -192,7 +197,7 @@ impl Run {
192197 if self.online.uli_taught {
193198 return Ok(Outcome::json(StatusCode::OK, forum_thread()));
194199 }
195- if self.here.thread_rolls >= ULI_READS {
200+ if self.here.thread_rolls >= THREAD_READS {
196201 let age = SystemTime::now()
197202 .duration_since(*ELDER_POSTED)
198203 .map_or(0, |age| age.as_secs());
@@ -202,21 +207,38 @@ impl Run {
202207 }
203208 self.here.thread_rolls += 1;
204209 self.touch();
205- if self.chance(
210+ let detail = if !self.online.meditation_taught {
211+ if !self.learn_meditation() {
212+ return Ok(nothing_new());
213+ }
214+ "Between two arguments about pumps, a reply explains how to breathe. In through the nose, out through the mouth. You write it down."
215+ } else {
216+ if !self.chance(
217+ self.decision(
218+ "teach-uli",
219+ "Does this reread finally surface the 2009 reply that teaches uli?",
220+ ),
221+ ULI_ODDS,
222+ ) {
223+ return Ok(nothing_new());
224+ }
225+ self.online.uli_taught = true;
226+ "Past the necroposting, a reply from TheElder lays out the technique. Uli. The full routine. You write it down before the tab can lose it."
227+ };
228+ Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail }))
229+ .header(header::LOCATION, self.path("/techniques")))
230+ }
231+
232+ fn learn_meditation(&mut self) -> bool {
233+ let taught = self.chance(
206234 self.decision(
207- "teach-uli",
208- "Does this reread finally surface the 2009 reply that teaches uli?",
235+ "teach-meditation",
236+ "Does the thread surface the reply that teaches the breathing technique?",
209237 ),
210- ULI_ODDS,
211- ) {
212- self.online.uli_taught = true;
213- return Ok(Outcome::json(
214- StatusCode::OK,
215- json!({ "detail": "Past the necroposting, a reply from TheElder lays out the technique. Uli. The full routine. You write it down before the tab can lose it." }),
216- )
217- .header(header::LOCATION, self.path("/techniques")));
218- }
219- Ok(nothing_new())
238+ MEDITATION_ODDS,
239+ );
240+ self.online.meditation_taught |= taught;
241+ taught
220242 }
221243
222244 fn elder_strike(&mut self, problem: Problem) -> Problem {
Modified file: src/game/gauges.rs+8 additions−3 deletions
@@ -3,8 +3,9 @@ use std::time::Instant;
33 use serde_json::{Value, json};
44
55 use super::{
6- Elder, Excuse, Floor, Grip, Item, Observer, Run, Story,
6+ Elder, Excuse, Floor, Grip, Item, Observer, Run, Story, Technique,
77 balance::{DANGER_EQ, KITCHEN_TELLINGS},
8+ techniques::{Training, Utility},
89 };
910
1011 pub enum Shown {
@@ -143,8 +144,12 @@ impl Run {
143144 self.body.grip
144145 }
145146
146- pub fn knows_uli(&self) -> bool {
147- self.online.uli_taught
147+ pub fn knows(&self, technique: Technique) -> bool {
148+ match technique {
149+ Technique::Training(Training::Uli) => self.online.uli_taught,
150+ Technique::Utility(Utility::Meditate) => self.online.meditation_taught,
151+ _ => true,
152+ }
148153 }
149154
150155 pub fn holds(&self, item: Item) -> bool {
Modified file: src/game/inventory.rs+1 additions−11 deletions
@@ -38,6 +38,7 @@ impl Default for Stores {
3838 }
3939 }
4040
41+#[derive(Default)]
4142 pub struct LubeStock {
4243 pub(crate) baby_oil: u32,
4344 pub(crate) coconut_oil: u32,
@@ -45,17 +46,6 @@ pub struct LubeStock {
4546 pub(crate) vaseline: u32,
4647 }
4748
48-impl Default for LubeStock {
49- fn default() -> Self {
50- Self {
51- baby_oil: 1,
52- coconut_oil: 0,
53- lotion: 1,
54- vaseline: 0,
55- }
56- }
57-}
58-
5949 impl LubeStock {
6050 /// Spit is the only lubricant that never runs out, so it has no count.
6151 fn bottle(&mut self, lube: Lube) -> Option<&mut u32> {
Modified file: src/game/rest.rs+7 additions
@@ -65,6 +65,13 @@ impl Run {
6565 /// The breathing technique from the forum's replies. It spends no stamina and the turn is
6666 /// not the floor's, but each floor allows it once.
6767 pub(super) fn meditate(&mut self) -> Result<Outcome, Problem> {
68+ if !self.online.meditation_taught {
69+ return Err(Problem::new(
70+ StatusCode::FORBIDDEN,
71+ "technique-locked",
72+ "Somebody on a forum knows how to breathe. You have not found the reply.",
73+ ));
74+ }
6875 if self.here.meditated {
6976 return Err(Problem::new(
7077 StatusCode::CONFLICT,
Modified file: src/game/run.rs+26 additions−4 deletions
@@ -7,10 +7,11 @@ use axum::http::StatusCode;
77 use serde_json::{Value, json};
88
99 use super::{
10- Body, Cause, Clinic, Ending, Floor, FloorState, Household, Items, Notice, Observer, Online,
11- RunStatus, Standing, Story,
10+ Body, Cause, Clinic, Ending, Floor, FloorState, Household, Item, Items, Notice, Observer,
11+ Online, RunStatus, Standing, Story,
1212 balance::{
13- BRANCH_TURN, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION, STARTING_EQ,
13+ BRANCH_TURN, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
14+ STARTING_BABY_OIL, STARTING_EQ, STARTING_LUBE,
1415 },
1516 measure::round2,
1617 oracle::{Oracle, OracleTemplate, Roll},
@@ -73,6 +74,27 @@ impl Run {
7374 0.4,
7475 2.4,
7576 ));
77+ let mut items = Items::default();
78+ if oracle.chance(
79+ Run::unborn(
80+ "starting-lube",
81+ "Is there a bottle of lube in the drawer already?",
82+ ),
83+ STARTING_LUBE,
84+ ) {
85+ let baby_oil = oracle.chance(
86+ Run::unborn(
87+ "starting-bottle",
88+ "Is the bottle baby oil, rather than lotion?",
89+ ),
90+ STARTING_BABY_OIL,
91+ );
92+ items.grant(if baby_oil {
93+ Item::BabyOil
94+ } else {
95+ Item::Lotion
96+ });
97+ }
7698 let etag_salt = oracle.u64();
7799 Self {
78100 ident: Identity {
@@ -87,7 +109,7 @@ impl Run {
87109 standing: Standing::default(),
88110 household: Household::default(),
89111 online: Online::default(),
90- items: Items::default(),
112+ items,
91113 clinic: Clinic::default(),
92114 turn: 0,
93115 floor: Floor::Bedroom,
Modified file: src/game/techniques.rs+7 additions−3 deletions
@@ -146,10 +146,14 @@ impl Technique {
146146 }
147147 }
148148
149- pub fn describe(self, uli_taught: bool) -> Value {
149+ pub fn describe(self, known: bool) -> Value {
150150 let training = match self {
151151 Self::Utility(utility) => {
152- return json!({ "name": utility.name(), "effect": utility.effect() });
152+ return json!({
153+ "name": utility.name(),
154+ "effect": utility.effect(),
155+ "locked": !known,
156+ });
153157 }
154158 Self::Training(training) => training,
155159 };
@@ -160,7 +164,7 @@ impl Technique {
160164 "dc": profile.dc,
161165 "lubricant": lubricant,
162166 "foreskin": profile.foreskin.map(|(min, max)| json!({ "min": min, "max": max })),
163- "locked": training == Training::Uli && !uli_taught,
167+ "locked": !known,
164168 })
165169 }
166170 }
Modified file: src/http/runs.rs+1 additions−1 deletions
@@ -177,7 +177,7 @@ fn delete_foreskin(run: &mut Run, ctx: &Ctx) -> Response {
177177 fn get_techniques(run: &mut Run, _: &Ctx) -> Response {
178178 let listed: Vec<Value> = Technique::ALL
179179 .into_iter()
180- .map(|t| t.describe(run.knows_uli()))
180+ .map(|t| t.describe(run.knows(t)))
181181 .collect();
182182 Json(json!({
183183 "usage": format!("JELQ {}?technique=<name>[,<name>]&intensity=<1-10>", run.path("")),
Modified file: src/tests/body.rs+17 additions
@@ -102,6 +102,7 @@ async fn the_displayed_length_is_accepted_as_honest() {
102102 async fn dry_jelqs_build_friction_and_real_lube_does_not() {
103103 let app = seeded_with(Floor::Bedroom, |run| {
104104 run.body.warmed_up = true;
105+ run.items.lubes.baby_oil = 1;
105106 run.body.foreskin = Some(0.9);
106107 });
107108 let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
@@ -163,6 +164,7 @@ async fn holding_and_meditating_shed_friction() {
163164
164165 let taught = seeded_with(Floor::Bedroom, |run| {
165166 run.body.friction = 5;
167+ run.online.meditation_taught = true;
166168 });
167169 let calmed = send(
168170 &taught,
@@ -398,3 +400,18 @@ async fn a_combo_chafes_twice_and_costs_two_stamina() {
398400 assert_eq!(header(&combo, "x-friction"), "2/10");
399401 assert_eq!(header(&combo, "x-stamina"), "8/10");
400402 }
403+
404+#[test]
405+fn the_drawer_starts_with_at_most_one_bottle() {
406+ let mut stocked = 0;
407+ for seed in 0..400u64 {
408+ let lubes = &Run::new("cafebabe".into(), "cafe".into(), seed).items.lubes;
409+ let bottles = lubes.baby_oil + lubes.lotion;
410+ assert!(bottles <= 1 && lubes.coconut_oil + lubes.vaseline == 0);
411+ stocked += bottles;
412+ }
413+ assert!(
414+ (160..=240).contains(&stocked),
415+ "{stocked}/400 started with lube"
416+ );
417+}
Modified file: src/tests/floors.rs+41 additions
@@ -198,6 +198,7 @@ async fn rereading_the_thread_teaches_uli() {
198198 run.turn = BRANCH_TURN;
199199 run.body.warmed_up = true;
200200 run.online.story = Story::ClosedTab;
201+ run.online.meditation_taught = true;
201202 });
202203 let mut taught = false;
203204 for _ in 0..2 {
@@ -454,3 +455,43 @@ async fn the_foreskin_window_has_hard_edges() {
454455 }
455456 }
456457 }
458+
459+#[tokio::test]
460+async fn the_forum_teaches_breathing_before_uli() {
461+ let mut breathing = 0;
462+ for seed in 0..100u64 {
463+ let app = app_with(Floor::Bedroom, seed, |run| {
464+ run.turn = BRANCH_TURN;
465+ run.body.warmed_up = true;
466+ run.online.story = Story::Pending;
467+ });
468+ let listed = json(send(&app, "GET", "/runs/cafebabe/techniques", &[], "").await).await;
469+ let meditate = listed["techniques"]
470+ .as_array()
471+ .unwrap()
472+ .iter()
473+ .find(|t| t["name"] == "meditate")
474+ .unwrap();
475+ assert_eq!(meditate["locked"], true);
476+ send(&app, "POST", "/runs/cafebabe/branches/forum", &[], "").await;
477+ for _ in 0..2 {
478+ send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
479+ }
480+ let meditated = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
481+ if meditated.status() == StatusCode::OK {
482+ breathing += 1;
483+ } else {
484+ assert_eq!(meditated.status(), StatusCode::FORBIDDEN);
485+ let uli = send(&app, "JELQ", "/runs/cafebabe?technique=uli", &[], "").await;
486+ assert_eq!(
487+ uli.status(),
488+ StatusCode::FORBIDDEN,
489+ "seed {seed} skipped ahead"
490+ );
491+ }
492+ }
493+ assert!(
494+ (75..=97).contains(&breathing),
495+ "{breathing}/100 learned to breathe on the branch floor"
496+ );
497+}
Modified file: src/tests/methods.rs+1 additions−1 deletions
@@ -119,7 +119,7 @@ async fn http_1_0_gets_the_pdf_treatment() {
119119 async fn links_offer_only_usable_techniques() {
120120 let app = seeded_with(Floor::Bedroom, |run| run.body.foreskin = Some(1.0));
121121 let cold = send(&app, "GET", "/runs/cafebabe", &[], "").await;
122- assert_eq!(advertised(&cold), ["warmup", "ice", "meditate"]);
122+ assert_eq!(advertised(&cold), ["warmup", "ice"]);
123123 let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
124124 let usable = advertised(&warm);
125125 assert!(usable.contains(&"wet".to_owned()) && usable.contains(&"dry".to_owned()));
Modified file: src/tests/mod.rs+1 additions−1 deletions
@@ -26,7 +26,7 @@ pub use crate::{
2626 router,
2727 };
2828
29-pub const SEED: u64 = 69;
29+pub const SEED: u64 = 420;
3030
3131 pub async fn send(
3232 app: &Router,
Modified file: src/tests/turns.rs+2 additions−1 deletions
@@ -2,7 +2,7 @@ use super::*;
22
33 #[tokio::test]
44 async fn meditation_is_free_but_only_once_per_floor() {
5- let app = seeded_with(Floor::Bedroom, |_| {});
5+ let app = seeded_with(Floor::Bedroom, |run| run.online.meditation_taught = true);
66 let response = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
77 assert_eq!(response.status(), StatusCode::OK);
88 assert_eq!(header(&response, "x-eq"), "10");
@@ -16,6 +16,7 @@ async fn meditation_respects_the_priapism_floor() {
1616 let app = seeded_with(Floor::Bedroom, |run| {
1717 run.body.eq = 80;
1818 run.body.conditions.priapism_turns = 3;
19+ run.online.meditation_taught = true;
1920 });
2021 let calmed = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
2122 assert_eq!(calmed.status(), StatusCode::OK);