an http based roguelike rpg about jelqing
Commit 880b29a0c11e
tarball15 files changed+158-44
@@ -1,5 +1,7 @@
11
pub const STARTING_CASH: u32 = 20;22
pub const STARTING_ICE_PACKS: u32 = 1;3
+pub const STARTING_LUBE: u32 = 2;4
+pub const STARTING_BABY_OIL: u32 = 2;35
pub const FLOOR_ALLOWANCE: u32 = 10;46
pub const THIN_MARKUP: u32 = 3;57
/// The footage gains this many views a second, starting from one.@@ -4,8 +4,9 @@ pub const ZIP: u32 = 200;
44
pub const RULER_IN_DRAWER: u32 = 2;55
pub const KEY_IN_DRAWER: u32 = 3;66
pub const GREGS_LOCKER: u32 = 20;7
+pub const MEDITATION_ODDS: u32 = 2;78
pub const ULI_ODDS: u32 = 4;8
-pub const ULI_READS: u8 = 2;9
+pub const THREAD_READS: u8 = 2;910
pub const DRAWER_JAMS: u32 = 4;1011
pub const OBSERVED: u32 = 6;1112
pub const CAT_TAKES_TOWEL: u32 = 5;@@ -107,7 +107,9 @@ impl Run {
107107
}108108
Technique::Utility(Utility::Ice) => self.items.stores.ice_packs > 0,109109
Technique::Utility(Utility::Supplement) => self.items.stores.supplements > 0,110
- Technique::Utility(Utility::Meditate) => !self.here.meditated,110
+ Technique::Utility(Utility::Meditate) => {111
+ self.online.meditation_taught && !self.here.meditated112
+ }111113
Technique::Utility(Utility::Energy) => self.items.stores.energy_drinks > 0,112114
_ => self.access(technique).is_ok() && self.check_storage().is_ok(),113115
})@@ -5,7 +5,7 @@ use serde_json::{Value, json};
55
66
use super::{77
Cause, Floor, Outcome, Run,8
- balance::{EXAGGERATION, PATIENCE, ULI_ODDS, ULI_READS},8
+ balance::{EXAGGERATION, MEDITATION_ODDS, PATIENCE, THREAD_READS, ULI_ODDS},99
credentials::{Scheme, parse_credentials},1010
measure::{exaggeration, honest},1111
};@@ -105,6 +105,7 @@ pub enum Elder {
105105
/// The run's life online, from the tab on turn 10 to the footage the neighbour took.106106
pub struct Online {107107
pub(crate) story: Story,108
+ pub(crate) meditation_taught: bool,108109
pub(crate) uli_taught: bool,109110
pub(crate) elder: Elder,110111
pub(crate) filmed_at: Option<SystemTime>,@@ -114,6 +115,7 @@ impl Default for Online {
114115
fn default() -> Self {115116
Self {116117
story: Story::Unopened,118
+ meditation_taught: false,117119
uli_taught: false,118120
elder: Elder::Unverified { strikes: 0 },119121
filmed_at: None,@@ -154,8 +156,11 @@ impl Run {
154156
let (story, location, detail) = match choice {155157
Branch::Forum => {156158
self.standing.shame += 3;157
- let detail =158
- "The thread is from 2009. A technique called uli is discussed at length.";159
+ let detail = if self.learn_meditation() {160
+ "The thread is from 2009. A technique called uli is discussed at length. Further down, someone explains how to breathe, and you write it down."161
+ } else {162
+ "The thread is from 2009. A technique called uli is discussed at length."163
+ };159164
(Story::ReadForum, self.path("/techniques"), detail)160165
}161166
Branch::CloseTab => {@@ -192,7 +197,7 @@ impl Run {
192197
if self.online.uli_taught {193198
return Ok(Outcome::json(StatusCode::OK, forum_thread()));194199
}195
- if self.here.thread_rolls >= ULI_READS {200
+ if self.here.thread_rolls >= THREAD_READS {196201
let age = SystemTime::now()197202
.duration_since(*ELDER_POSTED)198203
.map_or(0, |age| age.as_secs());@@ -202,21 +207,38 @@ impl Run {
202207
}203208
self.here.thread_rolls += 1;204209
self.touch();205
- if self.chance(210
+ let detail = if !self.online.meditation_taught {211
+ if !self.learn_meditation() {212
+ return Ok(nothing_new());213
+ }214
+ "Between two arguments about pumps, a reply explains how to breathe. In through the nose, out through the mouth. You write it down."215
+ } else {216
+ if !self.chance(217
+ self.decision(218
+ "teach-uli",219
+ "Does this reread finally surface the 2009 reply that teaches uli?",220
+ ),221
+ ULI_ODDS,222
+ ) {223
+ return Ok(nothing_new());224
+ }225
+ self.online.uli_taught = true;226
+ "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."227
+ };228
+ Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail }))229
+ .header(header::LOCATION, self.path("/techniques")))230
+ }231
+232
+ fn learn_meditation(&mut self) -> bool {233
+ let taught = self.chance(206234
self.decision(207
- "teach-uli",208
- "Does this reread finally surface the 2009 reply that teaches uli?",235
+ "teach-meditation",236
+ "Does the thread surface the reply that teaches the breathing technique?",209237
),210
- ULI_ODDS,211
- ) {212
- self.online.uli_taught = true;213
- return Ok(Outcome::json(214
- StatusCode::OK,215
- json!({ "detail": "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." }),216
- )217
- .header(header::LOCATION, self.path("/techniques")));218
- }219
- Ok(nothing_new())238
+ MEDITATION_ODDS,239
+ );240
+ self.online.meditation_taught |= taught;241
+ taught220242
}221243
222244
fn elder_strike(&mut self, problem: Problem) -> Problem {@@ -3,8 +3,9 @@ use std::time::Instant;
33
use serde_json::{Value, json};44
55
use super::{6
- Elder, Excuse, Floor, Grip, Item, Observer, Run, Story,6
+ Elder, Excuse, Floor, Grip, Item, Observer, Run, Story, Technique,77
balance::{DANGER_EQ, KITCHEN_TELLINGS},8
+ techniques::{Training, Utility},89
};910
1011
pub enum Shown {@@ -143,8 +144,12 @@ impl Run {
143144
self.body.grip144145
}145146
146
- pub fn knows_uli(&self) -> bool {147
- self.online.uli_taught147
+ pub fn knows(&self, technique: Technique) -> bool {148
+ match technique {149
+ Technique::Training(Training::Uli) => self.online.uli_taught,150
+ Technique::Utility(Utility::Meditate) => self.online.meditation_taught,151
+ _ => true,152
+ }148153
}149154
150155
pub fn holds(&self, item: Item) -> bool {@@ -38,6 +38,7 @@ impl Default for Stores {
3838
}3939
}4040
41
+#[derive(Default)]4142
pub struct LubeStock {4243
pub(crate) baby_oil: u32,4344
pub(crate) coconut_oil: u32,@@ -45,17 +46,6 @@ pub struct LubeStock {
4546
pub(crate) vaseline: u32,4647
}4748
48
-impl Default for LubeStock {49
- fn default() -> Self {50
- Self {51
- baby_oil: 1,52
- coconut_oil: 0,53
- lotion: 1,54
- vaseline: 0,55
- }56
- }57
-}58
-5949
impl LubeStock {6050
/// Spit is the only lubricant that never runs out, so it has no count.6151
fn bottle(&mut self, lube: Lube) -> Option<&mut u32> {@@ -65,6 +65,13 @@ impl Run {
6565
/// The breathing technique from the forum's replies. It spends no stamina and the turn is6666
/// not the floor's, but each floor allows it once.6767
pub(super) fn meditate(&mut self) -> Result<Outcome, Problem> {68
+ if !self.online.meditation_taught {69
+ return Err(Problem::new(70
+ StatusCode::FORBIDDEN,71
+ "technique-locked",72
+ "Somebody on a forum knows how to breathe. You have not found the reply.",73
+ ));74
+ }6875
if self.here.meditated {6976
return Err(Problem::new(7077
StatusCode::CONFLICT,@@ -7,10 +7,11 @@ use axum::http::StatusCode;
77
use serde_json::{Value, json};88
99
use super::{10
- Body, Cause, Clinic, Ending, Floor, FloorState, Household, Items, Notice, Observer, Online,11
- RunStatus, Standing, Story,10
+ Body, Cause, Clinic, Ending, Floor, FloorState, Household, Item, Items, Notice, Observer,11
+ Online, RunStatus, Standing, Story,1212
balance::{13
- BRANCH_TURN, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION, STARTING_EQ,13
+ BRANCH_TURN, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,14
+ STARTING_BABY_OIL, STARTING_EQ, STARTING_LUBE,1415
},1516
measure::round2,1617
oracle::{Oracle, OracleTemplate, Roll},@@ -73,6 +74,27 @@ impl Run {
7374
0.4,7475
2.4,7576
));77
+ let mut items = Items::default();78
+ if oracle.chance(79
+ Run::unborn(80
+ "starting-lube",81
+ "Is there a bottle of lube in the drawer already?",82
+ ),83
+ STARTING_LUBE,84
+ ) {85
+ let baby_oil = oracle.chance(86
+ Run::unborn(87
+ "starting-bottle",88
+ "Is the bottle baby oil, rather than lotion?",89
+ ),90
+ STARTING_BABY_OIL,91
+ );92
+ items.grant(if baby_oil {93
+ Item::BabyOil94
+ } else {95
+ Item::Lotion96
+ });97
+ }7698
let etag_salt = oracle.u64();7799
Self {78100
ident: Identity {@@ -87,7 +109,7 @@ impl Run {
87109
standing: Standing::default(),88110
household: Household::default(),89111
online: Online::default(),90
- items: Items::default(),112
+ items,91113
clinic: Clinic::default(),92114
turn: 0,93115
floor: Floor::Bedroom,@@ -146,10 +146,14 @@ impl Technique {
146146
}147147
}148148
149
- pub fn describe(self, uli_taught: bool) -> Value {149
+ pub fn describe(self, known: bool) -> Value {150150
let training = match self {151151
Self::Utility(utility) => {152
- return json!({ "name": utility.name(), "effect": utility.effect() });152
+ return json!({153
+ "name": utility.name(),154
+ "effect": utility.effect(),155
+ "locked": !known,156
+ });153157
}154158
Self::Training(training) => training,155159
};@@ -160,7 +164,7 @@ impl Technique {
160164
"dc": profile.dc,161165
"lubricant": lubricant,162166
"foreskin": profile.foreskin.map(|(min, max)| json!({ "min": min, "max": max })),163
- "locked": training == Training::Uli && !uli_taught,167
+ "locked": !known,164168
})165169
}166170
}@@ -177,7 +177,7 @@ fn delete_foreskin(run: &mut Run, ctx: &Ctx) -> Response {
177177
fn get_techniques(run: &mut Run, _: &Ctx) -> Response {178178
let listed: Vec<Value> = Technique::ALL179179
.into_iter()180
- .map(|t| t.describe(run.knows_uli()))180
+ .map(|t| t.describe(run.knows(t)))181181
.collect();182182
Json(json!({183183
"usage": format!("JELQ {}?technique=<name>[,<name>]&intensity=<1-10>", run.path("")),@@ -102,6 +102,7 @@ async fn the_displayed_length_is_accepted_as_honest() {
102102
async fn dry_jelqs_build_friction_and_real_lube_does_not() {103103
let app = seeded_with(Floor::Bedroom, |run| {104104
run.body.warmed_up = true;105
+ run.items.lubes.baby_oil = 1;105106
run.body.foreskin = Some(0.9);106107
});107108
let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;@@ -163,6 +164,7 @@ async fn holding_and_meditating_shed_friction() {
163164
164165
let taught = seeded_with(Floor::Bedroom, |run| {165166
run.body.friction = 5;167
+ run.online.meditation_taught = true;166168
});167169
let calmed = send(168170
&taught,@@ -398,3 +400,18 @@ async fn a_combo_chafes_twice_and_costs_two_stamina() {
398400
assert_eq!(header(&combo, "x-friction"), "2/10");399401
assert_eq!(header(&combo, "x-stamina"), "8/10");400402
}403
+404
+#[test]405
+fn the_drawer_starts_with_at_most_one_bottle() {406
+ let mut stocked = 0;407
+ for seed in 0..400u64 {408
+ let lubes = &Run::new("cafebabe".into(), "cafe".into(), seed).items.lubes;409
+ let bottles = lubes.baby_oil + lubes.lotion;410
+ assert!(bottles <= 1 && lubes.coconut_oil + lubes.vaseline == 0);411
+ stocked += bottles;412
+ }413
+ assert!(414
+ (160..=240).contains(&stocked),415
+ "{stocked}/400 started with lube"416
+ );417
+}@@ -198,6 +198,7 @@ async fn rereading_the_thread_teaches_uli() {
198198
run.turn = BRANCH_TURN;199199
run.body.warmed_up = true;200200
run.online.story = Story::ClosedTab;201
+ run.online.meditation_taught = true;201202
});202203
let mut taught = false;203204
for _ in 0..2 {@@ -454,3 +455,43 @@ async fn the_foreskin_window_has_hard_edges() {
454455
}455456
}456457
}458
+459
+#[tokio::test]460
+async fn the_forum_teaches_breathing_before_uli() {461
+ let mut breathing = 0;462
+ for seed in 0..100u64 {463
+ let app = app_with(Floor::Bedroom, seed, |run| {464
+ run.turn = BRANCH_TURN;465
+ run.body.warmed_up = true;466
+ run.online.story = Story::Pending;467
+ });468
+ let listed = json(send(&app, "GET", "/runs/cafebabe/techniques", &[], "").await).await;469
+ let meditate = listed["techniques"]470
+ .as_array()471
+ .unwrap()472
+ .iter()473
+ .find(|t| t["name"] == "meditate")474
+ .unwrap();475
+ assert_eq!(meditate["locked"], true);476
+ send(&app, "POST", "/runs/cafebabe/branches/forum", &[], "").await;477
+ for _ in 0..2 {478
+ send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;479
+ }480
+ let meditated = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;481
+ if meditated.status() == StatusCode::OK {482
+ breathing += 1;483
+ } else {484
+ assert_eq!(meditated.status(), StatusCode::FORBIDDEN);485
+ let uli = send(&app, "JELQ", "/runs/cafebabe?technique=uli", &[], "").await;486
+ assert_eq!(487
+ uli.status(),488
+ StatusCode::FORBIDDEN,489
+ "seed {seed} skipped ahead"490
+ );491
+ }492
+ }493
+ assert!(494
+ (75..=97).contains(&breathing),495
+ "{breathing}/100 learned to breathe on the branch floor"496
+ );497
+}@@ -119,7 +119,7 @@ async fn http_1_0_gets_the_pdf_treatment() {
119119
async fn links_offer_only_usable_techniques() {120120
let app = seeded_with(Floor::Bedroom, |run| run.body.foreskin = Some(1.0));121121
let cold = send(&app, "GET", "/runs/cafebabe", &[], "").await;122
- assert_eq!(advertised(&cold), ["warmup", "ice", "meditate"]);122
+ assert_eq!(advertised(&cold), ["warmup", "ice"]);123123
let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;124124
let usable = advertised(&warm);125125
assert!(usable.contains(&"wet".to_owned()) && usable.contains(&"dry".to_owned()));@@ -26,7 +26,7 @@ pub use crate::{
2626
router,2727
};2828
29
-pub const SEED: u64 = 69;29
+pub const SEED: u64 = 420;3030
3131
pub async fn send(3232
app: &Router,@@ -2,7 +2,7 @@ use super::*;
22
33
#[tokio::test]44
async fn meditation_is_free_but_only_once_per_floor() {5
- let app = seeded_with(Floor::Bedroom, |_| {});5
+ let app = seeded_with(Floor::Bedroom, |run| run.online.meditation_taught = true);66
let response = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;77
assert_eq!(response.status(), StatusCode::OK);88
assert_eq!(header(&response, "x-eq"), "10");@@ -16,6 +16,7 @@ async fn meditation_respects_the_priapism_floor() {
1616
let app = seeded_with(Floor::Bedroom, |run| {1717
run.body.eq = 80;1818
run.body.conditions.priapism_turns = 3;19
+ run.online.meditation_taught = true;1920
});2021
let calmed = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;2122
assert_eq!(calmed.status(), StatusCode::OK);