atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 0fdc47364c8c

tarball

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

8 files changed+152-22
game: the thread announces itself at turn ten and teaches meditation

The forum was invisible until floor 6 or a forum read, so a run could reach the clinic without ever learning that uli existed. The thread now rides the Link header from the branch turn, behind a Warning notice, and reading it rolls a coin for the meditate technique, a free once-per-floor drop of 10 EQ. The 403 for uli and the thread posts now carry a little of his lore.
Modified file: src/game/balance.rs+2 additions
@@ -58,6 +58,8 @@ pub const PULLED_MUSCLE: u32 = 12;
5858 pub const PULLED_MUSCLE_INTENSITY: i32 = 9;
5959 pub const RING_PRIAPISM: u32 = 10;
6060 pub const SUPPLEMENT_WORKS: u32 = 5;
61+pub const MEDITATION_ODDS: u32 = 2;
62+pub const MEDITATION_EQ: i32 = 10;
6163 pub const OBSERVED: u32 = 6;
6264 pub const CAT_TAKES_TOWEL: u32 = 5;
6365 pub const FRIEND: u32 = 10;
Modified file: src/game/forum.rs+12 additions−6 deletions
@@ -3,7 +3,7 @@ use serde_json::{Value, json};
33
44 use super::{
55 Cause, Elder, Floor, Outcome, Run, Story,
6- balance::{EXAGGERATION, PATIENCE},
6+ balance::{EXAGGERATION, MEDITATION_ODDS, PATIENCE},
77 body::exaggeration,
88 honest, parse_credentials,
99 };
@@ -27,6 +27,9 @@ pub fn forum_thread() -> Value {
2727 "posts": [
2828 { "author": "TheElder", "body": "Warm up. Always warm up. A cold jelq is a 412 waiting to happen." },
2929 { "author": "TheElder", "body": "Keep EQ between 50 and 70. Above 70 you are gambling. Above 90 you are a cautionary tale." },
30+ { "author": "newbie_2009", "body": "everyone keeps saying uli. who is uli" },
31+ { "author": "TheElder", "body": "uli was a member here. He posted the technique in 2001 and never posted again. Some say he ascended. The search function says his account is gone." },
32+ { "author": "TheElder", "body": "We do not say his name in vain. We say it in girth." },
3033 { "author": "newbie_2009", "body": "I have been sending PUT for three weeks and nothing has changed." },
3134 { "author": "TheElder", "body": "PUT is idempotent. Read the spec." },
3235 { "author": "TheElder", "body": "Members verify with a Ruler. Proxy-Authorization, inches, no rounding up. We can tell." },
@@ -78,11 +81,14 @@ impl Run {
7881 let (story, location, detail) = match choice {
7982 "forum" => {
8083 self.shame += 3;
81- (
82- Story::ReadForum,
83- self.path("/techniques"),
84- "The thread is from 2009. A technique called uli is discussed at length.",
85- )
84+ let taught = self.chance(MEDITATION_ODDS);
85+ self.meditation_taught |= taught;
86+ let detail = if taught {
87+ "The thread is from 2009. A technique called uli is discussed at length. Among the replies, a post teaches a way of breathing that lowers EQ."
88+ } else {
89+ "The thread is from 2009. A technique called uli is discussed at length."
90+ };
91+ (Story::ReadForum, self.path("/techniques"), detail)
8692 }
8793 "close-tab" => {
8894 self.suspicion = 0;
Modified file: src/game/mod.rs+2 additions
@@ -63,6 +63,7 @@ pub struct Run {
6363 pub warmed_up: bool,
6464 pub put_applied: bool,
6565 pub story: Story,
66+ pub meditation_taught: bool,
6667 pub mum: Option<MumVisit>,
6768 pub heard: Vec<String>,
6869 pub elder: Elder,
@@ -148,6 +149,7 @@ impl Run {
148149 warmed_up: false,
149150 put_applied: false,
150151 story: Story::Unopened,
152+ meditation_taught: false,
151153 mum: None,
152154 heard: Vec::new(),
153155 notices: Vec::new(),
Modified file: src/game/state.rs+1 additions
@@ -138,6 +138,7 @@ pub struct FloorState {
138138 pub towel_stolen: bool,
139139 pub brother_told: bool,
140140 pub shopping: bool,
141+ pub meditated: bool,
141142 pub wifi_accepted: bool,
142143 }
143144
Modified file: src/game/techniques.rs+5 additions−1 deletions
@@ -77,6 +77,7 @@ pub enum Utility {
7777 Edge,
7878 Pump,
7979 Supplement,
80+ Meditate,
8081 }
8182
8283 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -168,6 +169,7 @@ impl Utility {
168169 Self::Edge => "edge",
169170 Self::Pump => "pump",
170171 Self::Supplement => "supplement",
172+ Self::Meditate => "meditate",
171173 }
172174 }
173175
@@ -178,17 +180,19 @@ impl Utility {
178180 Self::Edge => "EQ +15 and a little girth. Three in a row is a loop.",
179181 Self::Pump => "Large gains, all temporary. Requires a pump.",
180182 Self::Supplement => "Consumes a supplement.",
183+ Self::Meditate => "EQ -10. Taught by the forum. Once per floor, no stamina.",
181184 }
182185 }
183186 }
184187
185188 impl Technique {
186- pub const ALL: [Self; 9] = [
189+ pub const ALL: [Self; 10] = [
187190 Self::Utility(Utility::Warmup),
188191 Self::Utility(Utility::Ice),
189192 Self::Utility(Utility::Edge),
190193 Self::Utility(Utility::Pump),
191194 Self::Utility(Utility::Supplement),
195+ Self::Utility(Utility::Meditate),
192196 Self::Training(Training::Wet),
193197 Self::Training(Training::Dry),
194198 Self::Training(Training::Stretch),
Modified file: src/game/turn.rs+44 additions−5 deletions
@@ -8,9 +8,9 @@ use super::{
88 Floor, Grip, LUBRICANTS, Lube, Outcome, Payload, Run, Story, Technique,
99 balance::{
1010 BAND_BONUS, BRANCH_TURN, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL, DANGER_EQ, EQ_BAND,
11- EQ_WOBBLE, EXTENDER_GAIN, HOT_EQ, OBSERVED, PRIAPISM_EQ, PRIAPISM_TURNS, PULLED_MUSCLE,
12- PULLED_MUSCLE_INTENSITY, RING_PRIAPISM, RING_RUPTURE_LINE, RUPTURE_LINE, STORAGE_GIRTH,
13- SUSPICION_LIMIT,
11+ EQ_WOBBLE, EXTENDER_GAIN, HOT_EQ, MEDITATION_EQ, OBSERVED, PRIAPISM_EQ, PRIAPISM_TURNS,
12+ PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, RING_PRIAPISM, RING_RUPTURE_LINE, RUPTURE_LINE,
13+ STORAGE_GIRTH, SUSPICION_LIMIT,
1414 },
1515 not_extended, round3,
1616 state::{MumVisit, Observer, Pending},
@@ -44,6 +44,7 @@ impl Run {
4444 "The cage is locked. The key is not in the box.",
4545 ));
4646 }
47+ self.here.shopping = false;
4748 match *techniques {
4849 [Technique::Utility(utility)] => self.utility(utility, lube, now),
4950 [Technique::Training(training)] => self.single(training, intensity, lube, now),
@@ -73,6 +74,7 @@ impl Run {
7374 Utility::Warmup => return self.warm_up(),
7475 Utility::Ice => return self.ice(),
7576 Utility::Supplement => return self.supplement(),
77+ Utility::Meditate => return self.meditate(),
7678 Utility::Edge | Utility::Pump => {}
7779 }
7880 if utility == Utility::Pump {
@@ -189,9 +191,20 @@ impl Run {
189191 return Err(Problem::new(
190192 StatusCode::FORBIDDEN,
191193 "technique-locked",
192- "This technique is discussed on the forum. You have not been to the forum.",
194+ "uli is discussed on the forum, in a thread from 2009, by a man who never posted again. You have not been to the forum.",
193195 ));
194196 }
197+ if technique == Technique::Utility(Utility::Meditate) {
198+ return if self.meditation_taught {
199+ Ok(())
200+ } else {
201+ Err(Problem::new(
202+ StatusCode::FORBIDDEN,
203+ "technique-locked",
204+ "The breathing technique is described in one of the forum's replies. You have not found it.",
205+ ))
206+ };
207+ }
195208 if !self.warmed_up {
196209 return Err(Problem::new(
197210 StatusCode::PRECONDITION_FAILED,
@@ -251,6 +264,9 @@ impl Run {
251264 Technique::Utility(Utility::Warmup) => true,
252265 Technique::Utility(Utility::Ice) => self.items.ice_packs > 0,
253266 Technique::Utility(Utility::Supplement) => self.items.supplements > 0,
267+ Technique::Utility(Utility::Meditate) => {
268+ self.meditation_taught && !self.here.meditated
269+ }
254270 _ => self.access(technique).is_ok() && self.check_storage().is_ok(),
255271 })
256272 .collect()
@@ -307,7 +323,6 @@ impl Run {
307323 self.touch();
308324 self.turn += 1;
309325 self.here.turns += 1;
310- self.here.shopping = false;
311326 self.realise();
312327 if self.items.extender {
313328 self.length += EXTENDER_GAIN;
@@ -362,9 +377,33 @@ impl Run {
362377 }
363378 if self.turn == BRANCH_TURN && self.story == Story::Unopened {
364379 self.story = Story::Pending;
380+ self.notices
381+ .push("Somewhere, a forum thread from 2009 still has replies.");
365382 }
366383 }
367384
385+ /// The breathing technique from the forum's replies. It spends no stamina and the turn is
386+ /// not the floor's, but each floor allows it once.
387+ fn meditate(&mut self) -> Result<Outcome, Problem> {
388+ self.access(Technique::Utility(Utility::Meditate))?;
389+ if self.here.meditated {
390+ return Err(Problem::new(
391+ StatusCode::CONFLICT,
392+ "already-meditated",
393+ "This floor has had its meditation.",
394+ ));
395+ }
396+ self.here.meditated = true;
397+ self.eq = (self.eq - MEDITATION_EQ).clamp(0, 100);
398+ self.touch();
399+ Ok(Outcome::json(
400+ StatusCode::OK,
401+ json!({
402+ "detail": "In through the nose, out through the mouth. EQ falls by ten.",
403+ }),
404+ ))
405+ }
406+
368407 pub(super) fn maybe_summon_mum(&mut self, was_below: bool) {
369408 if !self.floor.has_mum() || self.mum.is_some() {
370409 return;
Modified file: src/http/run.rs+13 additions−9 deletions
@@ -11,7 +11,10 @@ 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, SHOP, Story, balance::SUSPICION_LIMIT},
14+ game::{
15+ Elder, FORUM_THREAD, Floor, Run, SHOP, Story,
16+ balance::{BRANCH_TURN, SUSPICION_LIMIT},
17+ },
1518 headers::{
1619 X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_LENGTH, X_OBSERVED_BY,
1720 X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA, X_SUSPICION,
@@ -129,14 +132,15 @@ fn links(run: &Run, now: Instant) -> String {
129132 let allow = target.resource.allow(run);
130133 format!("<{path}>; rel=\"{REL_BASE}{name}\"; allow=\"{allow}\"")
131134 })
132- .chain(
133- (run.forum_read() || run.floor.number() >= Floor::Forum.number()).then(|| {
134- format!(
135- "<{FORUM_THREAD}>; rel=\"{REL_BASE}thread\"; allow=\"{}\"",
136- meta::THREAD.allow_any()
137- )
138- }),
139- )
135+ .chain((run.turn >= BRANCH_TURN
136+ || run.forum_read()
137+ || run.floor.number() >= Floor::Forum.number())
138+ .then(|| {
139+ format!(
140+ "<{FORUM_THREAD}>; rel=\"{REL_BASE}thread\"; allow=\"{}\"",
141+ meta::THREAD.allow_any()
142+ )
143+ }),)
140144 .chain((run.story == Story::Pending).then(|| {
141145 let choice = |slug: &str, title: &str| {
142146 format!(
Modified file: src/tests.rs+73 additions−1 deletions
@@ -11,7 +11,7 @@ use tower::ServiceExt;
1111
1212 use crate::{
1313 AppState, Config,
14- game::{Floor, MumVisit, Run, Story, balance::SUSPICION_LIMIT},
14+ game::{Floor, MumVisit, Run, Story, balance::BRANCH_TURN, balance::SUSPICION_LIMIT},
1515 router,
1616 };
1717
@@ -946,3 +946,75 @@ async fn shopping_survives_browsing_but_not_jelqing() {
946946 let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
947947 assert!(!header(&after, "link").contains("shop-item"));
948948 }
949+
950+#[tokio::test]
951+async fn the_thread_announces_itself_at_the_branch() {
952+ let app = seeded_with(Floor::Bathroom, |run| {
953+ run.turn = 8;
954+ run.warmed_up = true;
955+ });
956+ send(
957+ &app,
958+ "JELQ",
959+ "/runs/cafebabe?technique=wet",
960+ &[("content-type", "application/baby-oil")],
961+ "",
962+ )
963+ .await;
964+ let ninth = send(&app, "GET", "/runs/cafebabe", &[], "").await;
965+ assert!(!header(&ninth, "link").contains("/forum/threads/2009"));
966+ let tenth = send(
967+ &app,
968+ "JELQ",
969+ "/runs/cafebabe?technique=wet",
970+ &[("content-type", "application/baby-oil")],
971+ "",
972+ )
973+ .await;
974+ assert!(
975+ tenth
976+ .headers()
977+ .get_all("warning")
978+ .iter()
979+ .any(|w| w.to_str().unwrap().contains("a forum thread from 2009"))
980+ );
981+ assert!(header(&tenth, "link").contains("/forum/threads/2009"));
982+ let announced = send(&app, "GET", "/runs/cafebabe", &[], "").await;
983+ assert!(header(&announced, "link").contains("/forum/threads/2009"));
984+}
985+
986+#[tokio::test]
987+async fn reading_the_forum_may_teach_meditation() {
988+ let taught = (0..50u64)
989+ .filter(|seed| {
990+ let mut run = Run::new("cafebabe".into(), "cafe".into(), *seed);
991+ run.story = Story::Pending;
992+ run.turn = BRANCH_TURN;
993+ run.choose("forum").is_ok() && run.meditation_taught
994+ })
995+ .count();
996+ assert!(
997+ taught > 0 && taught < 50,
998+ "the teach roll should be a coin, it landed {taught}/50"
999+ );
1000+}
1001+
1002+#[tokio::test]
1003+async fn meditation_is_free_but_only_once_per_floor() {
1004+ let app = seeded_with(Floor::Bedroom, |run| run.meditation_taught = true);
1005+ let response = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1006+ assert_eq!(response.status(), StatusCode::OK);
1007+ assert_eq!(header(&response, "x-eq"), "10");
1008+ assert_eq!(header(&response, "x-stamina"), "10/10");
1009+ let again = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1010+ assert_eq!(again.status(), StatusCode::CONFLICT);
1011+}
1012+
1013+#[tokio::test]
1014+async fn meditation_stays_hidden_until_the_forum_teaches_it() {
1015+ let app = seeded_with(Floor::Bedroom, |_| {});
1016+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1017+ assert!(!header(&view, "link").contains("technique=meditate"));
1018+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1019+ assert_eq!(jelq.status(), StatusCode::FORBIDDEN);
1020+}