atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit e028a4184d00

tarball

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

17 files changed+211-62
game: name EQ in X-EQ rather than numbering it

A raw EQ alongside the published 50 to 70 band turned every turn into arithmetic, so X-EQ now names the tier the rolls actually use, burn and ring included, and item text speaks in the same vague terms. Edge links now advertise lubricant="optional" and training links an intensity range, since bad9f5b8 let an edge take lube but the UI only offered Content-Type to training moves.
Added file: src/game/arousal.rs+66 additions
@@ -0,0 +1,66 @@
1+use super::{
2+ Run,
3+ balance::{DANGER_EQ, EQ_BAND, HOT_EQ, PREMATURE_EQ, RING_RUPTURE_LINE, RUPTURE_LINE},
4+};
5+
6+/// What `X-EQ` shows in place of the number, cut on the lines the rolls already use.
7+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8+pub enum Arousal {
9+ Flaccid,
10+ Cold,
11+ Warm,
12+ Ideal,
13+ Swollen,
14+ Throbbing,
15+ Straining,
16+ Imminent,
17+ Priapic,
18+}
19+
20+impl Arousal {
21+ pub fn name(self) -> &'static str {
22+ match self {
23+ Self::Flaccid => "Flaccid",
24+ Self::Cold => "Cold",
25+ Self::Warm => "Warm",
26+ Self::Ideal => "Ideal",
27+ Self::Swollen => "Swollen",
28+ Self::Throbbing => "Throbbing",
29+ Self::Straining => "Straining",
30+ Self::Imminent => "Imminent",
31+ Self::Priapic => "Priapic",
32+ }
33+ }
34+}
35+
36+impl Run {
37+ pub(super) fn rupture_line(&self) -> i32 {
38+ if self.items.kit.ring {
39+ RING_RUPTURE_LINE
40+ } else {
41+ RUPTURE_LINE
42+ }
43+ }
44+
45+ pub fn arousal(&self) -> Arousal {
46+ if self.body.conditions.priapism_turns > 0 {
47+ Arousal::Priapic
48+ } else if self.hotter_than(PREMATURE_EQ) {
49+ Arousal::Imminent
50+ } else if self.hotter_than(self.rupture_line()) {
51+ Arousal::Straining
52+ } else if self.hotter_than(HOT_EQ) {
53+ Arousal::Throbbing
54+ } else if self.hotter_than(DANGER_EQ) {
55+ Arousal::Swollen
56+ } else if EQ_BAND.contains(&self.body.eq) {
57+ Arousal::Ideal
58+ } else if self.body.eq == 0 {
59+ Arousal::Flaccid
60+ } else if self.cold() {
61+ Arousal::Cold
62+ } else {
63+ Arousal::Warm
64+ }
65+ }
66+}
Modified file: src/game/cause.rs+1 additions−1 deletions
@@ -33,7 +33,7 @@ impl Cause {
3333
3434 pub fn slug(self) -> &'static str {
3535 match self {
36- Self::Ruptured { .. } => "vascular-event",
36+ Self::Ruptured { .. } => "ruptured",
3737 Self::Caught => "caught",
3838 Self::Opened => "opened",
3939 Self::Banned => "banned",
Modified file: src/game/forum.rs+1 additions−1 deletions
@@ -42,7 +42,7 @@ pub fn forum_thread() -> Value {
4242 "title": "The definitive jelqing FAQ (updated 2009)",
4343 "posts": [
4444 { "author": "TheElder", "body": "Warm up. Always warm up. A cold jelq is a 412 waiting to happen." },
45- { "author": "TheElder", "body": "Keep EQ between 50 and 70. Above 70 you are gambling. Above 90 you are a cautionary tale." },
45+ { "author": "TheElder", "body": "Keep it Ideal. Swollen is gambling. Straining is a cautionary tale." },
4646 { "author": "newbie_2009", "body": "everyone keeps saying uli. who is uli" },
4747 { "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." },
4848 { "author": "TheElder", "body": "We do not say his name in vain. We say it in girth." },
Modified file: src/game/gauges.rs+4 additions−4 deletions
@@ -3,7 +3,7 @@ use std::time::Instant;
33 use serde_json::{Value, json};
44
55 use super::{
6- Arrival, Elder, Excuse, Floor, Grip, Item, Observer, Run, Story, Technique,
6+ Arousal, Arrival, Elder, Excuse, Floor, Grip, Item, Observer, Run, Story, Technique,
77 balance::DANGER_EQ,
88 techniques::{Training, Utility},
99 };
@@ -18,7 +18,7 @@ pub struct Gauges {
1818 pub(crate) girth: f64,
1919 pub(crate) foreskin: Option<f64>,
2020 pub(crate) friction: i32,
21- pub(crate) eq: i32,
21+ pub(crate) arousal: Arousal,
2222 pub(crate) stamina: i32,
2323 pub(crate) max_stamina: i32,
2424 pub(crate) suspicion: u8,
@@ -62,7 +62,7 @@ impl Run {
6262 if arrived.is_some() {
6363 warnings.push("the queued gains have arrived");
6464 }
65- if self.body.eq > DANGER_EQ && self.ended.is_none() {
65+ if self.hotter_than(DANGER_EQ) && self.ended.is_none() {
6666 warnings.push("it is turning purple");
6767 }
6868 Gauges {
@@ -74,7 +74,7 @@ impl Run {
7474 girth: self.body.girth,
7575 foreskin: self.body.foreskin,
7676 friction: self.body.friction,
77- eq: self.body.eq,
77+ arousal: self.arousal(),
7878 stamina: self.body.stamina,
7979 max_stamina: self.max_stamina(),
8080 suspicion: self.standing.suspicion,
Modified file: src/game/items.rs+1 additions−1 deletions
@@ -98,7 +98,7 @@ impl Item {
9898 Self::WarmTowel => {
9999 "Reusable. Satisfies the warm-up precondition. Use technique=warmup."
100100 }
101- Self::IcePack => "Lowers EQ by 40. Use technique=ice.",
101+ Self::IcePack => "Cools things sharply. Use technique=ice.",
102102 Self::Ruler => "Measures honestly. The length resource now carries a Content-Length.",
103103 Self::DoorLock => "Keeps your brother out once.",
104104 Self::DndSign => "Works on housekeeping. Mum does not read signs.",
Modified file: src/game/mod.rs+2 additions
@@ -1,3 +1,4 @@
1+mod arousal;
12 pub mod balance;
23 mod body;
34 mod cause;
@@ -29,6 +30,7 @@ mod techniques;
2930 mod travel;
3031 mod utility;
3132
33+pub use arousal::Arousal;
3234 #[cfg(test)]
3335 pub use body::Pending;
3436 pub use body::{Arrival, Body, Grip};
Modified file: src/game/rest.rs+2 additions−2 deletions
@@ -11,7 +11,7 @@ use serde_json::json;
1111
1212 impl Run {
1313 pub fn can_hold(&self) -> bool {
14- self.body.eq > DANGER_EQ || self.body.stamina <= 2
14+ self.hotter_than(DANGER_EQ) || self.body.stamina <= 2
1515 }
1616
1717 pub fn hold(&mut self) -> Result<Outcome, Problem> {
@@ -95,7 +95,7 @@ impl Run {
9595 Ok(Outcome::json(
9696 StatusCode::OK,
9797 json!({
98- "detail": "In through the nose, out through the mouth. EQ falls by ten.",
98+ "detail": "In through the nose, out through the mouth. EQ falls a little.",
9999 }),
100100 ))
101101 }
Modified file: src/game/roll.rs+3 additions−7 deletions
@@ -2,7 +2,7 @@ use super::{
22 Grip, Lube, Outcome, Run,
33 balance::{
44 ARRIVAL_BONUS, BAND_BONUS, CLEAN_HIT, DANGER_EQ, EQ_BAND, GREED_HOT, GREED_WARM, HOT_EQ,
5- NEAR_MISS, QUEUED_ETA, RING_RUPTURE_LINE, RUPTURE_LINE, UNDERHAND_STRETCH, UNDERHAND_WET,
5+ NEAR_MISS, QUEUED_ETA, UNDERHAND_STRETCH, UNDERHAND_WET,
66 },
77 body::Pending,
88 measure::round3,
@@ -43,11 +43,7 @@ impl Run {
4343 let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
4444
4545 let (length, girth) = self.scaled_gains(&profile, intensity);
46- let rupture_line = if self.items.kit.ring {
47- RING_RUPTURE_LINE
48- } else {
49- RUPTURE_LINE
50- };
46+ let rupture_line = self.rupture_line();
5147
5248 if natural == 1 {
5349 return Err(timed(self.critical_failure(training, &profile)));
@@ -109,7 +105,7 @@ impl Run {
109105 Outcome::json(
110106 StatusCode::PARTIAL_CONTENT,
111107 gains(
112- "Partial gains were applied. EQ was below the band.",
108+ "Partial gains were applied. EQ was short of Ideal.",
113109 length / 2.0,
114110 girth / 2.0,
115111 foreskin,
Modified file: src/game/techniques.rs+17 additions−4 deletions
@@ -102,12 +102,16 @@ impl Utility {
102102
103103 fn effect(self) -> &'static str {
104104 match self {
105- Self::Warmup => "EQ +25. Satisfies the warm-up precondition.",
106- Self::Ice => "EQ -40. Treats a friction burn. Consumes an ice pack.",
107- Self::Edge => "EQ +15 and a little girth. Three in a row is a loop.",
105+ Self::Warmup => "Warms things a good deal. Satisfies the warm-up precondition.",
106+ Self::Ice => "Cools things sharply. Treats a friction burn. Consumes an ice pack.",
107+ Self::Edge => {
108+ "Warms things a little and adds a little girth. Three in a row is a loop."
109+ }
108110 Self::Pump => "Large gains, all temporary. Requires a pump.",
109111 Self::Supplement => "Consumes a supplement.",
110- Self::Meditate => "EQ -10, sheds 2 friction. Taught by the forum. Once per floor.",
112+ Self::Meditate => {
113+ "Cools things a little, sheds 2 friction. Taught by the forum. Once per floor."
114+ }
111115 Self::Energy => "+3 stamina. Consumes an energy drink. Tastes like a battery.",
112116 }
113117 }
@@ -142,6 +146,14 @@ impl Technique {
142146 pub fn lubricant(self) -> Option<&'static str> {
143147 match self {
144148 Self::Training(training) => Some(training.profile().lube.name()),
149+ Self::Utility(Utility::Edge) => Some(LubeRule::Optional.name()),
150+ Self::Utility(_) => None,
151+ }
152+ }
153+
154+ pub fn intensity(self) -> Option<&'static str> {
155+ match self {
156+ Self::Training(_) => Some("1-10"),
145157 Self::Utility(_) => None,
146158 }
147159 }
@@ -152,6 +164,7 @@ impl Technique {
152164 return json!({
153165 "name": utility.name(),
154166 "effect": utility.effect(),
167+ "lubricant": self.lubricant(),
155168 "locked": !known,
156169 });
157170 }
Modified file: src/http/links.rs+9 additions−1 deletions
@@ -5,7 +5,7 @@ use crate::game::{Branch, Run, SHOP};
55
66 pub const REL_BASE: &str = "https://jelq.dev/rels/";
77
8-/// One RFC 8288 link. `allow` and `lubricant` are extension attributes (§3), so a client never
8+/// One RFC 8288 link. `allow`, `lubricant` and `intensity` are extension attributes (§3), so a client never
99 /// has to guess what a target accepts.
1010 struct Link<'a> {
1111 href: &'a str,
@@ -13,6 +13,7 @@ struct Link<'a> {
1313 allow: &'a str,
1414 title: Option<&'a str>,
1515 lubricant: Option<&'a str>,
16+ intensity: Option<&'a str>,
1617 }
1718
1819 impl Link<'_> {
@@ -28,6 +29,9 @@ impl Link<'_> {
2829 if let Some(rule) = self.lubricant {
2930 let _ = write!(out, "; lubricant=\"{rule}\"");
3031 }
32+ if let Some(range) = self.intensity {
33+ let _ = write!(out, "; intensity=\"{range}\"");
34+ }
3135 }
3236 }
3337
@@ -63,6 +67,7 @@ pub fn header(run: &Run) -> String {
6367 allow: &target.resource.allow(run),
6468 title: None,
6569 lubricant: None,
70+ intensity: None,
6671 }
6772 .write(&mut out);
6873 }
@@ -77,6 +82,7 @@ pub fn header(run: &Run) -> String {
7782 allow: "POST",
7883 title: Some(title),
7984 lubricant: None,
85+ intensity: None,
8086 }
8187 .write(&mut out);
8288 }
@@ -90,6 +96,7 @@ pub fn header(run: &Run) -> String {
9096 allow: "JELQ",
9197 title: Some(name),
9298 lubricant: technique.lubricant(),
99+ intensity: technique.intensity(),
93100 }
94101 .write(&mut out);
95102 }
@@ -104,6 +111,7 @@ pub fn header(run: &Run) -> String {
104111 allow: "POST",
105112 title: Some(&format!("{} for {price}", item.slug())),
106113 lubricant: None,
114+ intensity: None,
107115 }
108116 .write(&mut out);
109117 }
Modified file: src/http/mod.rs+5 additions
@@ -71,6 +71,11 @@ impl AppState {
7171 pub(crate) fn insert(&self, run: crate::game::Run) {
7272 self.runs.insert_new(run);
7373 }
74+
75+ #[cfg(test)]
76+ pub(crate) fn inspect<T>(&self, visit: impl FnMut(&crate::game::Run) -> T) -> Vec<T> {
77+ self.runs.each(visit)
78+ }
7479 }
7580
7681 impl Default for AppState {
Modified file: src/http/run.rs+1 additions−1 deletions
@@ -152,7 +152,7 @@ fn stats(gauges: &Gauges, shame: u32) -> Vec<(HeaderName, String)> {
152152 gauges.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
153153 ),
154154 (X_FRICTION, format!("{}/{FRICTION_MAX}", gauges.friction)),
155- (X_EQ, gauges.eq.to_string()),
155+ (X_EQ, gauges.arousal.name().into()),
156156 (
157157 X_STAMINA,
158158 format!("{}/{}", gauges.stamina, gauges.max_stamina),
Modified file: src/http/runs.rs+2 additions−3 deletions
@@ -19,7 +19,7 @@ use super::{
1919 use crate::{
2020 game::{
2121 Branch, Cause, Floor, Grip, Item, LUBRICANTS, Lube, Run, Technique,
22- balance::{EQ_BAND, VIEWS_PER_SECOND},
22+ balance::VIEWS_PER_SECOND,
2323 },
2424 problem::Problem,
2525 };
@@ -49,7 +49,7 @@ pub static RUN: RunResource = RunResource {
4949 Verb::new("JELQ", jelq).only_when(not_in_clinic, "JELQ is not performed in the clinic."),
5050 Verb::new("HOLD", hold_run).only_when(
5151 Run::can_hold,
52- "HOLD is only available above 70 EQ or at 2 stamina or less.",
52+ "HOLD is only available once Swollen or at 2 stamina or less.",
5353 ),
5454 Verb::new("PUT", put_run),
5555 Verb::new("DELETE", delete_run),
@@ -157,7 +157,6 @@ fn get_techniques(run: &mut Run, _: &Ctx) -> Response {
157157 "usage": format!("JELQ {}?technique=<name>[,<name>]&intensity=<1-10>", run.path("")),
158158 "lubricant": "Declared with Content-Type.",
159159 "lubricants": LUBRICANTS,
160- "eq_band": [EQ_BAND.start(), EQ_BAND.end()],
161160 "techniques": listed,
162161 }))
163162 .into_response()
Modified file: src/tests/methods.rs+10 additions−1 deletions
@@ -107,7 +107,16 @@ async fn links_offer_only_usable_techniques() {
107107 assert!(!usable.contains(&"uli".to_owned()), "uli needs the forum");
108108 assert!(!usable.contains(&"pump".to_owned()), "pump needs a pump");
109109 assert!(
110- header(&warm, "link").contains("title=\"wet\"; allow=\"JELQ\"; lubricant=\"required\"")
110+ header(&warm, "link")
111+ .contains("title=\"wet\"; allow=\"JELQ\"; lubricant=\"required\"; intensity=\"1-10\"")
112+ );
113+ let edge = header(&warm, "link")
114+ .split(", ")
115+ .find(|link| link.contains("title=\"edge\""))
116+ .unwrap();
117+ assert!(
118+ edge.ends_with("allow=\"JELQ\"; lubricant=\"optional\""),
119+ "an edge takes lube but no intensity"
111120 );
112121
113122 let Harness { app: forum, .. } = harness(Floor::Forum, SEED, |_| {});
Modified file: src/tests/mod.rs+10 additions−1 deletions
@@ -79,6 +79,14 @@ pub fn warnings(response: &Response) -> Vec<String> {
7979 pub struct Harness {
8080 pub app: Router,
8181 pub origin: String,
82+ pub state: Arc<AppState>,
83+}
84+
85+impl Harness {
86+ /// The EQ of `cafebabe`, which `X-EQ` only names.
87+ pub fn eq(&self) -> i32 {
88+ self.state.inspect(|run| run.body.eq)[0]
89+ }
8290 }
8391
8492 pub fn harness(floor: Floor, seed: u64, setup: impl FnOnce(&mut Run)) -> Harness {
@@ -89,8 +97,9 @@ pub fn harness(floor: Floor, seed: u64, setup: impl FnOnce(&mut Run)) -> Harness
8997 let origin = run.origin_etag();
9098 state.insert(run);
9199 Harness {
92- app: router(state),
100+ app: router(state.clone()),
93101 origin,
102+ state,
94103 }
95104 }
96105
Modified file: src/tests/turns.rs+71 additions−29 deletions
@@ -2,49 +2,65 @@ use super::*;
22
33 #[tokio::test]
44 async fn meditation_is_free_but_only_once_per_floor() {
5- let app = seeded_with(Floor::Bedroom, |run| run.online.meditation_taught = true);
6- let response = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
5+ let held = harness(Floor::Bedroom, SEED, |run| {
6+ run.online.meditation_taught = true
7+ });
8+ let app = &held.app;
9+ let response = send(app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
710 assert_eq!(response.status(), StatusCode::OK);
8- assert_eq!(header(&response, "x-eq"), "10");
11+ assert_eq!(header(&response, "x-eq"), "Cold");
12+ assert_eq!(held.eq(), 10);
913 assert_eq!(header(&response, "x-stamina"), "10/10");
10- let again = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
14+ let again = send(app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1115 assert_eq!(again.status(), StatusCode::CONFLICT);
1216 }
1317
1418 #[tokio::test]
1519 async fn meditation_respects_the_priapism_floor() {
16- let app = seeded_with(Floor::Bedroom, |run| {
20+ let held = harness(Floor::Bedroom, SEED, |run| {
1721 run.body.eq = 80;
1822 run.body.conditions.priapism_turns = 3;
1923 run.online.meditation_taught = true;
2024 });
21- let calmed = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
25+ let calmed = send(
26+ &held.app,
27+ "JELQ",
28+ "/runs/cafebabe?technique=meditate",
29+ &[],
30+ "",
31+ )
32+ .await;
2233 assert_eq!(calmed.status(), StatusCode::OK);
23- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
24- assert_eq!(header(&view, "x-eq"), "75");
34+ assert_eq!(header(&calmed, "x-eq"), "Priapic");
35+ assert_eq!(held.eq(), 75);
2536 }
2637
2738 #[tokio::test]
2839 async fn priapism_holds_the_floor_through_every_reset() {
29- let exit = seeded_with(Floor::Bedroom, |run| {
40+ let exit = harness(Floor::Bedroom, SEED, |run| {
3041 run.body.eq = 90;
3142 run.here.turns = 99;
3243 run.body.conditions.priapism_turns = 3;
3344 });
34- let out = send(&exit, "POST", "/runs/cafebabe/floor", &[], "").await;
45+ let out = send(&exit.app, "POST", "/runs/cafebabe/floor", &[], "").await;
3546 assert_eq!(out.status(), StatusCode::SEE_OTHER);
36- let view = send(&exit, "GET", "/runs/cafebabe", &[], "").await;
37- assert_eq!(header(&view, "x-eq"), "75");
47+ assert_eq!(exit.eq(), 75);
3848
39- let melon = seeded_with(Floor::Bedroom, |run| {
49+ let melon = harness(Floor::Bedroom, SEED, |run| {
4050 run.body.eq = 95;
4151 run.here.kitchen.melon_offered = true;
4252 run.body.conditions.priapism_turns = 2;
4353 });
44- let bite = send(&melon, "POST", "/runs/cafebabe/kitchen/watermelon", &[], "").await;
54+ let bite = send(
55+ &melon.app,
56+ "POST",
57+ "/runs/cafebabe/kitchen/watermelon",
58+ &[],
59+ "",
60+ )
61+ .await;
4562 assert_eq!(bite.status(), StatusCode::OK);
46- let view = send(&melon, "GET", "/runs/cafebabe", &[], "").await;
47- assert_eq!(header(&view, "x-eq"), "75");
63+ assert_eq!(melon.eq(), 75);
4864 }
4965
5066 #[tokio::test]
@@ -261,7 +277,7 @@ async fn an_energy_drink_ends_exhaustion_without_the_refractory_period() {
261277 assert_eq!(header(&drink, "x-stamina"), "3/10");
262278 assert_eq!(
263279 header(&drink, "x-eq"),
264- "60",
280+ "Ideal",
265281 "still warm, unlike the refractory HOLD"
266282 );
267283 let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
@@ -286,29 +302,26 @@ async fn overstaying_a_floor_is_warned_then_punished() {
286302 let mut busted = 0;
287303 let mut moved = 0;
288304 for seed in 0..60u64 {
289- let app = app_with(Floor::ChangingRoom, seed, |run| {
305+ let held = harness(Floor::ChangingRoom, seed, |run| {
290306 run.body.eq = 80;
291307 run.body.conditions.priapism_turns = 9;
292308 run.here.turns = 7;
293309 });
294- let eighth = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
310+ let app = &held.app;
311+ let eighth = send(app, "HOLD", "/runs/cafebabe", &[], "").await;
295312 assert!(
296313 warnings(&eighth)
297314 .iter()
298315 .any(|w| w.contains("The gym closes"))
299316 );
300- let ninth = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
317+ let ninth = send(app, "HOLD", "/runs/cafebabe", &[], "").await;
301318 assert!(warnings(&ninth).iter().any(|w| w.contains("last turn")));
302- let tenth = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
319+ let tenth = send(app, "HOLD", "/runs/cafebabe", &[], "").await;
303320 match tenth.status() {
304321 StatusCode::GONE => busted += 1,
305322 StatusCode::REQUEST_TIMEOUT => {
306323 assert_eq!(header(&tenth, "x-floor"), "4");
307- assert_eq!(
308- header(&tenth, "x-eq"),
309- "75",
310- "priapism holds the deflation at 75"
311- );
324+ assert_eq!(held.eq(), 75, "priapism holds the deflation at 75");
312325 moved += 1;
313326 }
314327 other => panic!("seed {seed} overstayed into {other}"),
@@ -345,15 +358,15 @@ async fn friction_at_the_limit_tears_the_foreskin() {
345358
346359 #[tokio::test]
347360 async fn ice_treats_a_burn_even_through_priapism() {
348- let app = seeded_with(Floor::ChangingRoom, |run| {
361+ let held = harness(Floor::ChangingRoom, SEED, |run| {
349362 run.body.eq = 80;
350363 run.body.conditions.priapism_turns = 3;
351364 run.body.conditions.friction_burn = true;
352365 run.body.friction = 9;
353366 run.items.stores.ice_packs = 1;
354367 });
355- let iced = send(&app, "JELQ", "/runs/cafebabe?technique=ice", &[], "").await;
356- assert_eq!(header(&iced, "x-eq"), "80");
368+ let iced = send(&held.app, "JELQ", "/runs/cafebabe?technique=ice", &[], "").await;
369+ assert_eq!(held.eq(), 80);
357370 assert_eq!(header(&iced, "x-friction"), "6/10");
358371 assert!(warnings(&iced).iter().all(|w| !w.contains("friction burn")));
359372 }
@@ -424,3 +437,32 @@ async fn the_refractory_period_gives_back_some_stamina_and_rarely_all() {
424437 }
425438 assert!((8..=35).contains(&full), "{full} second winds in 400");
426439 }
440+
441+#[tokio::test]
442+async fn eq_is_named_rather_than_numbered() {
443+ for (eq, setup, named) in [
444+ (0, None, "Flaccid"),
445+ (20, None, "Cold"),
446+ (40, None, "Warm"),
447+ (60, None, "Ideal"),
448+ (60, Some("burn"), "Swollen"),
449+ (80, None, "Swollen"),
450+ (88, None, "Throbbing"),
451+ (85, Some("ring"), "Straining"),
452+ (93, None, "Straining"),
453+ (97, None, "Imminent"),
454+ (40, Some("priapism"), "Priapic"),
455+ ] {
456+ let app = seeded_with(Floor::Bedroom, |run| {
457+ run.body.eq = eq;
458+ match setup {
459+ Some("burn") => run.body.conditions.friction_burn = true,
460+ Some("ring") => run.items.kit.ring = true,
461+ Some("priapism") => run.body.conditions.priapism_turns = 3,
462+ _ => {}
463+ }
464+ });
465+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
466+ assert_eq!(header(&view, "x-eq"), named, "{eq} with {setup:?}");
467+ }
468+}
Modified file: src/ui.html+6 additions−6 deletions
@@ -53,7 +53,7 @@ pre, li, p, td, label { overflow-wrap: anywhere; }
5353 const REL = "https://jelq.dev/rels/";
5454 const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"];
5555 const ALL_LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];
56-const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "watermelon", "overstayed", "run-ended"];
56+const DEATHS = ["ruptured", "caught", "opened", "banned", "degloved", "zip", "watermelon", "overstayed", "run-ended"];
5757 let stocked = null;
5858 let excuses = [];
5959 let nonce = null;
@@ -64,7 +64,7 @@ const STATS = [
6464 ["x-girth", "Girth"],
6565 ["x-foreskin", "Foreskin"],
6666 ["x-friction", "Friction"],
67- ["x-eq", "EQ (aim for 50 to 70)"],
67+ ["x-eq", "EQ"],
6868 ["x-stamina", "Stamina"],
6969 ["x-suspicion", "Suspicion"],
7070 ["x-shame", "Shame"],
@@ -153,8 +153,8 @@ function options(name) {
153153 if (name === "technique") return techniques.map(t => t.name);
154154 if (name === "also") {
155155 const first = technique(fieldValue("technique"));
156- if (!first || !first.lubricant) return [""];
157- const partners = techniques.filter(t => t.lubricant && t.name !== first.name && !clashes(first, t));
156+ if (!first || !first.intensity) return [""];
157+ const partners = techniques.filter(t => t.intensity && t.name !== first.name && !clashes(first, t));
158158 return [""].concat(partners.map(t => t.name));
159159 }
160160 if (name === "lube") {
@@ -272,7 +272,7 @@ function fieldNames() {
272272 const chosen = technique(fieldValue("technique"));
273273 names = ["technique"];
274274 if (options("also").length > 1) names.push("also");
275- if (chosen && chosen.lubricant) names.push("intensity");
275+ if (chosen && chosen.intensity) names.push("intensity");
276276 if (["required", "optional"].includes(lubeRule())) names.push("lube");
277277 }
278278 const scoped = run && !["runs", "global"].includes(t.key);
@@ -330,7 +330,7 @@ function parseLinks(header) {
330330 const params = Object.fromEntries([...match[2].matchAll(/([a-z-]+)="([^"]*)"/g)].map(p => [p[1], p[2]]));
331331 const name = (params.rel || "").replace(REL, "");
332332 if (name === "technique") {
333- moves.push({ name: params.title, lubricant: params.lubricant || null });
333+ moves.push({ name: params.title, lubricant: params.lubricant || null, intensity: params.intensity || null });
334334 continue;
335335 }
336336 const methods = (params.allow || "GET").split(",").map(s => s.trim());