an http based roguelike rpg about jelqing
Commit 5dfebcc0ac43
tarball11 files changed+225-16
@@ -74,6 +74,16 @@ pub const WATERMELON: (u32, u32) = (2, 5);
7474
pub const CAUGHT_MELONED: u32 = 3;7575
pub const MELON_SHAME: u32 = 5;7676
pub const ENERGY_STAMINA: i32 = 3;77
+pub const FRICTION_MAX: i32 = 10;78
+pub const FRICTION_DRY: i32 = 2;79
+pub const FRICTION_SPIT: i32 = 1;80
+pub const FRICTION_GRIP: i32 = 1;81
+pub const FRICTION_HOLD: i32 = 2;82
+pub const FRICTION_MEDITATE: i32 = 2;83
+pub const FRICTION_FLOOR: i32 = 1;84
+pub const FRICTION_BURN_AT: i32 = 6;85
+pub const FRICTION_BURN: u32 = 10;86
+pub const BURN_RUPTURE: i32 = 15;7787
pub const OBSERVED: u32 = 6;7888
pub const CAT_TAKES_TOWEL: u32 = 5;7989
pub const FRIEND: u32 = 10;@@ -4,8 +4,8 @@ use serde_json::{Value, json};
44
use super::{55
Cause, Elder, Floor, FloorState, Item, Outcome, Run,66
balance::{7
- EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ,8
- SUSPICION_LIMIT, ZIP,7
+ EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, KEY_IN_DRAWER, RULER_IN_DRAWER,8
+ STARTING_EQ, SUSPICION_LIMIT, ZIP,99
},1010
round2,1111
};@@ -25,6 +25,7 @@ impl Run {
2525
self.warmed_up = false;2626
self.eq = STARTING_EQ;2727
self.suspicion = 0;28
+ self.shed(FRICTION_FLOOR);2829
self.mum = None;2930
if paid {3031
self.cash += FLOOR_ALLOWANCE;@@ -59,6 +59,8 @@ pub struct Run {
5959
pub shame_total: u32,6060
pub cash: u32,6161
pub grip: Grip,62
+ pub friction: i32,63
+ pub jelq_grip: Option<Grip>,6264
pub items: Items,6365
pub conditions: Conditions,6466
pub warmed_up: bool,@@ -146,6 +148,8 @@ impl Run {
146148
shame_total: 0,147149
cash: STARTING_CASH,148150
grip: Grip::Overhand,151
+ friction: 0,152
+ jelq_grip: None,149153
items: Items::default(),150154
conditions: Conditions::default(),151155
warmed_up: false,@@ -189,6 +193,10 @@ impl Run {
189193
self.version += 1;190194
}191195
196
+ pub(super) fn shed(&mut self, friction: i32) {197
+ self.friction = (self.friction - friction).max(0);198
+ }199
+192200
fn fresh_nonce(&mut self) -> String {193201
format!("{:016x}", self.rng.random::<u64>())194202
}@@ -153,6 +153,7 @@ pub struct Conditions {
153153
pub peyronie: bool,154154
pub pulled_muscle: bool,155155
pub priapism_turns: u8,156
+ pub friction_burn: bool,156157
}157158
158159
pub struct MumVisit {@@ -178,11 +178,11 @@ impl Utility {
178178
fn effect(self) -> &'static str {179179
match self {180180
Self::Warmup => "EQ +25. Satisfies the warm-up precondition.",181
- Self::Ice => "EQ -40. Consumes an ice pack.",181
+ Self::Ice => "EQ -40. Treats a friction burn. Consumes an ice pack.",182182
Self::Edge => "EQ +15 and a little girth. Three in a row is a loop.",183183
Self::Pump => "Large gains, all temporary. Requires a pump.",184184
Self::Supplement => "Consumes a supplement.",185
- Self::Meditate => "EQ -10. Taught by the forum. Once per floor, no stamina.",185
+ Self::Meditate => "EQ -10, sheds 2 friction. Taught by the forum. Once per floor.",186186
Self::Energy => "+3 stamina. Consumes an energy drink. Tastes like a battery.",187187
}188188
}@@ -7,10 +7,12 @@ use serde_json::json;
77
use super::{88
Floor, Grip, LUBRICANTS, Lube, Outcome, Payload, Run, Story, Technique,99
balance::{10
- ARRIVAL_BONUS, BAND_BONUS, BRANCH_TURN, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL, DANGER_EQ,11
- EQ_BAND, EQ_WOBBLE, EXTENDER_GAIN, GREED_HOT, GREED_WARM, HOLD_EQ, HOT_EQ, MEDITATION_EQ,12
- OBSERVED, PRIAPISM_EQ, PRIAPISM_TURNS, PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, REFLECTION,13
- RING_PRIAPISM, RING_RUPTURE_LINE, RUPTURE_LINE, STORAGE_GIRTH, SUSPICION_LIMIT,10
+ ARRIVAL_BONUS, BAND_BONUS, BRANCH_TURN, BURN_RUPTURE, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL,11
+ DANGER_EQ, EQ_BAND, EQ_WOBBLE, EXTENDER_GAIN, FRICTION_BURN, FRICTION_BURN_AT,12
+ FRICTION_DRY, FRICTION_GRIP, FRICTION_HOLD, FRICTION_MAX, FRICTION_MEDITATE, FRICTION_SPIT,13
+ GREED_HOT, GREED_WARM, HOLD_EQ, HOT_EQ, MEDITATION_EQ, OBSERVED, PRIAPISM_EQ,14
+ PRIAPISM_TURNS, PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, REFLECTION, RING_PRIAPISM,15
+ RING_RUPTURE_LINE, RUPTURE_LINE, STORAGE_GIRTH, SUSPICION_LIMIT,1416
},1517
not_extended, round3,1618
state::{MumVisit, Observer, Pending},@@ -102,6 +104,7 @@ impl Run {
102104
self.take_lube(lube)?;103105
self.check_storage()?;104106
self.floor_enemy()?;107
+ self.chafe(lube);105108
self.commit(intensity, lube, now, 1);106109
if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {107110
return Ok(interrupted);@@ -134,6 +137,7 @@ impl Run {
134137
self.take_lube(lube)?;135138
self.check_storage()?;136139
self.floor_enemy()?;140
+ self.chafe(lube);137141
self.commit(intensity, lube, now, 2);138142
for training in [a, b] {139143
if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {@@ -343,6 +347,25 @@ impl Run {
343347
Ok(())344348
}345349
350
+ fn chafe(&mut self, lube: Option<Lube>) {351
+ let mut friction = match lube {352
+ None => FRICTION_DRY,353
+ Some(Lube::Spit) => FRICTION_SPIT,354
+ Some(_) => 0,355
+ };356
+ if friction > 0 && self.jelq_grip.is_some_and(|grip| grip != self.grip) {357
+ friction -= FRICTION_GRIP;358
+ }359
+ self.jelq_grip = Some(self.grip);360
+ self.friction = (self.friction + friction).min(FRICTION_MAX);361
+ if self.friction >= FRICTION_BURN_AT362
+ && !self.conditions.friction_burn363
+ && self.chance(FRICTION_BURN)364
+ {365
+ self.conditions.friction_burn = true;366
+ }367
+ }368
+346369
pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {347370
self.touch();348371
self.turn += 1;@@ -442,6 +465,7 @@ impl Run {
442465
self.eq = (self.eq - HOLD_EQ).clamp(0, 100);443466
}444467
self.stamina = (self.stamina + 1).min(self.max_stamina());468
+ self.shed(FRICTION_HOLD);445469
self.settle(was_below);446470
Ok(Outcome::empty(StatusCode::NO_CONTENT))447471
}@@ -459,6 +483,7 @@ impl Run {
459483
}460484
self.here.meditated = true;461485
self.eq = (self.eq - MEDITATION_EQ).clamp(0, 100);486
+ self.shed(FRICTION_MEDITATE);462487
self.touch();463488
Ok(Outcome::json(464489
StatusCode::OK,@@ -520,6 +545,11 @@ impl Run {
520545
let timed = |p: Problem| p.header(SERVER_TIMING, &timing);521546
522547
let (length, girth) = self.scaled_gains(&profile, intensity);548
+ let burn = if self.conditions.friction_burn {549
+ BURN_RUPTURE550
+ } else {551
+ 0552
+ };523553
let rupture_line = if self.items.ring {524554
RING_RUPTURE_LINE525555
} else {@@ -529,10 +559,10 @@ impl Run {
529559
if natural == 1 {530560
return Err(timed(self.critical_failure(training, &profile)));531561
}532
- if natural == 2 && self.eq > DANGER_EQ {562
+ if natural == 2 && self.eq > DANGER_EQ - burn {533563
return Err(timed(self.rupture()));534564
}535
- if self.eq > rupture_line && total < dc {565
+ if self.eq > rupture_line - burn && total < dc {536566
return Err(timed(self.rupture()));537567
}538568
if natural == 20 {@@ -613,6 +643,10 @@ impl Run {
613643
if self.items.ring {614644
girth *= 2.0;615645
}646
+ if self.conditions.friction_burn {647
+ length *= 0.5;648
+ girth *= 0.5;649
+ }616650
(length, girth)617651
}618652
@@ -63,10 +63,13 @@ impl Run {
6363
));6464
}6565
self.eq = (self.eq - ICE_EQ).max(0);66
- Ok(Outcome::json(67
- StatusCode::OK,68
- json!({ "detail": "EQ has been reduced. The ice pack is gone." }),69
- ))66
+ let detail = if self.conditions.friction_burn {67
+ self.conditions.friction_burn = false;68
+ "The friction burn is treated. EQ has been reduced. The ice pack is gone."69
+ } else {70
+ "EQ has been reduced. The ice pack is gone."71
+ };72
+ Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))7073
}7174
7275
pub(super) fn energy(&mut self) -> Result<Outcome, Problem> {@@ -7,6 +7,7 @@ pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");
77
pub const X_EQ: HeaderName = HeaderName::from_static("x-eq");88
pub const X_FLOOR: HeaderName = HeaderName::from_static("x-floor");99
pub const X_FORESKIN: HeaderName = HeaderName::from_static("x-foreskin");10
+pub const X_FRICTION: HeaderName = HeaderName::from_static("x-friction");1011
pub const X_FORUM_NOTE: HeaderName = HeaderName::from_static("x-forum-note");1112
pub const X_GIRTH: HeaderName = HeaderName::from_static("x-girth");1213
pub const X_LENGTH: HeaderName = HeaderName::from_static("x-length");@@ -13,10 +13,10 @@ use super::{AppState, ctx::Ctx, meta, middleware::Compress, resource::Resource,
1313
use crate::{1414
game::{1515
Elder, FORUM_THREAD, Floor, Run, SHOP, Story,16
- balance::{BRANCH_TURN, KITCHEN_TELLINGS, SUSPICION_LIMIT},16
+ balance::{BRANCH_TURN, FRICTION_MAX, KITCHEN_TELLINGS, SUSPICION_LIMIT},1717
},1818
headers::{19
- X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_LENGTH, X_OBSERVED_BY,19
+ X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_FRICTION, X_GIRTH, X_LENGTH, X_OBSERVED_BY,2020
X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA, X_SUSPICION,2121
},2222
locale::Locale,@@ -213,6 +213,7 @@ pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response)
213213
X_FORESKIN,214214
run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),215215
),216
+ (X_FRICTION, format!("{}/{}", run.friction, FRICTION_MAX)),216217
(X_EQ, run.eq.to_string()),217218
(X_STAMINA, format!("{}/{}", run.stamina, run.max_stamina())),218219
(X_SUSPICION, format!("{}/{SUSPICION_LIMIT}", run.suspicion)),@@ -232,6 +233,9 @@ pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response)
232233
if run.conditions.pulled_muscle {233234
stats.push((header::WARNING, "199 - \"a muscle has been pulled\"".into()));234235
}236
+ if run.conditions.friction_burn {237
+ stats.push((header::WARNING, "199 - \"friction burn\"".into()));238
+ }235239
for notice in run.notices.drain(..) {236240
stats.push((header::WARNING, format!("199 - \"{notice}\"")));237241
}@@ -1292,3 +1292,149 @@ async fn the_working_supplement_resets_stamina() {
12921292
}12931293
assert!(worked > 0 && bunk > 0, "one in five should work too well");12941294
}1295
+1296
+#[tokio::test]1297
+async fn dry_jelqs_build_friction_and_real_lube_does_not() {1298
+ let app = seeded_with(Floor::Bedroom, |run| {1299
+ run.warmed_up = true;1300
+ });1301
+ let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;1302
+ assert_eq!(header(&dry, "x-friction"), "2/10");1303
+ let lube = [("content-type", "application/baby-oil")];1304
+ let wet = send(1305
+ &app,1306
+ "JELQ",1307
+ "/runs/cafebabe?technique=wet&intensity=5",1308
+ &lube,1309
+ "",1310
+ )1311
+ .await;1312
+ assert_eq!(header(&wet, "x-friction"), "2/10");1313
+ let spit = [("content-type", "text/plain; charset=spit")];1314
+ let glisten = send(1315
+ &app,1316
+ "JELQ",1317
+ "/runs/cafebabe?technique=wet&intensity=5",1318
+ &spit,1319
+ "",1320
+ )1321
+ .await;1322
+ assert_eq!(header(&glisten, "x-friction"), "3/10");1323
+}1324
+1325
+#[tokio::test]1326
+async fn switching_grips_makes_the_friction_cheaper() {1327
+ let app = seeded_with(Floor::Bedroom, |run| {1328
+ run.warmed_up = true;1329
+ });1330
+ let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;1331
+ assert_eq!(header(&dry, "x-friction"), "2/10");1332
+ send(1333
+ &app,1334
+ "GET",1335
+ "/runs/cafebabe/grip",1336
+ &[("upgrade", "underhand"), ("connection", "upgrade")],1337
+ "",1338
+ )1339
+ .await;1340
+ let switched = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;1341
+ assert_eq!(header(&switched, "x-friction"), "3/10");1342
+ let settled = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;1343
+ assert_eq!(header(&settled, "x-friction"), "5/10");1344
+}1345
+1346
+#[tokio::test]1347
+async fn holding_and_meditating_shed_friction() {1348
+ let app = seeded_with(Floor::Bedroom, |run| {1349
+ run.eq = 80;1350
+ run.friction = 5;1351
+ });1352
+ let held = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;1353
+ assert_eq!(held.status(), StatusCode::NO_CONTENT);1354
+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;1355
+ assert_eq!(header(&view, "x-friction"), "3/10");1356
+1357
+ let taught = seeded_with(Floor::Bedroom, |run| {1358
+ run.meditation_taught = true;1359
+ run.friction = 5;1360
+ });1361
+ let calmed = send(1362
+ &taught,1363
+ "JELQ",1364
+ "/runs/cafebabe?technique=meditate",1365
+ &[],1366
+ "",1367
+ )1368
+ .await;1369
+ assert_eq!(calmed.status(), StatusCode::OK);1370
+ let view = send(&taught, "GET", "/runs/cafebabe", &[], "").await;1371
+ assert_eq!(header(&view, "x-friction"), "3/10");1372
+}1373
+1374
+#[tokio::test]1375
+async fn a_friction_burn_halves_the_gains() {1376
+ let lube = [("content-type", "application/baby-oil")];1377
+ let path = "/runs/cafebabe?technique=wet&intensity=5";1378
+ for seed in 0..60u64 {1379
+ let steady = app_with(Floor::Bedroom, seed, |run| {1380
+ run.warmed_up = true;1381
+ run.eq = 60;1382
+ run.items.baby_oil = 9;1383
+ });1384
+ let hurt_app = app_with(Floor::Bedroom, seed, |run| {1385
+ run.warmed_up = true;1386
+ run.eq = 60;1387
+ run.items.baby_oil = 9;1388
+ run.conditions.friction_burn = true;1389
+ });1390
+ let calm = send(&steady, "JELQ", path, &lube, "").await;1391
+ let hurt = send(&hurt_app, "JELQ", path, &lube, "").await;1392
+ if calm.status() != StatusCode::OK || hurt.status() != StatusCode::OK {1393
+ continue;1394
+ }1395
+ let calm_gain = json(calm).await["length"].as_f64().unwrap();1396
+ let hurt_gain = json(hurt).await["length"].as_f64().unwrap();1397
+ assert!(1398
+ (hurt_gain * 2.0 - calm_gain).abs() < 1e-9,1399
+ "seed {seed} burned {hurt_gain} against {calm_gain}"1400
+ );1401
+ return;1402
+ }1403
+ panic!("no seed in 60 produced two clean rolls to compare");1404
+}1405
+1406
+#[tokio::test]1407
+async fn the_burn_warns_and_the_ice_treats_it() {1408
+ let app = seeded_with(Floor::Bedroom, |run| {1409
+ run.warmed_up = true;1410
+ run.eq = 60;1411
+ run.items.baby_oil = 9;1412
+ run.friction = 7;1413
+ run.conditions.friction_burn = true;1414
+ });1415
+ let lube = [("content-type", "application/baby-oil")];1416
+ let jelq = send(1417
+ &app,1418
+ "JELQ",1419
+ "/runs/cafebabe?technique=wet&intensity=5",1420
+ &lube,1421
+ "",1422
+ )1423
+ .await;1424
+ let warnings: Vec<_> = jelq1425
+ .headers()1426
+ .get_all("warning")1427
+ .iter()1428
+ .filter_map(|w| w.to_str().ok())1429
+ .collect();1430
+ assert!(warnings.contains(&"199 - \"friction burn\""));1431
+ send(&app, "JELQ", "/runs/cafebabe?technique=ice", &[], "").await;1432
+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;1433
+ let warnings: Vec<_> = view1434
+ .headers()1435
+ .get_all("warning")1436
+ .iter()1437
+ .filter_map(|w| w.to_str().ok())1438
+ .collect();1439
+ assert!(!warnings.contains(&"199 - \"friction burn\""));1440
+}@@ -65,6 +65,7 @@ const STATS = [
6565
["x-self-reported-length", "Self-reported length"],6666
["x-girth", "Girth"],6767
["x-foreskin", "Foreskin"],68
+ ["x-friction", "Friction"],6869
["x-eq", "EQ (aim for 50 to 70)"],6970
["x-stamina", "Stamina"],7071
["x-suspicion", "Suspicion"],