an http based roguelike rpg about jelqing
Commit 0d604ee39751
tarball8 files changed+107-20
@@ -50,6 +50,7 @@ pub const RUPTURE_HOT: u32 = 10;
5050
pub const RUPTURE_PREMATURE: u32 = 3;5151
pub const ZIP: u32 = 200;5252
pub const RULER_IN_DRAWER: u32 = 2;53
+pub const KEY_IN_DRAWER: u32 = 3;5354
pub const DEGLOVING: u32 = 60;5455
pub const DEGLOVING_INTENSITY: i32 = 8;5556
pub const WINDOW: u32 = 60;@@ -3,7 +3,10 @@ use serde_json::{Value, json};
33
44
use super::{55
Cause, Elder, Floor, FloorState, Item, Outcome, Run,6
- balance::{FLOOR_ALLOWANCE, FLOOR_TURNS, RULER_IN_DRAWER, STARTING_EQ, ZIP},6
+ balance::{7
+ FLOOR_ALLOWANCE, FLOOR_TURNS, KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT,8
+ ZIP,9
+ },710
round2,811
};912
use crate::problem::Problem;@@ -113,6 +116,9 @@ impl Run {
113116
self.here.drawer_searches += 1;114117
self.touch();115118
let first = self.here.drawer_searches == 1;119
+ if self.items.cage {120
+ return Ok(self.rummage_for_key());121
+ }116122
let ruler_here = self.floor == Floor::Bedroom && self.chance(RULER_IN_DRAWER);117123
match self.floor {118124
Floor::Bedroom if first && !self.items.ruler && ruler_here => {@@ -143,13 +149,6 @@ impl Run {
143149
"A small box. Inside was a cage. It is now on. The key is not in the box.",144150
))145151
}146
- Floor::Nans if self.items.cage => {147
- self.items.grant(Item::CageKey);148
- Ok(Outcome::json(149
- StatusCode::OK,150
- json!({ "detail": "The key was under where the box had been. The cage is off." }),151
- ))152
- }153152
Floor::Hotel if first => {154153
self.items.grant(Item::DndSign);155154
self.items.grant(Item::IcePack);@@ -192,8 +191,23 @@ impl Run {
192191
.header(header::LOCATION, self.path("")))193192
}194193
194
+ /// Searching while caged is noisy, and the key only turns up some of the time.195
+ fn rummage_for_key(&mut self) -> Outcome {196
+ let was_below = self.suspicion < SUSPICION_LIMIT;197
+ self.suspicion = (self.suspicion + 1).min(SUSPICION_LIMIT);198
+ self.maybe_summon_mum(was_below);199
+ if !self.chance(KEY_IN_DRAWER) {200
+ return Outcome::empty(StatusCode::NO_CONTENT);201
+ }202
+ self.items.grant(Item::CageKey);203
+ Outcome::json(204
+ StatusCode::OK,205
+ json!({ "detail": "The key was in the drawer. The cage is off." }),206
+ )207
+ }208
+195209
/// Whether searching the drawer on this floor would turn anything up.196210
pub fn drawer_worth_searching(&self) -> bool {197
- self.here.drawer_searches == 0 || (self.floor == Floor::Nans && self.items.cage)211
+ self.here.drawer_searches == 0 || self.items.cage198212
}199213
}@@ -94,13 +94,28 @@ impl Run {
9494
let challenge = |p: Problem| p.header(header::WWW_AUTHENTICATE, &challenge_value);9595
9696
let Some(authorization) = authorization else {97
- return Err(challenge(98
- Problem::new(97
+ let knocked_before = self.mum.as_ref().is_some_and(|visit| visit.knocked);98
+ if let Some(visit) = &mut self.mum {99
+ visit.knocked = true;100
+ }101
+ if !knocked_before {102
+ self.touch();103
+ return Err(challenge(104
+ Problem::new(105
+ StatusCode::UNAUTHORIZED,106
+ "someone-at-the-door",107
+ format!("Someone is at the door. {errand}"),108
+ )109
+ .with("inventory", self.path("/inventory")),110
+ ));111
+ }112
+ return Err(self.strike(113
+ challenge(Problem::new(99114
StatusCode::UNAUTHORIZED,100
- "someone-at-the-door",101
- format!("Someone is at the door. {errand}"),102
- )103
- .with("inventory", self.path("/inventory")),115
+ "knocking-again",116
+ format!("Mum is knocking again. {errand}"),117
+ )),118
+ Cause::Opened,104119
));105120
};106121
@@ -173,6 +188,10 @@ impl Run {
173188
}174189
175190
fn mum_strike(&mut self, problem: Problem) -> Problem {191
+ self.strike(problem, Cause::Caught)192
+ }193
+194
+ fn strike(&mut self, problem: Problem, cause: Cause) -> Problem {176195
self.touch();177196
let Some(visit) = &mut self.mum else {178197
return problem;@@ -181,7 +200,7 @@ impl Run {
181200
if visit.strikes < PATIENCE {182201
return problem;183202
}184
- self.end(Cause::Caught)203
+ self.end(cause)185204
}186205
187206
fn leave_door(&mut self) {@@ -151,6 +151,7 @@ pub struct MumVisit {
151151
pub errand: &'static str,152152
pub nonce: String,153153
pub strikes: u8,154
+ pub knocked: bool,154155
}155156
156157
#[derive(Clone, Copy, PartialEq, Eq, Debug)]@@ -179,6 +180,7 @@ pub enum Story {
179180
pub enum Cause {180181
Ruptured { ice_unused: bool },181182
Caught,183
+ Opened,182184
Banned,183185
Degloved,184186
Zip,@@ -190,6 +192,7 @@ impl Cause {
190192
match self {191193
Self::Ruptured { .. } => "vascular-event",192194
Self::Caught => "caught",195
+ Self::Opened => "opened",193196
Self::Banned => "banned",194197
Self::Degloved => "degloved",195198
Self::Zip => "zip",@@ -208,6 +211,9 @@ impl Cause {
208211
Self::Caught => {209212
"Mum has seen enough. The run has ended. Nobody will mention it at dinner."210213
}214
+ Self::Opened => {215
+ "Mum has stopped knocking and opened the door. The run has ended. Nobody will mention it at dinner."216
+ }211217
Self::Banned => "Your account has been banned. The forum can tell. The run has ended.",212218
Self::Degloved => {213219
"The outer layer has been removed in one piece. It will not be returning. The run has ended."@@ -222,7 +228,7 @@ impl Cause {
222228
fn status(self) -> StatusCode {223229
match self {224230
Self::Ruptured { .. } => StatusCode::INTERNAL_SERVER_ERROR,225
- Self::Caught | Self::Degloved | Self::Abandoned => StatusCode::GONE,231
+ Self::Caught | Self::Opened | Self::Degloved | Self::Abandoned => StatusCode::GONE,226232
Self::Banned => StatusCode::FORBIDDEN,227233
Self::Zip => StatusCode::CONFLICT,228234
}@@ -365,7 +365,7 @@ impl Run {
365365
}366366
}367367
368
- fn maybe_summon_mum(&mut self, was_below: bool) {368
+ pub(super) fn maybe_summon_mum(&mut self, was_below: bool) {369369
if !self.floor.has_mum() || self.mum.is_some() {370370
return;371371
}@@ -378,6 +378,7 @@ impl Run {
378378
errand,379379
nonce,380380
strikes: 0,381
+ knocked: false,381382
});382383
}383384
}@@ -79,7 +79,7 @@ pub static TECHNIQUES: RunResource = home(&[Verb::new("GET", get_techniques)]);
7979
pub static DRAWER: RunResource = home(&[Verb::new("POST", search_drawer)]);8080
pub static BRANCH: RunResource = home(&[Verb::new("POST", choose_branch)]);8181
pub static FLOOR: RunResource =82
- pockets(&[Verb::new("GET", get_floor), Verb::new("POST", leave_floor)]);82
+ home(&[Verb::new("GET", get_floor), Verb::new("POST", leave_floor)]);8383
pub static SHOP: RunResource = home(&[Verb::new("GET", get_shop)]);8484
pub static BUY: RunResource = home(&[Verb::new("POST", buy)]);8585
pub static PORTAL: RunResource = pockets(&[@@ -83,6 +83,7 @@ fn mum_at_the_door(run: &mut Run) {
8383
errand: "She heard a noise.",8484
nonce: "abc".into(),8585
strikes: 0,86
+ knocked: false,8687
});8788
}8889
@@ -778,3 +779,48 @@ async fn links_hide_resources_that_would_do_nothing() {
778779
let rels = linked_rels(&send(&clinic, "GET", "/runs/cafebabe", &[], "").await);779780
assert!(rels.contains(&"diagnosis".to_owned()) && rels.contains(&"thread".to_owned()));780781
}782
+783
+#[tokio::test]784
+async fn ignoring_mum_has_consequences() {785
+ let app = seeded_with(Floor::Bedroom, |run| {786
+ mum_at_the_door(run);787
+ run.here.turns = 6;788
+ });789
+ let knock = send(&app, "GET", "/runs/cafebabe", &[], "").await;790
+ assert_eq!(791
+ json(knock).await["type"],792
+ "https://jelq.dev/problems/someone-at-the-door"793
+ );794
+ let leave = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;795
+ assert_eq!(796
+ leave.status(),797
+ StatusCode::UNAUTHORIZED,798
+ "no leaving with Mum at the door"799
+ );800
+ send(&app, "GET", "/runs/cafebabe", &[], "").await;801
+ let opened = send(&app, "GET", "/runs/cafebabe", &[], "").await;802
+ assert_eq!(opened.status(), StatusCode::GONE);803
+ assert_eq!(804
+ json(opened).await["type"],805
+ "https://jelq.dev/problems/opened"806
+ );807
+}808
+809
+#[tokio::test]810
+async fn caged_drawers_are_noisy_and_eventually_give_up_the_key() {811
+ let app = seeded_with(Floor::ChangingRoom, |run| run.items.cage = true);812
+ let mut statuses = Vec::new();813
+ for _ in 0..12 {814
+ let response = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;815
+ statuses.push(response.status());816
+ if response.status() == StatusCode::OK {817
+ break;818
+ }819
+ }820
+ assert_eq!(statuses.last(), Some(&StatusCode::OK), "{statuses:?}");821
+ assert!(822
+ statuses[..statuses.len() - 1]823
+ .iter()824
+ .all(|s| *s == StatusCode::NO_CONTENT)825
+ );826
+}@@ -51,7 +51,7 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
5151
const REL = "https://jelq.dev/rels/";5252
const METHODS = ["JELQ", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];5353
const LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];54
-const DEATHS = ["vascular-event", "caught", "banned", "degloved", "zip", "run-ended"];54
+const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "run-ended"];5555
const STATS = [5656
["x-floor", "Floor"],5757
["x-self-reported-length", "Self-reported length"],