an http based roguelike rpg about jelqing
Commit 4347977b8192
tarball8 files changed+213-22
@@ -60,6 +60,11 @@ pub const RING_PRIAPISM: u32 = 10;
6060
pub const SUPPLEMENT_WORKS: u32 = 5;6161
pub const MEDITATION_ODDS: u32 = 2;6262
pub const MEDITATION_EQ: i32 = 10;63
+pub const HOLD_EQ: i32 = 15;64
+pub const REFLECTION: u32 = 12;65
+pub const ARRIVAL_BONUS: i32 = 1;66
+pub const GREED_WARM: f64 = 1.5;67
+pub const GREED_HOT: f64 = 2.0;6368
pub const OBSERVED: u32 = 6;6469
pub const CAT_TAKES_TOWEL: u32 = 5;6570
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
- FLOOR_ALLOWANCE, FLOOR_TURNS, KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT,8
- ZIP,7
+ EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ,8
+ SUSPICION_LIMIT, ZIP,99
},1010
round2,1111
};@@ -16,8 +16,12 @@ impl Run {
1616
let Some(next) = self.floor.next() else {1717
return;1818
};19
+ let warm = EQ_BAND.contains(&self.eq);1920
self.floor = next;20
- self.here = FloorState::default();21
+ self.here = FloorState {22
+ warm_arrival: warm,23
+ ..FloorState::default()24
+ };2125
self.warmed_up = false;2226
self.eq = STARTING_EQ;2327
self.suspicion = 0;@@ -63,8 +67,13 @@ impl Run {
6367
let left = self.floor.name();6468
let reverted = self.here.temp_length;6569
self.next_floor(true);70
+ let warm = if self.here.warm_arrival {71
+ ", and the warmth came with you"72
+ } else {73
+ ""74
+ };6675
let mut body = json!({67
- "detail": format!("You leave {left}. You have arrived at {}.", self.floor.name()),76
+ "detail": format!("You leave {left}. You have arrived at {}{warm}.", self.floor.name()),6877
"note": self.floor.note(),6978
});7079
if reverted > 0.0 {@@ -139,6 +139,7 @@ pub struct FloorState {
139139
pub brother_told: bool,140140
pub shopping: bool,141141
pub meditated: bool,142
+ pub warm_arrival: bool,142143
pub wifi_accepted: bool,143144
}144145
@@ -7,10 +7,10 @@ use serde_json::json;
77
use super::{88
Floor, Grip, LUBRICANTS, Lube, Outcome, Payload, Run, Story, Technique,99
balance::{10
- BAND_BONUS, BRANCH_TURN, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL, DANGER_EQ, EQ_BAND,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,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,1414
},1515
not_extended, round3,1616
state::{MumVisit, Observer, Pending},@@ -350,8 +350,20 @@ impl Run {
350350
self.suspicion =351351
u8::try_from((i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)))352352
.unwrap_or(SUSPICION_LIMIT);353
- self.maybe_summon_mum(was_below);354353
354
+ if intensity >= PULLED_MUSCLE_INTENSITY355
+ && !self.conditions.pulled_muscle356
+ && self.chance(PULLED_MUSCLE)357
+ {358
+ self.conditions.pulled_muscle = true;359
+ self.stamina = self.stamina.min(self.max_stamina());360
+ }361
+ self.settle(was_below);362
+ }363
+364
+ /// The part of a turn the house takes part in, whatever the action was.365
+ fn settle(&mut self, was_below: bool) {366
+ self.maybe_summon_mum(was_below);355367
if let Some(observer) = self.floor.observer()356368
&& self.chance(OBSERVED)357369
{@@ -361,13 +373,6 @@ impl Run {
361373
self.here.towel_stolen = true;362374
}363375
}364
- if intensity >= PULLED_MUSCLE_INTENSITY365
- && !self.conditions.pulled_muscle366
- && self.chance(PULLED_MUSCLE)367
- {368
- self.conditions.pulled_muscle = true;369
- self.stamina = self.stamina.min(self.max_stamina());370
- }371376
if self.items.ring && self.eq > HOT_EQ && self.chance(RING_PRIAPISM) {372377
self.conditions.priapism_turns = PRIAPISM_TURNS;373378
}@@ -382,6 +387,41 @@ impl Run {
382387
}383388
}384389
390
+ /// HOLD. A pause between strokes, offered when the band has been left or the stamina is gone.391
+ pub fn can_hold(&self) -> bool {392
+ self.eq > DANGER_EQ || self.stamina <= 2393
+ }394
+395
+ pub fn hold(&mut self, now: Instant) -> Result<Outcome, Problem> {396
+ self.check_refractory(now)?;397
+ if self.story == Story::Pending {398
+ return Ok(self.branch_offer());399
+ }400
+ if self.items.cage {401
+ return Err(Problem::new(402
+ StatusCode::LOCKED,403
+ "caged",404
+ "The cage is locked. The key is not in the box.",405
+ ));406
+ }407
+ self.touch();408
+ self.turn += 1;409
+ self.here.turns += 1;410
+ self.realise();411
+ let was_below = self.suspicion < SUSPICION_LIMIT;412
+ if self.chance(REFLECTION) {413
+ self.eq = 0;414
+ self.shame += 1;415
+ self.notices416
+ .push("the count reached ten, and then the reflection started");417
+ } else {418
+ self.eq = (self.eq - HOLD_EQ).clamp(0, 100);419
+ }420
+ self.stamina = (self.stamina + 1).min(self.max_stamina());421
+ self.settle(was_below);422
+ Ok(Outcome::empty(StatusCode::NO_CONTENT))423
+ }424
+385425
/// The breathing technique from the forum's replies. It spends no stamina and the turn is386426
/// not the floor's, but each floor allows it once.387427
fn meditate(&mut self) -> Result<Outcome, Problem> {@@ -443,7 +483,13 @@ impl Run {
443483
(Grip::Underhand, Training::Wet) => -1,444484
_ => 0,445485
};446
- let modifier = lube.map_or(0, Lube::modifier) + band + grip;486
+ let arrival = if self.here.warm_arrival {487
+ self.here.warm_arrival = false;488
+ ARRIVAL_BONUS489
+ } else {490
+ 0491
+ };492
+ let modifier = lube.map_or(0, Lube::modifier) + band + grip + arrival;447493
let dc = profile.dc + intensity / 3;448494
let total = natural + modifier;449495
let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");@@ -527,6 +573,15 @@ impl Run {
527573
fn scaled_gains(&self, profile: &Profile, intensity: i32) -> (f64, f64) {528574
let scale = f64::from(intensity);529575
let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);576
+ let greed = if self.eq > HOT_EQ {577
+ GREED_HOT578
+ } else if self.eq > DANGER_EQ {579
+ GREED_WARM580
+ } else {581
+ 1.0582
+ };583
+ length *= greed;584
+ girth *= greed;530585
if self.here.teapot {531586
length *= 0.5;532587
girth *= 1.5;@@ -113,9 +113,8 @@ pub fn unsupported(method: &Method, allow: &str) -> Response {
113113
"The server is a teapot. RFC 2324 is clear on this. It is not yet short and stout.",114114
)115115
.into_response(),116
- "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ" => {117
- method_not_allowed(format!("{method} is not supported here."), allow)118
- }116
+ "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ"117
+ | "HOLD" => method_not_allowed(format!("{method} is not supported here."), allow),119118
_ if name.eq_ignore_ascii_case("jelq") => Problem::new(120119
StatusCode::NOT_IMPLEMENTED,121120
"not-implemented",@@ -59,6 +59,10 @@ pub static RUN: RunResource = RunResource {
5959
Verb::new("GET", get_run),6060
Verb::new("JELQ", jelq)6161
.only_when(not_in_clinic, "JELQ is not performed in the clinic."),62
+ Verb::new("HOLD", hold_run).only_when(63
+ Run::can_hold,64
+ "HOLD is only available above 70 EQ or at 2 stamina or less.",65
+ ),6266
Verb::new("PUT", put_run),6367
Verb::new("PATCH", patch_run),6468
Verb::new("DELETE", delete_run),@@ -177,6 +181,10 @@ fn put_run(run: &mut Run, _: &Ctx) -> Response {
177181
run.put().into_response()178182
}179183
184
+fn hold_run(run: &mut Run, ctx: &Ctx) -> Response {185
+ respond(run.hold(ctx.now))186
+}187
+180188
fn patch_run(run: &mut Run, ctx: &Ctx) -> Response {181189
let is_patch = ctx182190
.header(header::CONTENT_TYPE)@@ -1,4 +1,7 @@
1
-use std::{sync::Arc, time::Duration};1
+use std::{2
+ sync::Arc,3
+ time::{Duration, Instant},4
+};25
36
use axum::{47
Router,@@ -1018,3 +1021,114 @@ async fn meditation_stays_hidden_until_the_forum_teaches_it() {
10181021
let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;10191022
assert_eq!(jelq.status(), StatusCode::FORBIDDEN);10201023
}1024
+1025
+fn app_with(floor: Floor, seed: u64, setup: impl FnOnce(&mut Run)) -> Router {1026
+ let state = Arc::new(AppState::default());1027
+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);1028
+ run.floor = floor;1029
+ setup(&mut run);1030
+ state.insert(run);1031
+ router(state)1032
+}1033
+1034
+#[tokio::test]1035
+async fn hold_is_offered_only_at_the_edges() {1036
+ let calm = seeded_with(Floor::Bedroom, |_| {});1037
+ let refused = send(&calm, "HOLD", "/runs/cafebabe", &[], "").await;1038
+ assert_eq!(refused.status(), StatusCode::METHOD_NOT_ALLOWED);1039
+ assert!(!header(&refused, "allow").contains("HOLD"));1040
+ let view = send(&calm, "GET", "/runs/cafebabe", &[], "").await;1041
+ assert!(!header(&view, "link").contains("HOLD"));1042
+ let options = send(&calm, "OPTIONS", "/runs/cafebabe", &[], "").await;1043
+ assert!(!header(&options, "allow").contains("HOLD"));1044
+1045
+ let winded = seeded_with(Floor::Bedroom, |run| run.stamina = 2);1046
+ let listed = send(&winded, "GET", "/runs/cafebabe", &[], "").await;1047
+ assert!(header(&listed, "link").contains("HOLD"));1048
+ let options = send(&winded, "OPTIONS", "/runs/cafebabe", &[], "").await;1049
+ assert!(header(&options, "allow").contains("HOLD"));1050
+1051
+ let hot = seeded_with(Floor::Bedroom, |run| {1052
+ run.eq = 75;1053
+ run.warmed_up = true;1054
+ });1055
+ let held = send(&hot, "HOLD", "/runs/cafebabe", &[], "").await;1056
+ assert_eq!(held.status(), StatusCode::NO_CONTENT);1057
+ assert_eq!(header(&held, "x-stamina"), "10/10");1058
+}1059
+1060
+#[tokio::test]1061
+async fn holding_calms_and_sometimes_reflects() {1062
+ let calmed = (0..60u64)1063
+ .filter(|seed| {1064
+ let mut run = Run::new("cafebabe".into(), "cafe".into(), *seed);1065
+ run.eq = 75;1066
+ run.hold(Instant::now()).is_ok() && run.eq == 601067
+ })1068
+ .count();1069
+ let reflected = (0..60u64)1070
+ .filter(|seed| {1071
+ let mut run = Run::new("cafebabe".into(), "cafe".into(), *seed);1072
+ run.eq = 75;1073
+ run.hold(Instant::now()).is_ok() && run.eq == 01074
+ })1075
+ .count();1076
+ assert!(1077
+ calmed > 40 && reflected > 0 && calmed + reflected == 60,1078
+ "one HOLD in twelve should reflect, got {calmed} calm and {reflected} reflective of 60"1079
+ );1080
+}1081
+1082
+#[tokio::test]1083
+async fn leaving_the_band_warms_the_next_floor() {1084
+ let app = seeded_with(Floor::Bedroom, |run| {1085
+ run.eq = 60;1086
+ run.warmed_up = true;1087
+ run.here.turns = 6;1088
+ });1089
+ let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;1090
+ assert_eq!(left.status(), StatusCode::SEE_OTHER);1091
+ let detail = json(left).await["detail"].as_str().unwrap().to_owned();1092
+ assert!(detail.contains("the warmth came with you"));1093
+1094
+ let lube = [("content-type", "application/baby-oil")];1095
+ send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;1096
+ // The shower is a +35 warm-up, so EQ lands in the band and the band bonus rides along.1097
+ // The arrival bonus is the one point the second jelq loses.1098
+ let first = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;1099
+ assert!(1100
+ header(&first, "server-timing").contains("d20+4"),1101
+ "the first jelq of a warm arrival rolls with +1, got {}",1102
+ header(&first, "server-timing")1103
+ );1104
+ let second = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;1105
+ assert!(header(&second, "server-timing").contains("d20+3"));1106
+}1107
+1108
+#[tokio::test]1109
+async fn greed_multiplies_gains_above_the_band() {1110
+ let lube = [("content-type", "application/baby-oil")];1111
+ for seed in 0..60u64 {1112
+ let app = app_with(Floor::Bedroom, seed, |run| {1113
+ run.eq = 60;1114
+ run.warmed_up = true;1115
+ });1116
+ let in_band = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;1117
+ let app = app_with(Floor::Bedroom, seed, |run| {1118
+ run.eq = 75;1119
+ run.warmed_up = true;1120
+ });1121
+ let greedy = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;1122
+ if in_band.status() != StatusCode::OK || greedy.status() != StatusCode::OK {1123
+ continue;1124
+ }1125
+ let steady = json(in_band).await["length"].as_f64().unwrap();1126
+ let eager = json(greedy).await["length"].as_f64().unwrap();1127
+ assert!(1128
+ (eager - steady * 1.5).abs() < 1e-9,1129
+ "seed {seed} gained {eager} above the band against {steady} inside it"1130
+ );1131
+ return;1132
+ }1133
+ panic!("no seed in 60 produced two clean rolls to compare");1134
+}@@ -56,7 +56,7 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
5656
5757
<script>5858
const REL = "https://jelq.dev/rels/";59
-const METHODS = ["JELQ", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];59
+const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];6060
const LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];6161
const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "run-ended"];6262
const STATS = [