an http based roguelike rpg about jelqing
Commit c3e0c27cf8fd
tarball6 files changed+227-24
@@ -194,7 +194,7 @@ pub struct ShopItem {
194194
pub description: &'static str,195195
}196196
197
-pub const SHOP: [ShopItem; 10] = [197
+pub const SHOP: [ShopItem; 11] = [198198
ShopItem {199199
slug: "pump",200200
price: 10,@@ -236,6 +236,11 @@ pub const SHOP: [ShopItem; 10] = [
236236
description: "Pump-Bro does not recommend it.",237237
},238238
ShopItem {239
+ slug: "window-blinds",240
+ price: 5,241
+ description: "The neighbour has a good camera.",242
+ },243
+ ShopItem {239244
slug: "ice-pack",240245
price: 3,241246
description: "Cold.",@@ -1,6 +1,6 @@
11
use std::collections::HashMap;22
3
-use axum::http::{StatusCode, header};3
+use axum::http::{HeaderName, StatusCode, header};44
use rand::Rng;55
use serde::Deserialize;66
use serde_json::{Value, json};@@ -162,7 +162,8 @@ impl Run {
162162
self.leave_door();163163
return Ok(());164164
}165
- if digest && self.heard.contains(&excuse) {165
+ let true_now = self.pulled_muscle && excuse == "pulled a muscle";166
+ if digest && self.heard.contains(&excuse) && !true_now {166167
self.shame += 3;167168
self.touch();168169
return Err(self.mom_strike(challenge(Problem::new(@@ -171,7 +172,7 @@ impl Run {
171172
"Mom has heard that one before. She would have preferred the truth.",172173
))));173174
}174
- if self.excuses().contains(&excuse.as_str()) {175
+ if true_now || self.excuses().contains(&excuse.as_str()) {175176
self.heard.push(excuse);176177
self.leave_door();177178
return Ok(());@@ -194,6 +195,9 @@ impl Run {
194195
if summoned {195196
return "She heard a noise.";196197
}198
+ if self.filmed_at.is_some() && self.rng.random_ratio(1, 2) {199
+ return "A neighbour has sent her something.";200
+ }197201
let errands: &[&'static str] = if self.floor == Floor::Grandmas {198202
&[199203
"Grandma would like to see you.",@@ -425,6 +429,7 @@ impl Run {
425429
"do-not-disturb-sign" => items.dnd_sign = true,426430
"ruler" => items.ruler = true,427431
"ice-pack" => items.ice_packs += 1,432
+ "window-blinds" => items.blinds = true,428433
_ => items.cage = false,429434
}430435
Ok(Outcome::json(@@ -686,10 +691,15 @@ impl Run {
686691
return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)687692
.header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED));688693
}689
- Ok(690
- Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)691
- .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED),692
- )694
+ let spreadsheet = Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)695
+ .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED);696
+ if self.filmed_at.is_some() {697
+ return Ok(spreadsheet.header(698
+ HeaderName::from_static("x-forum-note"),699
+ "We have seen the footage.",700
+ ));701
+ }702
+ Ok(spreadsheet)693703
}694704
695705
pub fn diagnose(@@ -727,6 +737,7 @@ impl Run {
727737
}728738
729739
self.peyronie = false;740
+ self.priapism_turns = 0;730741
let etag = self.origin_etag.clone();731742
if if_none_match.is_some_and(|inm| super::etag_matches(inm, &etag)) {732743
self.cured = true;@@ -14,6 +14,10 @@ use super::{
1414
use crate::{game::LUBRICANTS, problem::Problem};1515
1616
const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);17
+const PRIAPISM_TURNS: u8 = 5;18
+const PRIAPISM_EQ: i32 = 75;19
+const DEGLOVED: &str =20
+ "The outer layer has been removed in one piece. It will not be returning. The run has ended.";1721
1822
impl Run {1923
pub fn jelq(@@ -87,7 +91,13 @@ impl Run {
8791
}8892
let profile = technique.profile().expect("training technique");8993
self.commit(intensity, lube, now, 1);90
- self.hazards(intensity)?;94
+ if technique == Technique::Dry && intensity >= 8 && self.rng.random_ratio(1, 60) {95
+ self.end("degloved", DEGLOVED);96
+ return Err(Problem::new(StatusCode::GONE, "degloved", DEGLOVED));97
+ }98
+ if let Some(interrupted) = self.hazards(intensity)? {99
+ return Ok(interrupted);100
+ }91101
let result = self.roll(technique, &profile, intensity, lube);92102
Ok(self.gossip(result?))93103
}@@ -120,7 +130,9 @@ impl Run {
120130
self.check_storage()?;121131
self.floor_enemy()?;122132
self.commit(intensity, lube, now, 2);123
- self.hazards(intensity)?;133
+ if let Some(interrupted) = self.hazards(intensity)? {134
+ return Ok(interrupted);135
+ }124136
125137
let mut statuses = Vec::new();126138
for (technique, profile) in [(a, first), (b, second)] {@@ -309,21 +321,47 @@ impl Run {
309321
{310322
self.observed = Some(observer);311323
self.shame += 1;324
+ if observer == "cat" && self.rng.random_ratio(1, 5) {325
+ self.towel_stolen = true;326
+ }327
+ }328
+ if intensity >= 9 && !self.pulled_muscle && self.rng.random_ratio(1, 12) {329
+ self.pulled_muscle = true;330
+ self.stamina = self.stamina.min(self.max_stamina());331
+ }332
+ if self.items.ring && self.eq > 85 && self.rng.random_ratio(1, 10) {333
+ self.priapism_turns = PRIAPISM_TURNS;334
+ }335
+ if self.priapism_turns > 0 {336
+ self.priapism_turns -= 1;337
+ self.eq = self.eq.max(PRIAPISM_EQ);312338
}313339
if self.turn == super::BRANCH_TURN {314340
self.branch_pending = true;315341
}316342
}317343
318
- fn hazards(&mut self, intensity: i32) -> Result<(), Problem> {344
+ fn hazards(&mut self, intensity: i32) -> Result<Option<Outcome>, Problem> {319345
let odds = match self.eq {320
- ..=70 => 200,346
+ ..=70 => 300,321347
71..=85 => 25,322348
_ => 10,323349
};324350
if self.rng.random_ratio(1, odds) {325351
return Err(self.rupture());326352
}353
+ let windowed = matches!(self.floor, Floor::Bedroom | Floor::Grandmas | Floor::Hotel);354
+ if windowed && !self.items.blinds && self.rng.random_ratio(1, 60) {355
+ self.shame += 30;356
+ self.filmed_at.get_or_insert_with(SystemTime::now);357
+ return Ok(Some(358
+ Outcome::json(359
+ StatusCode::CREATED,360
+ json!({ "detail": "A neighbour was at the window. A recording has been created. It has been shared." }),361
+ )362
+ .header(header::LOCATION, format!("/runs/{}/footage", self.id)),363
+ ));364
+ }327365
if self.floor == Floor::LockerRoom && self.rng.random_ratio(1, 10) {328366
self.shame += 1;329367
return Err(Problem::new(@@ -369,7 +407,7 @@ impl Run {
369407
)370408
.with("eq", self.eq));371409
}372
- Ok(())410
+ Ok(None)373411
}374412
375413
fn roll(@@ -540,6 +578,8 @@ impl Run {
540578
35,541579
"Warmed up. The shower is hot. The roommate's will not be.",542580
)581
+ } else if self.towel_stolen {582
+ (10, "Warmed up, poorly. The cat has the towel.")543583
} else {544584
(25, "Warmed up. The towel has cooled.")545585
};@@ -559,6 +599,12 @@ impl Run {
559599
}560600
self.touch();561601
self.items.ice_packs -= 1;602
+ if self.priapism_turns > 0 {603
+ return Ok(Outcome::json(604
+ StatusCode::OK,605
+ json!({ "detail": "The ice has no effect. The condition persists. The ice pack is gone." }),606
+ ));607
+ }562608
self.eq = (self.eq - 40).max(0);563609
Ok(Outcome::json(564610
StatusCode::OK,@@ -576,6 +622,14 @@ impl Run {
576622
}577623
self.items.supplements -= 1;578624
self.touch();625
+ if self.rng.random_ratio(1, 5) {626
+ self.priapism_turns = PRIAPISM_TURNS;627
+ self.eq = self.eq.max(PRIAPISM_EQ);628
+ return Ok(Outcome::json(629
+ StatusCode::OK,630
+ json!({ "detail": "The upstream returned a valid response. It is still returning it." }),631
+ ));632
+ }579633
Err(Problem::new(580634
StatusCode::BAD_GATEWAY,581635
"bad-gateway",@@ -599,7 +653,9 @@ impl Run {
599653
"Edging has looped. The server detected an infinite loop and terminated it.",600654
));601655
}602
- self.hazards(5)?;656
+ if let Some(interrupted) = self.hazards(5)? {657
+ return Ok(interrupted);658
+ }603659
Ok(Outcome::json(604660
StatusCode::OK,605661
json!({ "detail": "Edged. EQ is rising. Nothing else is.", "edges": self.edges }),@@ -608,7 +664,9 @@ impl Run {
608664
609665
fn pump(&mut self, now: Instant) -> Result<Outcome, Problem> {610666
self.commit(4, None, now, 1);611
- self.hazards(4)?;667
+ if let Some(interrupted) = self.hazards(4)? {668
+ return Ok(interrupted);669
+ }612670
self.temp_length += 0.5;613671
Ok(Outcome::json(614672
StatusCode::TEMPORARY_REDIRECT,@@ -7,7 +7,7 @@ pub use encounters::{ELDER_LAST_MODIFIED, FORUM_THREAD, PatchOp, forum_thread};
77
88
use std::{99
sync::LazyLock,10
- time::{Duration, Instant},10
+ time::{Duration, Instant, SystemTime},1111
};1212
1313
use axum::{@@ -155,6 +155,7 @@ pub struct Items {
155155
pub cage: bool,156156
pub spreadsheet: bool,157157
pub teapot_spout: bool,158
+ pub blinds: bool,158159
}159160
160161
enum Payload {@@ -276,6 +277,10 @@ pub struct Run {
276277
pub mom_strikes: u8,277278
pub elder_strikes: u8,278279
pub seeded: bool,280
+ pub towel_stolen: bool,281
+ pub pulled_muscle: bool,282
+ pub priapism_turns: u8,283
+ pub filmed_at: Option<SystemTime>,279284
pub observed: Option<&'static str>,280285
pub measured: bool,281286
pub cured: bool,@@ -371,6 +376,10 @@ impl Run {
371376
mom_strikes: 0,372377
elder_strikes: 0,373378
seeded: false,379
+ towel_stolen: false,380
+ pulled_muscle: false,381
+ priapism_turns: 0,382
+ filmed_at: None,374383
observed: None,375384
measured: false,376385
cured: false,@@ -413,6 +422,16 @@ impl Run {
413422
self.current_length() + SELF_REPORT_INFLATION414423
}415424
425
+ pub fn max_stamina(&self) -> i32 {426
+ if self.pulled_muscle { 7 } else { MAX_STAMINA }427
+ }428
+429
+ pub fn footage(&self) -> Option<(SystemTime, u64)> {430
+ let filmed = self.filmed_at?;431
+ let age = filmed.elapsed().map_or(0, |d| d.as_secs());432
+ Some((filmed, age))433
+ }434
+416435
pub fn total_shame(&self) -> u32 {417436
self.shame_base + self.shame418437
}@@ -430,6 +449,9 @@ impl Run {
430449
(self.teapot, "short and stout"),431450
(self.peyronie, "curved"),432451
(self.items.cage, "caged"),452
+ (self.priapism_turns > 0, "priapism"),453
+ (self.pulled_muscle, "pulled muscle"),454
+ (self.filmed_at.is_some(), "viral"),433455
]434456
.into_iter()435457
.filter_map(|(active, name)| active.then_some(name))@@ -549,6 +571,11 @@ impl Run {
549571
"teapot-spout",550572
"Does nothing. Cannot be dropped.",551573
),574
+ (575
+ i.blinds,576
+ "window-blinds",577
+ "The neighbour has a good camera. The blinds are better.",578
+ ),552579
];553580
owned554581
.into_iter()@@ -578,7 +605,7 @@ impl Run {
578605
.header(header::RETRY_AFTER, seconds.to_string()));579606
}580607
self.refractory_until = None;581
- self.stamina = MAX_STAMINA;608
+ self.stamina = self.max_stamina();582609
self.eq = 20;583610
self.warmed_up = false;584611
self.touch();@@ -630,6 +657,7 @@ impl Run {
630657
self.mom_present = false;631658
self.roommate_told = false;632659
self.drawer_searches = 0;660
+ self.towel_stolen = false;633661
self.edges = 0;634662
if paid {635663
self.cash += FLOOR_ALLOWANCE;@@ -668,6 +696,12 @@ impl Run {
668696
}669697
_ => {}670698
}699
+ if self.rng.random_ratio(1, 120) {700
+ let detail =701
+ "The request conflicts with the current state of the zipper. The run has ended.";702
+ self.end("zipper", detail);703
+ return Err(Problem::new(StatusCode::CONFLICT, "zipper", detail));704
+ }671705
let reverted = self.temp_length;672706
self.next_floor(true);673707
let mut body = json!({@@ -26,8 +26,8 @@ use tower::ServiceExt;
2626
2727
use crate::{2828
game::{29
- ELDER_LAST_MODIFIED, FORUM_THREAD, Floor, Grip, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH,30
- PatchOp, RUN_ALLOW, Run, SUSPICION_LIMIT, Technique, forum_thread, shaft,29
+ ELDER_LAST_MODIFIED, FORUM_THREAD, Floor, Grip, LUBRICANTS, Lube, MM_PER_INCH, PatchOp,30
+ RUN_ALLOW, Run, SUSPICION_LIMIT, Technique, forum_thread, shaft,3131
},3232
problem::{PROBLEM_BASE, Problem, reason},3333
secret,@@ -95,6 +95,7 @@ fn api(state: Shared) -> Router {
9595
.route("/runs/{id}/elder", any(elder))9696
.route("/runs/{id}/grip", any(grip))9797
.route("/runs/{id}/diagnosis", any(diagnosis))98
+ .route("/runs/{id}/footage", any(footage))9899
.route("/forum/threads/{year}", any(thread))99100
.route("/graveyard", any(graveyard))100101
.route("/leaderboard", any(leaderboard))@@ -174,10 +175,9 @@ async fn chrome(request: Request, next: Next) -> Response {
174175
let headers = response.headers_mut();175176
headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9"));176177
headers.insert("x-powered-by", HeaderValue::from_static("blood"));177
- headers.insert(178
- header::CACHE_CONTROL,179
- HeaderValue::from_static("no-store, private"),180
- );178
+ headers179
+ .entry(header::CACHE_CONTROL)180
+ .or_insert(HeaderValue::from_static("no-store, private"));181181
response182182
}183183
@@ -310,7 +310,10 @@ fn finish(run: &Run, mut response: Response) -> Response {
310310
run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),311311
),312312
("x-eq", run.eq.to_string()),313
- ("x-stamina", format!("{}/{MAX_STAMINA}", run.stamina)),313
+ (314
+ "x-stamina",315
+ format!("{}/{}", run.stamina, run.max_stamina()),316
+ ),314317
(315318
"x-suspicion",316319
format!("{}/{SUSPICION_LIMIT}", run.suspicion),@@ -328,6 +331,9 @@ fn finish(run: &Run, mut response: Response) -> Response {
328331
stats.push(("vary", "Mom".into()));329332
}330333
// RFC 9111 obsoleted Warning, which is why nobody heeds it.334
+ if run.pulled_muscle {335
+ stats.push(("warning", "199 - \"a muscle has been pulled\"".into()));336
+ }331337
if run.eq > 70 && run.ended.is_none() {332338
stats.push(("warning", "199 - \"it is turning purple\"".into()));333339
}@@ -988,6 +994,50 @@ async fn diagnosis(
988994
})989995
}990996
997
+async fn footage(998
+ State(state): State<Shared>,999
+ Path(id): Path<String>,1000
+ method: Method,1001
+ headers: HeaderMap,1002
+) -> Response {1003
+ const ALLOW: &str = "GET, HEAD, OPTIONS";1004
+ with_run(&state, &id, &headers, Scope::Pockets, |run| {1005
+ match method.as_str() {1006
+ "GET" | "HEAD" => {1007
+ let Some((filmed, age)) = run.footage() else {1008
+ return Problem::new(1009
+ StatusCode::NOT_FOUND,1010
+ "no-footage",1011
+ "There is no footage. Yet.",1012
+ )1013
+ .into_response();1014
+ };1015
+ (1016
+ [1017
+ (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_owned()),1018
+ (header::AGE, age.to_string()),1019
+ (header::LAST_MODIFIED, httpdate::fmt_http_date(filmed)),1020
+ ],1021
+ Json(json!({1022
+ "detail": "A recording. It is grainy. It is unmistakably you. It has been shared.",1023
+ "views": 1 + age * 37,1024
+ })),1025
+ )1026
+ .into_response()1027
+ }1028
+ "DELETE" => Problem::new(1029
+ StatusCode::METHOD_NOT_ALLOWED,1030
+ "the-internet",1031
+ "The footage has been cached by intermediaries. It cannot be deleted.",1032
+ )1033
+ .header(header::ALLOW, ALLOW)1034
+ .into_response(),1035
+ "OPTIONS" => options(ALLOW, false),1036
+ _ => unsupported(&method, ALLOW),1037
+ }1038
+ })1039
+}1040
+9911041
async fn thread(Path(year): Path<String>, method: Method) -> Response {9921042
if method != Method::GET && method != Method::HEAD {9931043
return unsupported(&method, "GET, HEAD");@@ -1098,6 +1148,7 @@ async fn leaderboard(
10981148
"floor": run.floor.number(),10991149
"self_reported_length": (run.self_reported() * 100.0).round() / 100.0,11001150
"ranked": !run.seeded,1151
+ "viral": run.filmed_at.is_some(),11011152
});11021153
if truthful {11031154
entry["content_length"] = json!(millimetres(run.current_length()));@@ -504,3 +504,47 @@ async fn the_ui_replays_what_browsers_cannot_send() {
504504
assert_eq!(redirected["status"], 303);505505
assert_eq!(redirected["reason"], "See Other");506506
}507
+508
+#[tokio::test]509
+async fn the_internet_never_forgets() {510
+ let app = seeded_with(Floor::Bedroom, |run| {511
+ run.filmed_at = Some(std::time::SystemTime::now());512
+ });513
+ let footage = send(&app, "GET", "/runs/cafebabe/footage", &[], "").await;514
+ assert_eq!(footage.status(), StatusCode::OK);515
+ assert_eq!(516
+ footage.headers()[header::CACHE_CONTROL],517
+ "public, max-age=31536000, immutable"518
+ );519
+ assert!(footage.headers().contains_key(header::AGE));520
+ let delete = send(&app, "DELETE", "/runs/cafebabe/footage", &[], "").await;521
+ assert_eq!(delete.status(), StatusCode::METHOD_NOT_ALLOWED);522
+}523
+524
+#[tokio::test]525
+async fn a_pulled_muscle_is_a_true_excuse() {526
+ let app = seeded_with(Floor::Grandmas, |run| {527
+ run.mom_present = true;528
+ run.nonce = "abc".into();529
+ run.pulled_muscle = true;530
+ run.heard.push("pulled a muscle".into());531
+ });532
+ let response = send(533
+ &app,534
+ "GET",535
+ "/runs/cafebabe",536
+ &[(537
+ "authorization",538
+ "Digest username=\"pulled a muscle\", nonce=\"abc\"",539
+ )],540
+ "",541
+ )542
+ .await;543
+ assert_eq!(response.status(), StatusCode::OK);544
+ assert!(545
+ response.headers()["x-stamina"]546
+ .to_str()547
+ .unwrap()548
+ .ends_with("/7")549
+ );550
+}