atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit a9b016e4b248

tarball

atagen <boss@atagen.co>2026-10-10 15:15 UTC

11 files changed+2824-0
jelqd: add floor 1 vertical slice
Added file: .gitignore+2 additions
@@ -0,0 +1,2 @@
1+/target
2+/result
Added file: Cargo.lock+612 additions
@@ -0,0 +1,612 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "atomic-waker"
7+version = "1.1.2"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
10+
11+[[package]]
12+name = "axum"
13+version = "0.8.9"
14+source = "registry+https://github.com/rust-lang/crates.io-index"
15+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
16+dependencies = [
17+ "axum-core",
18+ "bytes",
19+ "form_urlencoded",
20+ "futures-util",
21+ "http",
22+ "http-body",
23+ "http-body-util",
24+ "hyper",
25+ "hyper-util",
26+ "itoa",
27+ "matchit",
28+ "memchr",
29+ "mime",
30+ "percent-encoding",
31+ "pin-project-lite",
32+ "serde_core",
33+ "serde_json",
34+ "serde_path_to_error",
35+ "serde_urlencoded",
36+ "sync_wrapper",
37+ "tokio",
38+ "tower",
39+ "tower-layer",
40+ "tower-service",
41+ "tracing",
42+]
43+
44+[[package]]
45+name = "axum-core"
46+version = "0.5.6"
47+source = "registry+https://github.com/rust-lang/crates.io-index"
48+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
49+dependencies = [
50+ "bytes",
51+ "futures-core",
52+ "http",
53+ "http-body",
54+ "http-body-util",
55+ "mime",
56+ "pin-project-lite",
57+ "sync_wrapper",
58+ "tower-layer",
59+ "tower-service",
60+ "tracing",
61+]
62+
63+[[package]]
64+name = "bytes"
65+version = "1.12.1"
66+source = "registry+https://github.com/rust-lang/crates.io-index"
67+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
68+
69+[[package]]
70+name = "cfg-if"
71+version = "1.0.5"
72+source = "registry+https://github.com/rust-lang/crates.io-index"
73+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
74+
75+[[package]]
76+name = "form_urlencoded"
77+version = "1.2.2"
78+source = "registry+https://github.com/rust-lang/crates.io-index"
79+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
80+dependencies = [
81+ "percent-encoding",
82+]
83+
84+[[package]]
85+name = "futures-core"
86+version = "0.3.34"
87+source = "registry+https://github.com/rust-lang/crates.io-index"
88+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
89+
90+[[package]]
91+name = "futures-macro"
92+version = "0.3.34"
93+source = "registry+https://github.com/rust-lang/crates.io-index"
94+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
95+dependencies = [
96+ "proc-macro2",
97+ "quote",
98+ "syn 3.0.7",
99+]
100+
101+[[package]]
102+name = "futures-task"
103+version = "0.3.34"
104+source = "registry+https://github.com/rust-lang/crates.io-index"
105+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
106+
107+[[package]]
108+name = "futures-util"
109+version = "0.3.34"
110+source = "registry+https://github.com/rust-lang/crates.io-index"
111+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
112+dependencies = [
113+ "futures-core",
114+ "futures-macro",
115+ "futures-task",
116+ "pin-project-lite",
117+ "slab",
118+]
119+
120+[[package]]
121+name = "getrandom"
122+version = "0.3.4"
123+source = "registry+https://github.com/rust-lang/crates.io-index"
124+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
125+dependencies = [
126+ "cfg-if",
127+ "libc",
128+ "r-efi",
129+ "wasip2",
130+]
131+
132+[[package]]
133+name = "http"
134+version = "1.5.0"
135+source = "registry+https://github.com/rust-lang/crates.io-index"
136+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
137+dependencies = [
138+ "bytes",
139+ "itoa",
140+]
141+
142+[[package]]
143+name = "http-body"
144+version = "1.1.0"
145+source = "registry+https://github.com/rust-lang/crates.io-index"
146+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
147+dependencies = [
148+ "bytes",
149+ "http",
150+]
151+
152+[[package]]
153+name = "http-body-util"
154+version = "0.1.5"
155+source = "registry+https://github.com/rust-lang/crates.io-index"
156+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
157+dependencies = [
158+ "bytes",
159+ "futures-core",
160+ "http",
161+ "http-body",
162+ "pin-project-lite",
163+]
164+
165+[[package]]
166+name = "httparse"
167+version = "1.10.1"
168+source = "registry+https://github.com/rust-lang/crates.io-index"
169+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
170+
171+[[package]]
172+name = "httpdate"
173+version = "1.0.3"
174+source = "registry+https://github.com/rust-lang/crates.io-index"
175+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
176+
177+[[package]]
178+name = "hyper"
179+version = "1.12.0"
180+source = "registry+https://github.com/rust-lang/crates.io-index"
181+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
182+dependencies = [
183+ "atomic-waker",
184+ "bytes",
185+ "futures-core",
186+ "http",
187+ "http-body",
188+ "httparse",
189+ "httpdate",
190+ "itoa",
191+ "pin-project-lite",
192+ "smallvec",
193+ "tokio",
194+]
195+
196+[[package]]
197+name = "hyper-util"
198+version = "0.1.21"
199+source = "registry+https://github.com/rust-lang/crates.io-index"
200+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
201+dependencies = [
202+ "bytes",
203+ "http",
204+ "http-body",
205+ "hyper",
206+ "pin-project-lite",
207+ "tokio",
208+ "tower-service",
209+]
210+
211+[[package]]
212+name = "itoa"
213+version = "1.0.18"
214+source = "registry+https://github.com/rust-lang/crates.io-index"
215+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
216+
217+[[package]]
218+name = "jelqd"
219+version = "0.6.9"
220+dependencies = [
221+ "axum",
222+ "futures-util",
223+ "rand",
224+ "serde",
225+ "serde_json",
226+ "tokio",
227+ "tower",
228+]
229+
230+[[package]]
231+name = "libc"
232+version = "0.2.190"
233+source = "registry+https://github.com/rust-lang/crates.io-index"
234+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
235+
236+[[package]]
237+name = "log"
238+version = "0.4.34"
239+source = "registry+https://github.com/rust-lang/crates.io-index"
240+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
241+
242+[[package]]
243+name = "matchit"
244+version = "0.8.4"
245+source = "registry+https://github.com/rust-lang/crates.io-index"
246+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
247+
248+[[package]]
249+name = "memchr"
250+version = "2.8.3"
251+source = "registry+https://github.com/rust-lang/crates.io-index"
252+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
253+
254+[[package]]
255+name = "mime"
256+version = "0.3.17"
257+source = "registry+https://github.com/rust-lang/crates.io-index"
258+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
259+
260+[[package]]
261+name = "mio"
262+version = "1.2.4"
263+source = "registry+https://github.com/rust-lang/crates.io-index"
264+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
265+dependencies = [
266+ "libc",
267+ "wasi",
268+ "windows-sys",
269+]
270+
271+[[package]]
272+name = "once_cell"
273+version = "1.21.4"
274+source = "registry+https://github.com/rust-lang/crates.io-index"
275+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
276+
277+[[package]]
278+name = "percent-encoding"
279+version = "2.3.2"
280+source = "registry+https://github.com/rust-lang/crates.io-index"
281+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
282+
283+[[package]]
284+name = "pin-project-lite"
285+version = "0.2.17"
286+source = "registry+https://github.com/rust-lang/crates.io-index"
287+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
288+
289+[[package]]
290+name = "ppv-lite86"
291+version = "0.2.21"
292+source = "registry+https://github.com/rust-lang/crates.io-index"
293+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
294+dependencies = [
295+ "zerocopy",
296+]
297+
298+[[package]]
299+name = "proc-macro2"
300+version = "1.0.107"
301+source = "registry+https://github.com/rust-lang/crates.io-index"
302+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
303+dependencies = [
304+ "unicode-ident",
305+]
306+
307+[[package]]
308+name = "quote"
309+version = "1.0.47"
310+source = "registry+https://github.com/rust-lang/crates.io-index"
311+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
312+dependencies = [
313+ "proc-macro2",
314+]
315+
316+[[package]]
317+name = "r-efi"
318+version = "5.3.0"
319+source = "registry+https://github.com/rust-lang/crates.io-index"
320+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
321+
322+[[package]]
323+name = "rand"
324+version = "0.9.5"
325+source = "registry+https://github.com/rust-lang/crates.io-index"
326+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
327+dependencies = [
328+ "rand_chacha",
329+ "rand_core",
330+]
331+
332+[[package]]
333+name = "rand_chacha"
334+version = "0.9.0"
335+source = "registry+https://github.com/rust-lang/crates.io-index"
336+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
337+dependencies = [
338+ "ppv-lite86",
339+ "rand_core",
340+]
341+
342+[[package]]
343+name = "rand_core"
344+version = "0.9.5"
345+source = "registry+https://github.com/rust-lang/crates.io-index"
346+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
347+dependencies = [
348+ "getrandom",
349+]
350+
351+[[package]]
352+name = "ryu"
353+version = "1.0.23"
354+source = "registry+https://github.com/rust-lang/crates.io-index"
355+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
356+
357+[[package]]
358+name = "serde"
359+version = "1.0.229"
360+source = "registry+https://github.com/rust-lang/crates.io-index"
361+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
362+dependencies = [
363+ "serde_core",
364+ "serde_derive",
365+]
366+
367+[[package]]
368+name = "serde_core"
369+version = "1.0.229"
370+source = "registry+https://github.com/rust-lang/crates.io-index"
371+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
372+dependencies = [
373+ "serde_derive",
374+]
375+
376+[[package]]
377+name = "serde_derive"
378+version = "1.0.229"
379+source = "registry+https://github.com/rust-lang/crates.io-index"
380+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
381+dependencies = [
382+ "proc-macro2",
383+ "quote",
384+ "syn 3.0.7",
385+]
386+
387+[[package]]
388+name = "serde_json"
389+version = "1.0.151"
390+source = "registry+https://github.com/rust-lang/crates.io-index"
391+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
392+dependencies = [
393+ "itoa",
394+ "memchr",
395+ "serde",
396+ "serde_core",
397+ "zmij",
398+]
399+
400+[[package]]
401+name = "serde_path_to_error"
402+version = "0.1.20"
403+source = "registry+https://github.com/rust-lang/crates.io-index"
404+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
405+dependencies = [
406+ "itoa",
407+ "serde",
408+ "serde_core",
409+]
410+
411+[[package]]
412+name = "serde_urlencoded"
413+version = "0.7.1"
414+source = "registry+https://github.com/rust-lang/crates.io-index"
415+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
416+dependencies = [
417+ "form_urlencoded",
418+ "itoa",
419+ "ryu",
420+ "serde",
421+]
422+
423+[[package]]
424+name = "slab"
425+version = "0.4.12"
426+source = "registry+https://github.com/rust-lang/crates.io-index"
427+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
428+
429+[[package]]
430+name = "smallvec"
431+version = "1.16.3"
432+source = "registry+https://github.com/rust-lang/crates.io-index"
433+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
434+
435+[[package]]
436+name = "socket2"
437+version = "0.6.5"
438+source = "registry+https://github.com/rust-lang/crates.io-index"
439+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
440+dependencies = [
441+ "libc",
442+ "windows-sys",
443+]
444+
445+[[package]]
446+name = "syn"
447+version = "2.0.119"
448+source = "registry+https://github.com/rust-lang/crates.io-index"
449+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
450+dependencies = [
451+ "proc-macro2",
452+ "quote",
453+ "unicode-ident",
454+]
455+
456+[[package]]
457+name = "syn"
458+version = "3.0.7"
459+source = "registry+https://github.com/rust-lang/crates.io-index"
460+checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc"
461+dependencies = [
462+ "proc-macro2",
463+ "quote",
464+ "unicode-ident",
465+]
466+
467+[[package]]
468+name = "sync_wrapper"
469+version = "1.0.2"
470+source = "registry+https://github.com/rust-lang/crates.io-index"
471+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
472+
473+[[package]]
474+name = "tokio"
475+version = "1.53.2"
476+source = "registry+https://github.com/rust-lang/crates.io-index"
477+checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
478+dependencies = [
479+ "libc",
480+ "mio",
481+ "pin-project-lite",
482+ "socket2",
483+ "tokio-macros",
484+ "windows-sys",
485+]
486+
487+[[package]]
488+name = "tokio-macros"
489+version = "2.7.2"
490+source = "registry+https://github.com/rust-lang/crates.io-index"
491+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
492+dependencies = [
493+ "proc-macro2",
494+ "quote",
495+ "syn 3.0.7",
496+]
497+
498+[[package]]
499+name = "tower"
500+version = "0.5.3"
501+source = "registry+https://github.com/rust-lang/crates.io-index"
502+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
503+dependencies = [
504+ "futures-core",
505+ "futures-util",
506+ "pin-project-lite",
507+ "sync_wrapper",
508+ "tokio",
509+ "tower-layer",
510+ "tower-service",
511+ "tracing",
512+]
513+
514+[[package]]
515+name = "tower-layer"
516+version = "0.3.3"
517+source = "registry+https://github.com/rust-lang/crates.io-index"
518+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
519+
520+[[package]]
521+name = "tower-service"
522+version = "0.3.3"
523+source = "registry+https://github.com/rust-lang/crates.io-index"
524+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
525+
526+[[package]]
527+name = "tracing"
528+version = "0.1.44"
529+source = "registry+https://github.com/rust-lang/crates.io-index"
530+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
531+dependencies = [
532+ "log",
533+ "pin-project-lite",
534+ "tracing-core",
535+]
536+
537+[[package]]
538+name = "tracing-core"
539+version = "0.1.36"
540+source = "registry+https://github.com/rust-lang/crates.io-index"
541+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
542+dependencies = [
543+ "once_cell",
544+]
545+
546+[[package]]
547+name = "unicode-ident"
548+version = "1.0.26"
549+source = "registry+https://github.com/rust-lang/crates.io-index"
550+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
551+
552+[[package]]
553+name = "wasi"
554+version = "0.11.1+wasi-snapshot-preview1"
555+source = "registry+https://github.com/rust-lang/crates.io-index"
556+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
557+
558+[[package]]
559+name = "wasip2"
560+version = "1.0.4+wasi-0.2.12"
561+source = "registry+https://github.com/rust-lang/crates.io-index"
562+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
563+dependencies = [
564+ "wit-bindgen",
565+]
566+
567+[[package]]
568+name = "windows-link"
569+version = "0.2.1"
570+source = "registry+https://github.com/rust-lang/crates.io-index"
571+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
572+
573+[[package]]
574+name = "windows-sys"
575+version = "0.61.2"
576+source = "registry+https://github.com/rust-lang/crates.io-index"
577+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
578+dependencies = [
579+ "windows-link",
580+]
581+
582+[[package]]
583+name = "wit-bindgen"
584+version = "0.57.1"
585+source = "registry+https://github.com/rust-lang/crates.io-index"
586+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
587+
588+[[package]]
589+name = "zerocopy"
590+version = "0.8.62"
591+source = "registry+https://github.com/rust-lang/crates.io-index"
592+checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
593+dependencies = [
594+ "zerocopy-derive",
595+]
596+
597+[[package]]
598+name = "zerocopy-derive"
599+version = "0.8.62"
600+source = "registry+https://github.com/rust-lang/crates.io-index"
601+checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
602+dependencies = [
603+ "proc-macro2",
604+ "quote",
605+ "syn 2.0.119",
606+]
607+
608+[[package]]
609+name = "zmij"
610+version = "1.0.23"
611+source = "registry+https://github.com/rust-lang/crates.io-index"
612+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Added file: Cargo.toml+17 additions
@@ -0,0 +1,17 @@
1+[package]
2+name = "jelqd"
3+version = "0.6.9"
4+edition = "2024"
5+description = "A roguelike played entirely over HTTP"
6+license = "MIT"
7+
8+[dependencies]
9+axum = "0.8"
10+futures-util = "0.3"
11+rand = "0.9"
12+serde = { version = "1", features = ["derive"] }
13+serde_json = "1"
14+tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
15+
16+[dev-dependencies]
17+tower = { version = "0.5", features = ["util"] }
Added file: flake.lock+77 additions
@@ -0,0 +1,77 @@
1+{
2+ "nodes": {
3+ "crane": {
4+ "locked": {
5+ "lastModified": 1790899423,
6+ "narHash": "sha256-THGtz65UGJU+sonZidRboyCMk+9DclpMF7pwL4uDV4M=",
7+ "owner": "ipetkov",
8+ "repo": "crane",
9+ "rev": "47b6b27ed9a3a9181415e4367d0c30ab2a0e0250",
10+ "type": "github"
11+ },
12+ "original": {
13+ "owner": "ipetkov",
14+ "repo": "crane",
15+ "type": "github"
16+ }
17+ },
18+ "flake-utils": {
19+ "inputs": {
20+ "systems": "systems"
21+ },
22+ "locked": {
23+ "lastModified": 1731533236,
24+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
25+ "owner": "numtide",
26+ "repo": "flake-utils",
27+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
28+ "type": "github"
29+ },
30+ "original": {
31+ "owner": "numtide",
32+ "repo": "flake-utils",
33+ "type": "github"
34+ }
35+ },
36+ "nixpkgs": {
37+ "locked": {
38+ "lastModified": 1791456040,
39+ "narHash": "sha256-H83yz/do+hjOO0uujHyH8RgtIQP2v7Mw+cLwcZK/xKc=",
40+ "owner": "NixOS",
41+ "repo": "nixpkgs",
42+ "rev": "e7439b6b14ad3cc35d05608ebca9bce01a25f5f8",
43+ "type": "github"
44+ },
45+ "original": {
46+ "owner": "NixOS",
47+ "ref": "nixos-unstable",
48+ "repo": "nixpkgs",
49+ "type": "github"
50+ }
51+ },
52+ "root": {
53+ "inputs": {
54+ "crane": "crane",
55+ "flake-utils": "flake-utils",
56+ "nixpkgs": "nixpkgs"
57+ }
58+ },
59+ "systems": {
60+ "locked": {
61+ "lastModified": 1681028828,
62+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
63+ "owner": "nix-systems",
64+ "repo": "default",
65+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
66+ "type": "github"
67+ },
68+ "original": {
69+ "owner": "nix-systems",
70+ "repo": "default",
71+ "type": "github"
72+ }
73+ }
74+ },
75+ "root": "root",
76+ "version": 7
77+}
Added file: flake.nix+64 additions
@@ -0,0 +1,64 @@
1+{
2+ description = "jelqd, a roguelike played entirely over HTTP";
3+
4+ inputs = {
5+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6+ crane.url = "github:ipetkov/crane";
7+ flake-utils.url = "github:numtide/flake-utils";
8+ };
9+
10+ outputs =
11+ {
12+ self,
13+ nixpkgs,
14+ crane,
15+ flake-utils,
16+ }:
17+ flake-utils.lib.eachDefaultSystem (
18+ system:
19+ let
20+ pkgs = nixpkgs.legacyPackages.${system};
21+ craneLib = crane.mkLib pkgs;
22+ src = craneLib.cleanCargoSource ./.;
23+ commonArgs = {
24+ inherit src;
25+ strictDeps = true;
26+ };
27+ cargoArtifacts = craneLib.buildDepsOnly commonArgs;
28+ jelqd = craneLib.buildPackage (
29+ commonArgs
30+ // {
31+ inherit cargoArtifacts;
32+ meta.mainProgram = "jelqd";
33+ }
34+ );
35+ in
36+ {
37+ packages.default = jelqd;
38+
39+ apps.default = flake-utils.lib.mkApp { drv = jelqd; } // {
40+ meta.description = "Run the jelqd server";
41+ };
42+
43+ checks = {
44+ inherit jelqd;
45+ clippy = craneLib.cargoClippy (
46+ commonArgs
47+ // {
48+ inherit cargoArtifacts;
49+ cargoClippyExtraArgs = "--all-targets -- --deny warnings";
50+ }
51+ );
52+ fmt = craneLib.cargoFmt { inherit src; };
53+ test = craneLib.cargoTest (commonArgs // { inherit cargoArtifacts; });
54+ };
55+
56+ devShells.default = craneLib.devShell {
57+ checks = self.checks.${system};
58+ packages = [ pkgs.rust-analyzer ];
59+ };
60+
61+ formatter = pkgs.nixfmt;
62+ }
63+ );
64+}
Added file: src/game.rs+1028 additions
@@ -0,0 +1,1028 @@
1+use std::time::{Duration, Instant};
2+
3+use axum::{
4+ Json,
5+ body::Body,
6+ http::{HeaderName, HeaderValue, StatusCode, header},
7+ response::{IntoResponse, Response},
8+};
9+use rand::{Rng, SeedableRng, rngs::StdRng};
10+use serde::Deserialize;
11+use serde_json::{Value, json};
12+
13+use crate::problem::Problem;
14+
15+pub const MAX_STAMINA: i32 = 10;
16+pub const SUSPICION_LIMIT: u8 = 5;
17+pub const BRANCH_TURN: u32 = 10;
18+pub const APPOINTMENT_TURN: u32 = 20;
19+pub const REFRACTORY: Duration = Duration::from_secs(30);
20+pub const SELF_REPORT_INFLATION: f64 = 1.5;
21+pub const MM_PER_INCH: f64 = 25.4;
22+pub const EXCUSES: [&str; 3] = ["changing", "homework", "pulled a muscle"];
23+pub const LUBRICANTS: [&str; 5] = [
24+ "application/baby-oil",
25+ "application/coconut-oil",
26+ "application/lotion",
27+ "application/vaseline",
28+ "text/plain; charset=spit",
29+];
30+pub const INTAKE: [&str; 3] = [
31+ "The clinic does not accept excuses.",
32+ "The clinic accepts one token. You have not been carrying it.",
33+ "Be honest.",
34+];
35+
36+pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");
37+pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");
38+
39+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
40+pub enum Lube {
41+ BabyOil,
42+ CoconutOil,
43+ Lotion,
44+ Vaseline,
45+ Spit,
46+}
47+
48+impl Lube {
49+ pub fn from_content_type(content_type: Option<&str>) -> Result<Option<Self>, Problem> {
50+ let Some(raw) = content_type else {
51+ return Ok(None);
52+ };
53+ let lowered = raw.to_ascii_lowercase();
54+ let mut parts = lowered.split(';').map(str::trim);
55+ let essence = parts.next().unwrap_or_default();
56+ let spit = parts.any(|p| p.replace(' ', "") == "charset=spit");
57+ match essence {
58+ "application/baby-oil" => Ok(Some(Self::BabyOil)),
59+ "application/coconut-oil" => Ok(Some(Self::CoconutOil)),
60+ "application/lotion" => Ok(Some(Self::Lotion)),
61+ "application/vaseline" => Ok(Some(Self::Vaseline)),
62+ "text/plain" if spit => Ok(Some(Self::Spit)),
63+ e if e.starts_with("image/") || e.starts_with("video/") => Err(Problem::new(
64+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
65+ "no",
66+ "No.",
67+ )),
68+ _ => Err(Problem::new(
69+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
70+ "unsupported-lubricant",
71+ format!("{essence} is not a lubricant."),
72+ )
73+ .with("lubricants", json!(LUBRICANTS))),
74+ }
75+ }
76+
77+ fn modifier(self) -> i32 {
78+ match self {
79+ Self::BabyOil => 1,
80+ Self::CoconutOil => 3,
81+ Self::Lotion => 0,
82+ Self::Vaseline => 2,
83+ Self::Spit => -2,
84+ }
85+ }
86+}
87+
88+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
89+pub enum Technique {
90+ Warmup,
91+ Ice,
92+ Wet,
93+ Dry,
94+ Stretch,
95+ Uli,
96+}
97+
98+#[derive(Clone, Copy)]
99+enum LubeRule {
100+ Required,
101+ Forbidden,
102+ Optional,
103+}
104+
105+struct Profile {
106+ dc: i32,
107+ length: f64,
108+ girth: f64,
109+ foreskin: Option<(f64, f64)>,
110+ foreskin_growth: f64,
111+ lube: LubeRule,
112+}
113+
114+impl Technique {
115+ pub const ALL: [Self; 6] = [
116+ Self::Warmup,
117+ Self::Ice,
118+ Self::Wet,
119+ Self::Dry,
120+ Self::Stretch,
121+ Self::Uli,
122+ ];
123+
124+ pub fn name(self) -> &'static str {
125+ match self {
126+ Self::Warmup => "warmup",
127+ Self::Ice => "ice",
128+ Self::Wet => "wet",
129+ Self::Dry => "dry",
130+ Self::Stretch => "stretch",
131+ Self::Uli => "uli",
132+ }
133+ }
134+
135+ pub fn parse(name: &str) -> Option<Self> {
136+ Self::ALL.into_iter().find(|t| t.name() == name)
137+ }
138+
139+ fn profile(self) -> Option<Profile> {
140+ let profile = match self {
141+ Self::Warmup | Self::Ice => return None,
142+ Self::Wet => Profile {
143+ dc: 10,
144+ length: 0.010,
145+ girth: 0.005,
146+ foreskin: None,
147+ foreskin_growth: 0.0,
148+ lube: LubeRule::Required,
149+ },
150+ Self::Dry => Profile {
151+ dc: 12,
152+ length: 0.008,
153+ girth: 0.003,
154+ foreskin: Some((0.8, 2.0)),
155+ foreskin_growth: 0.01,
156+ lube: LubeRule::Forbidden,
157+ },
158+ Self::Stretch => Profile {
159+ dc: 11,
160+ length: 0.015,
161+ girth: 0.0,
162+ foreskin: Some((0.0, 1.8)),
163+ foreskin_growth: 0.03,
164+ lube: LubeRule::Optional,
165+ },
166+ Self::Uli => Profile {
167+ dc: 15,
168+ length: 0.002,
169+ girth: 0.020,
170+ foreskin: None,
171+ foreskin_growth: 0.0,
172+ lube: LubeRule::Optional,
173+ },
174+ };
175+ Some(profile)
176+ }
177+
178+ pub fn describe(self, forum_read: bool) -> Value {
179+ let Some(p) = self.profile() else {
180+ let effect = match self {
181+ Self::Warmup => "EQ +25. Satisfies the warm-up precondition.",
182+ _ => "EQ -40. Consumes an ice pack.",
183+ };
184+ return json!({ "name": self.name(), "effect": effect });
185+ };
186+ let lubricant = match p.lube {
187+ LubeRule::Required => "required",
188+ LubeRule::Forbidden => "forbidden",
189+ LubeRule::Optional => "optional",
190+ };
191+ let foreskin = p
192+ .foreskin
193+ .map(|(min, max)| json!({ "min": min, "max": max }));
194+ json!({
195+ "name": self.name(),
196+ "dc": p.dc,
197+ "lubricant": lubricant,
198+ "foreskin": foreskin,
199+ "locked": self == Self::Uli && !forum_read,
200+ })
201+ }
202+}
203+
204+pub struct Outcome {
205+ status: StatusCode,
206+ body: Option<Value>,
207+ headers: Vec<(HeaderName, String)>,
208+}
209+
210+impl Outcome {
211+ pub fn new(status: StatusCode, body: Option<Value>) -> Self {
212+ Self {
213+ status,
214+ body,
215+ headers: Vec::new(),
216+ }
217+ }
218+
219+ pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {
220+ self.headers.push((name, value.into()));
221+ self
222+ }
223+}
224+
225+impl IntoResponse for Outcome {
226+ fn into_response(self) -> Response {
227+ let mut response = match self.body {
228+ // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is.
229+ Some(body) if self.status == StatusCode::PARTIAL_CONTENT => {
230+ let full = body.to_string().into_bytes();
231+ let half = full.len() / 2;
232+ let range = format!("bytes 0-{}/{}", half - 1, full.len());
233+ (
234+ self.status,
235+ [
236+ (header::CONTENT_TYPE, "application/json".to_owned()),
237+ (header::CONTENT_RANGE, range),
238+ ],
239+ Body::from(full[..half].to_vec()),
240+ )
241+ .into_response()
242+ }
243+ Some(body) => (self.status, Json(body)).into_response(),
244+ None => self.status.into_response(),
245+ };
246+ for (name, value) in self.headers {
247+ let value = HeaderValue::from_str(&value).expect("header values are visible ascii");
248+ response.headers_mut().append(name, value);
249+ }
250+ response
251+ }
252+}
253+
254+#[derive(Deserialize)]
255+pub struct PatchOp {
256+ pub op: String,
257+ pub path: String,
258+ #[serde(default)]
259+ pub value: Value,
260+}
261+
262+pub struct Pending {
263+ due: u32,
264+ length: f64,
265+ girth: f64,
266+}
267+
268+pub struct Ending {
269+ pub cause: &'static str,
270+ pub detail: String,
271+ pub turn: u32,
272+}
273+
274+pub struct Run {
275+ pub id: String,
276+ pub origin_etag: String,
277+ pub version: u64,
278+ pub turn: u32,
279+ pub length: f64,
280+ pub girth: f64,
281+ pub foreskin: Option<f64>,
282+ pub eq: i32,
283+ pub stamina: i32,
284+ pub suspicion: u8,
285+ pub shame_base: u32,
286+ pub shame: u32,
287+ pub ice_packs: u32,
288+ pub warmed_up: bool,
289+ pub teapot: bool,
290+ pub ruler: bool,
291+ pub put_applied: bool,
292+ pub forum_read: bool,
293+ pub branch_pending: bool,
294+ pub mom_present: bool,
295+ pub observed: bool,
296+ pub measured: bool,
297+ pub cured: bool,
298+ pub intake_attempts: usize,
299+ pub refractory_until: Option<Instant>,
300+ pub pending: Vec<Pending>,
301+ pub ended: Option<Ending>,
302+ rng: StdRng,
303+}
304+
305+fn round2(x: f64) -> f64 {
306+ (x * 100.0).round() / 100.0
307+}
308+
309+fn round3(x: f64) -> f64 {
310+ (x * 1000.0).round() / 1000.0
311+}
312+
313+fn tenths(x: f64) -> i64 {
314+ (x * 10.0).round() as i64
315+}
316+
317+fn not_extended() -> Problem {
318+ Problem::new(
319+ StatusCode::NOT_EXTENDED,
320+ "not-extended",
321+ "The requested extension was not applied. The server has recorded the attempt.",
322+ )
323+}
324+
325+pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {
326+ let (given, rest) = authorization.trim().split_once(' ')?;
327+ given
328+ .eq_ignore_ascii_case(scheme)
329+ .then(|| rest.trim().trim_matches('"'))
330+}
331+
332+impl Run {
333+ pub fn new(id: String, seed: u64, shame_base: u32) -> Self {
334+ let mut rng = StdRng::seed_from_u64(seed);
335+ let length = round2(rng.random_range(4.5..6.5));
336+ let girth = round2(rng.random_range(4.0..5.0));
337+ let foreskin = round2(rng.random_range(0.4..2.4));
338+ let mut run = Self {
339+ id,
340+ origin_etag: String::new(),
341+ version: 0,
342+ turn: 0,
343+ length,
344+ girth,
345+ foreskin: Some(foreskin),
346+ eq: 20,
347+ stamina: MAX_STAMINA,
348+ suspicion: 0,
349+ shame_base,
350+ shame: 0,
351+ ice_packs: 1,
352+ warmed_up: false,
353+ teapot: false,
354+ ruler: false,
355+ put_applied: false,
356+ forum_read: false,
357+ branch_pending: false,
358+ mom_present: false,
359+ observed: false,
360+ measured: false,
361+ cured: false,
362+ intake_attempts: 0,
363+ refractory_until: None,
364+ pending: Vec::new(),
365+ ended: None,
366+ rng,
367+ };
368+ run.origin_etag = run.etag();
369+ run
370+ }
371+
372+ pub fn etag(&self) -> String {
373+ format!("\"{}-{}\"", self.id, self.version)
374+ }
375+
376+ pub fn touch(&mut self) {
377+ self.version += 1;
378+ }
379+
380+ pub fn self_reported(&self) -> f64 {
381+ self.length + SELF_REPORT_INFLATION
382+ }
383+
384+ pub fn total_shame(&self) -> u32 {
385+ self.shame_base + self.shame
386+ }
387+
388+ pub fn status(&self) -> &'static str {
389+ match (&self.ended, self.cured) {
390+ (Some(_), _) => "gone",
391+ (None, true) => "normal",
392+ (None, false) => "alive",
393+ }
394+ }
395+
396+ pub fn view(&self) -> Value {
397+ let mut view = json!({
398+ "id": self.id,
399+ "status": self.status(),
400+ "turn": self.turn,
401+ "floor": 1,
402+ "self_reported_length": round2(self.self_reported()),
403+ "girth": round2(self.girth),
404+ "foreskin": self.foreskin.map(round2),
405+ "eq": self.eq,
406+ "stamina": self.stamina,
407+ "suspicion": self.suspicion,
408+ "shame": self.total_shame(),
409+ "warmed_up": self.warmed_up,
410+ });
411+ if self.ruler {
412+ view["length"] = json!(round2(self.length));
413+ }
414+ if self.teapot {
415+ view["condition"] = json!("short and stout");
416+ }
417+ view
418+ }
419+
420+ pub fn end(&mut self, cause: &'static str, detail: &str) {
421+ self.ended = Some(Ending {
422+ cause,
423+ detail: detail.into(),
424+ turn: self.turn,
425+ });
426+ }
427+
428+ pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {
429+ let challenge =
430+ |p: Problem| p.header(header::WWW_AUTHENTICATE, "Excuse realm=\"household\"");
431+ let Some(authorization) = authorization else {
432+ return Err(challenge(
433+ Problem::new(
434+ StatusCode::UNAUTHORIZED,
435+ "someone-at-the-door",
436+ "Someone is at the door.",
437+ )
438+ .with("inventory", format!("/runs/{}/inventory", self.id)),
439+ ));
440+ };
441+ let excuse = parse_credentials(authorization, "excuse");
442+ if excuse.is_some_and(|e| EXCUSES.iter().any(|known| known.eq_ignore_ascii_case(e))) {
443+ self.mom_present = false;
444+ self.suspicion = 0;
445+ self.touch();
446+ return Ok(());
447+ }
448+ self.shame += 3;
449+ self.touch();
450+ Err(challenge(Problem::new(
451+ StatusCode::UNAUTHORIZED,
452+ "excuse-rejected",
453+ "Mom has not heard that one before. She does not believe it.",
454+ )))
455+ }
456+
457+ fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {
458+ let Some(until) = self.refractory_until else {
459+ return Ok(());
460+ };
461+ if now < until {
462+ let seconds = (until - now).as_secs() + 1;
463+ return Err(Problem::new(
464+ StatusCode::TOO_MANY_REQUESTS,
465+ "overtrained",
466+ "Stamina is exhausted. The refractory period is in effect.",
467+ )
468+ .header(header::RETRY_AFTER, seconds.to_string()));
469+ }
470+ self.refractory_until = None;
471+ self.stamina = MAX_STAMINA;
472+ self.eq = 20;
473+ self.warmed_up = false;
474+ self.touch();
475+ Ok(())
476+ }
477+
478+ fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {
479+ let Some(f) = self.foreskin else {
480+ if min <= 0.0 {
481+ return Ok(());
482+ }
483+ return Err(Problem::new(
484+ StatusCode::GONE,
485+ "foreskin-gone",
486+ "This technique requires a foreskin. The foreskin is gone.",
487+ )
488+ .header(
489+ header::CONTENT_LOCATION,
490+ format!("/runs/{}/foreskin", self.id),
491+ ));
492+ };
493+ if f < min {
494+ return Err(Problem::new(
495+ StatusCode::LENGTH_REQUIRED,
496+ "foreskin-insufficient",
497+ format!(
498+ "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."
499+ ),
500+ )
501+ .with("required", min)
502+ .with("available", round2(f)));
503+ }
504+ if f > max {
505+ return Err(Problem::new(
506+ StatusCode::PAYLOAD_TOO_LARGE,
507+ "foreskin-excessive",
508+ format!(
509+ "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."
510+ ),
511+ )
512+ .with("maximum", max)
513+ .with("present", round2(f)));
514+ }
515+ Ok(())
516+ }
517+
518+ fn realize(&mut self) {
519+ let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
520+ .into_iter()
521+ .partition(|p| p.due <= self.turn);
522+ self.pending = waiting;
523+ for p in due {
524+ self.length += p.length;
525+ self.girth += p.girth;
526+ }
527+ }
528+
529+ fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
530+ self.length += length;
531+ self.girth += girth;
532+ if let Some(f) = &mut self.foreskin {
533+ *f += foreskin;
534+ }
535+ }
536+
537+ fn rupture(&mut self) -> Problem {
538+ let detail = if self.ice_packs > 0 {
539+ "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."
540+ } else {
541+ "A vessel has exceeded its rated pressure. The run has ended."
542+ };
543+ self.end("vascular-event", detail);
544+ Problem::new(StatusCode::INTERNAL_SERVER_ERROR, "vascular-event", detail)
545+ .with("eq_at_failure", self.eq)
546+ .with("turn", self.turn)
547+ }
548+
549+ pub fn jelq(
550+ &mut self,
551+ technique: Technique,
552+ intensity: i32,
553+ lube: Option<Lube>,
554+ now: Instant,
555+ ) -> Result<Outcome, Problem> {
556+ self.check_refractory(now)?;
557+ if self.branch_pending {
558+ return Ok(self.branch_offer());
559+ }
560+ let Some(profile) = technique.profile() else {
561+ return self.utility(technique);
562+ };
563+ if technique == Technique::Uli && !self.forum_read {
564+ return Err(Problem::new(
565+ StatusCode::FORBIDDEN,
566+ "technique-locked",
567+ "This technique is discussed on the forum. You have not been to the forum.",
568+ ));
569+ }
570+ if !self.warmed_up {
571+ return Err(Problem::new(
572+ StatusCode::PRECONDITION_FAILED,
573+ "cold-start",
574+ "Warm-up was skipped. The server has noted this.",
575+ )
576+ .with("technique", Technique::Warmup.name()));
577+ }
578+ match (profile.lube, lube) {
579+ (LubeRule::Required, None) => {
580+ return Err(Problem::new(
581+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
582+ "lubricant-required",
583+ format!(
584+ "The {} technique requires a lubricant. None was declared.",
585+ technique.name()
586+ ),
587+ )
588+ .with("lubricants", json!(LUBRICANTS)));
589+ }
590+ (LubeRule::Forbidden, Some(_)) => {
591+ return Err(Problem::new(
592+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
593+ "lubricant-declared",
594+ "The dry technique was requested. A lubricant was declared.",
595+ ));
596+ }
597+ _ => {}
598+ }
599+ if let Some((min, max)) = profile.foreskin {
600+ self.check_foreskin(min, max)?;
601+ }
602+
603+ self.touch();
604+ self.turn += 1;
605+ self.realize();
606+ self.stamina -= 1 + i32::from(lube == Some(Lube::Lotion));
607+ if self.stamina <= 0 {
608+ self.stamina = 0;
609+ self.refractory_until = Some(now + REFRACTORY);
610+ }
611+ self.eq = (self.eq + intensity * 2 - 6).clamp(0, 100);
612+ let suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
613+ self.suspicion = (self.suspicion + suspicion as u8).min(SUSPICION_LIMIT);
614+ if self.suspicion == SUSPICION_LIMIT {
615+ self.mom_present = true;
616+ }
617+ if self.rng.random_ratio(1, 6) {
618+ self.observed = true;
619+ self.shame += 1;
620+ }
621+ if self.turn == BRANCH_TURN {
622+ self.branch_pending = true;
623+ }
624+
625+ if self.eq > 95 {
626+ self.eq = 0;
627+ self.warmed_up = false;
628+ self.shame += 5;
629+ return Err(Problem::new(
630+ StatusCode::TOO_EARLY,
631+ "premature",
632+ "The floor has ended early. Everyone involved has agreed not to discuss it.",
633+ ));
634+ }
635+ if self.eq < 30 {
636+ return Err(Problem::new(
637+ StatusCode::NOT_FOUND,
638+ "cold",
639+ "The resource exists. It is cold.",
640+ )
641+ .with("eq", self.eq));
642+ }
643+ self.roll(technique, &profile, intensity, lube)
644+ }
645+
646+ fn roll(
647+ &mut self,
648+ technique: Technique,
649+ profile: &Profile,
650+ intensity: i32,
651+ lube: Option<Lube>,
652+ ) -> Result<Outcome, Problem> {
653+ let natural: i32 = self.rng.random_range(1..=20);
654+ let band = if (50..=70).contains(&self.eq) { 2 } else { 0 };
655+ let modifier = lube.map_or(0, Lube::modifier) + band;
656+ let dc = profile.dc + intensity / 3;
657+ let total = natural + modifier;
658+ let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");
659+ let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
660+
661+ let scale = f64::from(intensity);
662+ let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);
663+ if self.teapot {
664+ length *= 0.5;
665+ girth *= 1.5;
666+ }
667+
668+ if natural == 1 {
669+ let touches_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);
670+ if touches_foreskin && self.foreskin.is_some() {
671+ self.foreskin = None;
672+ self.shame += 25;
673+ return Err(timed(
674+ Problem::new(
675+ StatusCode::GONE,
676+ "foreskin-lost",
677+ "The technique has removed the foreskin. It will not be returning.",
678+ )
679+ .header(
680+ header::CONTENT_LOCATION,
681+ format!("/runs/{}/foreskin", self.id),
682+ ),
683+ ));
684+ }
685+ if self.eq > 70 {
686+ return Err(timed(self.rupture()));
687+ }
688+ if technique == Technique::Stretch && !self.teapot {
689+ self.teapot = true;
690+ self.shame += 5;
691+ return Err(timed(Problem::new(
692+ StatusCode::IM_A_TEAPOT,
693+ "teapot",
694+ "The subject is now short and stout. Length gains are halved for the rest of the run.",
695+ )));
696+ }
697+ return Err(timed(not_extended()));
698+ }
699+ if self.eq > 90 && total < dc {
700+ return Err(timed(self.rupture()));
701+ }
702+ if natural == 20 {
703+ self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
704+ return Ok(Outcome::new(
705+ StatusCode::CREATED,
706+ Some(json!({
707+ "detail": "A permanent gain has been created.",
708+ "length": round3(length * 2.0),
709+ "girth": round3(girth * 2.0),
710+ })),
711+ )
712+ .header(header::LOCATION, format!("/runs/{}/length", self.id))
713+ .header(SERVER_TIMING, timing));
714+ }
715+ if total < dc - 3 {
716+ return Err(timed(not_extended()));
717+ }
718+ if total < dc {
719+ return Ok(Outcome::new(StatusCode::NO_CONTENT, None).header(SERVER_TIMING, timing));
720+ }
721+
722+ let outcome = if self.eq < 50 {
723+ self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
724+ Outcome::new(
725+ StatusCode::PARTIAL_CONTENT,
726+ Some(json!({
727+ "detail": "Partial gains were applied. EQ was below the band.",
728+ "length": round3(length / 2.0),
729+ "girth": round3(girth / 2.0),
730+ })),
731+ )
732+ } else if total >= dc + 5 {
733+ self.gain(length, girth, profile.foreskin_growth);
734+ Outcome::new(
735+ StatusCode::OK,
736+ Some(json!({
737+ "detail": "Gains were applied.",
738+ "length": round3(length),
739+ "girth": round3(girth),
740+ })),
741+ )
742+ } else {
743+ let eta = self.rng.random_range(1..=4);
744+ self.pending.push(Pending {
745+ due: self.turn + eta,
746+ length,
747+ girth,
748+ });
749+ self.gain(0.0, 0.0, profile.foreskin_growth);
750+ Outcome::new(
751+ StatusCode::ACCEPTED,
752+ Some(json!({
753+ "detail": "Gains have been queued. They may arrive.",
754+ "eta_turns": eta,
755+ })),
756+ )
757+ };
758+ Ok(outcome.header(SERVER_TIMING, timing))
759+ }
760+
761+ fn utility(&mut self, technique: Technique) -> Result<Outcome, Problem> {
762+ if technique == Technique::Warmup {
763+ self.touch();
764+ self.warmed_up = true;
765+ self.eq = (self.eq + 25).min(100);
766+ return Ok(Outcome::new(
767+ StatusCode::OK,
768+ Some(json!({ "detail": "Warmed up. The towel has cooled." })),
769+ ));
770+ }
771+ if self.ice_packs == 0 {
772+ return Err(Problem::new(
773+ StatusCode::FAILED_DEPENDENCY,
774+ "no-ice",
775+ "The ice pack has been used. There was one ice pack.",
776+ ));
777+ }
778+ self.touch();
779+ self.ice_packs -= 1;
780+ self.eq = (self.eq - 40).max(0);
781+ Ok(Outcome::new(
782+ StatusCode::OK,
783+ Some(json!({ "detail": "EQ has been reduced. The ice pack is gone." })),
784+ ))
785+ }
786+
787+ fn branch_offer(&self) -> Outcome {
788+ let forum = format!("/runs/{}/branches/forum", self.id);
789+ let close = format!("/runs/{}/branches/close-tab", self.id);
790+ Outcome::new(
791+ StatusCode::MULTIPLE_CHOICES,
792+ Some(json!({
793+ "detail": "A browser tab is open. It has been open for some time.",
794+ "choices": [
795+ { "href": forum, "method": "POST", "title": "Read the forum" },
796+ { "href": close, "method": "POST", "title": "Close the tab" },
797+ ],
798+ })),
799+ )
800+ .header(
801+ header::LINK,
802+ format!(
803+ "<{forum}>; rel=\"alternate\"; title=\"Read the forum\", <{close}>; rel=\"alternate\"; title=\"Close the tab\""
804+ ),
805+ )
806+ }
807+
808+ pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {
809+ if !self.branch_pending {
810+ return Err(Problem::new(
811+ StatusCode::CONFLICT,
812+ "no-decision-pending",
813+ "There is no decision pending.",
814+ ));
815+ }
816+ let (location, detail) = match choice {
817+ "forum" => {
818+ self.forum_read = true;
819+ self.shame += 3;
820+ (
821+ format!("/runs/{}/techniques", self.id),
822+ "The thread is from 2009. A technique called uli is discussed at length.",
823+ )
824+ }
825+ "close-tab" => {
826+ self.suspicion = 0;
827+ (
828+ format!("/runs/{}", self.id),
829+ "The tab is closed. Nobody saw it.",
830+ )
831+ }
832+ _ => {
833+ return Err(Problem::new(
834+ StatusCode::NOT_FOUND,
835+ "no-such-choice",
836+ "That was not one of the choices.",
837+ ));
838+ }
839+ };
840+ self.branch_pending = false;
841+ self.touch();
842+ Ok(
843+ Outcome::new(StatusCode::SEE_OTHER, Some(json!({ "detail": detail })))
844+ .header(header::LOCATION, location),
845+ )
846+ }
847+
848+ pub fn search_drawer(&mut self) -> Outcome {
849+ if self.ruler {
850+ return Outcome::new(StatusCode::NO_CONTENT, None);
851+ }
852+ self.ruler = true;
853+ self.shame += 10;
854+ self.touch();
855+ Outcome::new(
856+ StatusCode::FOUND,
857+ Some(json!({
858+ "detail": "You now know. This is the first honest measurement of the run.",
859+ })),
860+ )
861+ .header(
862+ header::LOCATION,
863+ format!("/runs/{}/inventory/ruler", self.id),
864+ )
865+ }
866+
867+ pub fn put(&mut self) -> Outcome {
868+ if self.put_applied {
869+ return Outcome::new(StatusCode::NO_CONTENT, None);
870+ }
871+ self.put_applied = true;
872+ self.length += 0.01;
873+ self.touch();
874+ Outcome::new(StatusCode::OK, Some(json!({ "detail": "Applied." })))
875+ }
876+
877+ pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {
878+ for op in ops {
879+ if op.op != "test" {
880+ return Err(Problem::new(
881+ StatusCode::UNPROCESSABLE_ENTITY,
882+ "wishful-thinking",
883+ "The server accepts only the truth.",
884+ )
885+ .with("op", op.op.clone()));
886+ }
887+ let actual = match op.path.as_str() {
888+ "/length" => self.length,
889+ "/girth" => self.girth,
890+ "/foreskin" => self.foreskin.unwrap_or(0.0),
891+ _ => {
892+ return Err(Problem::new(
893+ StatusCode::UNPROCESSABLE_ENTITY,
894+ "no-such-measurement",
895+ "There is no such measurement.",
896+ )
897+ .with("path", op.path.clone()));
898+ }
899+ };
900+ let Some(claimed) = op.value.as_f64() else {
901+ return Err(Problem::new(
902+ StatusCode::BAD_REQUEST,
903+ "malformed-patch",
904+ "A test requires a numeric value.",
905+ ));
906+ };
907+ if op.path == "/length" && claimed >= self.length + 1.0 {
908+ self.shame += 2;
909+ self.touch();
910+ return Err(Problem::new(
911+ StatusCode::BAD_REQUEST,
912+ "exaggeration",
913+ "The server has measured. Lying is not a technique.",
914+ ));
915+ }
916+ if tenths(claimed) != tenths(actual) {
917+ self.shame += 1;
918+ self.touch();
919+ return Err(
920+ Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")
921+ .with("path", op.path.clone()),
922+ );
923+ }
924+ }
925+ Ok(Outcome::new(
926+ StatusCode::OK,
927+ Some(json!({ "detail": "Correct." })),
928+ ))
929+ }
930+
931+ pub fn delete_foreskin(&mut self, if_match: Option<&str>) -> Result<Outcome, Problem> {
932+ if self.foreskin.is_none() {
933+ return Err(Problem::new(
934+ StatusCode::GONE,
935+ "foreskin-gone",
936+ "The foreskin is gone. It will not be returning.",
937+ ));
938+ }
939+ let Some(if_match) = if_match else {
940+ return Err(Problem::new(
941+ StatusCode::PRECONDITION_REQUIRED,
942+ "confirmation-required",
943+ "The server requires you to be sure. Send If-Match.",
944+ ));
945+ };
946+ if !etag_matches(if_match, &self.etag()) {
947+ return Err(Problem::new(
948+ StatusCode::PRECONDITION_FAILED,
949+ "stale-foreskin",
950+ "The foreskin has changed since you last looked at it.",
951+ ));
952+ }
953+ self.foreskin = None;
954+ self.shame += 20;
955+ self.touch();
956+ Ok(Outcome::new(StatusCode::NO_CONTENT, None))
957+ }
958+
959+ pub fn diagnose(
960+ &mut self,
961+ authorization: Option<&str>,
962+ if_none_match: Option<&str>,
963+ ) -> Result<Outcome, Problem> {
964+ let remaining = APPOINTMENT_TURN.saturating_sub(self.turn);
965+ if remaining > 0 {
966+ return Err(Problem::new(
967+ StatusCode::TOO_EARLY,
968+ "no-appointment",
969+ format!("The clinic's next available appointment is in {remaining} turns."),
970+ ));
971+ }
972+ let intake = Problem::new(
973+ StatusCode::UNAUTHORIZED,
974+ "intake",
975+ "The clinic requires authorization.",
976+ );
977+ let Some(authorization) = authorization else {
978+ return Err(intake.header(header::WWW_AUTHENTICATE, "Bearer realm=\"clinic\""));
979+ };
980+ let honest = parse_credentials(authorization, "bearer")
981+ .is_some_and(|token| token.eq_ignore_ascii_case("honesty"));
982+ if !honest {
983+ let hint = INTAKE[self.intake_attempts.min(INTAKE.len() - 1)];
984+ self.intake_attempts += 1;
985+ return Err(intake.header(
986+ header::WWW_AUTHENTICATE,
987+ format!(
988+ "Bearer realm=\"clinic\", error=\"invalid_token\", error_description=\"{hint}\""
989+ ),
990+ ));
991+ }
992+
993+ let etag = self.origin_etag.clone();
994+ if if_none_match.is_some_and(|inm| etag_matches(inm, &etag)) {
995+ self.cured = true;
996+ return Ok(Outcome::new(StatusCode::NOT_MODIFIED, None)
997+ .header(header::ETAG, etag)
998+ .header(X_DIAGNOSIS, "normal"));
999+ }
1000+ if !self.measured {
1001+ self.measured = true;
1002+ if !self.ruler {
1003+ self.shame += 15;
1004+ }
1005+ }
1006+ Ok(Outcome::new(
1007+ StatusCode::OK,
1008+ Some(json!({
1009+ "diagnosis": "within normal range",
1010+ "measured_length": round2(self.length),
1011+ "you_said": round2(self.self_reported()),
1012+ "prescription": "Please stop.",
1013+ })),
1014+ )
1015+ .header(header::ETAG, etag))
1016+ }
1017+}
1018+
1019+pub fn etag_matches(condition: &str, etag: &str) -> bool {
1020+ condition
1021+ .split(',')
1022+ .map(str::trim)
1023+ .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
1024+}
1025+
1026+pub fn shaft(mm: usize) -> String {
1027+ format!("8{}D", "=".repeat(mm.saturating_sub(2)))
1028+}
Added file: src/http.rs+711 additions
@@ -0,0 +1,711 @@
1+use std::{
2+ collections::HashMap,
3+ convert::Infallible,
4+ fmt::Write as _,
5+ sync::{Arc, Mutex},
6+ time::Instant,
7+};
8+
9+use axum::{
10+ Json, Router,
11+ body::{Body, Bytes},
12+ extract::{Path, Query, Request, State},
13+ http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, Version, header},
14+ middleware::{self, Next},
15+ response::{IntoResponse, Response},
16+ routing::any,
17+};
18+use rand::Rng;
19+use serde_json::{Value, json};
20+
21+use crate::{
22+ game::{
23+ EXCUSES, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH, PatchOp, Run, SUSPICION_LIMIT,
24+ Technique, shaft,
25+ },
26+ problem::{PROBLEM_BASE, Problem},
27+};
28+
29+const REL_BASE: &str = "https://jelq.dev/rels/";
30+const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";
31+const THERAPY: &str = "Your options are listed in the Allow header. There are more of them than you think. You could also try being honest. It is not in the Allow header because it is not a method.";
32+
33+#[derive(Default)]
34+pub struct AppState {
35+ runs: Mutex<HashMap<String, Run>>,
36+}
37+
38+impl AppState {
39+ pub fn insert(&self, run: Run) {
40+ self.runs.lock().unwrap().insert(run.id.clone(), run);
41+ }
42+}
43+
44+type Shared = Arc<AppState>;
45+
46+pub fn app() -> Router {
47+ router(Arc::default())
48+}
49+
50+pub fn router(state: Shared) -> Router {
51+ Router::new()
52+ .route("/runs", any(runs))
53+ .route("/runs/{id}", any(run))
54+ .route("/runs/{id}/length", any(length))
55+ .route("/runs/{id}/foreskin", any(foreskin))
56+ .route("/runs/{id}/inventory", any(inventory))
57+ .route("/runs/{id}/inventory/{item}", any(item))
58+ .route("/runs/{id}/techniques", any(techniques))
59+ .route("/runs/{id}/drawer", any(drawer))
60+ .route("/runs/{id}/branches/{choice}", any(branch))
61+ .route("/runs/{id}/diagnosis", any(diagnosis))
62+ .route("/graveyard", any(graveyard))
63+ .route("/leaderboard", any(leaderboard))
64+ .fallback(not_found)
65+ .layer(middleware::from_fn(chrome))
66+ .with_state(state)
67+}
68+
69+async fn chrome(request: Request, next: Next) -> Response {
70+ let mut response = next.run(request).await;
71+ let headers = response.headers_mut();
72+ headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9"));
73+ headers.insert("x-powered-by", HeaderValue::from_static("blood"));
74+ headers.insert(
75+ header::CACHE_CONTROL,
76+ HeaderValue::from_static("no-store, private"),
77+ );
78+ response
79+}
80+
81+fn header_str(headers: &HeaderMap, name: HeaderName) -> Option<&str> {
82+ headers.get(name).and_then(|v| v.to_str().ok())
83+}
84+
85+fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
86+ headers
87+ .get_all(header::COOKIE)
88+ .iter()
89+ .filter_map(|v| v.to_str().ok())
90+ .flat_map(|v| v.split(';'))
91+ .filter_map(|pair| pair.trim().split_once('='))
92+ .find(|(key, _)| *key == name)
93+ .map(|(_, value)| value)
94+}
95+
96+fn unsupported(method: &Method, allow: &'static str) -> Response {
97+ if *method == Method::CONNECT {
98+ return StatusCode::NOT_IMPLEMENTED.into_response();
99+ }
100+ let known = matches!(
101+ method.as_str(),
102+ "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ"
103+ );
104+ if !known {
105+ return Problem::new(
106+ StatusCode::NOT_IMPLEMENTED,
107+ "not-implemented",
108+ format!("The server does not implement {method}."),
109+ )
110+ .into_response();
111+ }
112+ Problem::new(
113+ StatusCode::METHOD_NOT_ALLOWED,
114+ "method-not-allowed",
115+ format!("{method} is not supported here."),
116+ )
117+ .header(header::ALLOW, allow)
118+ .into_response()
119+}
120+
121+fn options(allow: &'static str, patchable: bool) -> Response {
122+ let mut response = (
123+ StatusCode::OK,
124+ [(header::ALLOW, allow)],
125+ Json(json!({ "detail": THERAPY })),
126+ )
127+ .into_response();
128+ if patchable {
129+ response.headers_mut().insert(
130+ "accept-patch",
131+ HeaderValue::from_static("application/json-patch+json"),
132+ );
133+ }
134+ response
135+}
136+
137+fn trace(method: &Method, uri: &Uri, version: Version, headers: &HeaderMap) -> Response {
138+ let mut message = format!("{method} {uri} {version:?}\r\n");
139+ for (name, value) in headers {
140+ let _ = write!(
141+ message,
142+ "{name}: {}\r\n",
143+ value.to_str().unwrap_or("<binary>")
144+ );
145+ }
146+ message.push_str("\r\n");
147+ (
148+ StatusCode::OK,
149+ [(header::CONTENT_TYPE, "message/http")],
150+ message,
151+ )
152+ .into_response()
153+}
154+
155+fn links(run: &Run) -> String {
156+ let id = &run.id;
157+ let mut links = vec![format!("</runs/{id}>; rel=\"{REL_BASE}run\"")];
158+ for rel in [
159+ "length",
160+ "foreskin",
161+ "inventory",
162+ "techniques",
163+ "drawer",
164+ "diagnosis",
165+ ] {
166+ links.push(format!("</runs/{id}/{rel}>; rel=\"{REL_BASE}{rel}\""));
167+ }
168+ links.join(", ")
169+}
170+
171+fn finish(run: &Run, mut response: Response) -> Response {
172+ if response.status() == StatusCode::NOT_MODIFIED {
173+ return response;
174+ }
175+ let mut stats = vec![
176+ (
177+ "x-self-reported-length",
178+ format!("{:.2}", run.self_reported()),
179+ ),
180+ ("x-girth", format!("{:.2}", run.girth)),
181+ (
182+ "x-foreskin",
183+ run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
184+ ),
185+ ("x-eq", run.eq.to_string()),
186+ ("x-stamina", format!("{}/{MAX_STAMINA}", run.stamina)),
187+ (
188+ "x-suspicion",
189+ format!("{}/{SUSPICION_LIMIT}", run.suspicion),
190+ ),
191+ ("x-shame", run.total_shame().to_string()),
192+ ("link", links(run)),
193+ ];
194+ if run.observed {
195+ stats.push(("x-observed-by", "cat".into()));
196+ }
197+ if run.mom_present {
198+ stats.push(("via", "1.1 mom".into()));
199+ stats.push(("vary", "Mom".into()));
200+ }
201+ // RFC 9111 obsoleted Warning, which is why nobody heeds it.
202+ if run.eq > 70 && run.ended.is_none() {
203+ stats.push(("warning", "199 - \"it is turning purple\"".into()));
204+ }
205+ stats.push((
206+ "set-cookie",
207+ format!("shame={}; {COOKIE_ATTRIBUTES}", run.total_shame()),
208+ ));
209+ if run.ruler {
210+ stats.push(("set-cookie", format!("ruler=1; {COOKIE_ATTRIBUTES}")));
211+ }
212+ let headers = response.headers_mut();
213+ for (name, value) in stats {
214+ headers.append(name, HeaderValue::from_str(&value).expect("visible ascii"));
215+ }
216+ response
217+}
218+
219+fn gone(run: &Run) -> Response {
220+ let ending = run.ended.as_ref().expect("only called on ended runs");
221+ Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")
222+ .with("cause", format!("{PROBLEM_BASE}{}", ending.cause))
223+ .with("turn", ending.turn)
224+ .with("graveyard", "/graveyard")
225+ .into_response()
226+}
227+
228+fn with_run(
229+ state: &AppState,
230+ id: &str,
231+ headers: &HeaderMap,
232+ mom_applies: bool,
233+ handle: impl FnOnce(&mut Run) -> Response,
234+) -> Response {
235+ let mut runs = state.runs.lock().unwrap();
236+ let Some(run) = runs.get_mut(id) else {
237+ return Problem::new(
238+ StatusCode::NOT_FOUND,
239+ "no-such-run",
240+ format!("There is no run {id}. There may never have been."),
241+ )
242+ .into_response();
243+ };
244+ if run.ended.is_some() {
245+ return gone(run);
246+ }
247+ run.observed = false;
248+ if mom_applies
249+ && run.mom_present
250+ && let Err(problem) = run.answer_door(header_str(headers, header::AUTHORIZATION))
251+ {
252+ return finish(run, problem.into_response());
253+ }
254+ let response = handle(run);
255+ finish(run, response)
256+}
257+
258+fn respond<T: IntoResponse, E: IntoResponse>(result: Result<T, E>) -> Response {
259+ match result {
260+ Ok(ok) => ok.into_response(),
261+ Err(err) => err.into_response(),
262+ }
263+}
264+
265+async fn runs(
266+ State(state): State<Shared>,
267+ method: Method,
268+ Query(query): Query<HashMap<String, String>>,
269+ headers: HeaderMap,
270+) -> Response {
271+ if method != Method::POST {
272+ return unsupported(&method, "POST");
273+ }
274+ let seed = match query.get("seed").map(|s| s.parse::<u64>()) {
275+ None => rand::rng().random(),
276+ Some(Ok(seed)) => seed,
277+ Some(Err(_)) => {
278+ return Problem::new(
279+ StatusCode::BAD_REQUEST,
280+ "bad-seed",
281+ "The seed must be an unsigned 64-bit integer.",
282+ )
283+ .into_response();
284+ }
285+ };
286+ let shame = cookie(&headers, "shame")
287+ .and_then(|s| s.parse().ok())
288+ .unwrap_or(0);
289+ let id = format!("{:08x}", rand::rng().random::<u32>());
290+ let run = Run::new(id.clone(), seed, shame);
291+ let response = (
292+ StatusCode::CREATED,
293+ [
294+ (header::LOCATION, format!("/runs/{id}")),
295+ (header::ETAG, run.etag()),
296+ (HeaderName::from_static("x-seed"), seed.to_string()),
297+ ],
298+ Json(run.view()),
299+ )
300+ .into_response();
301+ let response = finish(&run, response);
302+ state.insert(run);
303+ response
304+}
305+
306+#[allow(clippy::too_many_arguments)]
307+async fn run(
308+ State(state): State<Shared>,
309+ Path(id): Path<String>,
310+ method: Method,
311+ uri: Uri,
312+ version: Version,
313+ Query(query): Query<HashMap<String, String>>,
314+ headers: HeaderMap,
315+ body: Bytes,
316+) -> Response {
317+ const ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE";
318+ let now = Instant::now();
319+ with_run(&state, &id, &headers, true, |run| match method.as_str() {
320+ "GET" | "HEAD" => {
321+ let accept = header_str(&headers, header::ACCEPT).unwrap_or_default();
322+ if accept.contains("reassurance") {
323+ return Problem::new(
324+ StatusCode::NOT_ACCEPTABLE,
325+ "no-reassurance",
326+ "The server does not produce reassurance.",
327+ )
328+ .into_response();
329+ }
330+ (
331+ StatusCode::OK,
332+ [(header::ETAG, run.etag())],
333+ Json(run.view()),
334+ )
335+ .into_response()
336+ }
337+ "JELQ" => jelq(run, &query, &headers, now),
338+ "PUT" => run.put().into_response(),
339+ "PATCH" => patch(run, &headers, &body),
340+ "DELETE" => {
341+ run.end("abandoned", "The run was abandoned.");
342+ StatusCode::NO_CONTENT.into_response()
343+ }
344+ "OPTIONS" => options(ALLOW, true),
345+ "TRACE" => {
346+ run.shame += 1;
347+ trace(&method, &uri, version, &headers)
348+ }
349+ _ => unsupported(&method, ALLOW),
350+ })
351+}
352+
353+fn jelq(
354+ run: &mut Run,
355+ query: &HashMap<String, String>,
356+ headers: &HeaderMap,
357+ now: Instant,
358+) -> Response {
359+ let technique = match query.get("technique") {
360+ None => {
361+ return Problem::new(
362+ StatusCode::BAD_REQUEST,
363+ "technique-required",
364+ "No technique was specified.",
365+ )
366+ .with("techniques", format!("/runs/{}/techniques", run.id))
367+ .into_response();
368+ }
369+ Some(name) => match Technique::parse(name) {
370+ Some(t) => t,
371+ None => {
372+ return Problem::new(
373+ StatusCode::BAD_REQUEST,
374+ "unknown-technique",
375+ format!("{name} is not a technique."),
376+ )
377+ .with("techniques", format!("/runs/{}/techniques", run.id))
378+ .into_response();
379+ }
380+ },
381+ };
382+ let intensity = match query.get("intensity").map(|i| i.parse::<i32>()) {
383+ None => 5,
384+ Some(Ok(i)) if (1..=10).contains(&i) => i,
385+ Some(_) => {
386+ return Problem::new(
387+ StatusCode::BAD_REQUEST,
388+ "intensity-out-of-range",
389+ "Intensity is measured from 1 to 10.",
390+ )
391+ .into_response();
392+ }
393+ };
394+ let lube = match Lube::from_content_type(header_str(headers, header::CONTENT_TYPE)) {
395+ Ok(lube) => lube,
396+ Err(problem) => return problem.into_response(),
397+ };
398+ respond(run.jelq(technique, intensity, lube, now))
399+}
400+
401+fn patch(run: &mut Run, headers: &HeaderMap, body: &[u8]) -> Response {
402+ let content_type = header_str(headers, header::CONTENT_TYPE).unwrap_or_default();
403+ if !content_type.starts_with("application/json-patch+json") {
404+ return Problem::new(
405+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
406+ "patch-format",
407+ "Patches are accepted as application/json-patch+json.",
408+ )
409+ .header(
410+ HeaderName::from_static("accept-patch"),
411+ "application/json-patch+json",
412+ )
413+ .into_response();
414+ }
415+ match serde_json::from_slice::<Vec<PatchOp>>(body) {
416+ Ok(ops) => respond(run.patch(&ops)),
417+ Err(_) => Problem::new(
418+ StatusCode::BAD_REQUEST,
419+ "malformed-patch",
420+ "The patch could not be parsed.",
421+ )
422+ .into_response(),
423+ }
424+}
425+
426+async fn length(
427+ State(state): State<Shared>,
428+ Path(id): Path<String>,
429+ method: Method,
430+ headers: HeaderMap,
431+) -> Response {
432+ const ALLOW: &str = "GET, HEAD, OPTIONS";
433+ with_run(&state, &id, &headers, true, |run| match method.as_str() {
434+ "GET" | "HEAD" => {
435+ let content_type = [(header::CONTENT_TYPE, "text/plain; charset=us-ascii")];
436+ if run.ruler {
437+ let mm = (run.length * MM_PER_INCH).round() as usize;
438+ return (StatusCode::OK, content_type, shaft(mm)).into_response();
439+ }
440+ let mm = (run.self_reported() * MM_PER_INCH).round() as usize;
441+ let chunks: Vec<Result<Bytes, Infallible>> = shaft(mm)
442+ .into_bytes()
443+ .chunks(16)
444+ .map(|c| Ok(Bytes::copy_from_slice(c)))
445+ .collect();
446+ let body = Body::from_stream(futures_util::stream::iter(chunks));
447+ (StatusCode::OK, content_type, body).into_response()
448+ }
449+ "OPTIONS" => options(ALLOW, false),
450+ _ => unsupported(&method, ALLOW),
451+ })
452+}
453+
454+async fn foreskin(
455+ State(state): State<Shared>,
456+ Path(id): Path<String>,
457+ method: Method,
458+ headers: HeaderMap,
459+) -> Response {
460+ const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS";
461+ with_run(&state, &id, &headers, true, |run| match method.as_str() {
462+ "GET" | "HEAD" => match run.foreskin {
463+ Some(f) => (
464+ StatusCode::OK,
465+ [(header::ETAG, run.etag())],
466+ Json(json!({ "foreskin": (f * 100.0).round() / 100.0 })),
467+ )
468+ .into_response(),
469+ None => Problem::new(
470+ StatusCode::GONE,
471+ "foreskin-gone",
472+ "The foreskin is gone. It will not be returning.",
473+ )
474+ .into_response(),
475+ },
476+ "DELETE" => respond(run.delete_foreskin(header_str(&headers, header::IF_MATCH))),
477+ "OPTIONS" => options(ALLOW, false),
478+ _ => unsupported(&method, ALLOW),
479+ })
480+}
481+
482+fn items(run: &Run) -> Vec<(&'static str, &'static str)> {
483+ let mut items = vec![(
484+ "warm-towel",
485+ "Reusable. Satisfies the warm-up precondition. Use technique=warmup.",
486+ )];
487+ if run.ice_packs > 0 {
488+ items.push((
489+ "ice-pack",
490+ "Lowers EQ by 40. Use technique=ice. There is one.",
491+ ));
492+ }
493+ if run.ruler {
494+ items.push((
495+ "ruler",
496+ "Measures honestly. The length resource now carries a Content-Length.",
497+ ));
498+ }
499+ if run.teapot {
500+ items.push(("teapot-spout", "Does nothing. Cannot be dropped."));
501+ }
502+ items
503+}
504+
505+async fn inventory(
506+ State(state): State<Shared>,
507+ Path(id): Path<String>,
508+ method: Method,
509+ headers: HeaderMap,
510+) -> Response {
511+ const ALLOW: &str = "GET, HEAD, OPTIONS";
512+ with_run(&state, &id, &headers, false, |run| match method.as_str() {
513+ "GET" | "HEAD" => {
514+ let listed: Vec<Value> = items(run)
515+ .into_iter()
516+ .map(|(name, _)| json!({ "name": name, "href": format!("/runs/{}/inventory/{name}", run.id) }))
517+ .collect();
518+ Json(json!({ "items": listed, "excuses": EXCUSES })).into_response()
519+ }
520+ "OPTIONS" => options(ALLOW, false),
521+ _ => unsupported(&method, ALLOW),
522+ })
523+}
524+
525+async fn item(
526+ State(state): State<Shared>,
527+ Path((id, name)): Path<(String, String)>,
528+ method: Method,
529+ headers: HeaderMap,
530+) -> Response {
531+ const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS";
532+ with_run(&state, &id, &headers, false, |run| {
533+ let Some((name, description)) = items(run).into_iter().find(|(n, _)| *n == name) else {
534+ return Problem::new(
535+ StatusCode::NOT_FOUND,
536+ "no-such-item",
537+ "You do not have that.",
538+ )
539+ .into_response();
540+ };
541+ match method.as_str() {
542+ "GET" | "HEAD" => {
543+ Json(json!({ "name": name, "description": description })).into_response()
544+ }
545+ "DELETE" => Problem::new(
546+ StatusCode::FORBIDDEN,
547+ "cannot-be-dropped",
548+ "Items cannot be dropped.",
549+ )
550+ .into_response(),
551+ "OPTIONS" => options(ALLOW, false),
552+ _ => unsupported(&method, ALLOW),
553+ }
554+ })
555+}
556+
557+async fn techniques(
558+ State(state): State<Shared>,
559+ Path(id): Path<String>,
560+ method: Method,
561+ headers: HeaderMap,
562+) -> Response {
563+ const ALLOW: &str = "GET, HEAD, OPTIONS";
564+ with_run(&state, &id, &headers, true, |run| match method.as_str() {
565+ "GET" | "HEAD" => {
566+ let listed: Vec<Value> = Technique::ALL
567+ .into_iter()
568+ .map(|t| t.describe(run.forum_read))
569+ .collect();
570+ Json(json!({
571+ "usage": format!("JELQ /runs/{}?technique=<name>&intensity=<1-10>", run.id),
572+ "lubricant": "Declared with Content-Type.",
573+ "lubricants": LUBRICANTS,
574+ "eq_band": [50, 70],
575+ "techniques": listed,
576+ }))
577+ .into_response()
578+ }
579+ "OPTIONS" => options(ALLOW, false),
580+ _ => unsupported(&method, ALLOW),
581+ })
582+}
583+
584+async fn drawer(
585+ State(state): State<Shared>,
586+ Path(id): Path<String>,
587+ method: Method,
588+ headers: HeaderMap,
589+) -> Response {
590+ with_run(&state, &id, &headers, true, |run| match method.as_str() {
591+ "POST" => run.search_drawer().into_response(),
592+ "OPTIONS" => options("POST, OPTIONS", false),
593+ _ => unsupported(&method, "POST, OPTIONS"),
594+ })
595+}
596+
597+async fn branch(
598+ State(state): State<Shared>,
599+ Path((id, choice)): Path<(String, String)>,
600+ method: Method,
601+ headers: HeaderMap,
602+) -> Response {
603+ with_run(&state, &id, &headers, true, |run| match method.as_str() {
604+ "POST" => respond(run.choose(&choice)),
605+ "OPTIONS" => options("POST, OPTIONS", false),
606+ _ => unsupported(&method, "POST, OPTIONS"),
607+ })
608+}
609+
610+async fn diagnosis(
611+ State(state): State<Shared>,
612+ Path(id): Path<String>,
613+ method: Method,
614+ headers: HeaderMap,
615+) -> Response {
616+ const ALLOW: &str = "GET, HEAD, OPTIONS";
617+ with_run(&state, &id, &headers, false, |run| match method.as_str() {
618+ "GET" | "HEAD" => respond(run.diagnose(
619+ header_str(&headers, header::AUTHORIZATION),
620+ header_str(&headers, header::IF_NONE_MATCH),
621+ )),
622+ "OPTIONS" => options(ALLOW, false),
623+ _ => unsupported(&method, ALLOW),
624+ })
625+}
626+
627+async fn graveyard(State(state): State<Shared>, method: Method) -> Response {
628+ if method != Method::GET && method != Method::HEAD {
629+ return unsupported(&method, "GET, HEAD");
630+ }
631+ let runs = state.runs.lock().unwrap();
632+ let dead: Vec<Value> = runs
633+ .values()
634+ .filter_map(|run| {
635+ let ending = run.ended.as_ref()?;
636+ Some(json!({
637+ "id": run.id,
638+ "cause": format!("{PROBLEM_BASE}{}", ending.cause),
639+ "detail": ending.detail,
640+ "turn": ending.turn,
641+ }))
642+ })
643+ .collect();
644+ Json(json!({ "runs": dead })).into_response()
645+}
646+
647+async fn leaderboard(
648+ State(state): State<Shared>,
649+ method: Method,
650+ Query(query): Query<HashMap<String, String>>,
651+ headers: HeaderMap,
652+) -> Response {
653+ if method != Method::GET && method != Method::HEAD {
654+ return unsupported(&method, "GET, HEAD");
655+ }
656+ let truthful = match query.get("sort").map(String::as_str) {
657+ None | Some("self-reported-length") => false,
658+ Some("content-length") if cookie(&headers, "ruler") == Some("1") => true,
659+ Some("content-length") => {
660+ return Problem::new(
661+ StatusCode::FORBIDDEN,
662+ "ruler-required",
663+ "Sorting by the truth requires a Ruler.",
664+ )
665+ .into_response();
666+ }
667+ Some(_) => {
668+ return Problem::new(
669+ StatusCode::BAD_REQUEST,
670+ "unknown-sort",
671+ "The leaderboard sorts by self-reported-length or content-length.",
672+ )
673+ .into_response();
674+ }
675+ };
676+ let runs = state.runs.lock().unwrap();
677+ let mut entries: Vec<&Run> = runs.values().collect();
678+ entries.sort_by(|a, b| {
679+ let key = |r: &Run| {
680+ if truthful {
681+ r.length
682+ } else {
683+ r.self_reported()
684+ }
685+ };
686+ key(b).total_cmp(&key(a))
687+ });
688+ let entries: Vec<Value> = entries
689+ .into_iter()
690+ .map(|run| {
691+ let mut entry = json!({
692+ "id": run.id,
693+ "status": run.status(),
694+ "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,
695+ });
696+ if truthful {
697+ entry["content_length"] = json!((run.length * MM_PER_INCH).round() as u64);
698+ }
699+ entry
700+ })
701+ .collect();
702+ Json(json!({ "entries": entries })).into_response()
703+}
704+
705+async fn not_found() -> Problem {
706+ Problem::new(
707+ StatusCode::NOT_FOUND,
708+ "not-found",
709+ "The resource does not exist. This is not about you.",
710+ )
711+}
Added file: src/lib.rs+5 additions
@@ -0,0 +1,5 @@
1+pub mod game;
2+pub mod http;
3+pub mod problem;
4+
5+pub use http::{AppState, app, router};
Added file: src/main.rs+7 additions
@@ -0,0 +1,7 @@
1+#[tokio::main]
2+async fn main() -> std::io::Result<()> {
3+ let addr = std::env::var("JELQD_ADDR").unwrap_or_else(|_| "127.0.0.1:6969".into());
4+ let listener = tokio::net::TcpListener::bind(&addr).await?;
5+ eprintln!("jelqd listening on http://{addr}");
6+ axum::serve(listener, jelqd::app()).await
7+}
Added file: src/problem.rs+77 additions
@@ -0,0 +1,77 @@
1+use axum::{
2+ Json,
3+ http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
4+ response::{IntoResponse, Response},
5+};
6+use serde_json::{Map, Value, json};
7+
8+pub const PROBLEM_BASE: &str = "https://jelq.dev/problems/";
9+
10+/// An RFC 9457 problem document.
11+pub struct Problem(Box<Inner>);
12+
13+struct Inner {
14+ status: StatusCode,
15+ slug: &'static str,
16+ detail: String,
17+ extensions: Map<String, Value>,
18+ headers: HeaderMap,
19+}
20+
21+impl Problem {
22+ pub fn new(status: StatusCode, slug: &'static str, detail: impl Into<String>) -> Self {
23+ Self(Box::new(Inner {
24+ status,
25+ slug,
26+ detail: detail.into(),
27+ extensions: Map::new(),
28+ headers: HeaderMap::new(),
29+ }))
30+ }
31+
32+ pub fn with(mut self, key: &str, value: impl Into<Value>) -> Self {
33+ self.0.extensions.insert(key.into(), value.into());
34+ self
35+ }
36+
37+ pub fn header(mut self, name: HeaderName, value: impl AsRef<str>) -> Self {
38+ let value = HeaderValue::from_str(value.as_ref()).expect("header values are visible ascii");
39+ self.0.headers.append(name, value);
40+ self
41+ }
42+}
43+
44+// The http crate still uses the RFC 7231 names for these.
45+fn title(status: StatusCode) -> &'static str {
46+ match status.as_u16() {
47+ 413 => "Content Too Large",
48+ 422 => "Unprocessable Content",
49+ _ => status.canonical_reason().unwrap_or("Unknown"),
50+ }
51+}
52+
53+impl IntoResponse for Problem {
54+ fn into_response(self) -> Response {
55+ let problem = *self.0;
56+ let mut body = Map::new();
57+ body.insert(
58+ "type".into(),
59+ json!(format!("{PROBLEM_BASE}{}", problem.slug)),
60+ );
61+ body.insert("title".into(), json!(title(problem.status)));
62+ body.insert("status".into(), json!(problem.status.as_u16()));
63+ body.insert("detail".into(), json!(problem.detail));
64+ body.extend(problem.extensions);
65+
66+ let mut response = (problem.status, Json(body)).into_response();
67+ let headers = response.headers_mut();
68+ headers.insert(
69+ header::CONTENT_TYPE,
70+ HeaderValue::from_static("application/problem+json"),
71+ );
72+ for (name, value) in &problem.headers {
73+ headers.append(name, value.clone());
74+ }
75+ response
76+ }
77+}
Added file: tests/http.rs+224 additions
@@ -0,0 +1,224 @@
1+use std::sync::Arc;
2+
3+use axum::{
4+ Router,
5+ body::{Body, to_bytes},
6+ http::{Method, Request, StatusCode, header},
7+ response::Response,
8+};
9+use jelqd::{AppState, game::Run, router};
10+use serde_json::Value;
11+use tower::ServiceExt;
12+
13+async fn send(
14+ app: &Router,
15+ method: &str,
16+ uri: &str,
17+ headers: &[(&str, &str)],
18+ body: &str,
19+) -> Response {
20+ let mut request = Request::builder()
21+ .method(Method::from_bytes(method.as_bytes()).unwrap())
22+ .uri(uri);
23+ for (name, value) in headers {
24+ request = request.header(*name, *value);
25+ }
26+ app.clone()
27+ .oneshot(request.body(Body::from(body.to_owned())).unwrap())
28+ .await
29+ .unwrap()
30+}
31+
32+async fn json(response: Response) -> Value {
33+ serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()
34+}
35+
36+fn seeded(turn: u32) -> (Router, String) {
37+ let state = Arc::new(AppState::default());
38+ let mut run = Run::new("cafebabe".into(), 69, 0);
39+ run.turn = turn;
40+ let etag = run.origin_etag.clone();
41+ state.insert(run);
42+ (router(state), etag)
43+}
44+
45+#[tokio::test]
46+async fn creating_a_run_returns_location_and_etag() {
47+ let app = jelqd::app();
48+ let response = send(&app, "POST", "/runs?seed=1", &[], "").await;
49+ assert_eq!(response.status(), StatusCode::CREATED);
50+ assert!(response.headers().contains_key(header::LOCATION));
51+ assert!(response.headers().contains_key(header::ETAG));
52+ assert_eq!(response.headers()[header::SERVER], "jelqd/0.6.9");
53+}
54+
55+#[tokio::test]
56+async fn jelqing_cold_fails_the_precondition() {
57+ let (app, _) = seeded(0);
58+ let response = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
59+ assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
60+ assert_eq!(
61+ response.headers()[header::CONTENT_TYPE],
62+ "application/problem+json"
63+ );
64+}
65+
66+#[tokio::test]
67+async fn wet_without_lube_is_unsupported_media() {
68+ let (app, _) = seeded(0);
69+ send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
70+ let response = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
71+ assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
72+ let images = send(
73+ &app,
74+ "JELQ",
75+ "/runs/cafebabe?technique=wet",
76+ &[("content-type", "image/jpeg")],
77+ "",
78+ )
79+ .await;
80+ assert_eq!(json(images).await["detail"], "No.");
81+}
82+
83+#[tokio::test]
84+async fn unknown_methods_are_not_implemented() {
85+ let (app, _) = seeded(0);
86+ let response = send(&app, "MILK", "/runs/cafebabe", &[], "").await;
87+ assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
88+}
89+
90+#[tokio::test]
91+async fn patch_only_accepts_the_truth() {
92+ let (app, _) = seeded(0);
93+ let patch = [("content-type", "application/json-patch+json")];
94+ let replace = send(
95+ &app,
96+ "PATCH",
97+ "/runs/cafebabe",
98+ &patch,
99+ r#"[{"op":"replace","path":"/length","value":12}]"#,
100+ )
101+ .await;
102+ assert_eq!(replace.status(), StatusCode::UNPROCESSABLE_ENTITY);
103+ let lie = send(
104+ &app,
105+ "PATCH",
106+ "/runs/cafebabe",
107+ &patch,
108+ r#"[{"op":"test","path":"/length","value":40}]"#,
109+ )
110+ .await;
111+ assert_eq!(
112+ json(lie).await["type"],
113+ "https://jelq.dev/problems/exaggeration"
114+ );
115+}
116+
117+#[tokio::test]
118+async fn deleting_the_foreskin_requires_certainty() {
119+ let (app, _) = seeded(0);
120+ let response = send(&app, "DELETE", "/runs/cafebabe/foreskin", &[], "").await;
121+ assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
122+ let etag = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "")
123+ .await
124+ .headers()[header::ETAG]
125+ .to_str()
126+ .unwrap()
127+ .to_owned();
128+ let response = send(
129+ &app,
130+ "DELETE",
131+ "/runs/cafebabe/foreskin",
132+ &[("if-match", &etag)],
133+ "",
134+ )
135+ .await;
136+ assert_eq!(response.status(), StatusCode::NO_CONTENT);
137+ let response = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
138+ assert_eq!(response.status(), StatusCode::GONE);
139+}
140+
141+#[tokio::test]
142+async fn length_is_chunked_until_the_ruler() {
143+ let (app, _) = seeded(0);
144+ let lie = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
145+ assert!(!lie.headers().contains_key(header::CONTENT_LENGTH));
146+ let found = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
147+ assert_eq!(found.status(), StatusCode::FOUND);
148+ let truth = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
149+ let declared: usize = truth.headers()[header::CONTENT_LENGTH]
150+ .to_str()
151+ .unwrap()
152+ .parse()
153+ .unwrap();
154+ let body = to_bytes(truth.into_body(), usize::MAX).await.unwrap();
155+ assert_eq!(body.len(), declared);
156+ assert!(body.starts_with(b"8=") && body.ends_with(b"D"));
157+}
158+
159+#[tokio::test]
160+async fn the_clinic_escalates_toward_honesty() {
161+ let (app, _) = seeded(20);
162+ let mut hints = Vec::new();
163+ for _ in 0..3 {
164+ let response = send(
165+ &app,
166+ "GET",
167+ "/runs/cafebabe/diagnosis",
168+ &[("authorization", "Excuse \"changing\"")],
169+ "",
170+ )
171+ .await;
172+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
173+ hints.push(
174+ response.headers()[header::WWW_AUTHENTICATE]
175+ .to_str()
176+ .unwrap()
177+ .to_owned(),
178+ );
179+ }
180+ assert!(hints[2].contains("Be honest."));
181+}
182+
183+#[tokio::test]
184+async fn the_true_ending_is_not_modified() {
185+ let (app, etag) = seeded(20);
186+ let early = seeded(3).0;
187+ let response = send(&early, "GET", "/runs/cafebabe/diagnosis", &[], "").await;
188+ assert_eq!(response.status(), StatusCode::TOO_EARLY);
189+
190+ let response = send(
191+ &app,
192+ "GET",
193+ "/runs/cafebabe/diagnosis",
194+ &[
195+ ("authorization", "Bearer honesty"),
196+ ("if-none-match", &etag),
197+ ],
198+ "",
199+ )
200+ .await;
201+ assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
202+ assert_eq!(response.headers()["x-diagnosis"], "normal");
203+}
204+
205+#[tokio::test]
206+async fn mom_accepts_a_known_excuse() {
207+ let state = Arc::new(AppState::default());
208+ let mut run = Run::new("cafebabe".into(), 69, 0);
209+ run.mom_present = true;
210+ state.insert(run);
211+ let app = router(state);
212+ let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
213+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
214+ assert_eq!(response.headers()["via"], "1.1 mom");
215+ let response = send(
216+ &app,
217+ "GET",
218+ "/runs/cafebabe",
219+ &[("authorization", "Excuse \"homework\"")],
220+ "",
221+ )
222+ .await;
223+ assert_eq!(response.status(), StatusCode::OK);
224+}