an http based roguelike rpg about jelqing
Commit c4d70621d377
tarball11 files changed+159-81
@@ -81,10 +81,14 @@ pub const KITCHEN_ICE_ODDS: u32 = 8;
8181
pub const KETTLE_ODDS: u32 = 6;8282
pub const ULI_READS: u8 = 2;8383
pub const DRAWER_JAMS: u32 = 4;84
-pub const KITCHEN_TOLD: u32 = 4;84
+/// A telling is suspicion + 1 in this many, so a quiet house tolerates a raid and a noisy one does not.85
+pub const KITCHEN_TOLD: u32 = 8;86
+pub const RAID_NOISE: i32 = 1;87
+pub const PATCH_EQ: i32 = 3;8588
pub const KITCHEN_TELLINGS: u8 = 2;8689
pub const WATERMELON: (u32, u32) = (2, 5);87
-pub const CAUGHT_MELONED: u32 = 3;90
+/// Caught with the melon is suspicion + 2 in this many.91
+pub const CAUGHT_MELONED: u32 = 9;8892
pub const MELON_SHAME: u32 = 5;8993
pub const ENERGY_STAMINA: i32 = 3;9094
pub const FRICTION_MAX: i32 = 10;@@ -4,7 +4,7 @@ use serde_json::{Value, json};
44
55
use super::{66
Grip, Outcome, Run,7
- balance::{EXAGGERATION, GIRTH_RESPECTED, GIRTH_THICK, LOCKER_ROOM_BAR, PUT_GAIN},7
+ balance::{EXAGGERATION, GIRTH_RESPECTED, GIRTH_THICK, LOCKER_ROOM_BAR, PATCH_EQ, PUT_GAIN},88
etag_matches, honest,99
};1010
use crate::problem::Problem;@@ -36,7 +36,10 @@ impl Run {
3636
Outcome::json(StatusCode::OK, json!({ "detail": "Applied." }))3737
}3838
39
+ /// Being measured is not arousing, so every PATCH costs a little EQ whatever it finds.3940
pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {41
+ self.reset_eq(self.eq - PATCH_EQ);42
+ self.touch();4043
for op in ops {4144
if op.op != "test" {4245
return Err(Problem::new(@@ -69,10 +69,13 @@ impl Run {
6969
"length;dur={:.2}, girth;dur={:.2}, foreskin;dur={foreskin:.2}, volume;desc=\"cubic inches\";dur={volume:.2}",7070
self.length, self.girth7171
);72
+ self.clinic.prescribed = true;73
+ self.touch();7274
Ok(Outcome::json(7375
StatusCode::OK,7476
json!({7577
"diagnosis": "within normal range",78
+ "detail": "Congratulations. You have been cured of a condition you never had. The run is over.",7679
"measured_length": round2(self.length),7780
"measured_girth": round2(self.girth),7881
"volume": round2(volume),@@ -26,6 +26,11 @@ const CHRISTMAS_EXCUSES: [&str; 6] = [
2626
"helping Nan",2727
"peeling sprouts",2828
];29
+/// Every excuse Mum might hear, so a localised one can be traced back to the phrase she checks.30
+pub fn vocabulary() -> impl Iterator<Item = &'static str> {31
+ EXCUSES.into_iter().chain(CHRISTMAS_EXCUSES)32
+}33
+2934
const TRUTHS: [&str; 3] = ["the truth", "truth", "honesty"];3035
const TRUE_WHEN_PULLED: &str = "pulled a muscle";3136
@@ -75,7 +80,7 @@ impl Run {
7580
EXCUSES7681
.into_iter()7782
.chain(CHRISTMAS_EXCUSES)78
- .filter(|e| !self.heard.iter().any(|h| h == e))83
+ .filter(|e| !self.heard.iter().any(|h| h.eq_ignore_ascii_case(e)))7984
.collect()8085
}8186
@@ -194,7 +199,12 @@ impl Run {
194199
"Mum has heard that one before. She would have preferred the truth.",195200
))));196201
}197
- if true_now || self.excuses().contains(&excuse.as_str()) {202
+ if true_now203
+ || self204
+ .excuses()205
+ .iter()206
+ .any(|e| e.eq_ignore_ascii_case(&excuse))207
+ {198208
self.heard.push(excuse);199209
self.leave_door();200210
return Ok(());@@ -5,13 +5,13 @@ use super::{
55
Cause, Floor, Item, Outcome, Run, Story,66
balance::{77
CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KETTLE_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TELLINGS,8
- KITCHEN_TOLD, MELON_SHAME, RUPTURE_LINE, SUSPICION_LIMIT, WATERMELON,8
+ KITCHEN_TOLD, MELON_SHAME, RAID_NOISE, RUPTURE_LINE, SUSPICION_LIMIT, WATERMELON,99
},1010
};1111
use crate::problem::Problem;1212
1313
impl Run {14
- /// Raid the fridge. It costs the floor turn like anything else, and the household keeps count.14
+ /// The household keeps count, so every raid makes noise and the noise makes the next telling likelier.1515
pub fn raid_kitchen(&mut self) -> Result<Outcome, Problem> {1616
if !self.floor.has_kitchen() {1717
return Err(Problem::new(@@ -41,6 +41,7 @@ impl Run {
4141
return Err(self.rupture());4242
}4343
let was_below = self.suspicion < SUSPICION_LIMIT;44
+ self.make_noise(RAID_NOISE);4445
// One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5, 1 in 8 and 1 in 6.4546
let loot = self.oracle.pick(4647
self.decision("loot", "What does the fridge give up on this raid?"),@@ -62,7 +63,7 @@ impl Run {
6263
"Nothing. The fridge is ordinary.",6364
],6465
);65
- let outcome = if self.eq > RUPTURE_LINE66
+ let melon = self.eq > RUPTURE_LINE6667
&& self.oracle.ratio(6768
self.decision(6869
"watermelon",@@ -70,43 +71,14 @@ impl Run {
7071
),7172
WATERMELON.0,7273
WATERMELON.1,73
- ) {74
- self.here.melon_offered = true;75
- self.melon_offer()76
- } else if loot == 0 {77
- self.items.grant(Item::CoconutOil);78
- Ok(Outcome::json(79
- StatusCode::OK,80
- json!({ "detail": "A jar of coconut oil, half of it already gone." }),81
- ))82
- } else if loot == 1 {83
- self.items.grant(Item::EnergyDrink);84
- Ok(Outcome::json(85
- StatusCode::OK,86
- json!({ "detail": "An energy drink, cold and faintly alarming." }),87
- ))88
- } else if loot == 2 {89
- self.items.grant(Item::IcePack);90
- Ok(Outcome::json(91
- StatusCode::OK,92
- json!({ "detail": "An ice pack, buried under the peas." }),93
- ))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
- ))100
- } else {101
- Ok(Outcome::empty(StatusCode::NO_CONTENT))102
- };74
+ );10375
self.settle(was_below);104
- let outcome = outcome?;105
- if self.chance(76
+ if self.oracle.ratio(10677
self.decision(10778
"kitchen-told",10879
"Has the household had enough of the raiding?",10980
),81
+ u32::from(self.suspicion) + 1,11082
KITCHEN_TOLD,11183
) {11284
self.kitchen_tellings += 1;@@ -124,12 +96,40 @@ impl Run {
12496
"Stop raiding the kitchen."12597
});12698
}127
- Ok(outcome)99
+ if melon {100
+ self.here.melon_offered = true;101
+ return Ok(self.melon_offer());102
+ }103
+ Ok(self.unpack(loot))128104
}129105
130
- fn melon_offer(&self) -> Result<Outcome, Problem> {106
+ fn unpack(&mut self, loot: usize) -> Outcome {107
+ let (item, detail) = match loot {108
+ 0 => (109
+ Item::CoconutOil,110
+ "A jar of coconut oil, half of it already gone.",111
+ ),112
+ 1 => (113
+ Item::EnergyDrink,114
+ "An energy drink, cold and faintly alarming.",115
+ ),116
+ 2 => (Item::IcePack, "An ice pack, buried under the peas."),117
+ 3 => {118
+ self.here.kettle_boiled = true;119
+ return Outcome::json(120
+ StatusCode::OK,121
+ json!({ "detail": "The kettle has just boiled. The towel can be warmed once more." }),122
+ );123
+ }124
+ _ => return Outcome::empty(StatusCode::NO_CONTENT),125
+ };126
+ self.items.grant(item);127
+ Outcome::json(StatusCode::OK, json!({ "detail": detail }))128
+ }129
+130
+ fn melon_offer(&self) -> Outcome {131131
let commit = self.path("/kitchen/watermelon");132
- Ok(Outcome::json(132
+ Outcome::json(133133
StatusCode::MULTIPLE_CHOICES,134134
json!({135135
"detail": "The fridge holds a watermelon. It is exactly the right shape.",@@ -144,10 +144,10 @@ impl Run {
144144
format!(145145
"<{commit}>; rel=\"alternate\"; title=\"Commit to the watermelon\"; allow=\"POST\""146146
),147
- ))147
+ )148148
}149149
150
- /// The watermelon. It empties EQ, keeps the gains, costs shame, and the household may notice.150
+ /// A bail-out that costs no turn. The more the household has already heard, the likelier Mum is in the doorway.151151
pub fn commit_melon(&mut self) -> Result<Outcome, Problem> {152152
if !self.here.melon_offered {153153
return Err(Problem::new(@@ -160,11 +160,12 @@ impl Run {
160160
self.reset_eq(0);161161
self.shame += MELON_SHAME;162162
self.touch();163
- if self.chance(163
+ if self.oracle.ratio(164164
self.decision(165165
"caught-meloned",166166
"Is Mum standing in the doorway as you look up?",167167
),168
+ u32::from(self.suspicion) + 2,168169
CAUGHT_MELONED,169170
) {170171
return Err(self.end(Cause::Meloned));@@ -17,10 +17,12 @@ mod utility;
1717
1818
pub use body::{PatchOp, volume};1919
pub use forum::{ELDER_LAST_MODIFIED, FORUM_THREAD, forum_thread};20
+pub use household::vocabulary as excuse_vocabulary;2021
pub use items::{Item, Items, SHOP};2122
pub use outcome::{Outcome, Payload};2223
pub use state::{23
- Cause, Clinic, Conditions, Elder, Ending, Floor, FloorState, Grip, MumVisit, Observer, Story,24
+ Cause, Clinic, Conditions, Elder, Ending, Floor, FloorState, Grip, MumVisit, Observer,25
+ RunStatus, Story,2426
};2527
pub use techniques::{LUBRICANTS, Lube, Technique};2628
@@ -351,11 +353,15 @@ impl Run {
351353
Some((filmed, age))352354
}353355
354
- pub fn status(&self) -> &'static str {355
- match (&self.ended, self.clinic.cured) {356
- (Some(_), _) => "gone",357
- (None, true) => "normal",358
- (None, false) => "alive",356
+ pub fn status(&self) -> RunStatus {357
+ if self.ended.is_some() {358
+ RunStatus::Gone359
+ } else if self.clinic.cured {360
+ RunStatus::Normal361
+ } else if self.clinic.prescribed {362
+ RunStatus::Prescribed363
+ } else {364
+ RunStatus::Alive359365
}360366
}361367
@@ -374,7 +380,7 @@ impl Run {
374380
.collect();375381
json!({376382
"public_id": self.public_id,377
- "status": self.status(),383
+ "status": self.status().name(),378384
"turn": self.turn,379385
"run_shame": self.shame,380386
"grip": self.grip.name(),@@ -177,6 +177,7 @@ pub enum Elder {
177177
pub struct Clinic {178178
pub intake_attempts: usize,179179
pub measured: bool,180
+ pub prescribed: bool,180181
pub cured: bool,181182
}182183
@@ -257,6 +258,30 @@ impl Cause {
257258
}258259
}259260
261
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]262
+pub enum RunStatus {263
+ Alive,264
+ Prescribed,265
+ /// The true ending, the clinic's `304`.266
+ Normal,267
+ Gone,268
+}269
+270
+impl RunStatus {271
+ pub fn name(self) -> &'static str {272
+ match self {273
+ Self::Alive => "alive",274
+ Self::Prescribed => "prescribed",275
+ Self::Normal => "normal",276
+ Self::Gone => "gone",277
+ }278
+ }279
+280
+ pub fn finished(self) -> bool {281
+ self != Self::Alive282
+ }283
+}284
+260285
pub struct Ending {261286
pub cause: Cause,262287
pub turn: u32,@@ -124,6 +124,7 @@ impl Run {
124124
self.floor_enemy()?;125125
self.take_lube(lube)?;126126
self.chafe(lube, intensity);127
+ self.chafe(lube, intensity);127128
self.commit(intensity, lube, 2);128129
for training in [a, b] {129130
if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {@@ -397,22 +398,11 @@ impl Run {
397398
);398399
self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);399400
400
- let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));401
- let doubled = match self.floor {402
- Floor::Nans => true,403
- Floor::ChangingRoom => !self.holds_own_in_the_locker_room(),404
- _ => false,405
- };406
- if doubled {407
- suspicion *= 2;408
- }401
+ let was_below = self.suspicion < SUSPICION_LIMIT;402
+ self.make_noise(intensity / 5 + i32::from(lube == Some(Lube::Vaseline)));409403
if self.floor == Floor::Nans && lube == Some(Lube::CoconutOil) {410
- suspicion += 2;404
+ self.add_suspicion(2);411405
}412
- let was_below = self.suspicion < SUSPICION_LIMIT;413
- self.suspicion =414
- u8::try_from((i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)))415
- .unwrap_or(SUSPICION_LIMIT);416406
417407
if intensity >= PULLED_MUSCLE_INTENSITY418408
&& !self.conditions.pulled_muscle@@ -427,6 +417,21 @@ impl Run {
427417
self.settle(was_below);428418
}429419
420
+ pub(super) fn make_noise(&mut self, noise: i32) {421
+ let doubled = match self.floor {422
+ Floor::Nans => true,423
+ Floor::ChangingRoom => !self.holds_own_in_the_locker_room(),424
+ _ => false,425
+ };426
+ self.add_suspicion(if doubled { noise * 2 } else { noise });427
+ }428
+429
+ fn add_suspicion(&mut self, amount: i32) {430
+ self.suspicion =431
+ u8::try_from((i32::from(self.suspicion) + amount).clamp(0, i32::from(SUSPICION_LIMIT)))432
+ .unwrap_or(SUSPICION_LIMIT);433
+ }434
+430435
/// The part of a turn the house takes part in, whatever the action was.431436
pub(super) fn settle(&mut self, was_below: bool) {432437
self.maybe_summon_mum(was_below);@@ -493,7 +498,6 @@ impl Run {
493498
self.touch();494499
self.turn += 1;495500
self.here.turns += 1;496
- self.step_away();497501
self.realise();498502
if self.oracle.chance_not(499503
self.decision("rupture", "The pause is uneventful"),@@ -506,10 +510,12 @@ impl Run {
506510
self.recover();507511
self.shed(FRICTION_HOLD);508512
self.settle(was_below);509
- return Ok(Outcome::json(510
- StatusCode::OK,511
- json!({ "detail": "The count went well past ten. Stamina is back. Everything else has gone quiet, and you are cold again." }),512
- ));513
+ let detail = if self.conditions.priapism_turns > 0 {514
+ "The count went well past ten. Stamina is back. It went down a little. Maybe. You hope."515
+ } else {516
+ "The count went well past ten. Stamina is back. Everything else has gone quiet, and you are cold again."517
+ };518
+ return Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })));513519
}514520
if self.chance(515521
self.decision(@@ -610,7 +616,6 @@ impl Run {
610616
_ => 0,611617
};612618
let arrival = if self.here.warm_arrival {613
- self.here.warm_arrival = false;614619
ARRIVAL_BONUS615620
} else {616621
0@@ -632,6 +637,7 @@ impl Run {
632637
if natural == 1 {633638
return Err(timed(self.critical_failure(training, &profile)));634639
}640
+ self.here.warm_arrival = false;635641
if natural == 2 && self.eq > DANGER_EQ - burn {636642
return Err(timed(self.rupture()));637643
}@@ -765,7 +771,7 @@ impl Run {
765771
}766772
767773
fn gossip(&mut self, mut outcome: Outcome) -> Outcome {768
- let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206);774
+ let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206 | 308);769775
if !self.here.brother_told || !reportable {770776
return outcome;771777
}@@ -70,7 +70,7 @@ pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {
7070
let response =7171
gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx));7272
let response = finish(run, &ctx, &state.config.secret, response);73
- if run.ended.is_some() || run.clinic.cured {73
+ if run.status().finished() {7474
state.records.save(&Record::of(run));7575
}7676
let ended = run.ended.is_some();@@ -52,8 +52,6 @@ const EN_AU: &[(&str, &str)] = &[
5252
("£", "$"),5353
];5454
55
-const EXCUSES: &[&str] = &["wrapping presents", "folding the washing"];56
-5755
fn bounded(text: &str, at: usize, len: usize) -> bool {5856
let before = text[..at].chars().next_back();5957
let after = text[at + len..].chars().next();@@ -62,9 +60,19 @@ fn bounded(text: &str, at: usize, len: usize) -> bool {
6260
}6361
6462
fn replace_words(text: &str, from: &str, to: &str) -> String {63
+ replace_matching(text, text, from, to)64
+}65
+66
+/// Players type excuses in whatever case they like. ASCII lowering keeps byte offsets intact.67
+fn replace_words_ignoring_case(text: &str, from: &str, to: &str) -> String {68
+ let lowered = text.to_ascii_lowercase();69
+ replace_matching(text, &lowered, &from.to_ascii_lowercase(), to)70
+}71
+72
+fn replace_matching(text: &str, haystack: &str, from: &str, to: &str) -> String {6573
let mut out = String::with_capacity(text.len());6674
let mut rest = 0;67
- for (at, _) in text.match_indices(from) {75
+ for (at, _) in haystack.match_indices(from) {6876
if at < rest || !bounded(text, at, from.len()) {6977
continue;7078
}@@ -133,8 +141,12 @@ impl Locale {
133141
/// Translates the excuses a player types back into the en-GB phrases the game checks against.134142
pub fn canonicalise(self, text: &str) -> String {135143
let local = |excuse: &str| self.localise(excuse);136
- EXCUSES.iter().fold(text.to_owned(), |text, excuse| {137
- replace_words(&text, &local(excuse), excuse)144
+ crate::game::excuse_vocabulary().fold(text.to_owned(), |text, excuse| {145
+ let localised = local(excuse);146
+ if localised == excuse {147
+ return text;148
+ }149
+ replace_words_ignoring_case(&text, &localised, excuse)138150
})139151
}140152
@@ -191,5 +203,13 @@ mod tests {
191203
Locale::American.canonicalise("Digest username=\"carving the turkey\""),192204
"Digest username=\"wrapping presents\""193205
);206
+ assert_eq!(207
+ Locale::American.canonicalise("Excuse \"Studying\""),208
+ "Excuse \"revising\""209
+ );210
+ assert_eq!(211
+ Locale::American.canonicalise("Excuse \"helping grandma\""),212
+ "Excuse \"helping Nan\""213
+ );194214
}195215
}@@ -55,7 +55,7 @@ impl Record {
5555
id: run.id.clone(),5656
public_id: run.public_id.clone(),5757
seeded: run.seeded,58
- status: run.status().to_owned(),58
+ status: run.status().name().to_owned(),5959
ending: run.ended.as_ref().map(|ending| Ending {6060
cause: ending.cause.slug().to_owned(),6161
detail: ending.cause.detail().to_owned(),