atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit c3e0c27cf8fd

tarball

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

6 files changed+227-24
jelqd: add degloving, zipper incidents, window footage, and other mishaps

Careful play still won about 95% of runs, so a zipper risk on every floor exit and a 1 in 300 base rupture now give roughly 88% over 34 simulated attempts. The footage resource sends Cache-Control: public, max-age=31536000, immutable, so chrome only sets no-store when a handler has not chosen otherwise.
Modified file: src/game/catalog.rs+6 additions−1 deletions
@@ -194,7 +194,7 @@ pub struct ShopItem {
194194 pub description: &'static str,
195195 }
196196
197-pub const SHOP: [ShopItem; 10] = [
197+pub const SHOP: [ShopItem; 11] = [
198198 ShopItem {
199199 slug: "pump",
200200 price: 10,
@@ -236,6 +236,11 @@ pub const SHOP: [ShopItem; 10] = [
236236 description: "Pump-Bro does not recommend it.",
237237 },
238238 ShopItem {
239+ slug: "window-blinds",
240+ price: 5,
241+ description: "The neighbour has a good camera.",
242+ },
243+ ShopItem {
239244 slug: "ice-pack",
240245 price: 3,
241246 description: "Cold.",
Modified file: src/game/encounters.rs+18 additions−7 deletions
@@ -1,6 +1,6 @@
11 use std::collections::HashMap;
22
3-use axum::http::{StatusCode, header};
3+use axum::http::{HeaderName, StatusCode, header};
44 use rand::Rng;
55 use serde::Deserialize;
66 use serde_json::{Value, json};
@@ -162,7 +162,8 @@ impl Run {
162162 self.leave_door();
163163 return Ok(());
164164 }
165- if digest && self.heard.contains(&excuse) {
165+ let true_now = self.pulled_muscle && excuse == "pulled a muscle";
166+ if digest && self.heard.contains(&excuse) && !true_now {
166167 self.shame += 3;
167168 self.touch();
168169 return Err(self.mom_strike(challenge(Problem::new(
@@ -171,7 +172,7 @@ impl Run {
171172 "Mom has heard that one before. She would have preferred the truth.",
172173 ))));
173174 }
174- if self.excuses().contains(&excuse.as_str()) {
175+ if true_now || self.excuses().contains(&excuse.as_str()) {
175176 self.heard.push(excuse);
176177 self.leave_door();
177178 return Ok(());
@@ -194,6 +195,9 @@ impl Run {
194195 if summoned {
195196 return "She heard a noise.";
196197 }
198+ if self.filmed_at.is_some() && self.rng.random_ratio(1, 2) {
199+ return "A neighbour has sent her something.";
200+ }
197201 let errands: &[&'static str] = if self.floor == Floor::Grandmas {
198202 &[
199203 "Grandma would like to see you.",
@@ -425,6 +429,7 @@ impl Run {
425429 "do-not-disturb-sign" => items.dnd_sign = true,
426430 "ruler" => items.ruler = true,
427431 "ice-pack" => items.ice_packs += 1,
432+ "window-blinds" => items.blinds = true,
428433 _ => items.cage = false,
429434 }
430435 Ok(Outcome::json(
@@ -686,10 +691,15 @@ impl Run {
686691 return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)
687692 .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED));
688693 }
689- Ok(
690- Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)
691- .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED),
692- )
694+ let spreadsheet = Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)
695+ .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED);
696+ if self.filmed_at.is_some() {
697+ return Ok(spreadsheet.header(
698+ HeaderName::from_static("x-forum-note"),
699+ "We have seen the footage.",
700+ ));
701+ }
702+ Ok(spreadsheet)
693703 }
694704
695705 pub fn diagnose(
@@ -727,6 +737,7 @@ impl Run {
727737 }
728738
729739 self.peyronie = false;
740+ self.priapism_turns = 0;
730741 let etag = self.origin_etag.clone();
731742 if if_none_match.is_some_and(|inm| super::etag_matches(inm, &etag)) {
732743 self.cured = true;
Modified file: src/game/jelq.rs+65 additions−7 deletions
@@ -14,6 +14,10 @@ use super::{
1414 use crate::{game::LUBRICANTS, problem::Problem};
1515
1616 const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);
17+const PRIAPISM_TURNS: u8 = 5;
18+const PRIAPISM_EQ: i32 = 75;
19+const DEGLOVED: &str =
20+ "The outer layer has been removed in one piece. It will not be returning. The run has ended.";
1721
1822 impl Run {
1923 pub fn jelq(
@@ -87,7 +91,13 @@ impl Run {
8791 }
8892 let profile = technique.profile().expect("training technique");
8993 self.commit(intensity, lube, now, 1);
90- self.hazards(intensity)?;
94+ if technique == Technique::Dry && intensity >= 8 && self.rng.random_ratio(1, 60) {
95+ self.end("degloved", DEGLOVED);
96+ return Err(Problem::new(StatusCode::GONE, "degloved", DEGLOVED));
97+ }
98+ if let Some(interrupted) = self.hazards(intensity)? {
99+ return Ok(interrupted);
100+ }
91101 let result = self.roll(technique, &profile, intensity, lube);
92102 Ok(self.gossip(result?))
93103 }
@@ -120,7 +130,9 @@ impl Run {
120130 self.check_storage()?;
121131 self.floor_enemy()?;
122132 self.commit(intensity, lube, now, 2);
123- self.hazards(intensity)?;
133+ if let Some(interrupted) = self.hazards(intensity)? {
134+ return Ok(interrupted);
135+ }
124136
125137 let mut statuses = Vec::new();
126138 for (technique, profile) in [(a, first), (b, second)] {
@@ -309,21 +321,47 @@ impl Run {
309321 {
310322 self.observed = Some(observer);
311323 self.shame += 1;
324+ if observer == "cat" && self.rng.random_ratio(1, 5) {
325+ self.towel_stolen = true;
326+ }
327+ }
328+ if intensity >= 9 && !self.pulled_muscle && self.rng.random_ratio(1, 12) {
329+ self.pulled_muscle = true;
330+ self.stamina = self.stamina.min(self.max_stamina());
331+ }
332+ if self.items.ring && self.eq > 85 && self.rng.random_ratio(1, 10) {
333+ self.priapism_turns = PRIAPISM_TURNS;
334+ }
335+ if self.priapism_turns > 0 {
336+ self.priapism_turns -= 1;
337+ self.eq = self.eq.max(PRIAPISM_EQ);
312338 }
313339 if self.turn == super::BRANCH_TURN {
314340 self.branch_pending = true;
315341 }
316342 }
317343
318- fn hazards(&mut self, intensity: i32) -> Result<(), Problem> {
344+ fn hazards(&mut self, intensity: i32) -> Result<Option<Outcome>, Problem> {
319345 let odds = match self.eq {
320- ..=70 => 200,
346+ ..=70 => 300,
321347 71..=85 => 25,
322348 _ => 10,
323349 };
324350 if self.rng.random_ratio(1, odds) {
325351 return Err(self.rupture());
326352 }
353+ let windowed = matches!(self.floor, Floor::Bedroom | Floor::Grandmas | Floor::Hotel);
354+ if windowed && !self.items.blinds && self.rng.random_ratio(1, 60) {
355+ self.shame += 30;
356+ self.filmed_at.get_or_insert_with(SystemTime::now);
357+ return Ok(Some(
358+ Outcome::json(
359+ StatusCode::CREATED,
360+ json!({ "detail": "A neighbour was at the window. A recording has been created. It has been shared." }),
361+ )
362+ .header(header::LOCATION, format!("/runs/{}/footage", self.id)),
363+ ));
364+ }
327365 if self.floor == Floor::LockerRoom && self.rng.random_ratio(1, 10) {
328366 self.shame += 1;
329367 return Err(Problem::new(
@@ -369,7 +407,7 @@ impl Run {
369407 )
370408 .with("eq", self.eq));
371409 }
372- Ok(())
410+ Ok(None)
373411 }
374412
375413 fn roll(
@@ -540,6 +578,8 @@ impl Run {
540578 35,
541579 "Warmed up. The shower is hot. The roommate's will not be.",
542580 )
581+ } else if self.towel_stolen {
582+ (10, "Warmed up, poorly. The cat has the towel.")
543583 } else {
544584 (25, "Warmed up. The towel has cooled.")
545585 };
@@ -559,6 +599,12 @@ impl Run {
559599 }
560600 self.touch();
561601 self.items.ice_packs -= 1;
602+ if self.priapism_turns > 0 {
603+ return Ok(Outcome::json(
604+ StatusCode::OK,
605+ json!({ "detail": "The ice has no effect. The condition persists. The ice pack is gone." }),
606+ ));
607+ }
562608 self.eq = (self.eq - 40).max(0);
563609 Ok(Outcome::json(
564610 StatusCode::OK,
@@ -576,6 +622,14 @@ impl Run {
576622 }
577623 self.items.supplements -= 1;
578624 self.touch();
625+ if self.rng.random_ratio(1, 5) {
626+ self.priapism_turns = PRIAPISM_TURNS;
627+ self.eq = self.eq.max(PRIAPISM_EQ);
628+ return Ok(Outcome::json(
629+ StatusCode::OK,
630+ json!({ "detail": "The upstream returned a valid response. It is still returning it." }),
631+ ));
632+ }
579633 Err(Problem::new(
580634 StatusCode::BAD_GATEWAY,
581635 "bad-gateway",
@@ -599,7 +653,9 @@ impl Run {
599653 "Edging has looped. The server detected an infinite loop and terminated it.",
600654 ));
601655 }
602- self.hazards(5)?;
656+ if let Some(interrupted) = self.hazards(5)? {
657+ return Ok(interrupted);
658+ }
603659 Ok(Outcome::json(
604660 StatusCode::OK,
605661 json!({ "detail": "Edged. EQ is rising. Nothing else is.", "edges": self.edges }),
@@ -608,7 +664,9 @@ impl Run {
608664
609665 fn pump(&mut self, now: Instant) -> Result<Outcome, Problem> {
610666 self.commit(4, None, now, 1);
611- self.hazards(4)?;
667+ if let Some(interrupted) = self.hazards(4)? {
668+ return Ok(interrupted);
669+ }
612670 self.temp_length += 0.5;
613671 Ok(Outcome::json(
614672 StatusCode::TEMPORARY_REDIRECT,
Modified file: src/game/mod.rs+36 additions−2 deletions
@@ -7,7 +7,7 @@ pub use encounters::{ELDER_LAST_MODIFIED, FORUM_THREAD, PatchOp, forum_thread};
77
88 use std::{
99 sync::LazyLock,
10- time::{Duration, Instant},
10+ time::{Duration, Instant, SystemTime},
1111 };
1212
1313 use axum::{
@@ -155,6 +155,7 @@ pub struct Items {
155155 pub cage: bool,
156156 pub spreadsheet: bool,
157157 pub teapot_spout: bool,
158+ pub blinds: bool,
158159 }
159160
160161 enum Payload {
@@ -276,6 +277,10 @@ pub struct Run {
276277 pub mom_strikes: u8,
277278 pub elder_strikes: u8,
278279 pub seeded: bool,
280+ pub towel_stolen: bool,
281+ pub pulled_muscle: bool,
282+ pub priapism_turns: u8,
283+ pub filmed_at: Option<SystemTime>,
279284 pub observed: Option<&'static str>,
280285 pub measured: bool,
281286 pub cured: bool,
@@ -371,6 +376,10 @@ impl Run {
371376 mom_strikes: 0,
372377 elder_strikes: 0,
373378 seeded: false,
379+ towel_stolen: false,
380+ pulled_muscle: false,
381+ priapism_turns: 0,
382+ filmed_at: None,
374383 observed: None,
375384 measured: false,
376385 cured: false,
@@ -413,6 +422,16 @@ impl Run {
413422 self.current_length() + SELF_REPORT_INFLATION
414423 }
415424
425+ pub fn max_stamina(&self) -> i32 {
426+ if self.pulled_muscle { 7 } else { MAX_STAMINA }
427+ }
428+
429+ pub fn footage(&self) -> Option<(SystemTime, u64)> {
430+ let filmed = self.filmed_at?;
431+ let age = filmed.elapsed().map_or(0, |d| d.as_secs());
432+ Some((filmed, age))
433+ }
434+
416435 pub fn total_shame(&self) -> u32 {
417436 self.shame_base + self.shame
418437 }
@@ -430,6 +449,9 @@ impl Run {
430449 (self.teapot, "short and stout"),
431450 (self.peyronie, "curved"),
432451 (self.items.cage, "caged"),
452+ (self.priapism_turns > 0, "priapism"),
453+ (self.pulled_muscle, "pulled muscle"),
454+ (self.filmed_at.is_some(), "viral"),
433455 ]
434456 .into_iter()
435457 .filter_map(|(active, name)| active.then_some(name))
@@ -549,6 +571,11 @@ impl Run {
549571 "teapot-spout",
550572 "Does nothing. Cannot be dropped.",
551573 ),
574+ (
575+ i.blinds,
576+ "window-blinds",
577+ "The neighbour has a good camera. The blinds are better.",
578+ ),
552579 ];
553580 owned
554581 .into_iter()
@@ -578,7 +605,7 @@ impl Run {
578605 .header(header::RETRY_AFTER, seconds.to_string()));
579606 }
580607 self.refractory_until = None;
581- self.stamina = MAX_STAMINA;
608+ self.stamina = self.max_stamina();
582609 self.eq = 20;
583610 self.warmed_up = false;
584611 self.touch();
@@ -630,6 +657,7 @@ impl Run {
630657 self.mom_present = false;
631658 self.roommate_told = false;
632659 self.drawer_searches = 0;
660+ self.towel_stolen = false;
633661 self.edges = 0;
634662 if paid {
635663 self.cash += FLOOR_ALLOWANCE;
@@ -668,6 +696,12 @@ impl Run {
668696 }
669697 _ => {}
670698 }
699+ if self.rng.random_ratio(1, 120) {
700+ let detail =
701+ "The request conflicts with the current state of the zipper. The run has ended.";
702+ self.end("zipper", detail);
703+ return Err(Problem::new(StatusCode::CONFLICT, "zipper", detail));
704+ }
671705 let reverted = self.temp_length;
672706 self.next_floor(true);
673707 let mut body = json!({
Modified file: src/http.rs+58 additions−7 deletions
@@ -26,8 +26,8 @@ use tower::ServiceExt;
2626
2727 use crate::{
2828 game::{
29- ELDER_LAST_MODIFIED, FORUM_THREAD, Floor, Grip, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH,
30- PatchOp, RUN_ALLOW, Run, SUSPICION_LIMIT, Technique, forum_thread, shaft,
29+ ELDER_LAST_MODIFIED, FORUM_THREAD, Floor, Grip, LUBRICANTS, Lube, MM_PER_INCH, PatchOp,
30+ RUN_ALLOW, Run, SUSPICION_LIMIT, Technique, forum_thread, shaft,
3131 },
3232 problem::{PROBLEM_BASE, Problem, reason},
3333 secret,
@@ -95,6 +95,7 @@ fn api(state: Shared) -> Router {
9595 .route("/runs/{id}/elder", any(elder))
9696 .route("/runs/{id}/grip", any(grip))
9797 .route("/runs/{id}/diagnosis", any(diagnosis))
98+ .route("/runs/{id}/footage", any(footage))
9899 .route("/forum/threads/{year}", any(thread))
99100 .route("/graveyard", any(graveyard))
100101 .route("/leaderboard", any(leaderboard))
@@ -174,10 +175,9 @@ async fn chrome(request: Request, next: Next) -> Response {
174175 let headers = response.headers_mut();
175176 headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9"));
176177 headers.insert("x-powered-by", HeaderValue::from_static("blood"));
177- headers.insert(
178- header::CACHE_CONTROL,
179- HeaderValue::from_static("no-store, private"),
180- );
178+ headers
179+ .entry(header::CACHE_CONTROL)
180+ .or_insert(HeaderValue::from_static("no-store, private"));
181181 response
182182 }
183183
@@ -310,7 +310,10 @@ fn finish(run: &Run, mut response: Response) -> Response {
310310 run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
311311 ),
312312 ("x-eq", run.eq.to_string()),
313- ("x-stamina", format!("{}/{MAX_STAMINA}", run.stamina)),
313+ (
314+ "x-stamina",
315+ format!("{}/{}", run.stamina, run.max_stamina()),
316+ ),
314317 (
315318 "x-suspicion",
316319 format!("{}/{SUSPICION_LIMIT}", run.suspicion),
@@ -328,6 +331,9 @@ fn finish(run: &Run, mut response: Response) -> Response {
328331 stats.push(("vary", "Mom".into()));
329332 }
330333 // RFC 9111 obsoleted Warning, which is why nobody heeds it.
334+ if run.pulled_muscle {
335+ stats.push(("warning", "199 - \"a muscle has been pulled\"".into()));
336+ }
331337 if run.eq > 70 && run.ended.is_none() {
332338 stats.push(("warning", "199 - \"it is turning purple\"".into()));
333339 }
@@ -988,6 +994,50 @@ async fn diagnosis(
988994 })
989995 }
990996
997+async fn footage(
998+ State(state): State<Shared>,
999+ Path(id): Path<String>,
1000+ method: Method,
1001+ headers: HeaderMap,
1002+) -> Response {
1003+ const ALLOW: &str = "GET, HEAD, OPTIONS";
1004+ with_run(&state, &id, &headers, Scope::Pockets, |run| {
1005+ match method.as_str() {
1006+ "GET" | "HEAD" => {
1007+ let Some((filmed, age)) = run.footage() else {
1008+ return Problem::new(
1009+ StatusCode::NOT_FOUND,
1010+ "no-footage",
1011+ "There is no footage. Yet.",
1012+ )
1013+ .into_response();
1014+ };
1015+ (
1016+ [
1017+ (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_owned()),
1018+ (header::AGE, age.to_string()),
1019+ (header::LAST_MODIFIED, httpdate::fmt_http_date(filmed)),
1020+ ],
1021+ Json(json!({
1022+ "detail": "A recording. It is grainy. It is unmistakably you. It has been shared.",
1023+ "views": 1 + age * 37,
1024+ })),
1025+ )
1026+ .into_response()
1027+ }
1028+ "DELETE" => Problem::new(
1029+ StatusCode::METHOD_NOT_ALLOWED,
1030+ "the-internet",
1031+ "The footage has been cached by intermediaries. It cannot be deleted.",
1032+ )
1033+ .header(header::ALLOW, ALLOW)
1034+ .into_response(),
1035+ "OPTIONS" => options(ALLOW, false),
1036+ _ => unsupported(&method, ALLOW),
1037+ }
1038+ })
1039+}
1040+
9911041 async fn thread(Path(year): Path<String>, method: Method) -> Response {
9921042 if method != Method::GET && method != Method::HEAD {
9931043 return unsupported(&method, "GET, HEAD");
@@ -1098,6 +1148,7 @@ async fn leaderboard(
10981148 "floor": run.floor.number(),
10991149 "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,
11001150 "ranked": !run.seeded,
1151+ "viral": run.filmed_at.is_some(),
11011152 });
11021153 if truthful {
11031154 entry["content_length"] = json!(millimetres(run.current_length()));
Modified file: tests/http.rs+44 additions
@@ -504,3 +504,47 @@ async fn the_ui_replays_what_browsers_cannot_send() {
504504 assert_eq!(redirected["status"], 303);
505505 assert_eq!(redirected["reason"], "See Other");
506506 }
507+
508+#[tokio::test]
509+async fn the_internet_never_forgets() {
510+ let app = seeded_with(Floor::Bedroom, |run| {
511+ run.filmed_at = Some(std::time::SystemTime::now());
512+ });
513+ let footage = send(&app, "GET", "/runs/cafebabe/footage", &[], "").await;
514+ assert_eq!(footage.status(), StatusCode::OK);
515+ assert_eq!(
516+ footage.headers()[header::CACHE_CONTROL],
517+ "public, max-age=31536000, immutable"
518+ );
519+ assert!(footage.headers().contains_key(header::AGE));
520+ let delete = send(&app, "DELETE", "/runs/cafebabe/footage", &[], "").await;
521+ assert_eq!(delete.status(), StatusCode::METHOD_NOT_ALLOWED);
522+}
523+
524+#[tokio::test]
525+async fn a_pulled_muscle_is_a_true_excuse() {
526+ let app = seeded_with(Floor::Grandmas, |run| {
527+ run.mom_present = true;
528+ run.nonce = "abc".into();
529+ run.pulled_muscle = true;
530+ run.heard.push("pulled a muscle".into());
531+ });
532+ let response = send(
533+ &app,
534+ "GET",
535+ "/runs/cafebabe",
536+ &[(
537+ "authorization",
538+ "Digest username=\"pulled a muscle\", nonce=\"abc\"",
539+ )],
540+ "",
541+ )
542+ .await;
543+ assert_eq!(response.status(), StatusCode::OK);
544+ assert!(
545+ response.headers()["x-stamina"]
546+ .to_str()
547+ .unwrap()
548+ .ends_with("/7")
549+ );
550+}