an http based roguelike rpg about jelqing
Commit e807266c323b
tarball12 files changed+189-29
@@ -51,6 +51,7 @@ pub const RUPTURE_PREMATURE: u32 = 3;
5151
pub const ZIP: u32 = 200;5252
pub const RULER_IN_DRAWER: u32 = 2;5353
pub const KEY_IN_DRAWER: u32 = 3;54
+pub const GREGS_LOCKER: u32 = 20;5455
pub const DEGLOVING: u32 = 60;5556
pub const DEGLOVING_INTENSITY: i32 = 8;5657
pub const WINDOW: u32 = 60;@@ -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, KEY_IN_DRAWER, RULER_IN_DRAWER,8
- STARTING_EQ, SUSPICION_LIMIT, ZIP,7
+ EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, GREGS_LOCKER, KEY_IN_DRAWER,8
+ RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT, ZIP,99
},1010
round2,1111
};@@ -130,17 +130,24 @@ impl Run {
130130
}131131
132132
pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {133
- self.here.drawer_searches += 1;134133
self.touch();135
- let first = self.here.drawer_searches == 1;134
+ self.end_kitchen_visit();136135
if self.items.cage {137136
return Ok(self.rummage_for_key());138137
}138
+ let first = !self.here.drawer_searched;139
+ self.here.drawer_searched = true;139140
let ruler_here = self.floor == Floor::Bedroom140141
&& self.chance(141142
self.decision("ruler", "Is there a ruler hiding in the drawer?"),142143
RULER_IN_DRAWER,143144
);145
+ let locker_open = self.floor == Floor::ChangingRoom146
+ && first147
+ && self.chance(148
+ self.decision("gregs-locker", "Has Greg left his locker open?"),149
+ GREGS_LOCKER,150
+ );144151
match self.floor {145152
Floor::Bedroom if first && !self.items.ruler && ruler_here => {146153
self.items.grant(Item::Ruler);@@ -152,7 +159,18 @@ impl Run {
152159
}153160
Floor::Bathroom if first => {154161
self.items.grant(Item::DoorLock);155
- Ok(self.found(Item::DoorLock, "A door lock. It works once."))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
+ ))167
+ }168
+ Floor::ChangingRoom if locker_open => {169
+ self.items.grant(Item::BabyOil);170
+ Ok(self.found(171
+ Item::BabyOil,172
+ "Greg has left his locker open. The baby oil is yours now, legally speaking.",173
+ ))156174
}157175
Floor::ChangingRoom => {158176
self.shame += 3;@@ -232,6 +250,6 @@ impl Run {
232250
233251
/// Whether searching the drawer on this floor would turn anything up.234252
pub fn drawer_worth_searching(&self) -> bool {235
- self.here.drawer_searches == 0 || self.items.cage253
+ !self.here.drawer_searched || self.items.cage236254
}237255
}@@ -114,6 +114,13 @@ impl Run {
114114
if self.story == Story::Pending {115115
return Ok(self.branch_offer());116116
}117
+ if !self.thread_reachable() {118
+ return Err(Problem::new(119
+ StatusCode::NOT_FOUND,120
+ "no-thread",121
+ "There is no thread to re-read. Nobody has mentioned a forum yet.",122
+ ));123
+ }117124
self.touch();118125
self.turn += 1;119126
self.here.turns += 1;@@ -100,6 +100,7 @@ impl Run {
100100
) {101101
self.kitchen_tellings += 1;102102
if self.kitchen_tellings >= KITCHEN_TELLINGS {103
+ self.end_kitchen_visit();103104
return Err(Problem::new(104105
StatusCode::GONE,105106
"kitchen-off-limits",@@ -34,7 +34,7 @@ use serde_json::{Value, json};
3434
3535
use crate::problem::Problem;3636
use balance::{37
- HONEST_TOLERANCE, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,37
+ BRANCH_TURN, HONEST_TOLERANCE, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,3838
STARTING_CASH, STARTING_EQ,3939
};4040
use oracle::{Oracle, OracleTemplate};@@ -239,11 +239,17 @@ impl Run {
239239
self.eq = eq.max(floor);240240
}241241
242
- /// A melon offer stands only while the raid continues. Any other turn ends it.242
+ /// A melon offer stands only while the raiding streak continues. Any other action ends it.243243
pub(super) fn end_kitchen_visit(&mut self) {244244
self.here.melon_offered = false;245245
}246246
247
+ /// Going back to the jelq walks away from both the fridge and Pump-Bro.248
+ pub(super) fn step_away(&mut self) {249
+ self.end_kitchen_visit();250
+ self.here.shopping = false;251
+ }252
+247253
fn fresh_nonce(&mut self) -> String {248254
format!("{:016x}", self.oracle.u64())249255
}@@ -335,6 +341,12 @@ impl Run {
335341
self.story == Story::ReadForum336342
}337343
344
+ pub fn thread_reachable(&self) -> bool {345
+ self.turn >= BRANCH_TURN346
+ || self.forum_read()347
+ || self.floor.number() >= Floor::Forum.number()348
+ }349
+338350
pub fn footage(&self) -> Option<(SystemTime, u64)> {339351
let filmed = self.filmed_at?;340352
let age = filmed.elapsed().map_or(0, |d| d.as_secs());@@ -228,6 +228,8 @@ impl Clm {
228228
.unwrap_or_else(|| panic!("oracle choice answer for {site} missing"));229229
choice230230
.parse::<usize>()231
- .unwrap_or_else(|_| panic!("oracle chose {choice:?} for {site}, not an option index"))231
+ .ok()232
+ .filter(|&i| i < options.len())233
+ .unwrap_or_else(|| panic!("oracle chose {choice:?} for {site}, not an option index"))232234
}233235
}@@ -135,7 +135,7 @@ impl Grip {
135135
#[derive(Default)]136136
pub struct FloorState {137137
pub turns: u32,138
- pub drawer_searches: u8,138
+ pub drawer_searched: bool,139139
pub edges: u8,140140
pub temp_length: f64,141141
pub teapot: bool,@@ -45,7 +45,6 @@ impl Run {
4545
"The cage is locked. The key is not in the box.",4646
));4747
}48
- self.here.shopping = false;4948
match *techniques {5049
[Technique::Utility(utility)] => self.utility(utility, lube, now),5150
[Technique::Training(training)] => self.single(training, intensity, lube, now),@@ -79,12 +78,12 @@ impl Run {
7978
Utility::Energy => return self.energy(),8079
Utility::Edge | Utility::Pump => {}8180
}82
- if utility == Utility::Pump {83
- self.here.edges = 0;84
- }8581
self.ready(Technique::Utility(utility), lube)?;8682
self.check_storage()?;8783
self.floor_enemy()?;84
+ if utility == Utility::Pump {85
+ self.here.edges = 0;86
+ }8887
match utility {8988
Utility::Edge => self.edge(now),9089
_ => self.pump(now),@@ -98,11 +97,11 @@ impl Run {
9897
lube: Option<Lube>,9998
now: Instant,10099
) -> Result<Outcome, Problem> {101
- self.here.edges = 0;102100
self.ready(Technique::Training(training), lube)?;103101
self.check_storage()?;104102
self.lube_available(lube)?;105103
self.floor_enemy()?;104
+ self.here.edges = 0;106105
self.take_lube(lube)?;107106
self.chafe(lube);108107
self.commit(intensity, lube, now, 1);@@ -131,12 +130,12 @@ impl Run {
131130
"A technique cannot be combined with itself. The server has checked.",132131
));133132
}134
- self.here.edges = 0;135133
self.ready(Technique::Training(a), lube)?;136134
self.ready(Technique::Training(b), lube)?;137135
self.check_storage()?;138136
self.lube_available(lube)?;139137
self.floor_enemy()?;138
+ self.here.edges = 0;140139
self.take_lube(lube)?;141140
self.chafe(lube);142141
self.commit(intensity, lube, now, 2);@@ -376,7 +375,7 @@ impl Run {
376375
self.touch();377376
self.turn += 1;378377
self.here.turns += 1;379
- self.end_kitchen_visit();378
+ self.step_away();380379
self.realise();381380
if self.items.extender {382381
self.length += EXTENDER_GAIN;@@ -485,17 +484,17 @@ impl Run {
485484
"The cage is locked. The key is not in the box.",486485
));487486
}487
+ self.touch();488
+ self.turn += 1;489
+ self.here.turns += 1;490
+ self.step_away();491
+ self.realise();488492
if self.oracle.chance_not(489493
self.decision("rupture", "The pause is uneventful"),490494
RUPTURE_HOLD,491495
) {492496
return Err(self.rupture());493497
}494
- self.touch();495
- self.turn += 1;496
- self.here.turns += 1;497
- self.end_kitchen_visit();498
- self.realise();499498
let was_below = self.suspicion < SUSPICION_LIMIT;500499
if self.chance(501500
self.decision(@@ -528,6 +527,7 @@ impl Run {
528527
));529528
}530529
self.here.meditated = true;530
+ self.step_away();531531
self.reset_eq(self.eq - MEDITATION_EQ);532532
self.shed(FRICTION_MEDITATE);533533
self.touch();@@ -49,6 +49,7 @@ impl Run {
4949
(WARMUP_EQ, "Warmed up. The towel has cooled.")5050
};5151
self.touch();52
+ self.step_away();5253
self.warmed_up = true;5354
self.eq = (self.eq + boost).min(100);5455
Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))@@ -63,6 +64,7 @@ impl Run {
6364
));6465
}6566
self.touch();67
+ self.step_away();6668
self.items.ice_packs -= 1;6769
if self.conditions.priapism_turns > 0 {6870
return Ok(Outcome::json(@@ -89,6 +91,7 @@ impl Run {
8991
));9092
}9193
self.items.energy_drinks -= 1;94
+ self.step_away();9295
self.stamina = (self.stamina + ENERGY_STAMINA).min(self.max_stamina());9396
self.touch();9497
Ok(Outcome::json(@@ -106,6 +109,7 @@ impl Run {
106109
));107110
}108111
self.items.supplements -= 1;112
+ self.step_away();109113
self.touch();110114
if self.chance(111115
self.decision(@@ -132,6 +136,9 @@ impl Run {
132136
133137
pub(super) fn edge(&mut self, now: Instant) -> Result<Outcome, Problem> {134138
self.commit(5, None, now, 1);139
+ if let ControlFlow::Break(interrupted) = self.hazards(5)? {140
+ return Ok(interrupted);141
+ }135142
self.here.edges += 1;136143
self.eq = (self.eq + EDGE_EQ).min(100);137144
self.girth += EDGE_GIRTH;@@ -144,9 +151,6 @@ impl Run {
144151
"Edging has looped. The server detected an infinite loop and terminated it.",145152
));146153
}147
- if let ControlFlow::Break(interrupted) = self.hazards(5)? {148
- return Ok(interrupted);149
- }150154
Ok(Outcome::json(151155
StatusCode::OK,152156
json!({ "detail": "Edged. EQ is rising. Nothing else is.", "edges": self.here.edges }),@@ -1,5 +1,7 @@
11
//! Resources that do not belong to a single run.22
3
+use std::time::Instant;4
+35
use axum::{46
Json,57
http::{StatusCode, header},@@ -69,6 +71,18 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
6971
let mut runs = state.runs();7072
runs.retain(|_, run| ctx.now.duration_since(run.last_seen) < config.idle_ttl);7173
if runs.len() >= config.max_runs {74
+ let mut buried: Vec<(Instant, String)> = runs75
+ .iter()76
+ .filter(|(_, run)| run.ended.is_some())77
+ .map(|(id, run)| (run.last_seen, id.clone()))78
+ .collect();79
+ buried.sort_unstable();80
+ let excess = runs.len() + 1 - config.max_runs;81
+ for (_, id) in buried.into_iter().take(excess) {82
+ runs.remove(&id);83
+ }84
+ }85
+ if runs.len() >= config.max_runs {7286
return Problem::new(7387
StatusCode::SERVICE_UNAVAILABLE,7488
"server-full",@@ -13,7 +13,7 @@ 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, FRICTION_MAX, KITCHEN_TELLINGS, SUSPICION_LIMIT},16
+ balance::{FRICTION_MAX, KITCHEN_TELLINGS, SUSPICION_LIMIT},1717
},1818
headers::{1919
X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_FRICTION, X_GIRTH, X_LENGTH, X_OBSERVED_BY,@@ -97,8 +97,7 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
9797
/// Every resource the run can reach right now, then every technique a JELQ could use. The `allow`9898
/// and `lubricant` extension attributes (RFC 8288 §3) say what each accepts, so a client never guesses.9999
fn links(run: &Run, now: Instant) -> String {100
- let thread_reachable =101
- run.turn >= BRANCH_TURN || run.forum_read() || run.floor.number() >= Floor::Forum.number();100
+ let thread_reachable = run.thread_reachable();102101
let linked: [(&str, &RunResource, bool); 15] = [103102
("", &runs::RUN, true),104103
("floor", &runs::FLOOR, true),@@ -16,7 +16,7 @@ use crate::{
1616
AppState, Config,1717
game::{1818
Floor, MumVisit, Run, Story,19
- balance::{STORAGE_GIRTH, SUSPICION_LIMIT},19
+ balance::{BRANCH_TURN, STORAGE_GIRTH, SUSPICION_LIMIT},2020
},2121
router,2222
};@@ -903,6 +903,7 @@ async fn either_story_choice_is_reachable_during_cooldown() {
903903
let app = seeded_with(Floor::Bathroom, |run| {904904
run.turn = 9;905905
run.warmed_up = true;906
+ run.eq = 45;906907
run.stamina = 1;907908
run.items.door_locks = 1;908909
});@@ -996,6 +997,7 @@ async fn rereading_the_thread_teaches_uli() {
996997
let mut ruptured = 0;997998
for seed in 0..100u64 {998999
let app = app_with(Floor::Bedroom, seed, |run| {1000
+ run.turn = BRANCH_TURN;9991001
run.warmed_up = true;10001002
run.story = Story::ClosedTab;10011003
});@@ -1791,3 +1793,103 @@ async fn the_kitchen_rolls_the_normal_tiers() {
17911793
assert!((40..=80).contains(&hot), "hot kitchen ruptured {hot}/600");17921794
assert!((0..=9).contains(&cool), "cool kitchen ruptured {cool}/600");17931795
}1796
+1797
+#[tokio::test]1798
+async fn the_thread_cannot_be_reread_before_anyone_mentions_it() {1799
+ let app = seeded_with(Floor::Bedroom, |run| run.warmed_up = true);1800
+ let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;1801
+ assert_eq!(read.status(), StatusCode::NOT_FOUND);1802
+ let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;1803
+ assert_eq!(header(&after, "x-floor"), "1");1804
+ assert!(!header(&after, "link").contains("/re-read"));1805
+}1806
+1807
+#[tokio::test]1808
+async fn any_other_action_takes_the_melon_off_the_table() {1809
+ for (method, uri) in [1810
+ ("JELQ", "/runs/cafebabe?technique=warmup"),1811
+ ("POST", "/runs/cafebabe/drawer"),1812
+ ] {1813
+ let app = seeded_with(Floor::Bedroom, |run| {1814
+ run.eq = 95;1815
+ run.here.melon_offered = true;1816
+ });1817
+ send(&app, method, uri, &[], "").await;1818
+ let melon = send(&app, "POST", "/runs/cafebabe/kitchen/watermelon", &[], "").await;1819
+ assert_eq!(melon.status(), StatusCode::CONFLICT, "{method} {uri}");1820
+ }1821
+}1822
+1823
+#[tokio::test]1824
+async fn a_refused_jelq_does_not_break_the_edging_streak() {1825
+ let app = seeded_with(Floor::ChangingRoom, |run| {1826
+ run.warmed_up = true;1827
+ run.eq = 45;1828
+ run.here.edges = 2;1829
+ });1830
+ let refused = send(1831
+ &app,1832
+ "JELQ",1833
+ "/runs/cafebabe?technique=dry",1834
+ &[("content-type", "application/baby-oil")],1835
+ "",1836
+ )1837
+ .await;1838
+ assert_eq!(refused.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);1839
+ let third = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;1840
+ assert_eq!(third.status(), StatusCode::LOOP_DETECTED);1841
+}1842
+1843
+#[tokio::test]1844
+async fn an_interrupted_edge_keeps_no_gains() {1845
+ let app = seeded_with(Floor::ChangingRoom, |run| {1846
+ run.warmed_up = true;1847
+ run.eq = 10;1848
+ });1849
+ let before = send(&app, "GET", "/runs/cafebabe", &[], "").await;1850
+ let girth = header(&before, "x-girth").to_owned();1851
+ let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;1852
+ assert_eq!(edge.status(), StatusCode::NOT_FOUND);1853
+ assert_eq!(header(&edge, "x-girth"), girth);1854
+}1855
+1856
+#[tokio::test]1857
+async fn rummaging_for_the_key_leaves_the_drawer_unsearched() {1858
+ let mut found = 0;1859
+ for seed in 0..20u64 {1860
+ let app = app_with(Floor::Hotel, seed, |run| {1861
+ run.here.wifi_accepted = true;1862
+ run.items.cage = true;1863
+ });1864
+ for _ in 0..30 {1865
+ let rummage = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;1866
+ if rummage.status() == StatusCode::OK {1867
+ break;1868
+ }1869
+ }1870
+ let search = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;1871
+ if search.status() == StatusCode::FOUND {1872
+ assert!(header(&search, "location").ends_with("/inventory/do-not-disturb-sign"));1873
+ found += 1;1874
+ }1875
+ }1876
+ assert!(1877
+ found >= 18,1878
+ "only {found} of 20 hotel drawers survived the rummage"1879
+ );1880
+}1881
+1882
+#[tokio::test]1883
+async fn a_full_server_buries_the_dead_to_make_room() {1884
+ let app = crate::app(Config {1885
+ max_runs: 1,1886
+ ..Config::default()1887
+ });1888
+ let first = send(&app, "POST", "/runs", &[], "").await;1889
+ let location = header(&first, "location").to_owned();1890
+ let full = send(&app, "POST", "/runs", &[], "").await;1891
+ assert_eq!(full.status(), StatusCode::SERVICE_UNAVAILABLE);1892
+ send(&app, "DELETE", &location, &[], "").await;1893
+ let second = send(&app, "POST", "/runs", &[], "").await;1894
+ assert_eq!(second.status(), StatusCode::CREATED);1895
+}