an http based roguelike rpg about jelqing
Commit 58e1fb3943e6
tarball5 files changed+229-83
@@ -92,6 +92,16 @@ pub enum LubeRule {
9292
Optional,9393
}9494
95
+impl LubeRule {96
+ fn name(self) -> &'static str {97
+ match self {98
+ Self::Required => "required",99
+ Self::Forbidden => "forbidden",100
+ Self::Optional => "optional",101
+ }102
+ }103
+}104
+95105
pub struct Profile {96106
pub dc: i32,97107
pub length: f64,@@ -196,6 +206,13 @@ impl Technique {
196206
Self::ALL.into_iter().find(|t| t.name() == name)197207
}198208
209
+ pub fn lubricant(self) -> Option<&'static str> {210
+ match self {211
+ Self::Training(training) => Some(training.profile().lube.name()),212
+ Self::Utility(_) => None,213
+ }214
+ }215
+199216
pub fn describe(self, forum_read: bool) -> Value {200217
let training = match self {201218
Self::Utility(utility) => {@@ -204,11 +221,7 @@ impl Technique {
204221
Self::Training(training) => training,205222
};206223
let profile = training.profile();207
- let lubricant = match profile.lube {208
- LubeRule::Required => "required",209
- LubeRule::Forbidden => "forbidden",210
- LubeRule::Optional => "optional",211
- };224
+ let lubricant = profile.lube.name();212225
json!({213226
"name": training.name(),214227
"dc": profile.dc,@@ -176,6 +176,15 @@ impl Run {
176176
}177177
178178
fn ready(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {179
+ self.access(technique)?;180
+ match technique {181
+ Technique::Training(training) => Self::check_lube(training, lube),182
+ Technique::Utility(_) => Ok(()),183
+ }184
+ }185
+186
+ /// Whether the run could attempt the technique now, ignoring the lubricant.187
+ fn access(&self, technique: Technique) -> Result<(), Problem> {179188
if technique == Technique::Training(Training::Uli) && !self.forum_read() {180189
return Err(Problem::new(181190
StatusCode::FORBIDDEN,@@ -191,48 +200,65 @@ impl Run {
191200
)192201
.with("technique", Utility::Warmup.name()));193202
}194
- let training = match technique {195
- Technique::Utility(Utility::Pump) if !self.items.pump_pro => {196
- return Err(Problem::new(197
- StatusCode::FAILED_DEPENDENCY,198
- "no-pump",199
- "The pump technique depends on a pump.",200
- ));201
- }202
- Technique::Utility(_) => return Ok(()),203
- Technique::Training(training) => training,204
- };205
- let profile = training.profile();206
- match (profile.lube, lube) {207
- (LubeRule::Required, None) => {208
- return Err(Problem::new(209
- StatusCode::UNSUPPORTED_MEDIA_TYPE,210
- "lubricant-required",211
- format!(212
- "The {} technique requires a lubricant. None was declared.",213
- training.name()214
- ),215
- )216
- .with("lubricants", json!(LUBRICANTS)));217
- }218
- (LubeRule::Forbidden, Some(_)) => {219
- return Err(Problem::new(220
- StatusCode::UNSUPPORTED_MEDIA_TYPE,221
- "lubricant-declared",222
- format!(223
- "The {} technique was requested. A lubricant was declared.",224
- training.name()225
- ),226
- ));227
- }228
- _ => {}203
+ match technique {204
+ Technique::Utility(Utility::Pump) if !self.items.pump_pro => Err(Problem::new(205
+ StatusCode::FAILED_DEPENDENCY,206
+ "no-pump",207
+ "The pump technique depends on a pump.",208
+ )),209
+ Technique::Utility(_) => Ok(()),210
+ Technique::Training(training) => match training.profile().foreskin {211
+ Some((min, max)) => self.check_foreskin(min, max),212
+ None => Ok(()),213
+ },229214
}230
- match profile.foreskin {231
- Some((min, max)) => self.check_foreskin(min, max),232
- None => Ok(()),215
+ }216
+217
+ fn check_lube(training: Training, lube: Option<Lube>) -> Result<(), Problem> {218
+ match (training.profile().lube, lube) {219
+ (LubeRule::Required, None) => Err(Problem::new(220
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,221
+ "lubricant-required",222
+ format!(223
+ "The {} technique requires a lubricant. None was declared.",224
+ training.name()225
+ ),226
+ )227
+ .with("lubricants", json!(LUBRICANTS))),228
+ (LubeRule::Forbidden, Some(_)) => Err(Problem::new(229
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,230
+ "lubricant-declared",231
+ format!(232
+ "The {} technique was requested. A lubricant was declared.",233
+ training.name()234
+ ),235
+ )),236
+ _ => Ok(()),233237
}234238
}235239
240
+ /// The techniques a JELQ could use right now without being refused before the roll.241
+ pub fn techniques_now(&self, now: Instant) -> Vec<Technique> {242
+ let blocked = !self.floor.timed()243
+ || self.items.cage244
+ || self.story == Story::Pending245
+ || self.refractory_until.is_some_and(|until| now < until);246
+ if blocked {247
+ return Vec::new();248
+ }249
+ let rested = self.refractory_until.is_some();250
+ Technique::ALL251
+ .into_iter()252
+ .filter(|&technique| match technique {253
+ Technique::Utility(Utility::Warmup) => true,254
+ Technique::Utility(Utility::Ice) => self.items.ice_packs > 0,255
+ Technique::Utility(Utility::Supplement) => self.items.supplements > 0,256
+ _ if rested => false,257
+ _ => self.access(technique).is_ok() && self.check_storage().is_ok(),258
+ })259
+ .collect()260
+ }261
+236262
fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {237263
let Some(f) = self.foreskin else {238264
if min <= 0.0 {@@ -1,5 +1,7 @@
11
//! Plumbing shared by every resource under `/runs/{id}`.22
3
+use std::time::Instant;4
+35
use axum::{46
http::{HeaderName, HeaderValue, StatusCode, header},57
response::{IntoResponse, Response},@@ -88,9 +90,9 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
8890
.map(IntoResponse::into_response)8991
}9092
91
-/// Every resource the run can reach right now. The `allow` extension attribute (RFC 8288 §3)92
-/// carries the methods each one accepts, so a client never has to guess.93
-fn links(run: &Run) -> String {93
+/// Every resource the run can reach right now, then every technique a JELQ could use. The `allow`94
+/// and `lubricant` extension attributes (RFC 8288 §3) say what each accepts, so a client never guesses.95
+fn links(run: &Run, now: Instant) -> String {9496
let linked: [(&str, &RunResource, bool); 13] = [9597
("", &runs::RUN, true),9698
("floor", &runs::FLOOR, true),@@ -118,6 +120,16 @@ fn links(run: &Run) -> String {
118120
let allow = target.resource.allow(run);119121
format!("<{path}>; rel=\"{REL_BASE}{name}\"; allow=\"{allow}\"")120122
})123
+ .chain(run.techniques_now(now).into_iter().map(|technique| {124
+ let name = technique.name();125
+ let lubricant = technique126
+ .lubricant()127
+ .map_or(String::new(), |rule| format!("; lubricant=\"{rule}\""));128
+ format!(129
+ "<{}?technique={name}>; rel=\"{REL_BASE}technique\"; title=\"{name}\"; allow=\"JELQ\"{lubricant}",130
+ run.path("")131
+ )132
+ }))121133
.collect::<Vec<_>>()122134
.join(", ")123135
}@@ -157,7 +169,7 @@ pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response)
157169
(X_SHAME, shame.to_string()),158170
(X_CASH, run.cash.to_string()),159171
(X_FLOOR, run.floor.number().to_string()),160
- (header::LINK, links(run)),172
+ (header::LINK, links(run, ctx.now)),161173
];162174
if let Some(observer) = run.observed {163175
stats.push((X_OBSERVED_BY, observer.name().into()));@@ -715,3 +715,37 @@ async fn the_default_locale_is_british() {
715715
assert!(header(&floor, "vary").contains("Accept-Language"));716716
assert_eq!(json(floor).await["name"], "Nan's house at Christmas");717717
}718
+719
+fn advertised(response: &Response) -> Vec<String> {720
+ header(response, "link")721
+ .split(", <")722
+ .filter(|entry| entry.contains("rels/technique"))723
+ .filter_map(|entry| {724
+ entry725
+ .split("title=\"")726
+ .nth(1)?727
+ .split('"')728
+ .next()729
+ .map(str::to_owned)730
+ })731
+ .collect()732
+}733
+734
+#[tokio::test]735
+async fn links_offer_only_usable_techniques() {736
+ let app = seeded_with(Floor::Bedroom, |run| run.foreskin = Some(1.0));737
+ let cold = send(&app, "GET", "/runs/cafebabe", &[], "").await;738
+ assert_eq!(advertised(&cold), ["warmup", "ice"]);739
+ let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;740
+ let usable = advertised(&warm);741
+ assert!(usable.contains(&"wet".to_owned()) && usable.contains(&"dry".to_owned()));742
+ assert!(!usable.contains(&"uli".to_owned()), "uli needs the forum");743
+ assert!(!usable.contains(&"pump".to_owned()), "pump needs a pump");744
+ assert!(745
+ header(&warm, "link").contains("title=\"wet\"; allow=\"JELQ\"; lubricant=\"required\"")746
+ );747
+748
+ let (forum, _) = seeded(Floor::Forum);749
+ let response = send(&forum, "GET", "/runs/cafebabe", &[], "").await;750
+ assert!(advertised(&response).is_empty());751
+}@@ -50,7 +50,7 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
5050
<script>5151
const REL = "https://jelq.dev/rels/";5252
const METHODS = ["JELQ", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];53
-const TECHNIQUES = ["warmup", "wet", "dry", "stretch", "uli", "edge", "pump", "ice", "supplement"];53
+const LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];5454
const DEATHS = ["vascular-event", "caught", "banned", "degloved", "zip", "run-ended"];5555
const STATS = [5656
["x-floor", "Floor"],@@ -67,12 +67,11 @@ const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-co
6767
6868
const FIELDS = {6969
seed: { label: "seed (unranked)", kind: "text" },70
- technique: { label: "technique", kind: "select", options: TECHNIQUES },71
- also: { label: "combo with", kind: "select", options: ["", "wet", "dry", "stretch", "uli"] },70
+ technique: { label: "technique", kind: "select" },71
+ also: { label: "combo with", kind: "select" },7272
intensity: { label: "intensity, 1 to 10", kind: "number", value: "5" },73
- lube: { label: "Content-Type", header: "Content-Type", kind: "select", options: ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"] },73
+ lube: { label: "Content-Type", header: "Content-Type", kind: "select" },7474
patch: { label: "body", kind: "textarea", value: '[{"op": "test", "path": "/length", "value": 6}]' },75
- accept: { label: "Accept", header: "Accept", kind: "text" },7675
range: { label: "Range", header: "Range", kind: "text", placeholder: "inches=0-2" },7776
ifMatch: { label: "If-Match", header: "If-Match", kind: "text" },7877
ifNoneMatch: { label: "If-None-Match", header: "If-None-Match", kind: "text" },@@ -84,7 +83,6 @@ const FIELDS = {
8483
};8584
const BY_TARGET = {8685
"runs POST": ["seed"],87
- "run JELQ": ["technique", "also", "intensity", "lube"],8886
"run PATCH": ["patch"],8987
"length GET": ["range"],9088
"length HEAD": ["range"],@@ -102,6 +100,7 @@ const GLOBALS = [
102100
103101
let run = null;104102
let linked = [];103
+let techniques = [];105104
let offered = [];106105
let challenged = false;107106
let target = "/runs";@@ -109,7 +108,6 @@ let method = "POST";
109108
const values = {};110109
111110
const $ = id => document.getElementById(id);112
-const fieldValue = name => values[name] ?? FIELDS[name].value ?? (FIELDS[name].options ? FIELDS[name].options[0] : "");113111
114112
function short(path) {115113
return path.replace(/[0-9a-f]{32}/, id => id.slice(0, 6) + "…");@@ -121,6 +119,56 @@ function el(tag, props, ...children) {
121119
return node;122120
}123121
122
+function titled(text) {123
+ return text.charAt(0).toUpperCase() + text.slice(1);124
+}125
+126
+function technique(name) {127
+ return techniques.find(t => t.name === name);128
+}129
+130
+function clashes(a, b) {131
+ const rules = [a.lubricant, b.lubricant];132
+ return rules.includes("required") && rules.includes("forbidden");133
+}134
+135
+// The options a field can take right now, or null for free text.136
+function options(name) {137
+ if (name === "technique") return techniques.map(t => t.name);138
+ if (name === "also") {139
+ const first = technique(fieldValue("technique"));140
+ if (!first || !first.lubricant) return [""];141
+ const partners = techniques.filter(t => t.lubricant && t.name !== first.name && !clashes(first, t));142
+ return [""].concat(partners.map(t => t.name));143
+ }144
+ if (name === "lube") return lubeRule() === "required" ? LUBES.filter(Boolean) : LUBES;145
+ return FIELDS[name].options || null;146
+}147
+148
+function raw(name) {149
+ return values[name] ?? FIELDS[name].value ?? "";150
+}151
+152
+function fieldValue(name) {153
+ const allowed = options(name);154
+ const value = raw(name);155
+ if (allowed && !allowed.includes(value)) return allowed[0] ?? "";156
+ return value;157
+}158
+159
+function lubeRule() {160
+ const picked = [technique(fieldValue("technique")), technique(fieldValue("also"))].filter(Boolean);161
+ if (!picked.length || !picked[0].lubricant) return "none";162
+ for (const rule of ["forbidden", "required"]) {163
+ if (picked.some(t => t.lubricant === rule)) return rule;164
+ }165
+ return "optional";166
+}167
+168
+function allowedMethods(t) {169
+ return t.key === "run" && !techniques.length ? t.methods.filter(m => m !== "JELQ") : t.methods;170
+}171
+124172
function targets() {125173
return (run ? linked : []).concat(offered, GLOBALS);126174
}@@ -130,8 +178,9 @@ function current() {
130178
}131179
132180
function chooseTarget(t) {181
+ const allowed = allowedMethods(t);133182
target = t.path;134
- method = t.methods.includes("JELQ") ? "JELQ" : t.methods.find(m => m !== "HEAD" && m !== "OPTIONS") || t.methods[0];183
+ method = allowed.includes("JELQ") ? "JELQ" : allowed.find(m => m !== "HEAD" && m !== "OPTIONS") || allowed[0];135184
render();136185
}137186
@@ -148,10 +197,6 @@ function renderStats(headers) {
148197
}149198
}150199
151
-function titled(text) {152
- return text.charAt(0).toUpperCase() + text.slice(1);153
-}154
-155200
function renderAlerts(list) {156201
const box = $("alerts");157202
box.replaceChildren();@@ -179,9 +224,9 @@ function renderTargets() {
179224
function renderMethods() {180225
const box = $("methods");181226
box.replaceChildren();182
- const allowed = current().methods;183
- for (const m of METHODS) {184
- const radio = el("input", { type: "radio", name: "method", disabled: !allowed.includes(m), checked: m === method, onchange: () => { method = m; render(); } });227
+ const allowed = allowedMethods(current());228
+ for (const m of METHODS.filter(m => allowed.includes(m))) {229
+ const radio = el("input", { type: "radio", name: "method", checked: m === method, onchange: () => { method = m; render(); } });185230
const label = el("label", {}, radio, " ");186231
label.append(m === "JELQ" ? el("big", {}, el("b", { textContent: "JELQ" })) : m);187232
box.append(label, " ");@@ -190,7 +235,15 @@ function renderMethods() {
190235
191236
function fieldNames() {192237
const t = current();193
- const names = BY_TARGET[t.key + " " + method] || [];238
+ const key = t.key + " " + method;239
+ let names = BY_TARGET[key] || [];240
+ if (key === "run JELQ") {241
+ const chosen = technique(fieldValue("technique"));242
+ names = ["technique"];243
+ if (options("also").length > 1) names.push("also");244
+ if (chosen && chosen.lubricant) names.push("intensity");245
+ if (["required", "optional"].includes(lubeRule())) names.push("lube");246
+ }194247
const scoped = run && !["runs", "global"].includes(t.key);195248
const asked = challenged && scoped && !names.includes("authorization");196249
return (asked ? ["authorization"] : []).concat(names);@@ -202,18 +255,20 @@ function renderFields() {
202255
for (const name of fieldNames()) {203256
const f = FIELDS[name];204257
const value = fieldValue(name);258
+ const choices = options(name);205259
let input;206
- if (f.kind === "select") {207
- input = el("select", {}, ...f.options.map(o => el("option", { value: o, textContent: o || "(none)", selected: o === value })));260
+ if (choices) {261
+ input = el("select", {}, ...choices.map(o => el("option", { value: o, textContent: o || "(none)", selected: o === value })));208262
} else if (f.kind === "textarea") {209263
input = el("textarea", { rows: 3, cols: 40, value, placeholder: f.placeholder || "" });210264
} else {211265
input = el("input", { type: f.kind, value, placeholder: f.placeholder || "", size: 30 });212266
}213
- input.oninput = input.onchange = () => { values[name] = input.value; };267
+ input.oninput = () => { values[name] = input.value; };268
+ input.onchange = () => { values[name] = input.value; if (choices) renderFields(); };214269
table.append(el("tr", {}, el("td", { align: "right", valign: "top", textContent: f.label }), el("td", {}, input)));215270
}216
- const extra = el("textarea", { rows: 3, cols: 40, value: fieldValue("extra"), placeholder: FIELDS.extra.placeholder });271
+ const extra = el("textarea", { rows: 3, cols: 40, value: raw("extra"), placeholder: FIELDS.extra.placeholder });217272
extra.oninput = () => { values.extra = extra.value; };218273
const more = el("details", { open: Boolean(values.extra) }, el("summary", { textContent: "more headers" }), extra);219274
table.append(el("tr", {}, el("td"), el("td", {}, more)));@@ -221,21 +276,26 @@ function renderFields() {
221276
}222277
223278
function render() {224
- if (!current().methods.includes(method)) return chooseTarget(current());279
+ if (!allowedMethods(current()).includes(method)) return chooseTarget(current());225280
renderTargets();226281
renderMethods();227282
renderFields();228283
}229284
230285
function parseLinks(header) {231
- const found = [];286
+ const resources = [];287
+ const moves = [];232288
for (const match of header.matchAll(/<([^>]*)>((?:\s*;\s*[a-z-]+="[^"]*")*)/g)) {233289
const params = Object.fromEntries([...match[2].matchAll(/([a-z-]+)="([^"]*)"/g)].map(p => [p[1], p[2]]));234290
const name = (params.rel || "").replace(REL, "");291
+ if (name === "technique") {292
+ moves.push({ name: params.title, lubricant: params.lubricant || null });293
+ continue;294
+ }235295
const methods = (params.allow || "GET").split(",").map(s => s.trim());236
- found.push({ key: name, name: name === "run" ? "the run" : name, path: match[1], methods });296
+ resources.push({ key: name, name: name === "run" ? "the run" : name, path: match[1], methods });237297
}238
- return found;298
+ return [resources, moves];239299
}240300
241301
function hrefs(value, found) {@@ -277,21 +337,20 @@ function absorb(r, sentMethod, sentPath) {
277337
let body = null;278338
try { body = JSON.parse(r.body); } catch (e) {}279339
const problem = body && typeof body.type === "string" ? body.type.split("/").pop() : null;340
+ const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));280341
281
- if (sentPath.startsWith("/runs?") || sentPath === "/runs") {282
- if (r.status === 201) {283
- run = headers.location;284
- log("--- a new run has started at " + short(run) + " ---");285
- }342
+ if (started) {343
+ run = headers.location;344
+ log("--- a new run has started at " + short(run) + " ---");286345
}287346
if ("x-eq" in headers) {288347
renderStats(headers);289348
renderAlerts(r.headers);290349
challenged = r.status === 401 || "via" in headers;291350
}292
- if (headers.link) linked = parseLinks(headers.link);351
+ if (headers.link) [linked, techniques] = parseLinks(headers.link);293352
offered = body ? hrefs(body, []) : [];294
- if (headers.location && !(sentPath === "/runs" || sentPath.startsWith("/runs?"))) {353
+ if (headers.location && !started) {295354
offered.unshift({ key: "offered", name: "Location", path: headers.location, methods: ["GET"] });296355
}297356
const abandoned = sentMethod === "DELETE" && sentPath === run && r.status === 204;@@ -301,11 +360,11 @@ function absorb(r, sentMethod, sentPath) {
301360
challenged = false;302361
run = null;303362
linked = [];363
+ techniques = [];304364
}305
- const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));306365
if (started || !targets().some(t => t.path === target)) {307366
const home = linked.find(t => t.key === "run");308
- if (home) chooseTarget(home); else chooseTarget(GLOBALS[0]);367
+ chooseTarget(home || GLOBALS[0]);309368
} else {310369
render();311370
}@@ -324,13 +383,15 @@ async function go(event) {
324383
if (f.header && value(name)) headers.push([f.header, value(name)]);325384
}326385
if (fieldNames().includes("upgrade") && value("upgrade")) headers.push(["Connection", "upgrade"]);327
- for (const line of value("extra").split("\n")) {386
+ for (const line of raw("extra").split("\n")) {328387
const at = line.indexOf(":");329388
if (at > 0) headers.push([line.slice(0, at).trim(), line.slice(at + 1).trim()]);330389
}331390
if (key === "run JELQ") {332
- const techniques = value("also") ? value("technique") + "," + value("also") : value("technique");333
- path += "?technique=" + encodeURIComponent(techniques) + "&intensity=" + encodeURIComponent(value("intensity"));391
+ const names = fieldNames();392
+ const chosen = [value("technique")].concat(names.includes("also") && value("also") ? [value("also")] : []);393
+ path += "?technique=" + encodeURIComponent(chosen.join(","));394
+ if (names.includes("intensity")) path += "&intensity=" + encodeURIComponent(value("intensity"));334395
}335396
if (key === "runs POST" && value("seed")) path += "?seed=" + encodeURIComponent(value("seed"));336397
if (method === "PATCH") {