an http based roguelike rpg about jelqing
Commit a6caa2f99786
tarball14 files changed+239-135
@@ -5,7 +5,6 @@ use crate::secret::Secret;
55
66
pub struct Config {77
pub addr: String,8
- pub refractory: Duration,98
pub secret: Secret,109
pub max_runs: usize,1110
pub idle_ttl: Duration,@@ -16,7 +15,6 @@ impl Default for Config {
1615
fn default() -> Self {1716
Self {1817
addr: "127.0.0.1:6969".into(),19
- refractory: Duration::from_secs(30),2018
secret: Secret::random(),2119
max_runs: 10_000,2220
idle_ttl: Duration::from_secs(6 * 60 * 60),@@ -31,9 +29,6 @@ impl Config {
3129
let defaults = Self::default();3230
Self {3331
addr: var("JELQD_ADDR").unwrap_or(defaults.addr),34
- refractory: var("JELQD_REFRACTORY_SECS")35
- .and_then(|s| s.parse().ok())36
- .map_or(defaults.refractory, Duration::from_secs),3732
secret: var("JELQD_SECRET").map_or(defaults.secret, |s| Secret::new(s.into_bytes())),3833
oracle: match var("JELQD_ORACLE").as_deref() {3934
Some("clm") => OracleTemplate::Clm {@@ -70,6 +70,9 @@ pub const GREED_HOT: f64 = 2.0;
7070
pub const COCONUT_ODDS: u32 = 3;7171
pub const ENERGY_ODDS: u32 = 5;7272
pub const KITCHEN_ICE_ODDS: u32 = 8;73
+pub const KETTLE_ODDS: u32 = 6;74
+pub const ULI_READS: u8 = 2;75
+pub const DRAWER_JAMS: u32 = 4;7376
pub const KITCHEN_TOLD: u32 = 4;7477
pub const KITCHEN_TELLINGS: u8 = 2;7578
pub const WATERMELON: (u32, u32) = (2, 5);@@ -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, FRICTION_FLOOR, GREGS_LOCKER, KEY_IN_DRAWER,8
- RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT, ZIP,7
+ DRAWER_JAMS, EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, GREGS_LOCKER,8
+ KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT, ZIP,99
},1010
round2,1111
};@@ -142,6 +142,15 @@ impl Run {
142142
self.decision("ruler", "Is there a ruler hiding in the drawer?"),143143
RULER_IN_DRAWER,144144
);145
+ let vaseline_here = self.floor == Floor::Bathroom146
+ && first147
+ && self.chance(148
+ self.decision(149
+ "bathroom-vaseline",150
+ "Is it the vaseline behind the door lock, rather than the lotion?",151
+ ),152
+ 2,153
+ );145154
let locker_open = self.floor == Floor::ChangingRoom146155
&& first147156
&& self.chance(@@ -159,11 +168,14 @@ impl Run {
159168
}160169
Floor::Bathroom if first => {161170
self.items.grant(Item::DoorLock);162
- self.items.grant(Item::Vaseline);163
- Ok(self.found(164
- Item::DoorLock,165
- "A door lock, which works once, and a tub of vaseline behind it.",166
- ))171
+ let detail = if vaseline_here {172
+ self.items.grant(Item::Vaseline);173
+ "A door lock, which works once, and a tub of vaseline behind it."174
+ } else {175
+ self.items.grant(Item::Lotion);176
+ "A door lock, which works once, and a bottle of lotion behind it."177
+ };178
+ Ok(self.found(Item::DoorLock, detail))167179
}168180
Floor::ChangingRoom if locker_open => {169181
self.items.grant(Item::BabyOil);@@ -192,9 +204,10 @@ impl Run {
192204
self.items.grant(Item::DndSign);193205
self.items.grant(Item::IcePack);194206
self.items.grant(Item::IcePack);207
+ self.items.grant(Item::Lotion);195208
Ok(self.found(196209
Item::DndSign,197
- "A Do Not Disturb sign, and two ice packs from the machine down the hall.",210
+ "A Do Not Disturb sign, two ice packs from the machine down the hall, and a complimentary lotion.",198211
))199212
}200213
Floor::Forum if first => {@@ -230,15 +243,32 @@ impl Run {
230243
.header(header::LOCATION, self.path("")))231244
}232245
233
- /// Searching while caged is noisy, and the key only turns up some of the time.246
+ /// Searching while caged is noisy, costs the floor turn, and the key only turns up some of247
+ /// the time. A jammed drawer takes a whole turn to work loose.234248
fn rummage_for_key(&mut self) -> Outcome {249
+ self.turn += 1;250
+ self.here.turns += 1;251
+ self.realise();235252
let was_below = self.suspicion < SUSPICION_LIMIT;236253
self.suspicion = (self.suspicion + 1).min(SUSPICION_LIMIT);237254
self.maybe_summon_mum(was_below);255
+ if self.here.drawer_jammed {256
+ self.here.drawer_jammed = false;257
+ self.notices258
+ .push("The drawer comes loose. That took a while.");259
+ return Outcome::empty(StatusCode::NO_CONTENT);260
+ }238261
if !self.chance(239262
self.decision("key", "Does the drawer finally give up the cage key?"),240263
KEY_IN_DRAWER,241264
) {265
+ if self.chance(266
+ self.decision("drawer-jams", "Has the drawer jammed on its runners?"),267
+ DRAWER_JAMS,268
+ ) {269
+ self.here.drawer_jammed = true;270
+ self.notices.push("The drawer has jammed.");271
+ }242272
return Outcome::empty(StatusCode::NO_CONTENT);243273
}244274
self.items.grant(Item::CageKey);@@ -1,9 +1,11 @@
1
+use std::time::SystemTime;2
+13
use axum::http::{StatusCode, header};24
use serde_json::{Value, json};35
46
use super::{57
Cause, Elder, Floor, Outcome, Run, Story,6
- balance::{EXAGGERATION, PATIENCE, SUSPICION_LIMIT, ULI_ODDS},8
+ balance::{EXAGGERATION, PATIENCE, ULI_ODDS, ULI_READS},79
body::exaggeration,810
honest, parse_credentials,911
};@@ -21,6 +23,13 @@ date,length,girth,technique,notes,honest?
2123
2009-06-14,7.50,5.50,wet,progress is slow but real,no2224
";2325
26
+fn nothing_new() -> Outcome {27
+ Outcome::empty(StatusCode::NOT_MODIFIED).header(28
+ header::WARNING,29
+ r#"199 - "Nothing new has been posted since 2009.""#,30
+ )31
+}32
+2433
pub fn forum_thread() -> Value {2534
json!({2635
"title": "The definitive jelqing FAQ (updated 2009)",@@ -109,7 +118,8 @@ impl Run {
109118
)110119
}111120
112
- /// Opening the thread again costs the turn, and each read may finally surface the reply.121
+ /// Re-reading is free. Each floor gets a couple of live reads that may surface the reply,122
+ /// after which the forum is down and the browser serves what it kept.113123
pub fn read_thread(&mut self) -> Result<Outcome, Problem> {114124
if self.story == Story::Pending {115125
return Ok(self.branch_offer());@@ -121,20 +131,21 @@ impl Run {
121131
"There is no thread to re-read. Nobody has mentioned a forum yet.",122132
));123133
}124
- self.touch();125
- self.turn += 1;126
- self.here.turns += 1;127134
self.end_kitchen_visit();128
- self.realise();129
- if self.chance(130
- self.decision(131
- "rupture",132
- "Does the third read of the same thread rupture something?",133
- ),134
- self.rupture_odds(),135
- ) {136
- return Err(self.rupture());135
+ if self.uli_taught {136
+ return Ok(Outcome::json(StatusCode::OK, forum_thread()));137137
}138
+ if self.here.thread_rolls >= ULI_READS {139
+ let age = httpdate::parse_http_date(ELDER_LAST_MODIFIED)140
+ .ok()141
+ .and_then(|posted| SystemTime::now().duration_since(posted).ok())142
+ .map_or(0, |age| age.as_secs());143
+ return Ok(Outcome::json(StatusCode::OK, forum_thread())144
+ .header(header::AGE, age.to_string())145
+ .header(header::WARNING, r#"112 - "Disconnected operation""#));146
+ }147
+ self.here.thread_rolls += 1;148
+ self.touch();138149
if self.chance(139150
self.decision(140151
"teach-uli",@@ -149,12 +160,7 @@ impl Run {
149160
)150161
.header(header::LOCATION, self.path("/techniques")));151162
}152
- let was_below = self.suspicion < SUSPICION_LIMIT;153
- self.settle(was_below);154
- Ok(Outcome::empty(StatusCode::NOT_MODIFIED).header(155
- header::WARNING,156
- r#"199 - "Nothing new has been posted since 2009.""#,157
- ))163
+ Ok(nothing_new())158164
}159165
160166
fn elder_strike(&mut self, problem: Problem) -> Problem {@@ -4,7 +4,7 @@ use serde_json::json;
44
use super::{55
Cause, Floor, Item, Outcome, Run, Story,66
balance::{7
- CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TELLINGS,7
+ CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KETTLE_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TELLINGS,88
KITCHEN_TOLD, MELON_SHAME, RUPTURE_LINE, SUSPICION_LIMIT, WATERMELON,99
},1010
};@@ -41,19 +41,24 @@ impl Run {
4141
return Err(self.rupture());4242
}4343
let was_below = self.suspicion < SUSPICION_LIMIT;44
- // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5 and 1 in 8.44
+ // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5, 1 in 8 and 1 in 6.4545
let loot = self.oracle.pick(4646
self.decision("loot", "What does the fridge give up on this raid?"),4747
&[4848
120 / COCONUT_ODDS,4949
120 / ENERGY_ODDS,5050
120 / KITCHEN_ICE_ODDS,51
- 120 - (120 / COCONUT_ODDS + 120 / ENERGY_ODDS + 120 / KITCHEN_ICE_ODDS),51
+ 120 / KETTLE_ODDS,52
+ 120 - (120 / COCONUT_ODDS53
+ + 120 / ENERGY_ODDS54
+ + 120 / KITCHEN_ICE_ODDS55
+ + 120 / KETTLE_ODDS),5256
],5357
&[5458
"A jar of coconut oil, half of it already gone.",5559
"An energy drink, cold and faintly alarming.",5660
"An ice pack, buried under the peas.",61
+ "The kettle has just boiled.",5762
"Nothing. The fridge is ordinary.",5863
],5964
);@@ -86,6 +91,12 @@ impl Run {
8691
StatusCode::OK,8792
json!({ "detail": "An ice pack, buried under the peas." }),8893
))94
+ } else if loot == 3 {95
+ self.here.kettle_boiled = true;96
+ Ok(Outcome::json(97
+ StatusCode::OK,98
+ json!({ "detail": "The kettle has just boiled. The towel can be warmed once more." }),99
+ ))89100
} else {90101
Ok(Outcome::empty(StatusCode::NO_CONTENT))91102
};@@ -26,10 +26,10 @@ pub use techniques::{LUBRICANTS, Lube, Technique};
2626
2727
use std::{2828
hash::{DefaultHasher, Hash, Hasher},29
- time::{Duration, Instant, SystemTime},29
+ time::{Instant, SystemTime},3030
};3131
32
-use axum::http::{StatusCode, header};32
+use axum::http::StatusCode;3333
use serde_json::{Value, json};3434
3535
use crate::problem::Problem;@@ -76,8 +76,7 @@ pub struct Run {
7676
pub seeded: bool,7777
pub filmed_at: Option<SystemTime>,7878
pub observed: Option<Observer>,79
- pub refractory: Duration,80
- pub refractory_until: Option<Instant>,79
+ pub exhausted: bool,8180
pub pending: Vec<Pending>,8281
pub notices: Vec<&'static str>,8382
pub ended: Option<Ending>,@@ -188,8 +187,7 @@ impl Run {
188187
seeded: false,189188
filmed_at: None,190189
observed: None,191
- refractory: Duration::from_secs(30),192
- refractory_until: None,190
+ exhausted: false,193191
pending: Vec::new(),194192
ended: None,195193
last_seen: Instant::now(),@@ -394,34 +392,28 @@ impl Run {
394392
cause.problem().with("turn", self.turn)395393
}396394
397
- /// Ends the refractory period once it has run out, whatever the request.398
- pub fn recover(&mut self, now: Instant) {399
- if self.refractory_until.is_some_and(|until| now >= until) {400
- self.refractory_until = None;401
- self.stamina = self.max_stamina();402
- self.reset_eq(STARTING_EQ);403
- self.warmed_up = false;404
- self.touch();405
- }395
+ /// The refractory period is the next HOLD. It comes back with full stamina and nothing else.396
+ fn recover(&mut self) {397
+ self.exhausted = false;398
+ self.stamina = self.max_stamina();399
+ self.reset_eq(STARTING_EQ);400
+ self.warmed_up = false;406401
}407402
408
- fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {409
- self.recover(now);410
- let Some(until) = self.refractory_until else {403
+ fn check_exhausted(&self) -> Result<(), Problem> {404
+ if !self.exhausted {411405
return Ok(());412
- };413
- let seconds = until.saturating_duration_since(now).as_secs() + 1;406
+ }414407
Err(Problem::new(415408
StatusCode::TOO_MANY_REQUESTS,416409
"overtrained",417
- "Stamina is exhausted. The refractory period is in effect.",418
- )419
- .header(header::RETRY_AFTER, seconds.to_string()))410
+ "Stamina is exhausted. HOLD for the refractory period.",411
+ ))420412
}421413
422
- fn exhaust(&mut self, now: Instant) {414
+ fn exhaust(&mut self) {423415
self.stamina = 0;424
- self.refractory_until = Some(now + self.refractory);416
+ self.exhausted = true;425417
}426418
427419
fn realise(&mut self) {@@ -136,6 +136,9 @@ impl Grip {
136136
pub struct FloorState {137137
pub turns: u32,138138
pub drawer_searched: bool,139
+ pub drawer_jammed: bool,140
+ pub kettle_boiled: bool,141
+ pub thread_rolls: u8,139142
pub edges: u8,140143
pub temp_length: f64,141144
pub teapot: bool,@@ -1,4 +1,4 @@
1
-use std::{fmt::Write as _, ops::ControlFlow, time::Instant};1
+use std::{fmt::Write as _, ops::ControlFlow};22
33
use axum::http::{StatusCode, header};44
use serde_json::json;@@ -25,9 +25,8 @@ impl Run {
2525
techniques: &[Technique],2626
intensity: i32,2727
lube: Option<Lube>,28
- now: Instant,2928
) -> Result<Outcome, Problem> {30
- self.check_refractory(now)?;29
+ self.check_exhausted()?;3130
if self.story == Story::Pending {3231
return Ok(self.branch_offer());3332
}@@ -46,11 +45,9 @@ impl Run {
4645
));4746
}4847
match *techniques {49
- [Technique::Utility(utility)] => self.utility(utility, lube, now),50
- [Technique::Training(training)] => self.single(training, intensity, lube, now),51
- [Technique::Training(a), Technique::Training(b)] => {52
- self.combo(a, b, intensity, lube, now)53
- }48
+ [Technique::Utility(utility)] => self.utility(utility, lube),49
+ [Technique::Training(training)] => self.single(training, intensity, lube),50
+ [Technique::Training(a), Technique::Training(b)] => self.combo(a, b, intensity, lube),5451
[_, _] => Err(Problem::new(5552
StatusCode::BAD_REQUEST,5653
"combo-invalid",@@ -64,12 +61,7 @@ impl Run {
6461
}6562
}6663
67
- fn utility(68
- &mut self,69
- utility: Utility,70
- lube: Option<Lube>,71
- now: Instant,72
- ) -> Result<Outcome, Problem> {64
+ fn utility(&mut self, utility: Utility, lube: Option<Lube>) -> Result<Outcome, Problem> {7365
match utility {7466
Utility::Warmup => return self.warm_up(),7567
Utility::Ice => return self.ice(),@@ -81,12 +73,9 @@ impl Run {
8173
self.ready(Technique::Utility(utility), lube)?;8274
self.check_storage()?;8375
self.floor_enemy()?;84
- if utility == Utility::Pump {85
- self.here.edges = 0;86
- }8776
match utility {88
- Utility::Edge => self.edge(now),89
- _ => self.pump(now),77
+ Utility::Edge => self.edge(),78
+ _ => self.pump(),9079
}9180
}9281
@@ -95,16 +84,14 @@ impl Run {
9584
training: Training,9685
intensity: i32,9786
lube: Option<Lube>,98
- now: Instant,9987
) -> Result<Outcome, Problem> {10088
self.ready(Technique::Training(training), lube)?;10189
self.check_storage()?;10290
self.lube_available(lube)?;10391
self.floor_enemy()?;104
- self.here.edges = 0;10592
self.take_lube(lube)?;106
- self.chafe(lube);107
- self.commit(intensity, lube, now, 1);93
+ self.chafe(lube, intensity);94
+ self.commit(intensity, lube, 1);10895
if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {10996
return Ok(interrupted);11097
}@@ -121,7 +108,6 @@ impl Run {
121108
b: Training,122109
intensity: i32,123110
lube: Option<Lube>,124
- now: Instant,125111
) -> Result<Outcome, Problem> {126112
if a == b {127113
return Err(Problem::new(@@ -135,10 +121,9 @@ impl Run {
135121
self.check_storage()?;136122
self.lube_available(lube)?;137123
self.floor_enemy()?;138
- self.here.edges = 0;139124
self.take_lube(lube)?;140
- self.chafe(lube);141
- self.commit(intensity, lube, now, 2);125
+ self.chafe(lube, intensity);126
+ self.commit(intensity, lube, 2);142127
for training in [a, b] {143128
if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {144129
return Ok(interrupted);@@ -279,10 +264,8 @@ impl Run {
279264
}280265
281266
/// The techniques a JELQ could use right now without being refused before the roll.282
- pub fn techniques_now(&self, now: Instant) -> Vec<Technique> {283
- let blocked = !self.floor.timed()284
- || self.items.cage285
- || self.refractory_until.is_some_and(|until| now < until);267
+ pub fn techniques_now(&self) -> Vec<Technique> {268
+ let blocked = !self.floor.timed() || self.items.cage || self.exhausted;286269
if blocked {287270
return Vec::new();288271
}@@ -346,12 +329,20 @@ impl Run {
346329
Ok(())347330
}348331
349
- fn chafe(&mut self, lube: Option<Lube>) {350
- let mut friction = match lube {351
- None => FRICTION_DRY,352
- Some(Lube::Spit) => FRICTION_SPIT,353
- Some(_) => 0,332
+ pub(super) fn chafe(&mut self, lube: Option<Lube>, intensity: i32) {333
+ let (base, slip) = match lube {334
+ None => (FRICTION_DRY, 0),335
+ Some(Lube::Spit) => (FRICTION_SPIT, 0),336
+ Some(Lube::Lotion) => (0, 0),337
+ Some(Lube::BabyOil | Lube::CoconutOil) => (0, 1),338
+ Some(Lube::Vaseline) => (0, 2),354339
};340
+ let vigour = match intensity {341
+ ..=5 => 0,342
+ 6..=8 => 1,343
+ _ => 2,344
+ };345
+ let mut friction = (base + vigour - slip).max(0);355346
if friction > 0 && self.jelq_grip.is_some_and(|grip| grip != self.grip) {356347
friction -= FRICTION_GRIP;357348
}@@ -371,7 +362,7 @@ impl Run {
371362
}372363
}373364
374
- pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {365
+ pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, cost: i32) {375366
self.touch();376367
self.turn += 1;377368
self.here.turns += 1;@@ -382,7 +373,7 @@ impl Run {
382373
}383374
self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));384375
if self.stamina <= 0 {385
- self.exhaust(now);376
+ self.exhaust();386377
}387378
let chill = if self.floor == Floor::ChangingRoom {388379
CHANGING_ROOM_CHILL@@ -472,8 +463,7 @@ impl Run {
472463
self.eq > DANGER_EQ || self.stamina <= 2473464
}474465
475
- pub fn hold(&mut self, now: Instant) -> Result<Outcome, Problem> {476
- self.check_refractory(now)?;466
+ pub fn hold(&mut self) -> Result<Outcome, Problem> {477467
if self.story == Story::Pending {478468
return Ok(self.branch_offer());479469
}@@ -496,6 +486,15 @@ impl Run {
496486
return Err(self.rupture());497487
}498488
let was_below = self.suspicion < SUSPICION_LIMIT;489
+ if self.exhausted {490
+ self.recover();491
+ self.shed(FRICTION_HOLD);492
+ self.settle(was_below);493
+ return Ok(Outcome::json(494
+ StatusCode::OK,495
+ json!({ "detail": "The count went well past ten. Stamina is back. Everything else has gone quiet, and you are cold again." }),496
+ ));497
+ }499498
if self.chance(500499
self.decision(501500
"reflection",@@ -1,6 +1,6 @@
11
use std::{22
ops::ControlFlow,3
- time::{Duration, Instant, SystemTime},3
+ time::{Duration, SystemTime},44
};55
66
use axum::http::{StatusCode, header};@@ -19,6 +19,13 @@ const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);
1919
2020
impl Run {2121
pub(super) fn warm_up(&mut self) -> Result<Outcome, Problem> {22
+ if self.warmed_up && !self.here.kettle_boiled {23
+ return Err(Problem::new(24
+ StatusCode::CONFLICT,25
+ "towel-cold",26
+ "The towel has gone cold. It warms once per floor, or once after the refractory period, unless someone boils the kettle.",27
+ ));28
+ }2229
if self.floor == Floor::Hotel2330
&& self.chance(2431
self.decision(@@ -50,6 +57,9 @@ impl Run {
5057
};5158
self.touch();5259
self.step_away();60
+ if self.warmed_up {61
+ self.here.kettle_boiled = false;62
+ }5363
self.warmed_up = true;5464
self.eq = (self.eq + boost).min(100);5565
Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))@@ -134,8 +144,9 @@ impl Run {
134144
.with("upstream", "Pump-Bro"))135145
}136146
137
- pub(super) fn edge(&mut self, now: Instant) -> Result<Outcome, Problem> {138
- self.commit(5, None, now, 1);147
+ pub(super) fn edge(&mut self) -> Result<Outcome, Problem> {148
+ self.chafe(None, 5);149
+ self.commit(5, None, 1);139150
if let ControlFlow::Break(interrupted) = self.hazards(5)? {140151
return Ok(interrupted);141152
}@@ -144,7 +155,7 @@ impl Run {
144155
self.girth += EDGE_GIRTH;145156
if self.here.edges >= EDGE_LOOP {146157
self.here.edges = 0;147
- self.exhaust(now);158
+ self.exhaust();148159
return Err(Problem::new(149160
StatusCode::LOOP_DETECTED,150161
"edging-loop",@@ -157,8 +168,8 @@ impl Run {
157168
))158169
}159170
160
- pub(super) fn pump(&mut self, now: Instant) -> Result<Outcome, Problem> {161
- self.commit(4, None, now, 1);171
+ pub(super) fn pump(&mut self) -> Result<Outcome, Problem> {172
+ self.commit(4, None, 1);162173
if let ControlFlow::Break(interrupted) = self.hazards(4)? {163174
return Ok(interrupted);164175
}@@ -105,7 +105,6 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
105105
&config.oracle,106106
);107107
run.seeded = seeded;108
- run.refractory = config.refractory;109108
run.last_seen = ctx.now;110109
let response = (111110
StatusCode::CREATED,@@ -1,7 +1,5 @@
11
//! Plumbing shared by every resource under `/runs/{id}`.22
3
-use std::time::Instant;4
-53
use axum::{64
http::{HeaderName, HeaderValue, StatusCode, header},75
response::{IntoResponse, Response},@@ -60,7 +58,6 @@ pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {
6058
.into_response();6159
}6260
run.last_seen = ctx.now;63
- run.recover(ctx.now);6461
run.observed = None;6562
let response =6663
gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx));@@ -96,7 +93,7 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
9693
9794
/// Every resource the run can reach right now, then every technique a JELQ could use. The `allow`9895
/// and `lubricant` extension attributes (RFC 8288 §3) say what each accepts, so a client never guesses.99
-fn links(run: &Run, now: Instant) -> String {96
+fn links(run: &Run) -> String {10097
let thread_reachable = run.thread_reachable();10198
let linked: [(&str, &RunResource, bool); 15] = [10299
("", &runs::RUN, true),@@ -152,7 +149,7 @@ fn links(run: &Run, now: Instant) -> String {
152149
};153150
[choice("forum", "Read the forum"), choice("close-tab", "Close the tab")].join(", ")154151
}))155
- .chain(run.techniques_now(now).into_iter().map(|technique| {152
+ .chain(run.techniques_now().into_iter().map(|technique| {156153
let name = technique.name();157154
let lubricant = technique158155
.lubricant()@@ -219,7 +216,7 @@ pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response)
219216
(X_SHAME, shame.to_string()),220217
(X_CASH, run.cash.to_string()),221218
(X_FLOOR, run.floor.number().to_string()),222
- (header::LINK, links(run, ctx.now)),219
+ (header::LINK, links(run)),223220
];224221
if let Some(observer) = run.observed {225222
stats.push((X_OBSERVED_BY, observer.name().into()));@@ -173,9 +173,7 @@ fn jelq(run: &mut Run, ctx: &Ctx) -> Response {
173173
Ok((techniques, intensity, lube))174174
});175175
match parsed {176
- Ok((techniques, intensity, lube)) => {177
- respond(run.jelq(&techniques, intensity, lube, ctx.now))178
- }176
+ Ok((techniques, intensity, lube)) => respond(run.jelq(&techniques, intensity, lube)),179177
Err(problem) => problem.into_response(),180178
}181179
}@@ -184,8 +182,8 @@ fn put_run(run: &mut Run, _: &Ctx) -> Response {
184182
run.put().into_response()185183
}186184
187
-fn hold_run(run: &mut Run, ctx: &Ctx) -> Response {188
- respond(run.hold(ctx.now))185
+fn hold_run(run: &mut Run, _: &Ctx) -> Response {186
+ respond(run.hold())189187
}190188
191189
fn patch_run(run: &mut Run, ctx: &Ctx) -> Response {@@ -1,7 +1,4 @@
1
-use std::{2
- sync::Arc,3
- time::{Duration, Instant},4
-};1
+use std::{sync::Arc, time::Duration};52
63
use axum::{74
Router,@@ -994,7 +991,6 @@ async fn the_thread_announces_itself_at_the_branch() {
994991
#[tokio::test]995992
async fn rereading_the_thread_teaches_uli() {996993
let mut taught_runs = 0;997
- let mut ruptured = 0;998994
for seed in 0..100u64 {999995
let app = app_with(Floor::Bedroom, seed, |run| {1000996
run.turn = BRANCH_TURN;@@ -1002,34 +998,36 @@ async fn rereading_the_thread_teaches_uli() {
1002998
run.story = Story::ClosedTab;1003999
});10041000
let mut taught = false;1005
- for _ in 0..40 {1001
+ for _ in 0..2 {10061002
let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;10071003
match read.status() {10081004
StatusCode::NOT_MODIFIED => {10091005
assert!(header(&read, "warning").contains("2009"));10101006
}1011
- StatusCode::MULTIPLE_CHOICES => {1012
- send(&app, "POST", "/runs/cafebabe/branches/close-tab", &[], "").await;1013
- }10141007
StatusCode::OK => {10151008
assert!(header(&read, "location").contains("/techniques"));10161009
taught = true;10171010
break;10181011
}1019
- StatusCode::INTERNAL_SERVER_ERROR => break,10201012
other => panic!("seed {seed} read the thread into {other}"),10211013
}10221014
}1015
+ let state = send(&app, "GET", "/runs/cafebabe", &[], "").await;1016
+ assert_eq!(header(&state, "x-stamina"), "10/10", "reading is free");10231017
if taught {10241018
taught_runs += 1;10251019
let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=uli", &[], "").await;10261020
assert_ne!(jelq.status(), StatusCode::FORBIDDEN);1021
+ } else {1022
+ let cached = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;1023
+ assert_eq!(cached.status(), StatusCode::OK);1024
+ assert!(header(&cached, "warning").starts_with("112"));1025
+ assert!(header(&cached, "age").parse::<u64>().unwrap() > 0);10271026
}1028
- ruptured += u32::from(!taught);10291027
}10301028
assert!(1031
- taught_runs >= 85,1032
- "{taught_runs}/100 learned uli, {ruptured} ruptured first"1029
+ (30..=58).contains(&taught_runs),1030
+ "{taught_runs}/100 learned uli in two reads"10331031
);10341032
}10351033
@@ -1096,7 +1094,7 @@ async fn holding_calms_and_sometimes_reflects() {
10961094
for seed in 0..120u64 {10971095
let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);10981096
run.eq = 75;1099
- match run.hold(Instant::now()) {1097
+ match run.hold() {11001098
Ok(_) if run.eq == 0 => reflected += 1,11011099
Ok(_) => calmed += 1,11021100
Err(_) => ruptured += 1,@@ -1893,3 +1891,65 @@ async fn a_full_server_buries_the_dead_to_make_room() {
18931891
let second = send(&app, "POST", "/runs", &[], "").await;18941892
assert_eq!(second.status(), StatusCode::CREATED);18951893
}1894
+1895
+#[tokio::test]1896
+async fn the_towel_warms_once_until_the_kettle_boils() {1897
+ let app = seeded_with(Floor::Bedroom, |_| {});1898
+ let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;1899
+ assert_eq!(warm.status(), StatusCode::OK);1900
+ let again = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;1901
+ assert_eq!(again.status(), StatusCode::CONFLICT);1902
+1903
+ let app = seeded_with(Floor::Bedroom, |run| {1904
+ run.warmed_up = true;1905
+ run.here.kettle_boiled = true;1906
+ });1907
+ let reheated = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;1908
+ assert_eq!(reheated.status(), StatusCode::OK);1909
+ let cold = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;1910
+ assert_eq!(cold.status(), StatusCode::CONFLICT);1911
+}1912
+1913
+#[tokio::test]1914
+async fn the_refractory_period_is_the_next_hold() {1915
+ let app = seeded_with(Floor::Hotel, |run| {1916
+ run.here.wifi_accepted = true;1917
+ run.warmed_up = true;1918
+ run.eq = 60;1919
+ run.stamina = 0;1920
+ run.exhausted = true;1921
+ });1922
+ let tired = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;1923
+ assert_eq!(tired.status(), StatusCode::TOO_MANY_REQUESTS);1924
+ let hold = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;1925
+ assert_eq!(hold.status(), StatusCode::OK);1926
+ assert_eq!(header(&hold, "x-stamina"), "10/10");1927
+ let cold = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;1928
+ assert_eq!(cold.status(), StatusCode::PRECONDITION_FAILED);1929
+}1930
+1931
+#[tokio::test]1932
+async fn friction_rises_with_vigour_and_edging_chafes() {1933
+ let spit = [("content-type", "text/plain; charset=spit")];1934
+ for (intensity, friction) in [(5, "1/10"), (7, "2/10"), (10, "3/10")] {1935
+ let app = seeded_with(Floor::Hotel, |run| {1936
+ run.here.wifi_accepted = true;1937
+ run.warmed_up = true;1938
+ run.eq = 55;1939
+ });1940
+ let uri = format!("/runs/cafebabe?technique=wet&intensity={intensity}");1941
+ let jelq = send(&app, "JELQ", &uri, &spit, "").await;1942
+ assert_eq!(1943
+ header(&jelq, "x-friction"),1944
+ friction,1945
+ "intensity {intensity}"1946
+ );1947
+ }1948
+ let app = seeded_with(Floor::Hotel, |run| {1949
+ run.here.wifi_accepted = true;1950
+ run.warmed_up = true;1951
+ run.eq = 55;1952
+ });1953
+ let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;1954
+ assert_eq!(header(&edge, "x-friction"), "2/10");1955
+}@@ -238,7 +238,7 @@ function renderAlerts(list) {
238238
}239239
}240240
const stamina = (list || []).find(([name]) => name === "x-stamina");241
- if (stamina && stamina[1].startsWith("0/")) alerts.push("Exhausted. Wait out the refractory period, then GET the run.");241
+ if (stamina && stamina[1].startsWith("0/")) alerts.push("Exhausted. HOLD for the refractory period.");242242
for (const alert of alerts) box.append(el("h2", { textContent: alert }));243243
}