an http based roguelike rpg about jelqing
Commit 3ac624339c98
tarball18 files changed+214-44
@@ -1,4 +1,8 @@
11
pub const FLOOR_TURNS: u32 = 6;2
+pub const FLOOR_LIMIT: u32 = 10;3
+pub const OVERSTAY_BUSTED: u32 = 2;4
+pub const OVERSTAY_EQ: i32 = 5;5
+pub const OVERSTAY_SHAME: u32 = 10;26
pub const BRANCH_TURN: u32 = 10;37
pub const ZIP: u32 = 200;48
pub const RULER_IN_DRAWER: u32 = 2;@@ -2,6 +2,8 @@ pub const FRICTION_MAX: i32 = 10;
22
pub const FRICTION_DRY: i32 = 2;33
pub const FRICTION_SPIT: i32 = 1;44
pub const FRICTION_COLD: i32 = 2;5
+pub const FRICTION_TEAR: f64 = 0.1;6
+pub const RAW_DEGLOVING: u32 = 20;57
pub const FRICTION_GRIP: i32 = 1;68
pub const FRICTION_HOLD: i32 = 2;79
pub const FRICTION_MEDITATE: i32 = 2;@@ -22,6 +22,7 @@ pub struct Body {
2222
pub(crate) conditions: Conditions,2323
pub(crate) pending: Vec<Pending>,2424
pub(crate) arrived: Option<Arrival>,25
+ pub(crate) torn: f64,2526
}2627
2728
impl Body {@@ -40,6 +41,7 @@ impl Body {
4041
conditions: Conditions::default(),4142
pending: Vec::new(),4243
arrived: None,44
+ torn: 0.0,4345
}4446
}4547
}@@ -11,12 +11,13 @@ pub enum Cause {
1111
Degloved,1212
Zip,1313
Meloned,14
+ Overstayed,1415
Abandoned,1516
Lost,1617
}1718
1819
impl Cause {19
- pub const ALL: [Self; 10] = [20
+ pub const ALL: [Self; 11] = [2021
Self::Ruptured { ice_unused: true },2122
Self::Ruptured { ice_unused: false },2223
Self::Caught,@@ -25,6 +26,7 @@ impl Cause {
2526
Self::Degloved,2627
Self::Zip,2728
Self::Meloned,29
+ Self::Overstayed,2830
Self::Abandoned,2931
Self::Lost,3032
];@@ -38,6 +40,7 @@ impl Cause {
3840
Self::Degloved => "degloved",3941
Self::Zip => "zip",4042
Self::Meloned => "watermelon",43
+ Self::Overstayed => "overstayed",4144
Self::Abandoned => "abandoned",4245
Self::Lost => "lost",4346
}@@ -67,6 +70,9 @@ impl Cause {
6770
Self::Meloned => {6871
"You have been caught with the watermelon. Nobody will mention it at dinner. The run has ended."6972
}73
+ Self::Overstayed => {74
+ "The floor ran long and somebody walked in. The run has ended. Nobody will mention it at dinner."75
+ }7076
Self::Abandoned => "The run was abandoned.",7177
Self::Lost => {7278
"The server fell over mid-stroke and the run went with it. Nobody will mention it at dinner."@@ -85,9 +91,12 @@ impl Cause {
8591
fn status(self) -> StatusCode {8692
match self {8793
Self::Ruptured { .. } | Self::Lost => StatusCode::INTERNAL_SERVER_ERROR,88
- Self::Caught | Self::Opened | Self::Degloved | Self::Abandoned | Self::Meloned => {89
- StatusCode::GONE90
- }94
+ Self::Caught95
+ | Self::Opened96
+ | Self::Degloved97
+ | Self::Abandoned98
+ | Self::Meloned99
+ | Self::Overstayed => StatusCode::GONE,91100
Self::Banned => StatusCode::FORBIDDEN,92101
Self::Zip => StatusCode::CONFLICT,93102
}@@ -86,7 +86,7 @@ impl Run {
8686
self.take_lube(lube)?;8787
let cold = self.cold();8888
self.chafe(lube, intensity, cold);89
- self.commit(intensity, lube, 1);89
+ self.commit(intensity, lube, 1)?;9090
if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity, cold)? {9191
return Ok(interrupted);9292
}@@ -120,7 +120,7 @@ impl Run {
120120
let cold = self.cold();121121
self.chafe(lube, intensity, cold);122122
self.chafe(lube, intensity, cold);123
- self.commit(intensity, lube, 2);123
+ self.commit(intensity, lube, 2)?;124124
for training in [a, b] {125125
if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity, cold)? {126126
return Ok(interrupted);@@ -22,7 +22,7 @@ impl Run {
2222
self.touch();2323
self.end_kitchen_visit();2424
if self.items.kit.cage {25
- return Ok(self.rummage_for_key());25
+ return self.rummage_for_key();2626
}2727
let first = !self.here.drawer.searched;2828
self.here.drawer.searched = true;@@ -126,14 +126,14 @@ impl Run {
126126
127127
/// Searching while caged is noisy, costs the floor turn, and the key only turns up some of128128
/// the time. A jammed drawer takes a whole turn to work loose.129
- pub(super) fn rummage_for_key(&mut self) -> Outcome {130
- self.begin_turn();129
+ pub(super) fn rummage_for_key(&mut self) -> Result<Outcome, Problem> {130
+ self.begin_turn()?;131131
self.add_suspicion(1);132132
self.maybe_summon_mum();133133
if self.here.drawer.jammed {134134
self.here.drawer.jammed = false;135135
self.notices.push(Notice::DrawerComesLoose);136
- return Outcome::empty(StatusCode::NO_CONTENT);136
+ return Ok(Outcome::empty(StatusCode::NO_CONTENT));137137
}138138
if !self.chance(139139
self.decision("key", "Does the drawer finally give up the cage key?"),@@ -146,13 +146,13 @@ impl Run {
146146
self.here.drawer.jammed = true;147147
self.notices.push(Notice::DrawerJammed);148148
}149
- return Outcome::empty(StatusCode::NO_CONTENT);149
+ return Ok(Outcome::empty(StatusCode::NO_CONTENT));150150
}151151
self.items.grant(Item::CageKey);152
- Outcome::json(152
+ Ok(Outcome::json(153153
StatusCode::OK,154154
json!({ "detail": "The key was in the drawer. The cage is off." }),155
- )155
+ ))156156
}157157
158158
pub fn drawer_worth_searching(&self) -> bool {@@ -34,13 +34,6 @@ fn thread(detail: Option<&str>) -> Outcome {
3434
Outcome::json(StatusCode::OK, body).header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)3535
}3636
37
-fn nothing_new() -> Outcome {38
- thread(None).header(39
- header::WARNING,40
- r#"199 - "Nothing new has been posted since 2009.""#,41
- )42
-}43
-4437
pub fn forum_thread() -> Value {4538
json!({4639
"title": "The definitive jelqing FAQ (updated 2009)",@@ -191,9 +184,6 @@ impl Run {
191184
/// Re-reading is free. Each floor gets a couple of live reads that may surface the reply,192185
/// after which the forum is down and the browser serves what it kept.193186
pub fn read_thread(&mut self) -> Result<Outcome, Problem> {194
- if self.online.story == Story::Pending {195
- return Ok(self.branch_offer());196
- }197187
if !self.thread_reachable() {198188
return Err(Problem::new(199189
StatusCode::NOT_FOUND,@@ -201,6 +191,9 @@ impl Run {
201191
"There is no thread. Nobody has mentioned a forum yet.",202192
));203193
}194
+ if self.online.story == Story::Pending {195
+ return Ok(thread(None));196
+ }204197
self.end_kitchen_visit();205198
if self.online.uli_taught {206199
return Ok(thread(None));@@ -217,7 +210,7 @@ impl Run {
217210
self.touch();218211
let detail = if !self.online.meditation_taught {219212
if !self.learn_meditation() {220
- return Ok(nothing_new());213
+ return Ok(thread(None));221214
}222215
"Between two arguments about pumps, a reply explains how to breathe. In through the nose, out through the mouth. You write it down."223216
} else {@@ -228,7 +221,7 @@ impl Run {
228221
),229222
ULI_ODDS,230223
) {231
- return Ok(nothing_new());224
+ return Ok(thread(None));232225
}233226
self.online.uli_taught = true;234227
"Past the necroposting, a reply from TheElder lays out the technique. Uli. The full routine. You write it down before the tab can lose it."@@ -7,9 +7,9 @@ use super::{
77
Cause, Floor, Notice, Outcome, Run,88
balance::{99
BURN_RUPTURE, COLD_DEGLOVING, COLD_EQ, CURVE_MISSES, DANGER_EQ, DEGLOVING,10
- DEGLOVING_INTENSITY, EQ_BAND, FRIEND, HOT_EQ, LOST_IT_EQ, PREMATURE_EQ, RUPTURE_HOT,11
- RUPTURE_IN_BAND, RUPTURE_PREMATURE, RUPTURE_WARM, SOFT_INTENSITY, SUSPICION_LIMIT,12
- WANDERER_VISIT, WINDOW,10
+ DEGLOVING_INTENSITY, EQ_BAND, FRICTION_MAX, FRICTION_TEAR, FRIEND, HOT_EQ, LOST_IT_EQ,11
+ PREMATURE_EQ, RAW_DEGLOVING, RUPTURE_HOT, RUPTURE_IN_BAND, RUPTURE_PREMATURE, RUPTURE_WARM,12
+ SOFT_INTENSITY, SUSPICION_LIMIT, WANDERER_VISIT, WINDOW,1313
},1414
measure::round3,1515
techniques::Training,@@ -62,6 +62,9 @@ impl Run {
6262
return Err(self.deglove());6363
}6464
}65
+ if self.body.friction >= FRICTION_MAX {66
+ self.tear()?;67
+ }6568
if training == Training::Dry6669
&& intensity >= DEGLOVING_INTENSITY6770
&& self.chance(@@ -74,6 +77,39 @@ impl Run {
7477
Ok(ControlFlow::Continue(()))7578
}7679
80
+ fn tear(&mut self) -> Result<(), Problem> {81
+ if !self.notices.contains(&Notice::SkinGivingWay) {82
+ self.notices.push(Notice::SkinGivingWay);83
+ }84
+ match self.body.foreskin {85
+ Some(left) if left - FRICTION_TEAR > 1e-9 => {86
+ self.body.foreskin = Some(left - FRICTION_TEAR);87
+ self.body.torn += FRICTION_TEAR;88
+ Ok(())89
+ }90
+ Some(left) => {91
+ self.body.foreskin = None;92
+ self.standing.shame += 25;93
+ Err(Problem::new(94
+ StatusCode::GONE,95
+ "foreskin-lost",96
+ "The friction has removed the foreskin. It will not be returning.",97
+ )98
+ .with("foreskin", -round3(left))99
+ .header(header::CONTENT_LOCATION, self.path("/foreskin")))100
+ }101
+ None => {102
+ if self.chance(103
+ self.decision("raw-degloving", "Rubbed raw, does the skin tear?"),104
+ RAW_DEGLOVING,105
+ ) {106
+ return Err(self.deglove());107
+ }108
+ Ok(())109
+ }110
+ }111
+ }112
+77113
fn deglove(&mut self) -> Problem {78114
let lost = self.body.foreskin.take();79115
let problem = self.end(Cause::Degloved);@@ -26,7 +26,7 @@ impl Run {
2626
if self.online.story == Story::Pending {2727
return Ok(self.branch_offer());2828
}29
- self.begin_turn();29
+ self.begin_turn()?;3030
if self.chance(3131
self.decision("rupture", "Does this raid of the fridge rupture something?"),3232
self.rupture_odds(),@@ -1,3 +1,5 @@
1
+use super::Floor;2
+13
/// Something that happened alongside the turn, which rides on the response as a `Warning`.24
#[derive(Clone, Copy, PartialEq, Eq, Debug)]35
pub enum Notice {@@ -13,6 +15,9 @@ pub enum Notice {
1315
Reflection,1416
MumSatisfied,1517
ColdStart,18
+ RunningLong(Floor),19
+ LastTurn,20
+ SkinGivingWay,1621
}1722
1823
impl Notice {@@ -30,6 +35,16 @@ impl Notice {
3035
Self::Reflection => "the count reached ten, and then the reflection started",3136
Self::MumSatisfied => "Mum seems satisfied. She takes the washing with her.",3237
Self::ColdStart => "Cold start. The server has noted this.",38
+ Self::RunningLong(floor) => match floor {39
+ Floor::Bedroom => "Mum is coming up the stairs.",40
+ Floor::Bathroom => "Your brother has gone to get Mum.",41
+ Floor::ChangingRoom => "The gym closes in ten minutes.",42
+ Floor::Nans => "Dinner is nearly ready. Somebody has been sent to find you.",43
+ Floor::Hotel => "Checkout was at eleven. Housekeeping has a master key.",44
+ Floor::Forum | Floor::Clinic => "The floor is running long.",45
+ },46
+ Self::LastTurn => "This is the last turn on this floor. Leave now or be found.",47
+ Self::SkinGivingWay => "the skin is giving way",3348
}3449
}3550
}@@ -8,6 +8,7 @@ use super::{
88
STROKE_EQ, STROKE_EQ_COST, SUSPICION_LIMIT, VIGOUR,99
},1010
};11
+use crate::problem::Problem;1112
1213
impl Run {1314
pub(super) fn chafe(&mut self, lube: Option<Lube>, intensity: i32, cold: bool) {@@ -50,8 +51,13 @@ impl Run {
5051
}5152
}5253
53
- pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, cost: i32) {54
- self.begin_turn();54
+ pub(super) fn commit(55
+ &mut self,56
+ intensity: i32,57
+ lube: Option<Lube>,58
+ cost: i32,59
+ ) -> Result<(), Problem> {60
+ self.begin_turn()?;5561
self.step_away();5662
if self.items.kit.extender {5763
self.body.length += EXTENDER_GAIN;@@ -92,6 +98,7 @@ impl Run {
9298
self.body.stamina = self.body.stamina.min(self.max_stamina());9399
}94100
self.settle();101
+ Ok(())95102
}96103
97104
pub(super) fn make_noise(&mut self, noise: i32) {@@ -25,7 +25,7 @@ impl Run {
2525
"The cage is locked. The key is not in the box.",2626
));2727
}28
- self.begin_turn();28
+ self.begin_turn()?;2929
if self.oracle.chance_not(3030
self.decision("rupture", "The pause is uneventful"),3131
RUPTURE_HOLD,@@ -10,7 +10,7 @@ use super::{
1010
Body, Cause, Clinic, Ending, Floor, FloorState, Household, Item, Items, Notice, Observer,1111
Online, RunStatus, Standing, Story,1212
balance::{13
- BRANCH_TURN, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,13
+ BRANCH_TURN, FLOOR_LIMIT, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,1414
STARTING_BABY_OIL, STARTING_EQ, STARTING_LUBE,1515
},1616
measure::round2,@@ -148,12 +148,22 @@ impl Run {
148148
self.ident.version += 1;149149
}150150
151
- pub(super) fn begin_turn(&mut self) {151
+ pub(super) fn begin_turn(&mut self) -> Result<(), Problem> {152
+ if self.floor.timed() && self.here.turns + 1 >= FLOOR_LIMIT {153
+ return Err(self.overstay());154
+ }152155
self.touch();153156
self.turn += 1;154157
self.here.turns += 1;158
+ self.body.torn = 0.0;155159
self.standing.at_turn_start = self.standing.suspicion;156160
self.realise();161
+ if self.floor.timed() && self.here.turns + 2 == FLOOR_LIMIT {162
+ self.notices.push(Notice::RunningLong(self.floor));163
+ } else if self.floor.timed() && self.here.turns + 1 == FLOOR_LIMIT {164
+ self.notices.push(Notice::LastTurn);165
+ }166
+ Ok(())157167
}158168
159169
pub(super) fn shed(&mut self, friction: i32) {@@ -365,12 +375,19 @@ impl Run {
365375
}366376
}367377
378
+ /// The foreskin change it returns nets out whatever friction tore off this turn.368379
pub(super) fn gain(&mut self, length: f64, girth: f64, foreskin: f64) -> Option<f64> {369380
self.body.length += length;370381
self.body.girth += girth;371
- let f = self.body.foreskin.as_mut().filter(|_| foreskin != 0.0)?;372
- *f += foreskin;373
- Some(foreskin)382
+ let torn = std::mem::take(&mut self.body.torn);383
+ let grown = match &mut self.body.foreskin {384
+ Some(f) => {385
+ *f += foreskin;386
+ foreskin387
+ }388
+ None => 0.0,389
+ };390
+ (grown != 0.0 || torn != 0.0).then_some(grown - torn)374391
}375392
376393
pub(super) fn rupture(&mut self) -> Problem {@@ -2,7 +2,7 @@ use super::{
22
Cause, Elder, Floor, FloorState, Notice, Outcome, Run,33
balance::{44
EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, LOCKER_ROOM_BAR, LOCKER_ROOM_OTHERS,5
- LOCKER_ROOM_SHAME, STARTING_EQ, ZIP,5
+ LOCKER_ROOM_SHAME, OVERSTAY_BUSTED, OVERSTAY_EQ, OVERSTAY_SHAME, STARTING_EQ, ZIP,66
},77
measure::round2,88
};@@ -15,6 +15,7 @@ use serde_json::{Value, json};
1515
pub(super) enum Exit {1616
Proper,1717
Premature,18
+ Overstayed,1819
}1920
2021
impl Run {@@ -42,6 +43,30 @@ impl Run {
4243
self.touch();4344
}4445
46
+ /// A floor past its limit either ends the run or moves you along, shamed and deflated.47
+ pub(super) fn overstay(&mut self) -> Problem {48
+ self.touch();49
+ if self.chance(50
+ self.decision(51
+ "overstayed",52
+ "The floor has run long. Does somebody walk in?",53
+ ),54
+ OVERSTAY_BUSTED,55
+ ) {56
+ return self.end(Cause::Overstayed);57
+ }58
+ self.standing.shame += OVERSTAY_SHAME;59
+ self.next_floor(Exit::Overstayed);60
+ self.here.warm_arrival = false;61
+ self.reset_eq(OVERSTAY_EQ);62
+ Problem::new(63
+ StatusCode::REQUEST_TIMEOUT,64
+ "floor-timed-out",65
+ "The floor has timed out. You have been moved along.",66
+ )67
+ .header(header::CONNECTION, "close")68
+ }69
+4570
pub fn advance(&mut self) -> Result<Outcome, Problem> {4671
match self.floor {4772
Floor::Clinic => {@@ -147,7 +147,7 @@ impl Run {
147147
148148
pub(super) fn edge(&mut self) -> Result<Outcome, Problem> {149149
self.chafe(None, 5, false);150
- self.commit(5, None, 1);150
+ self.commit(5, None, 1)?;151151
if let ControlFlow::Break(interrupted) = self.hazards(5)? {152152
return Ok(interrupted);153153
}@@ -170,7 +170,7 @@ impl Run {
170170
}171171
172172
pub(super) fn pump(&mut self) -> Result<Outcome, Problem> {173
- self.commit(4, None, 1);173
+ self.commit(4, None, 1)?;174174
if let ControlFlow::Break(interrupted) = self.hazards(4)? {175175
return Ok(interrupted);176176
}@@ -200,15 +200,13 @@ async fn reading_the_thread_teaches_uli() {
200200
for _ in 0..2 {201201
let read = send(&app, "GET", "/runs/cafebabe/thread", &[], "").await;202202
assert_eq!(read.status(), StatusCode::OK, "seed {seed}");203
- let warned = warnings(&read).iter().any(|w| w.contains("2009"));203
+ assert!(warnings(&read).is_empty(), "a live read gives nothing away");204204
let body = json(read).await;205205
assert!(body["posts"].is_array(), "a read always shows the thread");206206
if body["detail"].is_string() {207
- assert!(!warned);208207
taught = true;209208
break;210209
}211
- assert!(warned);212210
}213211
let state = send(&app, "GET", "/runs/cafebabe", &[], "").await;214212
assert_eq!(header(&state, "x-stamina"), "10/10", "reading is free");@@ -280,3 +280,65 @@ async fn edging_climbs_out_of_the_cold() {
280280
"edging pays no cold tax"281281
);282282
}283
+284
+#[tokio::test]285
+async fn overstaying_a_floor_is_warned_then_punished() {286
+ let mut busted = 0;287
+ let mut moved = 0;288
+ for seed in 0..60u64 {289
+ let app = app_with(Floor::ChangingRoom, seed, |run| {290
+ run.body.eq = 80;291
+ run.body.conditions.priapism_turns = 9;292
+ run.here.turns = 7;293
+ });294
+ let eighth = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;295
+ assert!(296
+ warnings(&eighth)297
+ .iter()298
+ .any(|w| w.contains("The gym closes"))299
+ );300
+ let ninth = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;301
+ assert!(warnings(&ninth).iter().any(|w| w.contains("last turn")));302
+ let tenth = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;303
+ match tenth.status() {304
+ StatusCode::GONE => busted += 1,305
+ StatusCode::REQUEST_TIMEOUT => {306
+ 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
+ );312
+ moved += 1;313
+ }314
+ other => panic!("seed {seed} overstayed into {other}"),315
+ }316
+ }317
+ assert!(318
+ busted > 15 && moved > 15,319
+ "{busted} busted, {moved} moved along"320
+ );321
+}322
+323
+#[tokio::test]324
+async fn friction_at_the_limit_tears_the_foreskin() {325
+ let app = seeded_with(Floor::ChangingRoom, |run| {326
+ run.body.eq = 60;327
+ run.body.friction = 10;328
+ run.body.foreskin = Some(1.0);329
+ });330
+ let raw = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;331
+ assert!(warnings(&raw).iter().any(|w| w.contains("giving way")));332
+ let left: f64 = header(&raw, "x-foreskin").parse().unwrap();333
+ assert!(left < 1.0, "the foreskin is down to {left}");334
+335
+ let thin = seeded_with(Floor::ChangingRoom, |run| {336
+ run.body.eq = 60;337
+ run.body.friction = 10;338
+ run.body.foreskin = Some(0.05);339
+ });340
+ let gone = send(&thin, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;341
+ assert_eq!(gone.status(), StatusCode::GONE);342
+ assert_eq!(header(&gone, "x-foreskin"), "gone");343
+ assert_eq!(json(gone).await["foreskin"], -0.05);344
+}@@ -52,7 +52,7 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
5252
const REL = "https://jelq.dev/rels/";5353
const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];5454
const ALL_LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];55
-const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "watermelon", "run-ended"];55
+const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "watermelon", "overstayed", "run-ended"];5656
let stocked = null;5757
let excuses = [];5858
let nonce = null;