an http based roguelike rpg about jelqing
Commit 4f54028cba69
tarball23 files changed+380-276
@@ -1,5 +1,5 @@
11
use super::{2
- LUBRICANTS, Lube, Run, Technique,2
+ Lube, Run, Technique,33
balance::STORAGE_BULK,44
lube::LubeRule,55
techniques::{Training, Utility},@@ -8,7 +8,22 @@ use crate::problem::Problem;
88
use axum::http::{StatusCode, header};99
use serde_json::json;1010
11
+pub(super) fn caged() -> Problem {12
+ Problem::new(13
+ StatusCode::LOCKED,14
+ "caged",15
+ "The cage is locked. The key is not in the box.",16
+ )17
+}18
+1119
impl Run {20
+ pub(super) fn check_cage(&self) -> Result<(), Problem> {21
+ if self.items.kit.cage {22
+ return Err(caged());23
+ }24
+ Ok(())25
+ }26
+1227
pub(super) fn ready(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {1328
self.access(technique)?;1429
match technique {@@ -52,7 +67,7 @@ impl Run {
5267
training.name()5368
),5469
)55
- .with("lubricants", json!(LUBRICANTS))),70
+ .with("lubricants", json!(Lube::content_types()))),5671
(LubeRule::Forbidden, Some(_)) => Err(Problem::new(5772
StatusCode::UNSUPPORTED_MEDIA_TYPE,5873
"lubricant-declared",@@ -27,13 +27,7 @@ impl Run {
2727
)2828
.header(header::LOCATION, self.path("/thread")));2929
}30
- if self.items.kit.cage {31
- return Err(Problem::new(32
- StatusCode::LOCKED,33
- "caged",34
- "The cage is locked. The key is not in the box.",35
- ));36
- }30
+ self.check_cage()?;3731
match *techniques {3832
[Technique::Utility(utility)] => self.utility(utility, lube),3933
[Technique::Training(training)] => self.single(training, intensity, lube),@@ -14,10 +14,8 @@ fn nothing() -> Result<Outcome, Problem> {
1414
1515
impl Run {1616
pub(super) fn found(&self, item: Item, detail: &str) -> Outcome {17
- Outcome::json(StatusCode::FOUND, json!({ "detail": detail })).header(18
- header::LOCATION,19
- self.path(&format!("/inventory/{}", item.slug())),20
- )17
+ Outcome::json(StatusCode::FOUND, json!({ "detail": detail }))18
+ .header(header::LOCATION, self.item_path(item))2119
}2220
2321
pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {@@ -4,7 +4,7 @@ use axum::http::{StatusCode, header};
44
use serde_json::{Value, json};55
66
use super::{7
- Cause, Floor, Item, Outcome, Run,7
+ Cause, Choice, Floor, Item, Outcome, Run,88
balance::{99
EXAGGERATION, MEDITATION_ODDS, PATIENCE, PRINTABLE_RULER, RULER_SHAME, THREAD_READS,1010
ULI_ODDS,@@ -82,19 +82,28 @@ pub enum Branch {
8282
}8383
8484
impl Branch {85
+ pub const ALL: [Self; 2] = [Self::Forum, Self::CloseTab];86
+8587
pub fn slug(self) -> &'static str {8688
match self {8789
Self::Forum => "forum",8890
Self::CloseTab => "close-tab",8991
}9092
}93
+94
+ pub fn title(self) -> &'static str {95
+ match self {96
+ Self::Forum => "read the forum",97
+ Self::CloseTab => "close the tab",98
+ }99
+ }91100
}92101
93102
impl std::str::FromStr for Branch {94103
type Err = ();95104
96105
fn from_str(slug: &str) -> Result<Self, ()> {97
- [Self::Forum, Self::CloseTab]106
+ Self::ALL98107
.into_iter()99108
.find(|branch| branch.slug() == slug)100109
.ok_or(())@@ -131,23 +140,14 @@ impl Default for Online {
131140
132141
impl Run {133142
pub(super) fn branch_offer(&self) -> Outcome {134
- let forum = self.path(&format!("/branches/{}", Branch::Forum.slug()));135
- let close = self.path(&format!("/branches/{}", Branch::CloseTab.slug()));136
- Outcome::json(137
- StatusCode::MULTIPLE_CHOICES,138
- json!({139
- "detail": "A browser tab is open. It has been open for some time.",140
- "choices": [141
- { "href": forum, "method": "POST", "title": "Read the forum" },142
- { "href": close, "method": "POST", "title": "Close the tab" },143
- ],144
- }),145
- )146
- .header(147
- header::LINK,148
- format!(149
- "<{forum}>; rel=\"alternate\"; title=\"Read the forum\"; allow=\"POST\", <{close}>; rel=\"alternate\"; title=\"Close the tab\"; allow=\"POST\""150
- ),143
+ let choices = Branch::ALL.map(|branch| Choice {144
+ href: self.branch_path(branch),145
+ method: "POST",146
+ title: branch.title(),147
+ });148
+ Outcome::choices(149
+ "A browser tab is open. It has been open for some time.",150
+ &choices,151151
)152152
}153153
@@ -178,7 +178,7 @@ impl Run {
178178
.map(|item| {179179
json!({180180
"name": item.slug(),181
- "href": self.path(&format!("/inventory/{}", item.slug())),181
+ "href": self.item_path(item),182182
})183183
})184184
.collect();@@ -1,8 +1,8 @@
1
-use axum::http::{StatusCode, header};1
+use axum::http::StatusCode;22
use serde_json::json;33
44
use super::{5
- Cause, Floor, Item, Notice, Outcome, Run, Story,5
+ Cause, Choice, Floor, Item, Notice, Outcome, Run, Story,66
balance::{77
CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KETTLE_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TOLD,88
MELON_SHAME, RAID_NOISE, RUPTURE_LINE, WATERMELON,@@ -10,6 +10,46 @@ use super::{
1010
};1111
use crate::problem::Problem;1212
13
+#[derive(Clone, Copy)]14
+enum Loot {15
+ CoconutOil,16
+ EnergyDrink,17
+ IcePack,18
+ Kettle,19
+ Nothing,20
+}21
+22
+impl Loot {23
+ const ALL: [Self; 5] = [24
+ Self::CoconutOil,25
+ Self::EnergyDrink,26
+ Self::IcePack,27
+ Self::Kettle,28
+ Self::Nothing,29
+ ];30
+31
+ /// Out of 120, so one draw keeps the marginals at the documented 1 in 3, 1 in 5, 1 in 8 and 1 in 6.32
+ fn weight(self) -> u32 {33
+ match self {34
+ Self::CoconutOil => 120 / COCONUT_ODDS,35
+ Self::EnergyDrink => 120 / ENERGY_ODDS,36
+ Self::IcePack => 120 / KITCHEN_ICE_ODDS,37
+ Self::Kettle => 120 / KETTLE_ODDS,38
+ Self::Nothing => 120 - Self::ALL[..4].iter().map(|l| l.weight()).sum::<u32>(),39
+ }40
+ }41
+42
+ fn sight(self) -> &'static str {43
+ match self {44
+ Self::CoconutOil => "A jar of coconut oil, half of it already gone.",45
+ Self::EnergyDrink => "An energy drink, cold and faintly alarming.",46
+ Self::IcePack => "An ice pack, buried under the peas.",47
+ Self::Kettle => "The kettle has just boiled.",48
+ Self::Nothing => "Nothing. The fridge is ordinary.",49
+ }50
+ }51
+}52
+1353
impl Run {1454
/// The household keeps count, so every raid makes noise and the noise makes the next telling likelier.1555
pub fn raid_kitchen(&mut self) -> Result<Outcome, Problem> {@@ -34,27 +74,11 @@ impl Run {
3474
return Err(self.rupture());3575
}3676
self.make_noise(RAID_NOISE);37
- // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5, 1 in 8 and 1 in 6.38
- let loot = self.oracle.pick(77
+ let loot = Loot::ALL[self.oracle.pick(3978
self.decision("loot", "What does the fridge give up on this raid?"),40
- &[41
- 120 / COCONUT_ODDS,42
- 120 / ENERGY_ODDS,43
- 120 / KITCHEN_ICE_ODDS,44
- 120 / KETTLE_ODDS,45
- 120 - (120 / COCONUT_ODDS46
- + 120 / ENERGY_ODDS47
- + 120 / KITCHEN_ICE_ODDS48
- + 120 / KETTLE_ODDS),49
- ],50
- &[51
- "A jar of coconut oil, half of it already gone.",52
- "An energy drink, cold and faintly alarming.",53
- "An ice pack, buried under the peas.",54
- "The kettle has just boiled.",55
- "Nothing. The fridge is ordinary.",56
- ],57
- );79
+ &Loot::ALL.map(Loot::weight),80
+ &Loot::ALL.map(Loot::sight),81
+ )];5882
let melon = self.body.eq > RUPTURE_LINE5983
&& self.oracle.ratio(6084
self.decision(@@ -97,47 +121,39 @@ impl Run {
97121
)98122
}99123
100
- fn unpack(&mut self, loot: usize) -> Outcome {101
- let (item, detail) = match loot {102
- 0 => (103
- Item::CoconutOil,104
- "A jar of coconut oil, half of it already gone.",105
- ),106
- 1 => (107
- Item::EnergyDrink,108
- "An energy drink, cold and faintly alarming.",109
- ),110
- 2 => (Item::IcePack, "An ice pack, buried under the peas."),111
- 3 => {124
+ fn unpack(&mut self, loot: Loot) -> Outcome {125
+ let item = match loot {126
+ Loot::CoconutOil => Item::CoconutOil,127
+ Loot::EnergyDrink => Item::EnergyDrink,128
+ Loot::IcePack => Item::IcePack,129
+ Loot::Kettle => {112130
self.here.kitchen.kettle_boiled = true;113131
return Outcome::json(114132
StatusCode::OK,115
- json!({ "detail": "The kettle has just boiled. The towel can be warmed once more." }),133
+ json!({ "detail": format!("{} The towel can be warmed once more.", loot.sight()) }),116134
);117135
}118
- _ => return Outcome::empty(StatusCode::NO_CONTENT),136
+ Loot::Nothing => return Outcome::empty(StatusCode::NO_CONTENT),119137
};120138
self.items.grant(item);121
- Outcome::json(StatusCode::OK, json!({ "detail": detail }))139
+ Outcome::json(StatusCode::OK, json!({ "detail": loot.sight() }))122140
}123141
124142
fn melon_offer(&self) -> Outcome {125
- let commit = self.path("/kitchen/watermelon");126
- Outcome::json(127
- StatusCode::MULTIPLE_CHOICES,128
- json!({129
- "detail": "The fridge holds a watermelon. It is exactly the right shape.",130
- "choices": [131
- { "href": commit, "method": "POST", "title": "Commit to the watermelon" },132
- { "href": self.path(""), "method": "GET", "title": "Close the fridge" },133
- ],134
- }),135
- )136
- .header(137
- header::LINK,138
- format!(139
- "<{commit}>; rel=\"alternate\"; title=\"Commit to the watermelon\"; allow=\"POST\""140
- ),143
+ Outcome::choices(144
+ "The fridge holds a watermelon. It is exactly the right shape.",145
+ &[146
+ Choice {147
+ href: self.path("/kitchen/watermelon"),148
+ method: "POST",149
+ title: "Commit to the watermelon",150
+ },151
+ Choice {152
+ href: self.path(""),153
+ method: "GET",154
+ title: "Close the fridge",155
+ },156
+ ],141157
)142158
}143159
@@ -4,14 +4,6 @@ use serde_json::json;
44
use super::Item;55
use crate::problem::Problem;66
7
-pub const LUBRICANTS: [&str; 5] = [8
- "application/baby-oil",9
- "application/coconut-oil",10
- "application/lotion",11
- "application/vaseline",12
- "text/plain; charset=spit",13
-];14
-157
#[derive(Clone, Copy, PartialEq, Eq, Debug)]168
pub enum Lube {179
BabyOil,@@ -22,6 +14,28 @@ pub enum Lube {
2214
}2315
2416
impl Lube {17
+ const ALL: [Self; 5] = [18
+ Self::BabyOil,19
+ Self::CoconutOil,20
+ Self::Lotion,21
+ Self::Vaseline,22
+ Self::Spit,23
+ ];24
+25
+ pub fn content_types() -> [&'static str; 5] {26
+ Self::ALL.map(Self::content_type)27
+ }28
+29
+ fn content_type(self) -> &'static str {30
+ match self {31
+ Self::BabyOil => "application/baby-oil",32
+ Self::CoconutOil => "application/coconut-oil",33
+ Self::Lotion => "application/lotion",34
+ Self::Vaseline => "application/vaseline",35
+ Self::Spit => "text/plain; charset=spit",36
+ }37
+ }38
+2539
pub fn from_content_type(content_type: Option<&str>) -> Result<Option<Self>, Problem> {2640
let Some(raw) = content_type else {2741
return Ok(None);@@ -30,11 +44,10 @@ impl Lube {
3044
let mut parts = lowered.split(';').map(str::trim);3145
let essence = parts.next().unwrap_or_default();3246
let spit = parts.any(|p| p.replace(' ', "") == "charset=spit");47
+ if let Some(lube) = Self::ALL.into_iter().find(|l| l.content_type() == essence) {48
+ return Ok(Some(lube));49
+ }3350
match essence {34
- "application/baby-oil" => Ok(Some(Self::BabyOil)),35
- "application/coconut-oil" => Ok(Some(Self::CoconutOil)),36
- "application/lotion" => Ok(Some(Self::Lotion)),37
- "application/vaseline" => Ok(Some(Self::Vaseline)),3851
"text/plain" if spit => Ok(Some(Self::Spit)),3952
e if e.starts_with("image/") || e.starts_with("video/") => Err(Problem::new(4053
StatusCode::UNSUPPORTED_MEDIA_TYPE,@@ -46,7 +59,7 @@ impl Lube {
4659
"unsupported-lubricant",4760
format!("{essence} is not a lubricant."),4861
)49
- .with("lubricants", json!(LUBRICANTS))),62
+ .with("lubricants", json!(Self::content_types()))),5063
}5164
}5265
@@ -46,10 +46,10 @@ pub use household::HEARD_A_NOISE;
4646
pub use household::{Excuse, Household, MumVisit, vocabulary as excuse_vocabulary};4747
pub use inventory::Items;4848
pub use items::{Item, SHOP};49
-pub use lube::{LUBRICANTS, Lube};49
+pub use lube::Lube;5050
pub use measure::{shaft, volume};5151
pub use notice::Notice;52
-pub use outcome::{Outcome, Payload};52
+pub use outcome::{Choice, Outcome, Payload};5353
pub use run::Run;5454
pub use standing::Standing;5555
pub use techniques::Technique;@@ -1,5 +1,7 @@
1
-use axum::http::{HeaderName, StatusCode};2
-use serde_json::Value;1
+use axum::http::{HeaderName, StatusCode, header};2
+use serde_json::{Value, json};3
+4
+use crate::link::{self, Link};35
46
pub enum Payload {57
Empty,@@ -7,6 +9,13 @@ pub enum Payload {
79
Raw(&'static str, Vec<u8>),810
}911
12
+/// One way out of a 300, offered in the body and in `Link` alike.13
+pub struct Choice {14
+ pub(crate) href: String,15
+ pub(crate) method: &'static str,16
+ pub(crate) title: &'static str,17
+}18
+1019
/// A successful result of a game action, before it becomes an HTTP response.1120
pub struct Outcome {1221
status: StatusCode,@@ -37,6 +46,23 @@ impl Outcome {
3746
}3847
}3948
49
+ pub fn choices(detail: &str, choices: &[Choice]) -> Self {50
+ let listed: Vec<Value> = choices51
+ .iter()52
+ .map(|c| json!({ "href": c.href, "method": c.method, "title": c.title }))53
+ .collect();54
+ let links = link::join(choices.iter().map(|c| {55
+ Link::registered(&c.href, "alternate")56
+ .title(c.title)57
+ .allow(c.method)58
+ }));59
+ Self::json(60
+ StatusCode::MULTIPLE_CHOICES,61
+ json!({ "detail": detail, "choices": listed }),62
+ )63
+ .header(header::LINK, links)64
+ }65
+4066
pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {4167
self.headers.push((name, value.into()));4268
self@@ -18,13 +18,7 @@ impl Run {
1818
if self.online.story == Story::Pending {1919
return Ok(self.branch_offer());2020
}21
- if self.items.kit.cage {22
- return Err(Problem::new(23
- StatusCode::LOCKED,24
- "caged",25
- "The cage is locked. The key is not in the box.",26
- ));27
- }21
+ self.check_cage()?;2822
self.begin_turn()?;2923
if self.oracle.chance_not(3024
self.decision("rupture", "The pause is uneventful"),@@ -7,8 +7,8 @@ use axum::http::StatusCode;
77
use serde_json::{Value, json};88
99
use super::{10
- Body, Cause, Clinic, Ending, Floor, FloorState, Household, Item, Items, Notice, Observer,11
- Online, RunStatus, Standing, Story,10
+ Body, Branch, Cause, Clinic, Ending, Floor, FloorState, Household, Item, Items, Notice,11
+ Observer, Online, RunStatus, Standing, Story,1212
balance::{1313
BRANCH_TURN, FLOOR_LIMIT, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, REFRACTORY_STAMINA,1414
SECOND_WIND, SELF_REPORT_INFLATION, STARTING_BABY_OIL, STARTING_EQ, STARTING_LUBE,@@ -258,6 +258,18 @@ impl Run {
258258
format!("/runs/{}{sub}", self.ident.id)259259
}260260
261
+ pub fn item_path(&self, item: Item) -> String {262
+ self.path(&format!("/inventory/{}", item.slug()))263
+ }264
+265
+ pub fn shop_path(&self, item: Item) -> String {266
+ self.path(&format!("/shop/{}", item.slug()))267
+ }268
+269
+ pub fn branch_path(&self, branch: Branch) -> String {270
+ self.path(&format!("/branches/{}", branch.slug()))271
+ }272
+261273
pub fn current_length(&self) -> f64 {262274
self.body.length + self.here.temp_length263275
}@@ -3,7 +3,7 @@ use std::fmt::Write as _;
33
use axum::http::{StatusCode, header};44
use serde_json::{Value, json};55
6
-use super::{Item, Outcome, Run, balance::THIN_MARKUP, items::SHOP};6
+use super::{Item, Outcome, Run, balance::THIN_MARKUP, checks::caged, items::SHOP};77
use crate::problem::Problem;88
99
fn pump_bro_absent() -> Problem {@@ -42,7 +42,7 @@ impl Run {
4242
"item": item.slug(),4343
"price": self.price(*item)?,4444
"description": item.pitch(),45
- "href": self.path(&format!("/shop/{}", item.slug())),45
+ "href": self.shop_path(*item),4646
"method": "POST",4747
}))4848
})@@ -109,10 +109,8 @@ impl Run {
109109
);110110
}111111
Ok(112
- Outcome::json(StatusCode::CREATED, json!({ "detail": detail })).header(113
- header::LOCATION,114
- self.path(&format!("/inventory/{}", item.slug())),115
- ),112
+ Outcome::json(StatusCode::CREATED, json!({ "detail": detail }))113
+ .header(header::LOCATION, self.item_path(item)),116114
)117115
}118116
@@ -121,11 +119,7 @@ impl Run {
121119
Item::Extender => self.items.kit.extender = false,122120
Item::Ring => self.items.kit.ring = false,123121
Item::Cage => {124
- return Err(Problem::new(125
- StatusCode::LOCKED,126
- "caged",127
- "The cage is locked.",128
- ));122
+ return Err(caged());129123
}130124
_ => {131125
return Err(Problem::new(@@ -7,9 +7,15 @@ use axum::{
77
};88
use serde_json::{Value, json};99
10
-use super::{Shared, ctx::Ctx, run::records_unavailable};10
+use super::{11
+ Shared,12
+ ctx::Ctx,13
+ meta::{GRAVEYARD_PATH, LEADERBOARD_PATH},14
+ run::records_unavailable,15
+};1116
use crate::{1217
game::{balance::MM_PER_INCH, volume},18
+ link::Link,1319
problem::{PROBLEM_BASE, Problem},1420
records::{Ranking, Record},1521
};@@ -32,7 +38,7 @@ fn page(ctx: &Ctx) -> Result<usize, Problem> {
3238
fn paged(body: Value, path: &str, query: &str, page: usize, full: bool) -> Response {3339
let link = |page: usize, rel: &str| {3440
let separator = if query.is_empty() { "" } else { "&" };35
- format!("<{path}?{query}{separator}page={page}>; rel=\"{rel}\"")41
+ Link::registered(&format!("{path}?{query}{separator}page={page}"), rel).to_string()3642
};3743
let mut links = Vec::new();3844
if page > 1 {@@ -73,7 +79,7 @@ pub fn graveyard(state: &mut Shared, ctx: &Ctx) -> Response {
7379
}))7480
})7581
.collect();76
- paged(json!({ "runs": runs }), "/graveyard", "", page, full)82
+ paged(json!({ "runs": runs }), GRAVEYARD_PATH, "", page, full)7783
}7884
7985
fn ranking(state: &Shared, ctx: &Ctx) -> Result<Ranking, Problem> {@@ -175,7 +181,7 @@ pub fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
175181
}176182
paged(177183
json!({ "entries": listed }),178
- "/leaderboard",184
+ LEADERBOARD_PATH,179185
&query.join("&"),180186
page,181187
full,@@ -1,43 +1,12 @@
1
-use std::fmt::Write as _;2
-31
use super::{run::RunResource, runs};4
-use crate::game::{Branch, Run, SHOP};5
-6
-pub const REL_BASE: &str = "https://jelq.dev/rels/";7
-8
-/// One RFC 8288 link. `allow`, `lubricant` and `intensity` are extension attributes (§3), so a client never9
-/// has to guess what a target accepts.10
-struct Link<'a> {11
- href: &'a str,12
- rel: &'a str,13
- allow: &'a str,14
- title: Option<&'a str>,15
- lubricant: Option<&'a str>,16
- intensity: Option<&'a str>,17
-}18
-19
-impl Link<'_> {20
- fn write(&self, out: &mut String) {21
- if !out.is_empty() {22
- out.push_str(", ");23
- }24
- let _ = write!(out, "<{}>; rel=\"{REL_BASE}{}\"", self.href, self.rel);25
- if let Some(title) = self.title {26
- let _ = write!(out, "; title=\"{title}\"");27
- }28
- let _ = write!(out, "; allow=\"{}\"", self.allow);29
- if let Some(rule) = self.lubricant {30
- let _ = write!(out, "; lubricant=\"{rule}\"");31
- }32
- if let Some(range) = self.intensity {33
- let _ = write!(out, "; intensity=\"{range}\"");34
- }35
- }36
-}2
+use crate::{3
+ game::{Branch, Run, SHOP},4
+ link::Link,5
+};376
387
pub fn header(run: &Run) -> String {398
let reach = run.reach();40
- let mut out = String::new();9
+ let mut links = Vec::new();4110
let resources: [(&str, &RunResource, bool); 15] = [4211
("", &runs::RUN, true),4312
("floor", &runs::FLOOR, true),@@ -61,60 +30,47 @@ pub fn header(run: &Run) -> String {
6130
} else {6231
run.path(&format!("/{rel}"))6332
};64
- Link {65
- href: &href,66
- rel: if rel.is_empty() { "run" } else { rel },67
- allow: &target.resource.allow(run),68
- title: None,69
- lubricant: None,70
- intensity: None,71
- }72
- .write(&mut out);33
+ let rel = if rel.is_empty() { "run" } else { rel };34
+ links.push(35
+ Link::jelq(&href, rel)36
+ .allow(&target.resource.allow(run))37
+ .to_string(),38
+ );7339
}7440
if reach.branch_pending {75
- for (branch, title) in [76
- (Branch::Forum, "Read the forum"),77
- (Branch::CloseTab, "Close the tab"),78
- ] {79
- Link {80
- href: &run.path(&format!("/branches/{}", branch.slug())),81
- rel: "branch",82
- allow: "POST",83
- title: Some(title),84
- lubricant: None,85
- intensity: None,86
- }87
- .write(&mut out);41
+ for branch in Branch::ALL {42
+ links.push(43
+ Link::jelq(&run.branch_path(branch), "branch")44
+ .title(branch.title())45
+ .allow("POST")46
+ .to_string(),47
+ );8848
}8949
}9050
let run_path = run.path("");9151
for technique in run.techniques_now() {9252
let name = technique.name();93
- Link {94
- href: &format!("{run_path}?technique={name}"),95
- rel: "technique",96
- allow: "JELQ",97
- title: Some(name),98
- lubricant: technique.lubricant(),99
- intensity: technique.intensity(),100
- }101
- .write(&mut out);53
+ links.push(54
+ Link::jelq(&format!("{run_path}?technique={name}"), "technique")55
+ .title(name)56
+ .allow("JELQ")57
+ .lubricant(technique.lubricant())58
+ .intensity(technique.intensity())59
+ .to_string(),60
+ );10261
}10362
if reach.shopping {10463
for item in SHOP {10564
let Some(price) = run.price(item) else {10665
continue;10766
};108
- Link {109
- href: &run.path(&format!("/shop/{}", item.slug())),110
- rel: "shop-item",111
- allow: "POST",112
- title: Some(&format!("{} for {price}", item.slug())),113
- lubricant: None,114
- intensity: None,115
- }116
- .write(&mut out);67
+ links.push(68
+ Link::jelq(&run.shop_path(item), "shop-item")69
+ .title(&format!("{} for {price}", item.slug()))70
+ .allow("POST")71
+ .to_string(),72
+ );11773
}11874
}119
- out75
+ links.join(", ")12076
}@@ -24,6 +24,9 @@ use crate::{
2424
pub static INDEX: Resource<Shared> = Resource::new(&[Verb::new("GET", index)]);2525
pub static RUNS: Resource<Shared> = Resource::new(&[Verb::new("POST", create_run)]);2626
pub static THREAD: Resource<Shared> = Resource::new(&[Verb::new("GET", thread)]);27
+pub const GRAVEYARD_PATH: &str = "/graveyard";28
+pub const LEADERBOARD_PATH: &str = "/leaderboard";29
+2730
pub static GRAVEYARD: Resource<Shared> = Resource::new(&[Verb::new("GET", graveyard)]);2831
pub static LEADERBOARD: Resource<Shared> = Resource::new(&[Verb::new("GET", leaderboard)]);2932
@@ -43,8 +46,8 @@ fn index(_: &mut Shared, ctx: &Ctx) -> Response {
4346
Json(json!({4447
"detail": "This is jelqd. It is a roguelike. It is played over HTTP.",4548
"start": { "href": "/runs", "method": "POST" },46
- "leaderboard": "/leaderboard",47
- "graveyard": "/graveyard",49
+ "leaderboard": LEADERBOARD_PATH,50
+ "graveyard": GRAVEYARD_PATH,4851
"forum": FORUM_THREAD,4952
"ui": "/ui/",5053
})),@@ -161,8 +161,8 @@ fn api(state: Shared) -> Router {
161161
.route("/runs/{id}/diagnosis", scoped(&runs::DIAGNOSIS))162162
.route("/runs/{id}/footage", scoped(&runs::FOOTAGE))163163
.route("/forum/threads/{year}", global(&meta::THREAD))164
- .route("/graveyard", global(&meta::GRAVEYARD))165
- .route("/leaderboard", global(&meta::LEADERBOARD))164
+ .route(meta::GRAVEYARD_PATH, global(&meta::GRAVEYARD))165
+ .route(meta::LEADERBOARD_PATH, global(&meta::LEADERBOARD))166166
.fallback(meta::not_found)167167
.layer(axum::middleware::from_fn(middleware::chrome))168168
.layer(CatchPanicLayer::custom(middleware::panicked))@@ -7,13 +7,13 @@ use crate::{
77
};88
99
pub(super) fn parse_techniques(run: &Run, ctx: &Ctx) -> Result<Vec<Technique>, Problem> {10
+ let listed = |problem: Problem| problem.with("techniques", run.path("/techniques"));1011
let required = || {11
- Problem::new(12
+ listed(Problem::new(1213
StatusCode::BAD_REQUEST,1314
"technique-required",1415
"No technique was specified.",15
- )16
- .with("techniques", run.path("/techniques"))16
+ ))1717
};1818
let names = ctx1919
.query("technique")@@ -25,12 +25,11 @@ pub(super) fn parse_techniques(run: &Run, ctx: &Ctx) -> Result<Vec<Technique>, P
2525
.filter(|n| !n.is_empty())2626
.map(|name| {2727
Technique::parse(name).ok_or_else(|| {28
- Problem::new(28
+ listed(Problem::new(2929
StatusCode::BAD_REQUEST,3030
"unknown-technique",3131
format!("{name} is not a technique."),32
- )33
- .with("techniques", run.path("/techniques"))32
+ ))3433
})3534
})3635
.collect()@@ -8,7 +8,8 @@ use hyper_util::rt::TokioIo;
88
use tokio::io::AsyncWriteExt;99
1010
use super::{11
- AppState, ctx::Ctx, links, middleware::Compress, registry::Lock, resource::Resource, shame,11
+ AppState, ctx::Ctx, links, meta::GRAVEYARD_PATH, middleware::Compress, registry::Lock,12
+ resource::Resource, shame,1213
};1314
use crate::{1415
game::{@@ -20,6 +21,7 @@ use crate::{
2021
X_GIRTH, X_LENGTH, X_OBSERVED_BY, X_RUN_OVER, X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA,2122
X_SUSPICION, X_TURN,2223
},24
+ link::Link,2325
locale::Locale,2426
problem::{PROBLEM_BASE, Problem},2527
records::Record,@@ -108,7 +110,7 @@ fn gone(cause: &str, turn: u32) -> Response {
108110
Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")109111
.with("cause", format!("{PROBLEM_BASE}{cause}"))110112
.with("turn", turn)111
- .with("graveyard", "/graveyard")113
+ .with("graveyard", GRAVEYARD_PATH)112114
.header(X_RUN_OVER, cause)113115
.into_response()114116
}@@ -126,10 +128,7 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
126128
"The network requires authentication. The hotel would like you to agree to some terms.",127129
)128130
.with("portal", portal.as_str())129
- .header(130
- header::LINK,131
- format!("<{portal}>; rel=\"{}portal\"", links::REL_BASE),132
- )131
+ .header(header::LINK, Link::jelq(&portal, "portal").to_string())133132
.into_response(),134133
);135134
}@@ -17,10 +17,7 @@ use super::{
1717
shaft::{get_foreskin, get_length},1818
};1919
use crate::{20
- game::{21
- Branch, Cause, Floor, Grip, Item, LUBRICANTS, Lube, Run, Technique,22
- balance::VIEWS_PER_SECOND,23
- },20
+ game::{Branch, Cause, Floor, Grip, Item, Lube, Run, Technique, balance::VIEWS_PER_SECOND},2421
problem::Problem,2522
};2623
@@ -156,7 +153,7 @@ fn get_techniques(run: &mut Run, _: &Ctx) -> Response {
156153
Json(json!({157154
"usage": format!("JELQ {}?technique=<name>[,<name>]&intensity=<1-10>", run.path("")),158155
"lubricant": "Declared with Content-Type.",159
- "lubricants": LUBRICANTS,156
+ "lubricants": Lube::content_types(),160157
"techniques": listed,161158
}))162159
.into_response()@@ -13,6 +13,8 @@ use crate::{
1313
};1414
1515
const SHAFT_CHUNK: usize = 16;16
+const SHAFT_TYPE: &str = "text/plain; charset=us-ascii";17
+const RANGE_UNIT: &str = "inches";1618
1719
pub(super) fn millimetres(inches: f64) -> usize {1820
(inches * MM_PER_INCH).round() as usize@@ -72,11 +74,8 @@ pub(super) fn partial_inches(
7274
(7375
StatusCode::PARTIAL_CONTENT,7476
[75
- (76
- header::CONTENT_TYPE,77
- "text/plain; charset=us-ascii".to_owned(),78
- ),79
- (header::ACCEPT_RANGES, "inches".to_owned()),77
+ (header::CONTENT_TYPE, SHAFT_TYPE.to_owned()),78
+ (header::ACCEPT_RANGES, RANGE_UNIT.to_owned()),8079
(8180
header::CONTENT_RANGE,8281
format!("inches {start:.2}-{end:.2}/{total:.2}"),@@ -90,11 +89,8 @@ pub(super) fn partial_inches(
9089
pub(super) fn get_length(run: &mut Run, ctx: &Ctx) -> Response {9190
let inches = run.shown_length();9291
let body = shaft(millimetres(inches));93
- let content_type = (94
- header::CONTENT_TYPE,95
- "text/plain; charset=us-ascii".to_owned(),96
- );97
- let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());92
+ let content_type = (header::CONTENT_TYPE, SHAFT_TYPE.to_owned());93
+ let accept_ranges = (header::ACCEPT_RANGES, RANGE_UNIT.to_owned());9894
9995
if let Some(range) = ctx10096
.header(header::RANGE)@@ -145,11 +141,8 @@ pub(super) fn get_foreskin(run: &mut Run, ctx: &Ctx) -> Response {
145141
StatusCode::OK,146142
[147143
(header::ETAG, run.etag()),148
- (149
- header::CONTENT_TYPE,150
- "text/plain; charset=us-ascii".to_owned(),151
- ),152
- (header::ACCEPT_RANGES, "inches".to_owned()),144
+ (header::CONTENT_TYPE, SHAFT_TYPE.to_owned()),145
+ (header::ACCEPT_RANGES, RANGE_UNIT.to_owned()),153146
],154147
body,155148
)@@ -2,6 +2,7 @@ mod config;
22
mod game;33
mod headers;44
mod http;5
+mod link;56
mod locale;67
mod problem;78
mod records;@@ -0,0 +1,94 @@
1
+use std::fmt;2
+3
+pub const REL_BASE: &str = "https://jelq.dev/rels/";4
+5
+enum Rel<'a> {6
+ Jelq(&'a str),7
+ Registered(&'a str),8
+}9
+10
+/// One RFC 8288 link. `allow`, `lubricant` and `intensity` are extension attributes (§3), so a client never11
+/// has to guess what a target accepts.12
+pub struct Link<'a> {13
+ href: &'a str,14
+ rel: Rel<'a>,15
+ title: Option<&'a str>,16
+ allow: Option<&'a str>,17
+ lubricant: Option<&'a str>,18
+ intensity: Option<&'a str>,19
+}20
+21
+impl<'a> Link<'a> {22
+ fn new(href: &'a str, rel: Rel<'a>) -> Self {23
+ Self {24
+ href,25
+ rel,26
+ title: None,27
+ allow: None,28
+ lubricant: None,29
+ intensity: None,30
+ }31
+ }32
+33
+ /// A relation of this game's own, named under `REL_BASE`.34
+ pub fn jelq(href: &'a str, rel: &'a str) -> Self {35
+ Self::new(href, Rel::Jelq(rel))36
+ }37
+38
+ /// A relation from the IANA registry, such as `next` or `alternate`.39
+ pub fn registered(href: &'a str, rel: &'a str) -> Self {40
+ Self::new(href, Rel::Registered(rel))41
+ }42
+43
+ pub fn title(self, title: &'a str) -> Self {44
+ Self {45
+ title: Some(title),46
+ ..self47
+ }48
+ }49
+50
+ pub fn allow(self, allow: &'a str) -> Self {51
+ Self {52
+ allow: Some(allow),53
+ ..self54
+ }55
+ }56
+57
+ pub fn lubricant(self, lubricant: Option<&'a str>) -> Self {58
+ Self { lubricant, ..self }59
+ }60
+61
+ pub fn intensity(self, intensity: Option<&'a str>) -> Self {62
+ Self { intensity, ..self }63
+ }64
+}65
+66
+impl fmt::Display for Link<'_> {67
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {68
+ write!(f, "<{}>; rel=\"", self.href)?;69
+ match self.rel {70
+ Rel::Jelq(rel) => write!(f, "{REL_BASE}{rel}\"")?,71
+ Rel::Registered(rel) => write!(f, "{rel}\"")?,72
+ }73
+ for (name, value) in [74
+ ("title", self.title),75
+ ("allow", self.allow),76
+ ("lubricant", self.lubricant),77
+ ("intensity", self.intensity),78
+ ] {79
+ if let Some(value) = value {80
+ write!(f, "; {name}=\"{value}\"")?;81
+ }82
+ }83
+ Ok(())84
+ }85
+}86
+87
+/// A `Link` header value listing every link in order.88
+pub fn join<'a>(links: impl IntoIterator<Item = Link<'a>>) -> String {89
+ links90
+ .into_iter()91
+ .map(|link| link.to_string())92
+ .collect::<Vec<_>>()93
+ .join(", ")94
+}@@ -53,32 +53,28 @@ pre, li, p, td, label { overflow-wrap: anywhere; }
5353
const REL = "https://jelq.dev/rels/";5454
const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"];5555
const ALL_LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];56
-const DEATHS = ["ruptured", "caught", "opened", "banned", "degloved", "zip", "watermelon", "overstayed", "run-ended"];5756
let stocked = null;5857
let excuses = [];5958
let nonce = null;6059
let inventorySeq = 0;6160
const STATS = [62
- ["x-turn", "Turn"],63
- ["x-floor-turn", "Floor turn"],64
- ["x-floor", "Floor"],65
- ["x-cash", "Cash"],66
- ["x-self-reported-length", "Self-reported length"],67
- ["x-girth", "Girth"],68
- ["x-foreskin", "Foreskin"],69
- ["x-friction", "Friction"],70
- ["x-eq", "EQ"],71
- ["x-stamina", "Stamina"],72
- ["x-suspicion", "Suspicion"],73
- ["x-shame", "Shame"],61
+ { header: "x-turn", label: "Turn" },62
+ { header: "x-floor-turn", label: "Floor turn" },63
+ { header: "x-floor", label: "Floor", better: 1 },64
+ { header: "x-cash", label: "Cash", better: 1 },65
+ { header: "x-self-reported-length", label: "Self-reported length", better: 1, places: 2, measured: { header: "x-length", label: "Length" } },66
+ { header: "x-girth", label: "Girth", better: 1, places: 2 },67
+ { header: "x-foreskin", label: "Foreskin", better: 1, places: 2 },68
+ { header: "x-friction", label: "Friction", better: -1 },69
+ { header: "x-eq", label: "EQ" },70
+ { header: "x-stamina", label: "Stamina", better: 1 },71
+ { header: "x-suspicion", label: "Suspicion", better: -1 },72
+ { header: "x-shame", label: "Shame", better: -1 },7473
];75
-const MEASURED = ["x-length", "Length"];76
-const GROWTH = new Set(["x-length", "x-self-reported-length", "x-girth", "x-foreskin"]);77
-const BETTER = { "x-length": 1, "x-self-reported-length": 1, "x-girth": 1, "x-foreskin": 1, "x-friction": -1, "x-stamina": 1, "x-suspicion": -1, "x-shame": -1, "x-cash": 1, "x-floor": 1 };7874
const AROUSAL = ["Flaccid", "Cold", "Warm", "Ideal", "Swollen", "Throbbing", "Straining", "Imminent"];7975
const DANGER_COLOURS = { dangerous: "#b8860b", critical: "red" };8076
let lastStats = {};81
-const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-control", "content-length", "content-type", "date"].concat(STATS.map(s => s[0]), MEASURED[0]));77
+const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-control", "content-length", "content-type", "date"].concat(STATS.flatMap(s => [s.header, s.measured?.header]).filter(Boolean)));8278
8379
const FIELDS = {8480
seed: { label: "seed (unranked)", kind: "text" },@@ -219,18 +215,17 @@ function renderStats(headers) {
219215
const table = $("stats");220216
table.replaceChildren();221217
const dangers = Object.fromEntries((headers && headers["x-danger"] || "").split(",").filter(Boolean).map(d => d.trim().split("=")));222
- const stats = STATS.map(s => s[0] === "x-self-reported-length" && headers && MEASURED[0] in headers ? MEASURED : s);218
+ const stats = STATS.map(s => s.measured && headers && s.measured.header in headers ? { ...s, ...s.measured, unmeasured: s.header } : s);223219
for (let i = 0; i < stats.length; i += 2) {224220
const row = el("tr");225
- for (const [name, label] of stats.slice(i, i + 2)) {226
- const value = headers ? headers[name] : undefined;221
+ for (const stat of stats.slice(i, i + 2)) {222
+ const value = headers ? headers[stat.header] : undefined;227223
const cell = el("td", {}, value === undefined ? "—" : value);228
- const changed = value === undefined ? null : change(name, value);224
+ const changed = value === undefined ? null : change(stat, value);229225
if (changed) cell.append(" ", changed);230
- const measured = name === MEASURED[0] && "x-self-reported-length" in lastStats;231
- const danger = DANGER_COLOURS[dangers[name.slice(2)]];232
- const colour = danger || (measured ? "green" : "");233
- row.append(el("th", { align: "left", textContent: label, style: colour ? "color: " + colour : "" }), cell);226
+ const measured = stat.unmeasured in lastStats;227
+ const colour = DANGER_COLOURS[dangers[stat.header.slice(2)]] || (measured ? "green" : "");228
+ row.append(el("th", { align: "left", textContent: stat.label, style: colour ? "color: " + colour : "" }), cell);234229
}235230
table.append(row);236231
}@@ -241,18 +236,19 @@ function fromIdeal(arousal) {
241236
return arousal === "Priapic" ? AROUSAL.length : Math.abs(AROUSAL.indexOf(arousal) - AROUSAL.indexOf("Ideal"));242237
}243238
244
-function change(name, value) {245
- const before = lastStats[name];246
- if (before === undefined || before === value || !(name in BETTER || name === "x-eq")) return null;247
- if (name === "x-eq") {239
+function change(stat, value) {240
+ const before = lastStats[stat.header];241
+ if (before === undefined || before === value) return null;242
+ if (stat.header === "x-eq") {248243
const closer = Math.sign(fromIdeal(before) - fromIdeal(value));249244
const was = el("b", { textContent: before, style: closer ? "color: " + (closer > 0 ? "green" : "red") : "" });250245
return el("small", {}, "(was ", was, ")");251246
}247
+ if (!stat.better) return null;252248
const delta = parseFloat(value) - parseFloat(before);253249
if (!(Math.abs(delta) >= 0.005)) return null;254
- const text = (delta > 0 ? "+" : "") + (GROWTH.has(name) ? delta.toFixed(2) : delta);255
- return el("b", { textContent: text, style: "color: " + (Math.sign(delta) === BETTER[name] ? "green" : "red") });250
+ const text = (delta > 0 ? "+" : "") + (stat.places ? delta.toFixed(stat.places) : delta);251
+ return el("b", { textContent: text, style: "color: " + (Math.sign(delta) === stat.better ? "green" : "red") });256252
}257253
258254
function renderAlerts(list) {@@ -415,7 +411,7 @@ function describe(r) {
415411
return lines.join("\n");416412
}417413
418
-function absorb(r, sentMethod, sentPath) {414
+function absorb(r, sentPath) {419415
const headers = Object.fromEntries(r.headers.map(([n, v]) => [n.toLowerCase(), v]));420416
if (headers.etag) etags[sentPath] = headers.etag;421417
const digest = (headers["www-authenticate"] || "").match(/nonce="([^"]*)"/);@@ -423,7 +419,6 @@ function absorb(r, sentMethod, sentPath) {
423419
renderExcuses();424420
let body = null;425421
try { body = JSON.parse(r.body); } catch (e) {}426
- const problem = body && typeof body.type === "string" ? body.type.split("/").pop() : null;427422
const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));428423
429424
if (started) {@@ -457,8 +452,7 @@ function absorb(r, sentMethod, sentPath) {
457452
if (headers.location && !started) {458453
offered.unshift({ key: "offered", group: "said", name: short(headers.location).split("/").pop() + " (Location)", path: headers.location, methods: ["GET"] });459454
}460
- const abandoned = sentMethod === "DELETE" && sentPath === run && r.status === 204;461
- if (run && (abandoned || DEATHS.includes(problem))) {455
+ if (run && "x-run-over" in headers) {462456
log("--- the run has ended ---");463457
renderAlerts(null);464458
challenged = false;@@ -522,7 +516,7 @@ async function go(event) {
522516
return;523517
}524518
log(sent + "\n" + describe(r));525
- absorb(r, method, path);519
+ absorb(r, path);526520
}527521
528522
$("form").onsubmit = go;