Public: anyone can read this; 1 person and the crew can change it.

Build cheffry-s-dungeon #2

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

cheffry-james/cheffry-s-dungeon:cheffry-james/1-build-cheffry-s-dungeon → main 4 commits fork

  • Merged. cheffry-james/1-build-cheffry-s-dungeon was merged into main as 5d461ee.

Review

Sign in to review this change.
38 files changed +6364 −253
.gitignore +1
@@ -2,3 +2,4 @@ node_modules/
22 *.db
33 *.db-wal
44 *.db-shm
5 data/
README.md +61 −9
@@ -20,6 +20,46 @@ bun run contract:emit # rewrite contract/opena
2020 it: the preview comes up the moment the app answers, and says `live`, or
2121 `serving · tests failed` the moment the suite disagrees.
2222
23 ## How to play
24
25 - **The hub** (`/`). Sign in, step into the square and you get an apron.
26 Tap the floor (or use the arrow keys) to walk; emote, chat. Only
27 positions, emotes and chat cross the wire — a walk is sent once, as a
28 target, and every client slides the avatar there itself.
29 - **A delve** (`/delve/<id>`). Open a table or join one: up to four chefs a
30 party. Click to walk, click a monster to swing at it (or `Space`/`J` for
31 the nearest), right-click / `Shift`+click / `K` / `Shift` to dodge through
32 a hit. WASD or arrows steer. A downed chef gets back up if a friend stands
33 over them. Clear five rooms of the day's dungeon to win.
34 - **Three enemies, three brains** — the Meatball Slime (chases), the
35 Baguette Knight (winds up and charges), the Pepper Imp (keeps its distance
36 and shoots) — plus Skewer Spikes and chests of coins, truffles, soup and
37 the Whisk of Power.
38 - **The daily board.** Everyone gets the same seed each UTC day. Finished
39 runs land on the leaderboard with a ghost (the party's path, sampled every
40 ten ticks); your next party races the best one on screen.
41 - **The graffiti wall** is the notes: sign it and it stays, live for
42 everyone in the hub.
43
44 ## Content is data
45
46 `content/*.json` is the game: `rooms.json` (layouts, spawns, traps, chests),
47 `monsters.json`, `traps.json`, `loot.json`, `rules.json` (every tuning number,
48 in tiles and ticks) and `hub.json`. `src/content.ts` checks every file whole
49 at boot and refuses to start on a bad one, naming the field. A new room or a
50 new monster built from an existing brain is a JSON edit and nothing else.
51
52 ## The networking
53
54 A delve is `src/sim.ts`: a pure, seeded, 20 Hz tick with no imports, so the
55 same seed and the same orders make the same run anywhere. The server owns
56 every instance (`src/delves.ts`) and is the authority; clients send orders
57 (`move`, `attack`, `dodge`, `stop`) and receive snapshots over
58 `/delves/<id>/events`. The page loads the very same sim as `/sim.js` to lay
59 out rooms. Instances share nothing but the database, so they would split
60 across processes by delve id when one process is no longer enough — v1
61 keeps them in one.
62
2363 ## The shape
2464
2565 One implementation, many doors. `src/actions.ts` is every operation as a
@@ -29,7 +69,11 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2969
3070 | file | what it is |
3171 | ---------------------------------- | -------------------------------------------------------------- |
32 | `src/actions.ts` | list, add, delete; the audit trail, read |
72 | `src/actions.ts` | notes, avatars, delves, heroes, runs; the audit trail, read |
73 | `src/sim.ts` | the delve: a pure seeded tick — melee, dodge, brains, traps |
74 | `src/content.ts`, `content/` | the game as JSON, checked whole at boot |
75 | `src/hub.ts` | the square in memory: who stands where, emotes, chat |
76 | `src/delves.ts` | the live instances, their parties, ticking; runs and ghosts |
3377 | `contract.json` | who may do what: roles, and the least role each verb needs |
3478 | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim |
3579 | `src/security.ts` | the door: rationing, same origin, the table before the body |
@@ -40,13 +84,15 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
4084 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
4185 | `src/routes.ts` | every path, in one table, each API route described beside it |
4286 | `src/openapi.ts` | the routes as OpenAPI; committed at `contract/openapi.json` |
43 | `src/page.ts` | the HTML, rendered on the server, with its share metadata |
87 | `src/pages/` | the HTML — hub, delve, layout — rendered on the server |
4488 | `design/tokens.json`, `src/tokens.ts` | the look as data, light and dark; compiled to `/tokens.css` |
4589 | `src/http.ts` | the floor: headers, body cap, files |
4690 | `src/server.ts` | boot: open the database, serve |
4791 | `src/tty.ts` | the terminal, over the same actions |
4892 | `src/env.ts` | what the forge told this process |
4993 | `static/app.css`, `static/live.js` | the look in the tokens' words, and the list that stays current |
94 | `static/gl.js` | a small hand-rolled WebGL renderer for the low-poly scene |
95 | `static/hub.js`, `static/delve.js` | the hub and delve clients: input out, snapshots in |
5096
5197 ## What every app stands on
5298
@@ -78,9 +124,10 @@ The forge says who is asking on every request it proxies, and the app
78124 verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
79125
80126 - **anon** — anyone. Reads.
81 - **user** — signed in to the forge and able to see this app. Adds notes, as
82 themselves, and removes their own.
83 - **admin** — able to write this repository. Removes any note.
127 - **user** — signed in to the forge and able to see this app. Stands in the
128 hub, opens and joins delves, writes on the wall — as themselves — and
129 removes their own.
130 - **admin** — able to write this repository. Removes any note or run.
84131
85132 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
86133 `/__forge/signout`; the forge answers both on the app's own host.
@@ -96,16 +143,21 @@ curl -X POST https://<app>/api/notes -H 'content-type: application/json' -d '{"b
96143 curl -X DELETE https://<app>/api/notes/1
97144 ```
98145
99 As an MCP server, for Claude Code or Claude Desktop — `list_notes`,
100 `add_note`, `delete_note`:
146 ```sh
147 curl https://<app>/api/runs # today's board, best first
148 curl https://<app>/api/delves # open tables
149 ```
150
151 As an MCP server, for Claude Code or Claude Desktop — `leaderboard`,
152 `list_delves`, `list_notes`, `add_note`, `delete_note`:
101153
102154 ```sh
103 claude mcp add --transport http __TITLE__ https://<app>/mcp
155 claude mcp add --transport http cheffry-s-dungeon https://<app>/mcp
104156 ```
105157
106158 ```json
107159 {
108 "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } }
160 "mcpServers": { "cheffry-s-dungeon": { "type": "http", "url": "https://<app>/mcp" } }
109161 }
110162 ```
111163
content/hub.json added +12
@@ -0,0 +1,12 @@
1 {
2 "$description": "The hub's furniture: where it stands, how big it is, and which palette token paints it. Nothing here is solid; it is the room people meet in.",
3 "props": [
4 { "id": "fountain", "shape": "prism", "x": 11, "z": 8, "w": 2.4, "h": 0.8, "d": 2.4, "paint": "game-water" },
5 { "id": "stove", "shape": "box", "x": 4, "z": 3, "w": 3, "h": 1.1, "d": 1.4, "paint": "game-chest" },
6 { "id": "pantry-door", "shape": "box", "x": 18, "z": 3, "w": 2.2, "h": 2.4, "d": 0.4, "paint": "game-exit" },
7 { "id": "graffiti-wall", "shape": "box", "x": 11, "z": 0.6, "w": 10, "h": 2, "d": 0.4, "paint": "game-wall" },
8 { "id": "barrel-1", "shape": "prism", "x": 3, "z": 13, "w": 0.9, "h": 1, "d": 0.9, "paint": "game-baguette" },
9 { "id": "barrel-2", "shape": "prism", "x": 4.2, "z": 13.5, "w": 0.9, "h": 1, "d": 0.9, "paint": "game-baguette" },
10 { "id": "table", "shape": "box", "x": 17, "z": 12, "w": 3, "h": 0.7, "d": 1.6, "paint": "game-wall" }
11 ]
12 }
content/loot.json added +9
@@ -0,0 +1,9 @@
1 {
2 "$description": "What a chest holds, by weight. `gold` is score, `heal` is hearts back, `power` is damage added to every swing for the rest of the run.",
3 "loot": [
4 { "id": "coins", "name": "Pile of Coins", "weight": 6, "gold": 50 },
5 { "id": "truffle", "name": "Golden Truffle", "weight": 2, "gold": 150 },
6 { "id": "soup", "name": "Hearty Soup", "weight": 4, "heal": 2 },
7 { "id": "whisk", "name": "Whisk of Power", "weight": 2, "power": 1 }
8 ]
9 }
content/monsters.json added +49
@@ -0,0 +1,49 @@
1 {
2 "$description": "The kitchen's rejects. `glyph` is how a room map places one; `behavior` is which brain it gets (chase, charge or shoot) and the numbers beside it tune that brain.",
3 "monsters": [
4 {
5 "id": "meatball",
6 "name": "Meatball Slime",
7 "glyph": "m",
8 "behavior": "chase",
9 "hp": 3,
10 "radius": 0.35,
11 "speed": 0.07,
12 "damage": 1,
13 "aggro": 7,
14 "score": 20
15 },
16 {
17 "id": "baguette",
18 "name": "Baguette Knight",
19 "glyph": "b",
20 "behavior": "charge",
21 "hp": 4,
22 "radius": 0.35,
23 "speed": 0.04,
24 "damage": 2,
25 "aggro": 6,
26 "windup": 16,
27 "chargeSpeed": 0.3,
28 "chargeTicks": 16,
29 "stun": 30,
30 "score": 35
31 },
32 {
33 "id": "pepper",
34 "name": "Pepper Imp",
35 "glyph": "p",
36 "behavior": "shoot",
37 "hp": 2,
38 "radius": 0.3,
39 "speed": 0.06,
40 "damage": 1,
41 "aggro": 9,
42 "range": 6,
43 "keepAway": 3,
44 "cooldown": 45,
45 "shotSpeed": 0.18,
46 "score": 30
47 }
48 ]
49 }
content/rooms.json added +113
@@ -0,0 +1,113 @@
1 {
2 "$description": "Rooms, drawn in text. # wall, . floor, S where the party starts, E the exit (it opens when every monster is down), C a chest; a monster's or a trap's glyph puts one there. `depth` is the first and last floor a room may appear on (0-based); a `final` room ends the run.",
3 "rooms": [
4 {
5 "id": "pantry",
6 "name": "The Pantry",
7 "depth": [0, 1],
8 "map": [
9 "###############",
10 "#.............#",
11 "#..m.......m..#",
12 "#.............#",
13 "#....##.##....#",
14 "#S...#...#...E#",
15 "#....##.##....#",
16 "#.............#",
17 "#......C......#",
18 "#.............#",
19 "###############"
20 ]
21 },
22 {
23 "id": "bakery",
24 "name": "The Bakery",
25 "depth": [0, 2],
26 "map": [
27 "###############",
28 "#.....#.......#",
29 "#.....#...b...#",
30 "#.....#.......#",
31 "#.............#",
32 "#S.....m.....E#",
33 "#.............#",
34 "#.....#.......#",
35 "#..C..#...m...#",
36 "#.....#.......#",
37 "###############"
38 ]
39 },
40 {
41 "id": "spice-rack",
42 "name": "The Spice Rack",
43 "depth": [1, 3],
44 "map": [
45 "###############",
46 "#......^......#",
47 "#..p...^...p..#",
48 "#......^......#",
49 "#.............#",
50 "#S....^^^....E#",
51 "#.............#",
52 "#......^......#",
53 "#..m...^...C..#",
54 "#......^......#",
55 "###############"
56 ]
57 },
58 {
59 "id": "cold-room",
60 "name": "The Cold Room",
61 "depth": [2, 4],
62 "map": [
63 "###############",
64 "#.............#",
65 "#.##..b..b..#.#",
66 "#.#.........#.#",
67 "#.............#",
68 "#S.....C.....E#",
69 "#.............#",
70 "#.#.........#.#",
71 "#.##..m..p..#.#",
72 "#.............#",
73 "###############"
74 ]
75 },
76 {
77 "id": "scullery",
78 "name": "The Scullery",
79 "depth": [1, 4],
80 "map": [
81 "###############",
82 "#...^.....^...#",
83 "#.m.^..p..^.m.#",
84 "#...^.....^...#",
85 "#.............#",
86 "#S...........E#",
87 "#.............#",
88 "#...^.....^...#",
89 "#.C.^..b..^...#",
90 "#...^.....^...#",
91 "###############"
92 ]
93 },
94 {
95 "id": "pass",
96 "name": "The Pass",
97 "final": true,
98 "map": [
99 "###############",
100 "#.............#",
101 "#..b...p...b..#",
102 "#.............#",
103 "#...^.....^...#",
104 "#S....m.m....E#",
105 "#...^.....^...#",
106 "#.............#",
107 "#..p...C...p..#",
108 "#.............#",
109 "###############"
110 ]
111 }
112 ]
113 }
content/rules.json added +28
@@ -0,0 +1,28 @@
1 {
2 "$description": "The rules of a delve, as numbers. Distances are in tiles, times in ticks (20 a second), speeds in tiles per tick.",
3 "tickHz": 20,
4 "roomsPerRun": 5,
5 "maxTicks": 24000,
6 "hero": {
7 "hp": 6,
8 "radius": 0.3,
9 "speed": 0.16,
10 "damage": 1,
11 "reach": 1.1,
12 "swing": 6,
13 "cooldown": 9,
14 "dodgeSpeed": 0.42,
15 "dodgeTicks": 6,
16 "dodgeCooldown": 24,
17 "invuln": 16,
18 "revive": 40
19 },
20 "score": { "room": 100, "victory": 500 },
21 "hub": {
22 "width": 22,
23 "depth": 16,
24 "speed": 3.5,
25 "idleMinutes": 10,
26 "emotes": ["wave", "dance", "cheer", "taste", "bow"]
27 }
28 }
content/traps.json added +13
@@ -0,0 +1,13 @@
1 {
2 "$description": "Things in the floor. A trap is up for `active` ticks out of every `period`, and hurts whoever stands on it then, unless they are dodging.",
3 "traps": [
4 {
5 "id": "spikes",
6 "name": "Skewer Spikes",
7 "glyph": "^",
8 "damage": 1,
9 "period": 50,
10 "active": 16
11 }
12 ]
13 }
contract.json +40
@@ -16,6 +16,46 @@
1616 "list": "anon"
1717 }
1818 },
19 "avatar": {
20 "api": "/api/avatars",
21 "can": {
22 "create": "user",
23 "read": "anon",
24 "update": "author",
25 "delete": "author",
26 "list": "anon"
27 }
28 },
29 "delve": {
30 "api": "/api/delves",
31 "can": {
32 "create": "user",
33 "read": "anon",
34 "update": "nobody",
35 "delete": "nobody",
36 "list": "anon"
37 }
38 },
39 "hero": {
40 "api": "/api/heroes",
41 "can": {
42 "create": "user",
43 "read": "anon",
44 "update": "author",
45 "delete": "author",
46 "list": "anon"
47 }
48 },
49 "run": {
50 "api": "/api/runs",
51 "can": {
52 "create": "nobody",
53 "read": "anon",
54 "update": "nobody",
55 "delete": "admin",
56 "list": "anon"
57 }
58 },
1959 "audit": {
2060 "api": "/api/audit",
2161 "can": {
contract/openapi.json +970 −1
@@ -3,7 +3,7 @@
33 "info": {
44 "title": "cheffry-s-dungeon",
55 "version": "0.1.0",
6 "description": "Cheffry's Dungeon is a low-poly 3D multiplayer dungeon adventure engineered to feel massive on minimal infrastructure. A persistent social hub syncs only positions, emotes, and chat; dungeon delves run as small 4-player instances that scale horizontally; asynchronous systems — daily seeded runs, leaderboards, ghost data, player graffiti — deliver the massively-multiplayer feel without the server bill. Tick-based authoritative networking, fully data-driven content (rooms, monsters, traps, loot), and low-poly art keep bandwidth tiny and iteration fast. The v1 foundation: explorable hub, melee combat with dodge, three enemy types, loot chests, and party play — every system built content-pipeline-ready for rapid expansion."
6 "description": "A low-poly 3D dungeon you cook through with friends: a live hub, four-chef parties, melee with dodge, and one seeded dungeon a day with a leaderboard and ghosts to race."
77 },
88 "paths": {
99 "/api/notes": {
@@ -258,6 +258,748 @@
258258 }
259259 }
260260 },
261 "/api/avatars": {
262 "get": {
263 "summary": "Every chef in the hub.",
264 "operationId": "getApiAvatars",
265 "responses": {
266 "200": {
267 "description": "Every chef in the hub.",
268 "content": {
269 "application/json": {
270 "schema": {
271 "type": "array",
272 "items": {
273 "$ref": "#/components/schemas/Avatar"
274 }
275 }
276 }
277 }
278 },
279 "default": {
280 "description": "{ error, message }: why not",
281 "content": {
282 "application/json": {
283 "schema": {
284 "$ref": "#/components/schemas/Error"
285 }
286 }
287 }
288 }
289 },
290 "x-contract": {
291 "resource": "avatar",
292 "verb": "list",
293 "least": "anon"
294 }
295 },
296 "post": {
297 "summary": "Step into the hub as yourself (already there, you stay put).",
298 "operationId": "postApiAvatars",
299 "responses": {
300 "201": {
301 "description": "Step into the hub as yourself (already there, you stay put).",
302 "content": {
303 "application/json": {
304 "schema": {
305 "$ref": "#/components/schemas/Avatar"
306 }
307 }
308 }
309 },
310 "default": {
311 "description": "{ error, message }: why not",
312 "content": {
313 "application/json": {
314 "schema": {
315 "$ref": "#/components/schemas/Error"
316 }
317 }
318 }
319 }
320 },
321 "x-contract": {
322 "resource": "avatar",
323 "verb": "create",
324 "least": "user"
325 }
326 }
327 },
328 "/api/avatars/{id}": {
329 "get": {
330 "summary": "One chef in the hub.",
331 "operationId": "getApiAvatarsId",
332 "parameters": [
333 {
334 "name": "id",
335 "in": "path",
336 "required": true,
337 "schema": {
338 "type": "integer"
339 }
340 }
341 ],
342 "responses": {
343 "200": {
344 "description": "One chef in the hub.",
345 "content": {
346 "application/json": {
347 "schema": {
348 "$ref": "#/components/schemas/Avatar"
349 }
350 }
351 }
352 },
353 "default": {
354 "description": "{ error, message }: why not",
355 "content": {
356 "application/json": {
357 "schema": {
358 "$ref": "#/components/schemas/Error"
359 }
360 }
361 }
362 }
363 },
364 "x-contract": {
365 "resource": "avatar",
366 "verb": "read",
367 "least": "anon"
368 }
369 },
370 "patch": {
371 "summary": "Walk somewhere, emote, or say something.",
372 "operationId": "patchApiAvatarsId",
373 "parameters": [
374 {
375 "name": "id",
376 "in": "path",
377 "required": true,
378 "schema": {
379 "type": "integer"
380 }
381 }
382 ],
383 "requestBody": {
384 "required": true,
385 "content": {
386 "application/json": {
387 "schema": {
388 "type": "object",
389 "description": "Any of: where to walk (x and z together), an emote, words.",
390 "properties": {
391 "x": {
392 "type": "number"
393 },
394 "z": {
395 "type": "number"
396 },
397 "emote": {
398 "enum": [
399 "wave",
400 "dance",
401 "cheer",
402 "taste",
403 "bow"
404 ]
405 },
406 "say": {
407 "type": "string",
408 "minLength": 1,
409 "maxLength": 140
410 }
411 }
412 }
413 }
414 }
415 },
416 "responses": {
417 "200": {
418 "description": "Walk somewhere, emote, or say something.",
419 "content": {
420 "application/json": {
421 "schema": {
422 "$ref": "#/components/schemas/Avatar"
423 }
424 }
425 }
426 },
427 "default": {
428 "description": "{ error, message }: why not",
429 "content": {
430 "application/json": {
431 "schema": {
432 "$ref": "#/components/schemas/Error"
433 }
434 }
435 }
436 }
437 },
438 "x-contract": {
439 "resource": "avatar",
440 "verb": "update",
441 "least": "author"
442 }
443 },
444 "delete": {
445 "summary": "Leave the hub.",
446 "operationId": "deleteApiAvatarsId",
447 "parameters": [
448 {
449 "name": "id",
450 "in": "path",
451 "required": true,
452 "schema": {
453 "type": "integer"
454 }
455 }
456 ],
457 "responses": {
458 "200": {
459 "description": "Leave the hub.",
460 "content": {
461 "application/json": {
462 "schema": {
463 "type": "object",
464 "properties": {
465 "id": {
466 "type": "integer"
467 }
468 },
469 "required": [
470 "id"
471 ]
472 }
473 }
474 }
475 },
476 "default": {
477 "description": "{ error, message }: why not",
478 "content": {
479 "application/json": {
480 "schema": {
481 "$ref": "#/components/schemas/Error"
482 }
483 }
484 }
485 }
486 },
487 "x-contract": {
488 "resource": "avatar",
489 "verb": "delete",
490 "least": "author"
491 }
492 }
493 },
494 "/api/delves": {
495 "get": {
496 "summary": "The tables, newest first.",
497 "operationId": "getApiDelves",
498 "responses": {
499 "200": {
500 "description": "The tables, newest first.",
501 "content": {
502 "application/json": {
503 "schema": {
504 "type": "array",
505 "items": {
506 "$ref": "#/components/schemas/Delve"
507 }
508 }
509 }
510 }
511 },
512 "default": {
513 "description": "{ error, message }: why not",
514 "content": {
515 "application/json": {
516 "schema": {
517 "$ref": "#/components/schemas/Error"
518 }
519 }
520 }
521 }
522 },
523 "x-contract": {
524 "resource": "delve",
525 "verb": "list",
526 "least": "anon"
527 }
528 },
529 "post": {
530 "summary": "Open today's dungeon with yourself at the table.",
531 "operationId": "postApiDelves",
532 "responses": {
533 "201": {
534 "description": "Open today's dungeon with yourself at the table.",
535 "content": {
536 "application/json": {
537 "schema": {
538 "$ref": "#/components/schemas/Delve"
539 }
540 }
541 }
542 },
543 "default": {
544 "description": "{ error, message }: why not",
545 "content": {
546 "application/json": {
547 "schema": {
548 "$ref": "#/components/schemas/Error"
549 }
550 }
551 }
552 }
553 },
554 "x-contract": {
555 "resource": "delve",
556 "verb": "create",
557 "least": "user"
558 }
559 }
560 },
561 "/api/delves/{id}": {
562 "get": {
563 "summary": "One table. Its ticks stream at /delves/{id}/events.",
564 "operationId": "getApiDelvesId",
565 "parameters": [
566 {
567 "name": "id",
568 "in": "path",
569 "required": true,
570 "schema": {
571 "type": "integer"
572 }
573 }
574 ],
575 "responses": {
576 "200": {
577 "description": "One table. Its ticks stream at /delves/{id}/events.",
578 "content": {
579 "application/json": {
580 "schema": {
581 "$ref": "#/components/schemas/Delve"
582 }
583 }
584 }
585 },
586 "default": {
587 "description": "{ error, message }: why not",
588 "content": {
589 "application/json": {
590 "schema": {
591 "$ref": "#/components/schemas/Error"
592 }
593 }
594 }
595 }
596 },
597 "x-contract": {
598 "resource": "delve",
599 "verb": "read",
600 "least": "anon"
601 }
602 }
603 },
604 "/api/heroes": {
605 "get": {
606 "summary": "Every chef at every table.",
607 "operationId": "getApiHeroes",
608 "responses": {
609 "200": {
610 "description": "Every chef at every table.",
611 "content": {
612 "application/json": {
613 "schema": {
614 "type": "array",
615 "items": {
616 "$ref": "#/components/schemas/Hero"
617 }
618 }
619 }
620 }
621 },
622 "default": {
623 "description": "{ error, message }: why not",
624 "content": {
625 "application/json": {
626 "schema": {
627 "$ref": "#/components/schemas/Error"
628 }
629 }
630 }
631 }
632 },
633 "x-contract": {
634 "resource": "hero",
635 "verb": "list",
636 "least": "anon"
637 }
638 },
639 "post": {
640 "summary": "Take a seat at a table (four a table).",
641 "operationId": "postApiHeroes",
642 "requestBody": {
643 "required": true,
644 "content": {
645 "application/json": {
646 "schema": {
647 "type": "object",
648 "properties": {
649 "delve": {
650 "type": "integer"
651 }
652 },
653 "required": [
654 "delve"
655 ]
656 }
657 }
658 }
659 },
660 "responses": {
661 "201": {
662 "description": "Take a seat at a table (four a table).",
663 "content": {
664 "application/json": {
665 "schema": {
666 "$ref": "#/components/schemas/Hero"
667 }
668 }
669 }
670 },
671 "default": {
672 "description": "{ error, message }: why not",
673 "content": {
674 "application/json": {
675 "schema": {
676 "$ref": "#/components/schemas/Error"
677 }
678 }
679 }
680 }
681 },
682 "x-contract": {
683 "resource": "hero",
684 "verb": "create",
685 "least": "user"
686 }
687 }
688 },
689 "/api/heroes/{id}": {
690 "get": {
691 "summary": "One chef at a table.",
692 "operationId": "getApiHeroesId",
693 "parameters": [
694 {
695 "name": "id",
696 "in": "path",
697 "required": true,
698 "schema": {
699 "type": "integer"
700 }
701 }
702 ],
703 "responses": {
704 "200": {
705 "description": "One chef at a table.",
706 "content": {
707 "application/json": {
708 "schema": {
709 "$ref": "#/components/schemas/Hero"
710 }
711 }
712 }
713 },
714 "default": {
715 "description": "{ error, message }: why not",
716 "content": {
717 "application/json": {
718 "schema": {
719 "$ref": "#/components/schemas/Error"
720 }
721 }
722 }
723 }
724 },
725 "x-contract": {
726 "resource": "hero",
727 "verb": "read",
728 "least": "anon"
729 }
730 },
731 "patch": {
732 "summary": "Give your chef an order.",
733 "operationId": "patchApiHeroesId",
734 "parameters": [
735 {
736 "name": "id",
737 "in": "path",
738 "required": true,
739 "schema": {
740 "type": "integer"
741 }
742 }
743 ],
744 "requestBody": {
745 "required": true,
746 "content": {
747 "application/json": {
748 "schema": {
749 "type": "object",
750 "description": "move: walk to (x, z). attack: fight `target` (a monster's id), or swing where you face. dodge: roll toward (x, z). stop.",
751 "properties": {
752 "type": {
753 "enum": [
754 "move",
755 "attack",
756 "dodge",
757 "stop"
758 ]
759 },
760 "x": {
761 "type": "number"
762 },
763 "z": {
764 "type": "number"
765 },
766 "target": {
767 "type": "integer"
768 }
769 },
770 "required": [
771 "type"
772 ]
773 }
774 }
775 }
776 },
777 "responses": {
778 "200": {
779 "description": "Give your chef an order.",
780 "content": {
781 "application/json": {
782 "schema": {
783 "$ref": "#/components/schemas/Hero"
784 }
785 }
786 }
787 },
788 "default": {
789 "description": "{ error, message }: why not",
790 "content": {
791 "application/json": {
792 "schema": {
793 "$ref": "#/components/schemas/Error"
794 }
795 }
796 }
797 }
798 },
799 "x-contract": {
800 "resource": "hero",
801 "verb": "update",
802 "least": "author"
803 }
804 },
805 "delete": {
806 "summary": "Leave the table. The last chef out ends the delve.",
807 "operationId": "deleteApiHeroesId",
808 "parameters": [
809 {
810 "name": "id",
811 "in": "path",
812 "required": true,
813 "schema": {
814 "type": "integer"
815 }
816 }
817 ],
818 "responses": {
819 "200": {
820 "description": "Leave the table. The last chef out ends the delve.",
821 "content": {
822 "application/json": {
823 "schema": {
824 "type": "object",
825 "properties": {
826 "id": {
827 "type": "integer"
828 }
829 },
830 "required": [
831 "id"
832 ]
833 }
834 }
835 }
836 },
837 "default": {
838 "description": "{ error, message }: why not",
839 "content": {
840 "application/json": {
841 "schema": {
842 "$ref": "#/components/schemas/Error"
843 }
844 }
845 }
846 }
847 },
848 "x-contract": {
849 "resource": "hero",
850 "verb": "delete",
851 "least": "author"
852 }
853 }
854 },
855 "/api/runs": {
856 "get": {
857 "summary": "A day's leaderboard (today unless `day`), best first.",
858 "operationId": "getApiRuns",
859 "parameters": [
860 {
861 "name": "day",
862 "in": "query",
863 "schema": {
864 "type": "string",
865 "format": "date"
866 }
867 },
868 {
869 "name": "limit",
870 "in": "query",
871 "schema": {
872 "type": "integer",
873 "minimum": 1,
874 "maximum": 100,
875 "default": 20
876 }
877 }
878 ],
879 "responses": {
880 "200": {
881 "description": "A day's leaderboard (today unless `day`), best first.",
882 "content": {
883 "application/json": {
884 "schema": {
885 "type": "array",
886 "items": {
887 "$ref": "#/components/schemas/Run"
888 }
889 }
890 }
891 }
892 },
893 "default": {
894 "description": "{ error, message }: why not",
895 "content": {
896 "application/json": {
897 "schema": {
898 "$ref": "#/components/schemas/Error"
899 }
900 }
901 }
902 }
903 },
904 "x-contract": {
905 "resource": "run",
906 "verb": "list",
907 "least": "anon"
908 }
909 }
910 },
911 "/api/runs/{id}": {
912 "get": {
913 "summary": "One run, with its ghost.",
914 "operationId": "getApiRunsId",
915 "parameters": [
916 {
917 "name": "id",
918 "in": "path",
919 "required": true,
920 "schema": {
921 "type": "integer"
922 }
923 }
924 ],
925 "responses": {
926 "200": {
927 "description": "One run, with its ghost.",
928 "content": {
929 "application/json": {
930 "schema": {
931 "$ref": "#/components/schemas/Run"
932 }
933 }
934 }
935 },
936 "default": {
937 "description": "{ error, message }: why not",
938 "content": {
939 "application/json": {
940 "schema": {
941 "$ref": "#/components/schemas/Error"
942 }
943 }
944 }
945 }
946 },
947 "x-contract": {
948 "resource": "run",
949 "verb": "read",
950 "least": "anon"
951 }
952 },
953 "delete": {
954 "summary": "Strike a run from the board.",
955 "operationId": "deleteApiRunsId",
956 "parameters": [
957 {
958 "name": "id",
959 "in": "path",
960 "required": true,
961 "schema": {
962 "type": "integer"
963 }
964 }
965 ],
966 "responses": {
967 "200": {
968 "description": "Strike a run from the board.",
969 "content": {
970 "application/json": {
971 "schema": {
972 "type": "object",
973 "properties": {
974 "id": {
975 "type": "integer"
976 }
977 },
978 "required": [
979 "id"
980 ]
981 }
982 }
983 }
984 },
985 "default": {
986 "description": "{ error, message }: why not",
987 "content": {
988 "application/json": {
989 "schema": {
990 "$ref": "#/components/schemas/Error"
991 }
992 }
993 }
994 }
995 },
996 "x-contract": {
997 "resource": "run",
998 "verb": "delete",
999 "least": "admin"
1000 }
1001 }
1002 },
2611003 "/api/audit": {
2621004 "get": {
2631005 "summary": "The audit trail, newest first; `before` pages back by id.",
@@ -498,6 +1240,233 @@
4981240 "updated_at"
4991241 ]
5001242 },
1243 "Avatar": {
1244 "type": "object",
1245 "description": "A chef in the hub: walking from (x, z) to (tx, tz), set off `age` ms ago at the hub's speed.",
1246 "properties": {
1247 "id": {
1248 "type": "integer"
1249 },
1250 "author_id": {
1251 "type": "string"
1252 },
1253 "name": {
1254 "type": "string"
1255 },
1256 "x": {
1257 "type": "number"
1258 },
1259 "z": {
1260 "type": "number"
1261 },
1262 "tx": {
1263 "type": "number"
1264 },
1265 "tz": {
1266 "type": "number"
1267 },
1268 "age": {
1269 "type": "integer"
1270 },
1271 "emote": {
1272 "type": [
1273 "string",
1274 "null"
1275 ]
1276 },
1277 "emoteAge": {
1278 "type": "integer"
1279 },
1280 "say": {
1281 "type": [
1282 "string",
1283 "null"
1284 ]
1285 },
1286 "sayAge": {
1287 "type": "integer"
1288 }
1289 },
1290 "required": [
1291 "id",
1292 "author_id",
1293 "name",
1294 "x",
1295 "z",
1296 "tx",
1297 "tz",
1298 "age"
1299 ]
1300 },
1301 "Delve": {
1302 "type": "object",
1303 "properties": {
1304 "id": {
1305 "type": "integer"
1306 },
1307 "day": {
1308 "type": "string",
1309 "format": "date"
1310 },
1311 "author_id": {
1312 "type": "string"
1313 },
1314 "host_name": {
1315 "type": "string"
1316 },
1317 "party": {
1318 "type": "array",
1319 "maxItems": 4,
1320 "items": {
1321 "type": "object",
1322 "properties": {
1323 "id": {
1324 "type": "integer"
1325 },
1326 "author_id": {
1327 "type": "string"
1328 },
1329 "name": {
1330 "type": "string"
1331 }
1332 }
1333 }
1334 },
1335 "depth": {
1336 "type": "integer"
1337 },
1338 "rooms": {
1339 "type": "integer"
1340 },
1341 "room": {
1342 "type": "string"
1343 },
1344 "score": {
1345 "type": "integer"
1346 },
1347 "over": {
1348 "enum": [
1349 null,
1350 "victory",
1351 "wiped",
1352 "timeout",
1353 "abandoned"
1354 ]
1355 },
1356 "joinable": {
1357 "type": "boolean"
1358 }
1359 },
1360 "required": [
1361 "id",
1362 "day",
1363 "author_id",
1364 "host_name",
1365 "party",
1366 "depth",
1367 "rooms",
1368 "room",
1369 "score",
1370 "over",
1371 "joinable"
1372 ]
1373 },
1374 "Hero": {
1375 "type": "object",
1376 "properties": {
1377 "id": {
1378 "type": "integer"
1379 },
1380 "delve_id": {
1381 "type": "integer"
1382 },
1383 "author_id": {
1384 "type": "string"
1385 },
1386 "author_name": {
1387 "type": "string"
1388 }
1389 },
1390 "required": [
1391 "id",
1392 "delve_id",
1393 "author_id",
1394 "author_name"
1395 ]
1396 },
1397 "Run": {
1398 "type": "object",
1399 "properties": {
1400 "id": {
1401 "type": "integer"
1402 },
1403 "day": {
1404 "type": "string",
1405 "format": "date"
1406 },
1407 "seed": {
1408 "type": "integer"
1409 },
1410 "outcome": {
1411 "enum": [
1412 "victory",
1413 "wiped",
1414 "timeout",
1415 "abandoned"
1416 ]
1417 },
1418 "score": {
1419 "type": "integer"
1420 },
1421 "depth": {
1422 "type": "integer"
1423 },
1424 "kills": {
1425 "type": "integer"
1426 },
1427 "gold": {
1428 "type": "integer"
1429 },
1430 "ticks": {
1431 "type": "integer"
1432 },
1433 "crew": {
1434 "type": "array",
1435 "items": {
1436 "type": "string"
1437 }
1438 },
1439 "author_id": {
1440 "type": "string"
1441 },
1442 "author_name": {
1443 "type": "string"
1444 },
1445 "created_at": {
1446 "type": "string",
1447 "format": "date-time"
1448 },
1449 "ghost": {
1450 "type": "string",
1451 "description": "Where the lead chef was every 10 ticks: `floor,x*10,z*10;…`. Only on a single run."
1452 }
1453 },
1454 "required": [
1455 "id",
1456 "day",
1457 "seed",
1458 "outcome",
1459 "score",
1460 "depth",
1461 "kills",
1462 "gold",
1463 "ticks",
1464 "crew",
1465 "author_id",
1466 "author_name",
1467 "created_at"
1468 ]
1469 },
5011470 "AuditRow": {
5021471 "type": "object",
5031472 "properties": {
design/tokens.json +259 −35
@@ -3,63 +3,267 @@
33 "color": {
44 "$type": "color",
55 "bg": {
6 "$value": "#f6f6f3",
7 "$extensions": { "mode": { "dark": "#131312" } }
6 "$value": "#f7f1e6",
7 "$extensions": {
8 "mode": {
9 "dark": "#17120f"
10 }
11 }
812 },
913 "surface": {
10 "$value": "#ffffff",
11 "$extensions": { "mode": { "dark": "#1d1d1b" } }
14 "$value": "#fffaf2",
15 "$extensions": {
16 "mode": {
17 "dark": "#221b16"
18 }
19 }
1220 },
1321 "fg": {
14 "$value": "#1c1c1a",
15 "$extensions": { "mode": { "dark": "#ecece7" } }
22 "$value": "#221a14",
23 "$extensions": {
24 "mode": {
25 "dark": "#f1e8dc"
26 }
27 }
1628 },
1729 "muted": {
18 "$value": "#6b6b65",
19 "$extensions": { "mode": { "dark": "#9a9a93" } }
30 "$value": "#6b5a4b",
31 "$extensions": {
32 "mode": {
33 "dark": "#ab9c8d"
34 }
35 }
2036 },
2137 "line": {
22 "$value": "#e3e3dd",
23 "$extensions": { "mode": { "dark": "#2c2c29" } }
38 "$value": "#e6dccb",
39 "$extensions": {
40 "mode": {
41 "dark": "#352b23"
42 }
43 }
2444 },
2545 "accent": {
26 "$value": "#2f5bea",
27 "$extensions": { "mode": { "dark": "#7c96ff" } }
46 "$value": "#b3261e",
47 "$extensions": {
48 "mode": {
49 "dark": "#ff8a65"
50 }
51 }
2852 },
2953 "accent-fg": {
3054 "$value": "#ffffff",
31 "$extensions": { "mode": { "dark": "#0f1a4d" } }
55 "$extensions": {
56 "mode": {
57 "dark": "#2a0e05"
58 }
59 }
3260 },
3361 "danger": {
34 "$value": "#c2410c",
35 "$extensions": { "mode": { "dark": "#fb923c" } }
62 "$value": "#b3261e",
63 "$extensions": {
64 "mode": {
65 "dark": "#ff8a65"
66 }
67 }
3668 },
3769 "toast-bg": {
38 "$value": "#1c1c1a",
39 "$extensions": { "mode": { "dark": "#ecece7" } }
70 "$value": "#221a14",
71 "$extensions": {
72 "mode": {
73 "dark": "#f1e8dc"
74 }
75 }
4076 },
4177 "toast-fg": {
42 "$value": "#f6f6f3",
43 "$extensions": { "mode": { "dark": "#131312" } }
78 "$value": "#f7f1e6",
79 "$extensions": {
80 "mode": {
81 "dark": "#17120f"
82 }
83 }
4484 },
4585 "shadow": {
4686 "$value": "#0000002e",
47 "$extensions": { "mode": { "dark": "#00000080" } }
87 "$extensions": {
88 "mode": {
89 "dark": "#00000080"
90 }
91 }
92 },
93 "game-floor": {
94 "$value": "#e3d2b3",
95 "$extensions": {
96 "mode": {
97 "dark": "#4b3e32"
98 }
99 }
100 },
101 "game-floor-alt": {
102 "$value": "#d8c5a2",
103 "$extensions": {
104 "mode": {
105 "dark": "#43372c"
106 }
107 }
108 },
109 "game-wall": {
110 "$value": "#8a6548",
111 "$extensions": {
112 "mode": {
113 "dark": "#2f251e"
114 }
115 }
116 },
117 "game-hero": {
118 "$value": "#fbfaf5",
119 "$extensions": {
120 "mode": {
121 "dark": "#ece6da"
122 }
123 }
124 },
125 "game-apron": {
126 "$value": "#3a6ea5",
127 "$extensions": {
128 "mode": {
129 "dark": "#6f9fd6"
130 }
131 }
132 },
133 "game-meatball": {
134 "$value": "#9c4a2f",
135 "$extensions": {
136 "mode": {
137 "dark": "#b8603f"
138 }
139 }
140 },
141 "game-baguette": {
142 "$value": "#d9a441",
143 "$extensions": {
144 "mode": {
145 "dark": "#d19a38"
146 }
147 }
148 },
149 "game-pepper": {
150 "$value": "#d7322a",
151 "$extensions": {
152 "mode": {
153 "dark": "#e8483f"
154 }
155 }
156 },
157 "game-leaf": {
158 "$value": "#3f8f3a",
159 "$extensions": {
160 "mode": {
161 "dark": "#5cb356"
162 }
163 }
164 },
165 "game-seed": {
166 "$value": "#f2e3b3",
167 "$extensions": {
168 "mode": {
169 "dark": "#f2e3b3"
170 }
171 }
172 },
173 "game-chest": {
174 "$value": "#a8702a",
175 "$extensions": {
176 "mode": {
177 "dark": "#b98035"
178 }
179 }
180 },
181 "game-gold": {
182 "$value": "#f2c230",
183 "$extensions": {
184 "mode": {
185 "dark": "#f2c230"
186 }
187 }
188 },
189 "game-spike": {
190 "$value": "#9aa3ab",
191 "$extensions": {
192 "mode": {
193 "dark": "#b8c0c8"
194 }
195 }
196 },
197 "game-exit": {
198 "$value": "#4e8a52",
199 "$extensions": {
200 "mode": {
201 "dark": "#5fa864"
202 }
203 }
204 },
205 "game-door": {
206 "$value": "#6b4a33",
207 "$extensions": {
208 "mode": {
209 "dark": "#5a3e2b"
210 }
211 }
212 },
213 "game-water": {
214 "$value": "#5aa7d6",
215 "$extensions": {
216 "mode": {
217 "dark": "#3d7fa8"
218 }
219 }
220 },
221 "game-ghost": {
222 "$value": "#8fb8ff",
223 "$extensions": {
224 "mode": {
225 "dark": "#8fb8ff"
226 }
227 }
228 },
229 "game-sky": {
230 "$value": "#f1e6d2",
231 "$extensions": {
232 "mode": {
233 "dark": "#120e0b"
234 }
235 }
48236 }
49237 },
50238 "space": {
51239 "$type": "dimension",
52 "1": { "$value": "0.25rem" },
53 "2": { "$value": "0.5rem" },
54 "3": { "$value": "0.75rem" },
55 "4": { "$value": "1rem" },
56 "5": { "$value": "1.5rem" },
57 "6": { "$value": "2.5rem" }
240 "1": {
241 "$value": "0.25rem"
242 },
243 "2": {
244 "$value": "0.5rem"
245 },
246 "3": {
247 "$value": "0.75rem"
248 },
249 "4": {
250 "$value": "1rem"
251 },
252 "5": {
253 "$value": "1.5rem"
254 },
255 "6": {
256 "$value": "2.5rem"
257 }
58258 },
59259 "radius": {
60260 "$type": "dimension",
61 "md": { "$value": "10px" },
62 "pill": { "$value": "999px" }
261 "md": {
262 "$value": "10px"
263 },
264 "pill": {
265 "$value": "999px"
266 }
63267 },
64268 "size": {
65269 "$type": "dimension",
@@ -71,25 +275,45 @@
71275 "font": {
72276 "$type": "fontFamily",
73277 "sans": {
74 "$value": ["system-ui", "-apple-system", "Segoe UI", "sans-serif"]
278 "$value": [
279 "system-ui",
280 "-apple-system",
281 "Segoe UI",
282 "sans-serif"
283 ]
75284 },
76285 "mono": {
77 "$value": ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"]
286 "$value": [
287 "ui-monospace",
288 "SFMono-Regular",
289 "Menlo",
290 "monospace"
291 ]
78292 }
79293 },
80294 "text": {
81295 "$type": "dimension",
82 "sm": { "$value": "0.875rem" },
296 "sm": {
297 "$value": "0.875rem"
298 },
83299 "base": {
84300 "$value": "1rem",
85301 "$description": "Never below 16px: a phone zooms into a smaller input."
86302 },
87 "lg": { "$value": "1.125rem" },
88 "xl": { "$value": "1.5rem" }
303 "lg": {
304 "$value": "1.125rem"
305 },
306 "xl": {
307 "$value": "1.5rem"
308 }
89309 },
90310 "leading": {
91311 "$type": "number",
92 "body": { "$value": 1.55 },
93 "tight": { "$value": 1.2 }
312 "body": {
313 "$value": 1.55
314 },
315 "tight": {
316 "$value": 1.2
317 }
94318 }
95319 }
package.json +2 −1
@@ -1,7 +1,8 @@
11 {
22 "name": "cheffry-s-dungeon",
3 "displayName": "Cheffry's Dungeon",
34 "version": "0.1.0",
4 "description": "Cheffry's Dungeon is a low-poly 3D multiplayer dungeon adventure engineered to feel massive on minimal infrastructure. A persistent social hub syncs only positions, emotes, and chat; dungeon delves run as small 4-player instances that scale horizontally; asynchronous systems — daily seeded runs, leaderboards, ghost data, player graffiti — deliver the massively-multiplayer feel without the server bill. Tick-based authoritative networking, fully data-driven content (rooms, monsters, traps, loot), and low-poly art keep bandwidth tiny and iteration fast. The v1 foundation: explorable hub, melee combat with dodge, three enemy types, loot chests, and party play — every system built content-pipeline-ready for rapid expansion.",
5 "description": "A low-poly 3D dungeon you cook through with friends: a live hub, four-chef parties, melee with dodge, and one seeded dungeon a day with a leaderboard and ghosts to race.",
56 "private": true,
67 "type": "module",
78 "scripts": {
src/actions.ts +527 −1
@@ -8,8 +8,14 @@
88
99 import type { Database } from "bun:sqlite";
1010 import { record, type AuditRow } from "./audit.ts";
11 import { game } from "./content.ts";
1112 import { permit } from "./contract.ts";
12 import { emit } from "./live.ts";
13 import * as kitchen from "./delves.ts";
14 import type { Delve, DelveView, HeroRow, HeroView } from "./delves.ts";
15 import * as hub from "./hub.ts";
16 import { SAY_MAX, type Avatar, type AvatarView, type Line } from "./hub.ts";
17 import { emit, type Bus } from "./live.ts";
18 import { dayOf, type Order } from "./sim.ts";
1319 import type { Who } from "./who.ts";
1420
1521 export interface Note {
@@ -269,3 +275,523 @@ export function getAudit(
269275 return fail("invalid", "An audit id is a whole number.");
270276 return fail("not_found", "No such audit row.");
271277 }
278
279 // ── What a list shows ──
280
281 /**
282 * The rows a `list` answer lets `who` see: all of them for "yes", none for
283 * "no", and for a role a row holds, the rows whose named columns hold them.
284 */
285 function visible<T extends object>(
286 may: ReturnType<typeof permit>,
287 who: Who,
288 rows: readonly T[],
289 ): T[] {
290 if (may === "yes") return [...rows];
291 if (may === "no" || who.role === "anon") return [];
292 return rows.filter((row) =>
293 may.where.some(
294 (column) => (row as Record<string, unknown>)[column] === who.id,
295 ),
296 );
297 }
298
299 const finite = (n: unknown): n is number =>
300 typeof n === "number" && Number.isFinite(n);
301
302 // ── The hub: who is in the square, where they walk, what they say ──
303
304 export function listAvatars(db: Database, who: Who): Result<AvatarView[]> {
305 const may = permit(who, "avatar", "list");
306 if (may === "no")
307 return refuse(who, "Sign in to see the hub.", "You may not see the hub.");
308 const now = Date.now();
309 return ok(visible(may, who, hub.all().map((a) => hub.view(a, now))));
310 }
311
312 /** The hub's recent words, for whoever may see who is there. */
313 export function hubChat(db: Database, who: Who): Result<readonly Line[]> {
314 if (permit(who, "avatar", "list") === "no")
315 return refuse(who, "Sign in to see the hub.", "You may not see the hub.");
316 return ok(hub.chatLog());
317 }
318
319 /** The hub's stream, for whoever may see who is there. */
320 export function watchHub(db: Database, who: Who): Result<Bus> {
321 if (permit(who, "avatar", "list") === "no")
322 return refuse(who, "Sign in to see the hub.", "You may not see the hub.");
323 return ok(hub.hubBus);
324 }
325
326 function avatarFor(
327 who: Who,
328 verb: "read" | "update" | "delete",
329 id: number,
330 said: { signIn: string; no: string },
331 ): Result<Avatar> {
332 const avatar = Number.isInteger(id) ? hub.find(id) : undefined;
333 const row: Readonly<Record<string, unknown>> =
334 avatar === undefined
335 ? { author_id: who.role === "anon" ? null : who.id }
336 : { ...avatar };
337 if (permit(who, "avatar", verb, row) !== "yes")
338 return refuse(who, said.signIn, said.no);
339 if (avatar !== undefined) return ok(avatar);
340 if (!Number.isInteger(id))
341 return fail("invalid", "An avatar id is a whole number.");
342 return fail("not_found", "Nobody by that id is in the hub.");
343 }
344
345 export function getAvatar(
346 db: Database,
347 who: Who,
348 input: { id: number },
349 ): Result<AvatarView> {
350 const found = avatarFor(who, "read", input.id, {
351 signIn: "Sign in to see the hub.",
352 no: "You may not see the hub.",
353 });
354 return found.ok ? ok(hub.view(found.value, Date.now())) : found;
355 }
356
357 /** Step into the hub as yourself. Already there, you stay where you are. */
358 export const enterHub = (db: Database, who: Who): Result<AvatarView> =>
359 audited(db, who, "avatar", "create", undefined, enter(who));
360
361 function enter(who: Who): Result<AvatarView> {
362 if (who.role === "anon" || permit(who, "avatar", "create") !== "yes")
363 return refuse(who, "Sign in to enter the hub.", "You may not enter the hub.");
364 const now = Date.now();
365 const fresh = hub.ofAuthor(who.id) === undefined;
366 const avatar = hub.place(who.id, who.name, now);
367 const shown = hub.view(avatar, now);
368 hub.hubBus.emit("avatar", shown);
369 if (fresh) emit("hub.roster", { id: avatar.id });
370 return ok(shown);
371 }
372
373 export interface AvatarChange {
374 readonly id: number;
375 readonly x?: unknown;
376 readonly z?: unknown;
377 readonly emote?: unknown;
378 readonly say?: unknown;
379 }
380
381 /** Walk somewhere, strike a pose, or say something — any of them at once. */
382 export const updateAvatar = (
383 db: Database,
384 who: Who,
385 input: AvatarChange,
386 ): Result<AvatarView> =>
387 audited(db, who, "avatar", "update", input.id, changeAvatar(who, input));
388
389 function changeAvatar(who: Who, input: AvatarChange): Result<AvatarView> {
390 const found = avatarFor(who, "update", input.id, {
391 signIn: "Sign in to move in the hub.",
392 no: "That is someone else's chef.",
393 });
394 if (!found.ok) return found;
395 const walking = input.x !== undefined || input.z !== undefined;
396 if (walking && !(finite(input.x) && finite(input.z)))
397 return fail("invalid", "A place in the hub is two numbers, x and z.");
398 if (
399 input.emote !== undefined &&
400 !game.hub.emotes.includes(String(input.emote))
401 )
402 return fail("invalid", `An emote is one of ${game.hub.emotes.join(", ")}.`);
403 const words =
404 input.say === undefined ? undefined : String(input.say).trim();
405 if (words !== undefined && (words.length === 0 || words.length > SAY_MAX))
406 return fail("invalid", `Words in the hub are 1 to ${SAY_MAX} characters.`);
407 if (!walking && input.emote === undefined && words === undefined)
408 return fail("invalid", "Say where to walk, an emote, or something to say.");
409 const now = Date.now();
410 const avatar = found.value;
411 if (walking) hub.walk(avatar, input.x as number, input.z as number, now);
412 if (input.emote !== undefined) hub.emote(avatar, String(input.emote), now);
413 if (words !== undefined) {
414 hub.say(avatar, words, now);
415 emit("hub.chat", { id: avatar.id });
416 }
417 const shown = hub.view(avatar, now);
418 hub.hubBus.emit("avatar", shown);
419 return ok(shown);
420 }
421
422 export const leaveHub = (
423 db: Database,
424 who: Who,
425 input: { id: number },
426 ): Result<{ id: number }> =>
427 audited(db, who, "avatar", "delete", input.id, leave(who, input));
428
429 function leave(who: Who, input: { id: number }): Result<{ id: number }> {
430 const found = avatarFor(who, "delete", input.id, {
431 signIn: "Sign in to leave the hub.",
432 no: "That is someone else's chef.",
433 });
434 if (!found.ok) return found;
435 hub.remove(found.value.id);
436 hub.hubBus.emit("left", { id: found.value.id });
437 emit("hub.roster", { id: found.value.id });
438 return ok({ id: found.value.id });
439 }
440
441 /** Show out whoever has wandered off: the app's housekeeping, not anyone's act. */
442 export function sweepHub(now = Date.now()): number[] {
443 const gone = hub.sweep(now);
444 for (const id of gone) hub.hubBus.emit("left", { id });
445 if (gone.length > 0) emit("hub.roster", { gone });
446 return gone;
447 }
448
449 // ── Delves: today's dungeon, in parties of up to four ──
450
451 export function listDelves(db: Database, who: Who): Result<DelveView[]> {
452 const may = permit(who, "delve", "list");
453 if (may === "no")
454 return refuse(who, "Sign in to see the delves.", "You may not see the delves.");
455 return ok(
456 visible(
457 may,
458 who,
459 kitchen
460 .allDelves()
461 .sort((a, b) => b.id - a.id)
462 .map(kitchen.view),
463 ),
464 );
465 }
466
467 function delveFor(
468 who: Who,
469 id: number,
470 ): Result<Delve> {
471 const delve = Number.isInteger(id) ? kitchen.delveOf(id) : undefined;
472 const row: Readonly<Record<string, unknown>> =
473 delve === undefined
474 ? { author_id: who.role === "anon" ? null : who.id }
475 : { author_id: delve.host_id };
476 if (permit(who, "delve", "read", row) !== "yes")
477 return refuse(who, "Sign in to watch this delve.", "You may not watch this delve.");
478 if (delve !== undefined) return ok(delve);
479 if (!Number.isInteger(id)) return fail("invalid", "A delve id is a whole number.");
480 return fail("not_found", "That delve is over and cleared away, or never was.");
481 }
482
483 export function getDelve(
484 db: Database,
485 who: Who,
486 input: { id: number },
487 ): Result<DelveView> {
488 const found = delveFor(who, input.id);
489 return found.ok ? ok(kitchen.view(found.value)) : found;
490 }
491
492 /** A delve's tick stream, for whoever may watch it. */
493 export function watchDelve(
494 db: Database,
495 who: Who,
496 input: { id: number },
497 ): Result<Bus> {
498 const found = delveFor(who, input.id);
499 return found.ok ? ok(found.value.bus) : found;
500 }
501
502 /**
503 * Open today's dungeon with yourself at the table. Whoever is in another
504 * delve leaves it first: a chef cooks in one kitchen at a time.
505 */
506 export const openDelve = (db: Database, who: Who): Result<DelveView> =>
507 audited(db, who, "delve", "create", undefined, openOne(db, who));
508
509 function openOne(db: Database, who: Who): Result<DelveView> {
510 if (who.role === "anon" || permit(who, "delve", "create") !== "yes")
511 return refuse(who, "Sign in to start a delve.", "You may not start a delve.");
512 const now = Date.now();
513 const before = kitchen.heroOfAuthor(who.id);
514 if (before !== undefined) kitchen.dismiss(before, now);
515 const delve = kitchen.open(who, now, (d) => recordRun(db, d));
516 kitchen.enlist(delve, who, now);
517 emit("delve.opened", { id: delve.id });
518 return ok(kitchen.view(delve));
519 }
520
521 export function listHeroes(db: Database, who: Who): Result<HeroView[]> {
522 const may = permit(who, "hero", "list");
523 if (may === "no")
524 return refuse(who, "Sign in to see the heroes.", "You may not see the heroes.");
525 return ok(visible(may, who, kitchen.allHeroes().map(kitchen.heroView)));
526 }
527
528 function heroFor(
529 who: Who,
530 verb: "read" | "update" | "delete",
531 id: number,
532 said: { signIn: string; no: string },
533 ): Result<HeroRow> {
534 const hero = Number.isInteger(id) ? kitchen.heroOf(id) : undefined;
535 const row: Readonly<Record<string, unknown>> =
536 hero === undefined
537 ? { author_id: who.role === "anon" ? null : who.id }
538 : { ...hero };
539 if (permit(who, "hero", verb, row) !== "yes")
540 return refuse(who, said.signIn, said.no);
541 if (hero !== undefined) return ok(hero);
542 if (!Number.isInteger(id)) return fail("invalid", "A hero id is a whole number.");
543 return fail("not_found", "No such hero at any table.");
544 }
545
546 export function getHero(
547 db: Database,
548 who: Who,
549 input: { id: number },
550 ): Result<HeroView> {
551 const found = heroFor(who, "read", input.id, {
552 signIn: "Sign in to see this hero.",
553 no: "You may not see this hero.",
554 });
555 return found.ok ? ok(kitchen.heroView(found.value)) : found;
556 }
557
558 /** Join a party: take one of its four places, as yourself. */
559 export const joinDelve = (
560 db: Database,
561 who: Who,
562 input: { delve: number },
563 ): Result<HeroView> =>
564 audited(db, who, "hero", "create", undefined, join(who, input));
565
566 function join(who: Who, input: { delve: number }): Result<HeroView> {
567 if (who.role === "anon" || permit(who, "hero", "create") !== "yes")
568 return refuse(who, "Sign in to join a party.", "You may not join a party.");
569 if (!Number.isInteger(input.delve))
570 return fail("invalid", "A delve id is a whole number.");
571 const delve = kitchen.delveOf(input.delve);
572 if (delve === undefined || delve.ended !== null)
573 return fail("not_found", "That delve is over, or never was.");
574 const now = Date.now();
575 const before = kitchen.heroOfAuthor(who.id);
576 if (before?.delve_id === delve.id) return ok(kitchen.heroView(before));
577 if (!kitchen.joinable(delve))
578 return fail("invalid", `That party is full: ${kitchen.PARTY_MAX} chefs a table.`);
579 if (before !== undefined) kitchen.dismiss(before, now);
580 const hero = kitchen.enlist(delve, who, now);
581 emit("delve.changed", { id: delve.id });
582 return ok(kitchen.heroView(hero));
583 }
584
585 /** An order the sim can take, read from whatever the wire carried; a reason when it is not one. */
586 export function readOrder(
587 input: Record<string, unknown>,
588 bounds: { w: number; h: number },
589 ): Order | string {
590 const type = input.type;
591 const place = (): { x: number; z: number } | string =>
592 finite(input.x) &&
593 finite(input.z) &&
594 input.x >= 0 &&
595 input.z >= 0 &&
596 input.x <= bounds.w &&
597 input.z <= bounds.h
598 ? { x: input.x, z: input.z }
599 : `A place in this room is x from 0 to ${bounds.w} and z from 0 to ${bounds.h}.`;
600 if (type === "move" || type === "dodge") {
601 const at = place();
602 return typeof at === "string" ? at : { type, ...at };
603 }
604 if (type === "attack") {
605 if (input.target === undefined || input.target === null)
606 return { type: "attack" };
607 return Number.isInteger(input.target)
608 ? { type: "attack", target: input.target as number }
609 : "A target is a monster's id, a whole number.";
610 }
611 if (type === "stop") return { type: "stop" };
612 return "An order is move, attack, dodge or stop.";
613 }
614
615 /** Give your hero an order: move, attack, dodge or stop. */
616 export const commandHero = (
617 db: Database,
618 who: Who,
619 input: { id: number; order: Record<string, unknown> },
620 ): Result<HeroView> =>
621 audited(db, who, "hero", "update", input.id, command(who, input));
622
623 function command(
624 who: Who,
625 input: { id: number; order: Record<string, unknown> },
626 ): Result<HeroView> {
627 const found = heroFor(who, "update", input.id, {
628 signIn: "Sign in to play.",
629 no: "That is someone else's hero.",
630 });
631 if (!found.ok) return found;
632 const delve = kitchen.delveOf(found.value.delve_id);
633 if (delve === undefined || delve.ended !== null)
634 return fail("invalid", "That delve is over.");
635 const o = readOrder(input.order, delve.world.layout);
636 if (typeof o === "string") return fail("invalid", o);
637 kitchen.command(found.value, o, Date.now());
638 return ok(kitchen.heroView(found.value));
639 }
640
641 /** Leave the table. The last chef out ends the delve. */
642 export const leaveDelve = (
643 db: Database,
644 who: Who,
645 input: { id: number },
646 ): Result<{ id: number }> =>
647 audited(db, who, "hero", "delete", input.id, quit(who, input));
648
649 function quit(who: Who, input: { id: number }): Result<{ id: number }> {
650 const found = heroFor(who, "delete", input.id, {
651 signIn: "Sign in to leave.",
652 no: "That is someone else's hero.",
653 });
654 if (!found.ok) return found;
655 kitchen.dismiss(found.value, Date.now());
656 emit("delve.changed", { id: found.value.delve_id });
657 return ok({ id: found.value.id });
658 }
659
660 // ── Runs: the day's leaderboard, and the ghosts in it ──
661
662 export interface Run {
663 readonly id: number;
664 readonly day: string;
665 readonly seed: number;
666 readonly outcome: string;
667 readonly score: number;
668 readonly depth: number;
669 readonly kills: number;
670 readonly gold: number;
671 readonly ticks: number;
672 readonly crew: string[];
673 readonly author_id: string;
674 readonly author_name: string;
675 readonly created_at: string;
676 /** Where the lead chef was, every half second: `depth,x*10,z*10;…`. Only on a single run. */
677 readonly ghost?: string;
678 }
679 type RunRow = Omit<Run, "crew"> & { crew: string };
680 const RUN_COLUMNS =
681 "id, day, seed, outcome, score, depth, kills, gold, ticks, crew, author_id, author_name, created_at";
682 const runOf = (row: RunRow): Run => {
683 let crew: string[] = [];
684 try {
685 crew = JSON.parse(row.crew) as string[];
686 } catch {}
687 return { ...row, crew };
688 };
689
690 /** Today, as the daily seed spells it. */
691 export const today = (): string => dayOf(Date.now());
692 const isDay = (day: unknown): day is string =>
693 typeof day === "string" && /^\d{4}-\d{2}-\d{2}$/.test(day);
694
695 /** The leaderboard for a day (today unless said), best first. */
696 export function listRuns(
697 db: Database,
698 who: Who,
699 input: { day?: string; limit?: number } = {},
700 ): Result<Run[]> {
701 const may = permit(who, "run", "list");
702 if (may === "no")
703 return refuse(who, "Sign in to see the leaderboard.", "You may not see the leaderboard.");
704 if (input.day !== undefined && !isDay(input.day))
705 return fail("invalid", "A day is YYYY-MM-DD.");
706 const limit = Math.min(Math.max(Math.trunc(input.limit ?? 20) || 20, 1), 100);
707 const rows = db
708 .query<RunRow, [string, number]>(
709 `SELECT ${RUN_COLUMNS} FROM run WHERE day = ?1 ORDER BY score DESC, ticks ASC, id ASC LIMIT ?2`,
710 )
711 .all(input.day ?? today(), limit)
712 .map(runOf);
713 return ok(visible(may, who, rows));
714 }
715
716 function runFor(
717 db: Database,
718 who: Who,
719 verb: "read" | "delete",
720 id: number,
721 said: { signIn: string; no: string },
722 ): Result<Run> {
723 const row = Number.isInteger(id)
724 ? db.query<RunRow, [number]>(`SELECT ${RUN_COLUMNS}, ghost FROM run WHERE id = ?1`).get(id)
725 : null;
726 const probe: Readonly<Record<string, unknown>> =
727 row === null ? { author_id: who.role === "anon" ? null : who.id } : { ...row };
728 if (permit(who, "run", verb, probe) !== "yes") return refuse(who, said.signIn, said.no);
729 if (row !== null) return ok(runOf(row));
730 if (!Number.isInteger(id)) return fail("invalid", "A run id is a whole number.");
731 return fail("not_found", "No such run.");
732 }
733
734 /** One run, with its ghost. */
735 export function getRun(db: Database, who: Who, input: { id: number }): Result<Run> {
736 return runFor(db, who, "read", input.id, {
737 signIn: "Sign in to see this run.",
738 no: "You may not see this run.",
739 });
740 }
741
742 /** Strike a run from the board (a cheat, a bug): the table says who may. */
743 export const deleteRun = (
744 db: Database,
745 who: Who,
746 input: { id: number },
747 ): Result<{ id: number }> =>
748 audited(db, who, "run", "delete", input.id, strike(db, who, input));
749
750 function strike(db: Database, who: Who, input: { id: number }): Result<{ id: number }> {
751 const found = runFor(db, who, "delete", input.id, {
752 signIn: "Sign in to strike a run.",
753 no: "Only an admin can strike a run from the board.",
754 });
755 if (!found.ok) return found;
756 db.query("DELETE FROM run WHERE id = ?1").run(found.value.id);
757 emit("run.recorded", { id: found.value.id });
758 return ok({ id: found.value.id });
759 }
760
761 /**
762 * A delve is over: what it made goes on the board. The app writes this,
763 * not a person — the contract lets nobody create a run through a door —
764 * and a delve that got nowhere leaves nothing.
765 */
766 export function recordRun(db: Database, delve: Delve): Run | undefined {
767 const w = delve.world;
768 emit("delve.closed", { id: delve.id });
769 if (w.score === 0 && w.depth === 0) return undefined;
770 try {
771 const row = db
772 .query<RunRow, [string, number, string, number, number, number, number, number, string, string, string, string]>(
773 `INSERT INTO run (day, seed, outcome, score, depth, kills, gold, ticks, crew, author_id, author_name, ghost)
774 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) RETURNING ${RUN_COLUMNS}`,
775 )
776 .get(
777 delve.day,
778 delve.seed,
779 w.over ?? "abandoned",
780 w.score,
781 w.over === "victory" ? w.plan.length : w.depth,
782 w.kills,
783 w.gold,
784 w.tick,
785 JSON.stringify(delve.crew),
786 delve.host_id,
787 delve.host_name,
788 delve.ghost.join(";"),
789 )!;
790 const run = runOf(row);
791 emit("run.recorded", { id: run.id, score: run.score });
792 return run;
793 } catch (error) {
794 console.error(error);
795 return undefined;
796 }
797 }
src/assets.ts added +14
@@ -0,0 +1,14 @@
1 /**
2 * What the game's pages load besides static/: the sim itself, transpiled
3 * from `src/sim.ts` once at boot — so the page lays out a room with the
4 * very code the server steps it with — and linked by its content, so it is
5 * cached for a year and never stale.
6 */
7
8 import { createHash } from "node:crypto";
9 import { readFileSync } from "node:fs";
10
11 export const simJs: string = new Bun.Transpiler({ loader: "ts" }).transformSync(
12 readFileSync(new URL("./sim.ts", import.meta.url), "utf8"),
13 );
14 export const simHref = `/sim.js?v=${createHash("sha256").update(simJs).digest("hex").slice(0, 8)}`;
src/content.ts added +225
@@ -0,0 +1,225 @@
1 /**
2 * The game's content, as data: `content/*.json` — the rules, the monsters,
3 * the traps, the loot, the rooms and the hub's furniture — read once at
4 * boot and checked whole. A new monster, room or chest drop is a change to
5 * a JSON file, never to code (a new *brain* is code: `behavior` in
6 * `src/sim.ts`).
7 *
8 * Content that does not read is not a warning. The app does not start,
9 * and the error names every problem at once.
10 */
11
12 import { createHash } from "node:crypto";
13 import { readFileSync } from "node:fs";
14 import type { Content } from "./sim.ts";
15 import { tokens } from "./tokens.ts";
16
17 export interface HubRules {
18 readonly width: number;
19 readonly depth: number;
20 /** Tiles a second. */
21 readonly speed: number;
22 readonly idleMinutes: number;
23 readonly emotes: readonly string[];
24 }
25 export interface Prop {
26 readonly id: string;
27 readonly shape: "box" | "prism";
28 readonly x: number;
29 readonly z: number;
30 readonly w: number;
31 readonly h: number;
32 readonly d: number;
33 /** A colour token, without `--color-`. */
34 readonly paint: string;
35 }
36 export interface Game extends Content {
37 readonly hub: HubRules & { readonly props: readonly Prop[] };
38 }
39
40 const BEHAVIORS = ["chase", "charge", "shoot"];
41 const RESERVED = "#.SEC";
42
43 const isObject = (v: unknown): v is Record<string, unknown> =>
44 typeof v === "object" && v !== null && !Array.isArray(v);
45 const positive = (v: unknown): boolean =>
46 typeof v === "number" && Number.isFinite(v) && v > 0;
47 const whole = (v: unknown): boolean => Number.isInteger(v) && (v as number) >= 0;
48
49 /** Check the files' parsed JSON; every problem, or none. */
50 export function check(files: {
51 rules: unknown;
52 monsters: unknown;
53 traps: unknown;
54 loot: unknown;
55 rooms: unknown;
56 hub: unknown;
57 }): string[] {
58 const problems: string[] = [];
59 const say = (where: string, what: string) => problems.push(`${where}: ${what}`);
60 const need = (where: string, obj: Record<string, unknown>, keys: string[], test = positive) => {
61 for (const key of keys)
62 if (!test(obj[key])) say(where, `${key} must be a positive number`);
63 };
64
65 const rules = isObject(files.rules) ? files.rules : {};
66 need("rules", rules, ["tickHz", "roomsPerRun", "maxTicks"]);
67 const hero = isObject(rules.hero) ? rules.hero : {};
68 need("rules.hero", hero, [
69 "hp", "radius", "speed", "damage", "reach", "swing", "cooldown",
70 "dodgeSpeed", "dodgeTicks", "dodgeCooldown", "invuln", "revive",
71 ]);
72 need("rules.score", isObject(rules.score) ? rules.score : {}, ["room", "victory"], whole);
73 const hub = isObject(rules.hub) ? rules.hub : {};
74 need("rules.hub", hub, ["width", "depth", "speed", "idleMinutes"]);
75 if (
76 !Array.isArray(hub.emotes) ||
77 hub.emotes.length === 0 ||
78 !hub.emotes.every((e) => typeof e === "string" && /^[a-z]{1,16}$/.test(e))
79 )
80 say("rules.hub", "emotes must be a list of short lowercase words");
81
82 const glyphs = new Map<string, string>();
83 const claim = (where: string, glyph: unknown) => {
84 if (typeof glyph !== "string" || glyph.length !== 1 || RESERVED.includes(glyph))
85 say(where, `glyph must be one character, not one of ${RESERVED}`);
86 else if (glyphs.has(glyph)) say(where, `glyph ${glyph} is already ${glyphs.get(glyph)}`);
87 else glyphs.set(glyph, where);
88 };
89 const ids = (where: string, list: unknown[]) => {
90 const seen = new Set<unknown>();
91 list.forEach((item, i) => {
92 const id = isObject(item) ? item.id : undefined;
93 if (typeof id !== "string" || !/^[a-z0-9-]{1,32}$/.test(id))
94 say(`${where}[${i}]`, "id must be a short lowercase name");
95 else if (seen.has(id)) say(`${where}[${i}]`, `id ${id} appears twice`);
96 seen.add(id);
97 if (isObject(item) && (typeof item.name !== "string" || item.name === ""))
98 say(`${where}[${i}]`, "name is missing");
99 });
100 };
101 const listOf = (file: unknown, key: string): unknown[] => {
102 const list = isObject(file) ? file[key] : undefined;
103 if (!Array.isArray(list) || list.length === 0) {
104 say(key, "must be a non-empty list");
105 return [];
106 }
107 return list;
108 };
109
110 const monsters = listOf(files.monsters, "monsters");
111 ids("monsters", monsters);
112 monsters.forEach((m, i) => {
113 const where = `monsters[${i}]`;
114 if (!isObject(m)) return;
115 claim(where, m.glyph);
116 if (!BEHAVIORS.includes(String(m.behavior)))
117 say(where, `behavior must be one of ${BEHAVIORS.join(", ")}`);
118 need(where, m, ["hp", "radius", "speed", "damage", "aggro"]);
119 need(where, m, ["score"], whole);
120 if (m.behavior === "charge") need(where, m, ["windup", "chargeSpeed", "chargeTicks", "stun"]);
121 if (m.behavior === "shoot") need(where, m, ["range", "keepAway", "cooldown", "shotSpeed"]);
122 });
123
124 const traps = listOf(files.traps, "traps");
125 ids("traps", traps);
126 traps.forEach((t, i) => {
127 const where = `traps[${i}]`;
128 if (!isObject(t)) return;
129 claim(where, t.glyph);
130 need(where, t, ["damage", "period", "active"]);
131 if (positive(t.active) && positive(t.period) && (t.active as number) >= (t.period as number))
132 say(where, "active must be shorter than period");
133 });
134
135 const loot = listOf(files.loot, "loot");
136 ids("loot", loot);
137 loot.forEach((l, i) => {
138 if (!isObject(l)) return;
139 need(`loot[${i}]`, l, ["weight"]);
140 for (const key of ["gold", "heal", "power"])
141 if (l[key] !== undefined && !positive(l[key]))
142 say(`loot[${i}]`, `${key} must be a positive number when given`);
143 });
144
145 const rooms = listOf(files.rooms, "rooms");
146 ids("rooms", rooms);
147 let finals = 0;
148 rooms.forEach((r, i) => {
149 const where = `rooms[${i}]`;
150 if (!isObject(r)) return;
151 if (r.final === true) finals++;
152 else if (
153 r.depth !== undefined &&
154 !(Array.isArray(r.depth) && r.depth.length === 2 && r.depth.every(whole) && r.depth[0] <= r.depth[1])
155 )
156 say(where, "depth must be [first, last] floors");
157 const map = r.map;
158 if (!Array.isArray(map) || map.length < 3 || !map.every((row) => typeof row === "string")) {
159 say(where, "map must be at least three rows of text");
160 return;
161 }
162 const rows = map as string[];
163 const w = rows[0]!.length;
164 if (w < 3 || !rows.every((row) => row.length === w)) say(where, "map rows must all be the same length");
165 let starts = 0;
166 let exits = 0;
167 rows.forEach((row, z) =>
168 [...row].forEach((g, x) => {
169 if (g === "S") starts++;
170 if (g === "E") exits++;
171 if (!RESERVED.includes(g) && !glyphs.has(g)) say(where, `unknown glyph ${g} at row ${z}`);
172 const edge = z === 0 || z === rows.length - 1 || x === 0 || x === w - 1;
173 if (edge && g !== "#") say(where, `the edge must be wall (${g} at row ${z}, column ${x})`);
174 }),
175 );
176 if (starts !== 1 || exits !== 1) say(where, "needs exactly one S and one E");
177 });
178 if (finals === 0) say("rooms", "at least one room must be final");
179
180 const paints = new Set(
181 tokens.filter((t) => t.name.startsWith("--color-")).map((t) => t.name.slice(8)),
182 );
183 const props = listOf(files.hub, "props");
184 ids("props", props.map((p) => (isObject(p) ? { ...p, name: p.id } : p)));
185 props.forEach((p, i) => {
186 const where = `props[${i}]`;
187 if (!isObject(p)) return;
188 if (p.shape !== "box" && p.shape !== "prism") say(where, "shape must be box or prism");
189 need(where, p, ["x", "z", "w", "h", "d"]);
190 if (!paints.has(String(p.paint))) say(where, `paint ${String(p.paint)} is not a colour token`);
191 });
192 return problems;
193 }
194
195 const read = (name: string): unknown =>
196 JSON.parse(readFileSync(new URL(`../content/${name}.json`, import.meta.url), "utf8"));
197
198 function load(): Game {
199 const files = {
200 rules: read("rules"),
201 monsters: read("monsters"),
202 traps: read("traps"),
203 loot: read("loot"),
204 rooms: read("rooms"),
205 hub: read("hub"),
206 };
207 const problems = check(files);
208 if (problems.length > 0) throw new Error(`content/: ${problems.join("; ")}`);
209 const f = files as Record<string, Record<string, unknown>>;
210 const { hub, ...rules } = f.rules as unknown as Game["rules"] & { hub: HubRules };
211 return {
212 rules,
213 monsters: f.monsters!.monsters as Game["monsters"],
214 traps: f.traps!.traps as Game["traps"],
215 loot: f.loot!.loot as Game["loot"],
216 rooms: f.rooms!.rooms as Game["rooms"],
217 hub: { ...hub, props: f.hub!.props as Prop[] },
218 };
219 }
220
221 export const game: Game = load();
222
223 /** The content as the page fetches it: one file, linked by its content, cached for a year. */
224 export const gameJson: string = JSON.stringify(game);
225 export const gameHref = `/content.json?v=${createHash("sha256").update(gameJson).digest("hex").slice(0, 8)}`;
src/contract.test.ts +70
@@ -10,17 +10,36 @@ import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import {
1212 addNote,
13 commandHero,
1314 deleteNote,
15 deleteRun,
16 enterHub,
1417 getAudit,
18 getAvatar,
19 getDelve,
20 getHero,
1521 getNote,
22 getRun,
23 joinDelve,
24 leaveDelve,
25 leaveHub,
1626 listAudit,
27 listAvatars,
28 listDelves,
29 listHeroes,
1730 listNotes,
31 listRuns,
32 openDelve,
33 today,
34 updateAvatar,
1835 updateNote,
1936 } from "./actions.ts";
2037 import { record } from "./audit.ts";
2138 import { actingAs, identities, table, type Verb } from "./can.ts";
2239 import { contract } from "./contract.ts";
2340 import { openDb } from "./db.ts";
41 import * as kitchen from "./delves.ts";
42 import * as hub from "./hub.ts";
2443 import type { Who } from "./who.ts";
2544
2645 const db = openDb(mkdtempSync(join(tmpdir(), "app-contract-")));
@@ -53,6 +72,24 @@ const auditRow = (): number => {
5372 .id;
5473 };
5574
75 const avatarBy = (authorId: string): number =>
76 hub.place(authorId, authorId, Date.now()).id;
77
78 const delveBy = (authorId: string): number =>
79 kitchen.open({ id: authorId, name: authorId }, Date.now(), () => {}).id;
80
81 const heroBy = (authorId: string): number => {
82 const delve = kitchen.delveOf(delveBy(`host-of-${authorId}`))!;
83 return kitchen.enlist(delve, { id: authorId, name: authorId }, Date.now()).id;
84 };
85
86 const runBy = (authorId: string): number =>
87 db
88 .query<{ id: number }, [string, string]>(
89 "INSERT INTO run (day, seed, outcome, score, depth, kills, gold, ticks, crew, author_id, author_name, ghost) VALUES (?1, 1, 'wiped', 10, 0, 1, 0, 100, '[]', ?2, ?2, '') RETURNING id",
90 )
91 .get(today(), authorId)!.id;
92
5693 /** One door per verb an action serves: did it let `who` through, on `row`? */
5794 const doors: Record<
5895 string,
@@ -66,6 +103,39 @@ const doors: Record<
66103 updateNote(db, who, { id: noteBy(authorId), body: "changed" }).ok,
67104 delete: (who, authorId) => deleteNote(db, who, { id: noteBy(authorId) }).ok,
68105 },
106 avatar: {
107 create: (who) => enterHub(db, who).ok,
108 list: (who) => listAvatars(db, who).ok,
109 read: (who, authorId) => getAvatar(db, who, { id: avatarBy(authorId) }).ok,
110 update: (who, authorId) =>
111 updateAvatar(db, who, { id: avatarBy(authorId), emote: "wave" }).ok,
112 delete: (who, authorId) => leaveHub(db, who, { id: avatarBy(authorId) }).ok,
113 },
114 // A delve is changed by its tick, not by a person: no action updates or
115 // removes one, and the table says nobody may.
116 delve: {
117 create: (who) => openDelve(db, who).ok,
118 list: (who) => listDelves(db, who).ok,
119 read: (who, authorId) => getDelve(db, who, { id: delveBy(authorId) }).ok,
120 update: () => false,
121 delete: () => false,
122 },
123 hero: {
124 create: (who) => joinDelve(db, who, { delve: delveBy("some-host") }).ok,
125 list: (who) => listHeroes(db, who).ok,
126 read: (who, authorId) => getHero(db, who, { id: heroBy(authorId) }).ok,
127 update: (who, authorId) =>
128 commandHero(db, who, { id: heroBy(authorId), order: { type: "stop" } }).ok,
129 delete: (who, authorId) => leaveDelve(db, who, { id: heroBy(authorId) }).ok,
130 },
131 // A run is written by the app when a delve ends, never through a door.
132 run: {
133 list: (who) => listRuns(db, who).ok,
134 read: (who, authorId) => getRun(db, who, { id: runBy(authorId) }).ok,
135 delete: (who, authorId) => deleteRun(db, who, { id: runBy(authorId) }).ok,
136 create: () => false,
137 update: () => false,
138 },
69139 // The trail is written by the app itself, never through a door: there
70140 // is no action that creates, changes or removes a row of it.
71141 audit: {
src/db.ts +19
@@ -38,6 +38,25 @@ export const migrations: string[] = [
3838 allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)),
3939 outcome TEXT NOT NULL
4040 )`,
41 // A finished delve: the day's leaderboard, and the ghost the next party
42 // races. Written by the app when a delve ends (src/actions.ts recordRun).
43 `CREATE TABLE run (
44 id INTEGER PRIMARY KEY,
45 day TEXT NOT NULL CHECK (length(day) = 10),
46 seed INTEGER NOT NULL,
47 outcome TEXT NOT NULL,
48 score INTEGER NOT NULL,
49 depth INTEGER NOT NULL,
50 kills INTEGER NOT NULL,
51 gold INTEGER NOT NULL,
52 ticks INTEGER NOT NULL,
53 crew TEXT NOT NULL,
54 author_id TEXT NOT NULL,
55 author_name TEXT NOT NULL,
56 ghost TEXT NOT NULL,
57 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
58 )`,
59 `CREATE INDEX run_board ON run (day, score DESC, ticks)`,
4160 ];
4261
4362 export function openDb(dir = dataDir): Database {
src/delves.ts added +257
@@ -0,0 +1,257 @@
1 /**
2 * Delves: the running dungeon instances, each a party of one to four and a
3 * world from `src/sim.ts`, ticking twenty times a second on this process.
4 * An instance is everything its party needs and nothing another needs —
5 * its own world, its own stream of ticks, its own clock — so instances
6 * share nothing, and more of them is only more of the same loop (the
7 * boundary another process, or another machine, would take a share at).
8 *
9 * The server is the authority: players send orders, the instance steps,
10 * and every watcher is told the result ten times a second. What a delve
11 * leaves behind — the run, its score, its ghost — is written by whoever
12 * opened it (`onEnd`), the actions, into the database.
13 *
14 * Who may do what here is the actions' business; this is the kitchen.
15 */
16
17 import { game } from "./content.ts";
18 import { bus, type Bus } from "./live.ts";
19 import {
20 addHero,
21 createWorld,
22 daySeed,
23 dayOf,
24 finish,
25 order,
26 removeHero,
27 snapshot,
28 step,
29 type Order,
30 type World,
31 } from "./sim.ts";
32
33 export const PARTY_MAX = 4;
34 /** Ticks between ghost samples: a ghost is where the lead chef was, twice a second. */
35 export const GHOST_EVERY = 10;
36 /** A tick goes out to watchers every this many. */
37 const SEND_EVERY = 2;
38 const TICK_MS = 1000 / game.rules.tickHz;
39 /** A chef who gives no order for this long has left the table. */
40 export const IDLE_MS = 3 * 60_000;
41 /** A finished delve stays to be looked at for this long. */
42 const LINGER_MS = 5 * 60_000;
43
44 export interface HeroRow {
45 readonly id: number;
46 readonly delve_id: number;
47 readonly author_id: string;
48 readonly author_name: string;
49 seen: number;
50 }
51
52 export interface Delve {
53 readonly id: number;
54 readonly day: string;
55 readonly seed: number;
56 readonly host_id: string;
57 readonly host_name: string;
58 readonly started: number;
59 readonly world: World;
60 readonly bus: Bus;
61 /** `depth,x*10,z*10` of the lead chef, every GHOST_EVERY ticks. */
62 readonly ghost: string[];
63 /** Everyone who ever cooked here, by name. */
64 readonly crew: string[];
65 ended: number | null;
66 readonly onEnd: (delve: Delve) => void;
67 }
68
69 /** A delve as the page and the API show it. */
70 export interface DelveView {
71 readonly id: number;
72 readonly day: string;
73 readonly author_id: string;
74 readonly host_name: string;
75 readonly party: readonly { id: number; author_id: string; name: string }[];
76 readonly depth: number;
77 readonly rooms: number;
78 readonly room: string;
79 readonly score: number;
80 readonly over: string | null;
81 readonly joinable: boolean;
82 }
83
84 export interface HeroView {
85 readonly id: number;
86 readonly delve_id: number;
87 readonly author_id: string;
88 readonly author_name: string;
89 }
90
91 const delves = new Map<number, Delve>();
92 const heroes = new Map<number, HeroRow>();
93 let nextDelve = 1;
94 let nextHero = 1;
95 let loop: ReturnType<typeof setInterval> | undefined;
96
97 export const allDelves = (): Delve[] => [...delves.values()];
98 export const delveOf = (id: number): Delve | undefined => delves.get(id);
99 export const heroOf = (id: number): HeroRow | undefined => heroes.get(id);
100 export const allHeroes = (): HeroRow[] => [...heroes.values()];
101 export const partyOf = (delve: Delve): HeroRow[] =>
102 allHeroes().filter((h) => h.delve_id === delve.id);
103 /** The hero this person is playing right now, in a delve still going. */
104 export const heroOfAuthor = (authorId: string): HeroRow | undefined =>
105 allHeroes().find(
106 (h) => h.author_id === authorId && delves.get(h.delve_id)?.ended === null,
107 );
108 export const joinable = (delve: Delve): boolean =>
109 delve.ended === null && partyOf(delve).length < PARTY_MAX;
110
111 export const view = (d: Delve): DelveView => ({
112 id: d.id,
113 day: d.day,
114 author_id: d.host_id,
115 host_name: d.host_name,
116 party: partyOf(d).map((h) => ({
117 id: h.id,
118 author_id: h.author_id,
119 name: h.author_name,
120 })),
121 depth: d.world.depth,
122 rooms: d.world.plan.length,
123 room: d.world.plan[d.world.depth]!,
124 score: d.world.score,
125 over: d.world.over,
126 joinable: joinable(d),
127 });
128
129 export const heroView = (h: HeroRow): HeroView => ({
130 id: h.id,
131 delve_id: h.delve_id,
132 author_id: h.author_id,
133 author_name: h.author_name,
134 });
135
136 /** Open today's dungeon for a party. `onEnd` is told once, when it is over. */
137 export function open(
138 host: { id: string; name: string },
139 now: number,
140 onEnd: (delve: Delve) => void,
141 ): Delve {
142 const day = dayOf(now);
143 const seed = daySeed(day);
144 const delve: Delve = {
145 id: nextDelve++,
146 day,
147 seed,
148 host_id: host.id,
149 host_name: host.name,
150 started: now,
151 world: createWorld(game, seed),
152 // The last tick is kept, so whoever tunes in sees the room at once.
153 bus: bus(1),
154 ghost: [],
155 crew: [],
156 ended: null,
157 onEnd,
158 };
159 delves.set(delve.id, delve);
160 loop ??= setInterval(() => run(Date.now()), TICK_MS);
161 loop.unref?.();
162 return delve;
163 }
164
165 /** A chef takes a place at the table. */
166 export function enlist(
167 delve: Delve,
168 who: { id: string; name: string },
169 now: number,
170 ): HeroRow {
171 const row: HeroRow = {
172 id: nextHero++,
173 delve_id: delve.id,
174 author_id: who.id,
175 author_name: who.name,
176 seen: now,
177 };
178 heroes.set(row.id, row);
179 addHero(game, delve.world, row.id, who.name);
180 if (!delve.crew.includes(who.name)) delve.crew.push(who.name);
181 send(delve);
182 return row;
183 }
184
185 /** A chef leaves. The last one out ends the delve. */
186 export function dismiss(row: HeroRow, now: number): void {
187 heroes.delete(row.id);
188 const delve = delves.get(row.delve_id);
189 if (delve === undefined) return;
190 removeHero(delve.world, row.id);
191 if (delve.ended === null && delve.world.heroes.length === 0) {
192 finish(delve.world, "abandoned");
193 end(delve, now);
194 } else send(delve);
195 }
196
197 export function command(row: HeroRow, o: Order, now: number): void {
198 const delve = delves.get(row.delve_id);
199 if (delve === undefined || delve.ended !== null) return;
200 row.seen = now;
201 order(delve.world, row.id, o);
202 }
203
204 const send = (delve: Delve): void => {
205 delve.bus.emit("tick", snapshot(delve.world));
206 };
207
208 function end(delve: Delve, now: number): void {
209 if (delve.ended !== null) return;
210 delve.ended = now;
211 send(delve);
212 try {
213 delve.onEnd(delve);
214 } catch (error) {
215 console.error(error);
216 }
217 }
218
219 /** One tick of one delve. */
220 export function tick(delve: Delve, now: number): void {
221 if (delve.ended !== null) return;
222 const w = delve.world;
223 step(game, w);
224 const lead = w.heroes[0];
225 if (w.tick % GHOST_EVERY === 0 && lead !== undefined)
226 delve.ghost.push(
227 `${w.depth},${Math.round(lead.x * 10)},${Math.round(lead.z * 10)}`,
228 );
229 if (w.over !== null) end(delve, now);
230 else if (w.tick % SEND_EVERY === 0) send(delve);
231 }
232
233 /**
234 * The loop: every delve catches up to its clock (a few ticks at most, so a
235 * stall never turns into a stampede), idle chefs are shown out, and
236 * finished delves are cleared away after a while.
237 */
238 export function run(now: number): void {
239 for (const delve of delves.values()) {
240 if (delve.ended !== null) {
241 if (now - delve.ended > LINGER_MS) {
242 delve.bus.close();
243 delves.delete(delve.id);
244 for (const h of partyOf(delve)) heroes.delete(h.id);
245 }
246 continue;
247 }
248 for (const h of partyOf(delve)) if (now - h.seen > IDLE_MS) dismiss(h, now);
249 const due = Math.floor((now - delve.started) / TICK_MS) - delve.world.tick;
250 for (let i = 0; i < Math.min(due, 5) && delve.ended === null; i++)
251 tick(delve, now);
252 }
253 if (delves.size === 0 && loop !== undefined) {
254 clearInterval(loop);
255 loop = undefined;
256 }
257 }
src/env.ts +3
@@ -7,6 +7,9 @@
77 import pkg from "../package.json";
88
99 export const title: string = pkg.name;
10 /** The name a person reads: package.json's `displayName`, or the name. */
11 export const displayName: string =
12 (pkg as { displayName?: string }).displayName ?? pkg.name;
1013 /** The app's own version (package.json): what `/healthz` and `/openapi.json` say. */
1114 export const version: string = pkg.version ?? "0.0.0";
1215 /** When this process started, for `/healthz`'s uptime. */
src/game.test.ts added +191
@@ -0,0 +1,191 @@
1 /**
2 * The game's actions, for what the contract cannot say: one avatar a
3 * person, words and places checked, four chefs a table, one table a chef,
4 * orders that make sense, and a finished delve on the board with its ghost.
5 */
6 import { expect, test } from "bun:test";
7 import { mkdtempSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import {
11 commandHero,
12 enterHub,
13 getRun,
14 hubChat,
15 joinDelve,
16 leaveDelve,
17 listRuns,
18 openDelve,
19 readOrder,
20 recordRun,
21 sweepHub,
22 today,
23 updateAvatar,
24 } from "./actions.ts";
25 import { game } from "./content.ts";
26 import { openDb } from "./db.ts";
27 import * as kitchen from "./delves.ts";
28 import * as hub from "./hub.ts";
29 import type { Who } from "./who.ts";
30
31 const db = openDb(mkdtempSync(join(tmpdir(), "app-game-")));
32 let n = 0;
33 const chef = (): Who & { id: string } => {
34 const id = `g${++n}`;
35 return { role: "user", id, name: `chef${n}` };
36 };
37 const anon: Who = { role: "anon" };
38 const failed = (r: { ok: boolean; error?: string }) => (r.ok ? "ok" : r.error);
39
40 test("one avatar a person: entering again finds you where you stand", () => {
41 const me = chef();
42 const first = enterHub(db, me);
43 const again = enterHub(db, me);
44 expect(first.ok && again.ok).toBe(true);
45 if (!first.ok || !again.ok) return;
46 expect(again.value.id).toBe(first.value.id);
47 expect(failed(enterHub(db, anon))).toBe("signin_required");
48 });
49
50 test("a walk is two numbers, and the square's edge stops it", () => {
51 const me = chef();
52 const entered = enterHub(db, me);
53 if (!entered.ok) throw new Error(entered.message);
54 const id = entered.value.id;
55 expect(failed(updateAvatar(db, me, { id, x: "far", z: 2 }))).toBe("invalid");
56 expect(failed(updateAvatar(db, me, { id, x: Infinity, z: 2 }))).toBe("invalid");
57 expect(failed(updateAvatar(db, me, { id, x: 3 }))).toBe("invalid");
58 expect(failed(updateAvatar(db, me, { id }))).toBe("invalid");
59 const walked = updateAvatar(db, me, { id, x: 1e6, z: -50 });
60 expect(walked.ok).toBe(true);
61 if (walked.ok) {
62 expect(walked.value.tx).toBe(game.hub.width - 0.5);
63 expect(walked.value.tz).toBe(0.5);
64 }
65 // Someone else's chef is not yours to steer.
66 expect(failed(updateAvatar(db, chef(), { id, x: 2, z: 2 }))).toBe("forbidden");
67 });
68
69 test("a walk arrives at the hub's speed", () => {
70 const a = hub.place("walker", "walker", 0);
71 hub.walk(a, a.x - game.hub.speed * 2, a.z, 0);
72 const start = a.x;
73 expect(hub.positionAt(a, 1000).x).toBeCloseTo(start - game.hub.speed, 5);
74 expect(hub.positionAt(a, 10_000).x).toBeCloseTo(a.tx, 5);
75 });
76
77 test("emotes are the content's; words are 1 to 140 and go in the chat", () => {
78 const me = chef();
79 const entered = enterHub(db, me);
80 if (!entered.ok) throw new Error(entered.message);
81 const id = entered.value.id;
82 expect(failed(updateAvatar(db, me, { id, emote: "moonwalk" }))).toBe("invalid");
83 expect(failed(updateAvatar(db, me, { id, emote: game.hub.emotes[0] }))).toBe("ok");
84 expect(failed(updateAvatar(db, me, { id, say: " " }))).toBe("invalid");
85 expect(failed(updateAvatar(db, me, { id, say: "x".repeat(141) }))).toBe("invalid");
86 expect(failed(updateAvatar(db, me, { id, say: " <b>yes chef</b> " }))).toBe("ok");
87 const chat = hubChat(db, anon);
88 expect(chat.ok && chat.value.at(-1)).toMatchObject({
89 name: me.name,
90 text: "<b>yes chef</b>",
91 });
92 });
93
94 test("whoever does nothing for a while is shown out of the hub", () => {
95 const idle = hub.place("idler", "idler", 0);
96 const gone = sweepHub(game.hub.idleMinutes * 60_000 + 1);
97 expect(gone).toContain(idle.id);
98 expect(hub.find(idle.id)).toBeUndefined();
99 });
100
101 test("four chefs a table; the fifth is told it is full", () => {
102 const host = chef();
103 const opened = openDelve(db, host);
104 if (!opened.ok) throw new Error(opened.message);
105 const id = opened.value.id;
106 for (let i = 0; i < 3; i++) expect(joinDelve(db, chef(), { delve: id }).ok).toBe(true);
107 const fifth = joinDelve(db, chef(), { delve: id });
108 expect(failed(fifth)).toBe("invalid");
109 expect(kitchen.view(kitchen.delveOf(id)!).joinable).toBe(false);
110 expect(failed(joinDelve(db, chef(), { delve: 999_999 }))).toBe("not_found");
111 expect(failed(joinDelve(db, chef(), { delve: 1.5 }))).toBe("invalid");
112 });
113
114 test("one table a chef: joining another leaves the first, and the last out ends it", () => {
115 const me = chef();
116 const first = openDelve(db, me);
117 if (!first.ok) throw new Error(first.message);
118 const other = openDelve(db, chef());
119 if (!other.ok) throw new Error(other.message);
120 const moved = joinDelve(db, me, { delve: other.value.id });
121 expect(moved.ok).toBe(true);
122 // I was the only one at the first table: it is over now.
123 expect(kitchen.delveOf(first.value.id)!.world.over).toBe("abandoned");
124 expect(kitchen.heroOfAuthor(me.id)?.delve_id).toBe(other.value.id);
125 // Joining where you already sit is where you sit.
126 const again = joinDelve(db, me, { delve: other.value.id });
127 expect(again.ok && moved.ok && again.value.id === moved.value.id).toBe(true);
128 if (moved.ok) expect(leaveDelve(db, me, { id: moved.value.id }).ok).toBe(true);
129 });
130
131 test("an order is move, attack, dodge or stop, inside the room", () => {
132 const room = { w: 15, h: 11 };
133 expect(readOrder({ type: "move", x: 3, z: 4 }, room)).toEqual({ type: "move", x: 3, z: 4 });
134 expect(readOrder({ type: "attack" }, room)).toEqual({ type: "attack" });
135 expect(readOrder({ type: "attack", target: 7 }, room)).toEqual({ type: "attack", target: 7 });
136 expect(readOrder({ type: "stop" }, room)).toEqual({ type: "stop" });
137 for (const bad of [
138 { type: "move", x: 99, z: 1 },
139 { type: "move", x: -1, z: 1 },
140 { type: "dodge", x: "1", z: 1 },
141 { type: "attack", target: "7" },
142 { type: "fly" },
143 {},
144 ])
145 expect(typeof readOrder(bad, room)).toBe("string");
146
147 const me = chef();
148 const opened = openDelve(db, me);
149 if (!opened.ok) throw new Error(opened.message);
150 const hero = kitchen.heroOfAuthor(me.id)!;
151 expect(failed(commandHero(db, me, { id: hero.id, order: { type: "move", x: 5, z: 5 } }))).toBe("ok");
152 expect(failed(commandHero(db, me, { id: hero.id, order: { type: "teleport" } }))).toBe("invalid");
153 expect(failed(commandHero(db, chef(), { id: hero.id, order: { type: "stop" } }))).toBe("forbidden");
154 expect(failed(commandHero(db, anon, { id: hero.id, order: { type: "stop" } }))).toBe("signin_required");
155 });
156
157 test("a delve ticks on its own stream, and leaves a ghost as it goes", () => {
158 const me = chef();
159 const opened = openDelve(db, me);
160 if (!opened.ok) throw new Error(opened.message);
161 const delve = kitchen.delveOf(opened.value.id)!;
162 const heard: string[] = [];
163 const stop = delve.bus.subscribe((e) => heard.push(e.type));
164 for (let i = 0; i < 40; i++) kitchen.tick(delve, Date.now());
165 stop();
166 expect(heard.filter((t) => t === "tick").length).toBe(20);
167 expect(delve.ghost.length).toBe(40 / kitchen.GHOST_EVERY);
168 expect(delve.ghost[0]).toMatch(/^0,\d+,\d+$/);
169 });
170
171 test("a finished delve goes on the board with its crew and ghost; an empty one does not", () => {
172 const me = chef();
173 const opened = openDelve(db, me);
174 if (!opened.ok) throw new Error(opened.message);
175 const delve = kitchen.delveOf(opened.value.id)!;
176 expect(recordRun(db, delve)).toBeUndefined();
177
178 delve.world.score = 1234;
179 delve.world.kills = 3;
180 delve.world.over = "wiped";
181 delve.ghost.push("0,15,55", "0,20,55");
182 const run = recordRun(db, delve)!;
183 expect(run.crew).toEqual([me.name]);
184 expect(run.day).toBe(today());
185 const board = listRuns(db, anon);
186 expect(board.ok && board.value[0]?.id).toBe(run.id);
187 expect(board.ok && board.value[0]?.ghost).toBeUndefined();
188 const one = getRun(db, anon, { id: run.id });
189 expect(one.ok && one.value.ghost).toBe("0,15,55;0,20,55");
190 expect(failed(listRuns(db, anon, { day: "yesterday" }))).toBe("invalid");
191 });
src/http.ts +3 −3
@@ -61,12 +61,12 @@ export const text = (body: string, status = 200): Response =>
6161 headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY },
6262 });
6363
64 /** Back to the page, with a word for it to show once (`readNotice`). */
65 export const back = (notice?: string): Response =>
64 /** Back to a page (the hub unless said), with a word for it to show once (`readNotice`). */
65 export const back = (notice?: string, to = "/"): Response =>
6666 new Response(null, {
6767 status: 303,
6868 headers: {
69 location: "/",
69 location: to.startsWith("/") && !to.startsWith("//") ? to : "/",
7070 ...SECURITY,
7171 ...(notice === undefined
7272 ? {}
src/hub.ts added +167
@@ -0,0 +1,167 @@
1 /**
2 * The hub: the square where chefs meet between delves. It holds only what
3 * a crowd needs to see each other — where each avatar stands, where it is
4 * walking to, its emote and its last words — in memory, because none of
5 * it outlives a visit. A position is never streamed: an avatar is a start,
6 * a destination and the moment it set off, and every watcher walks it
7 * there at the same speed. One click is one small event, however long
8 * the walk.
9 *
10 * Who may do what here is the actions' business (`src/actions.ts`); this
11 * is the square itself.
12 */
13
14 import { game } from "./content.ts";
15 import { bus } from "./live.ts";
16
17 export interface Avatar {
18 readonly id: number;
19 readonly author_id: string;
20 readonly name: string;
21 x: number;
22 z: number;
23 tx: number;
24 tz: number;
25 /** When it set off from (x, z) toward (tx, tz), in ms. */
26 at: number;
27 emote: string | null;
28 emoteAt: number;
29 say: string | null;
30 sayAt: number;
31 /** The last time its person did anything. */
32 seen: number;
33 }
34
35 /** An avatar as a watcher gets it: times as ages, so no clocks need agree. */
36 export interface AvatarView {
37 readonly id: number;
38 readonly author_id: string;
39 readonly name: string;
40 readonly x: number;
41 readonly z: number;
42 readonly tx: number;
43 readonly tz: number;
44 readonly age: number;
45 readonly emote: string | null;
46 readonly emoteAge: number;
47 readonly say: string | null;
48 readonly sayAge: number;
49 }
50
51 export interface Line {
52 readonly name: string;
53 readonly text: string;
54 readonly at: string;
55 }
56
57 export const SAY_MAX = 140;
58 const CHAT_KEEP = 30;
59 const { width, depth, speed, idleMinutes } = game.hub;
60
61 const avatars = new Map<number, Avatar>();
62 const chat: Line[] = [];
63 let nextId = 1;
64
65 /** The hub's own stream: footsteps, emotes and words, to whoever watches the square. */
66 export const hubBus = bus(50);
67
68 const clamp = (n: number, lo: number, hi: number): number =>
69 Math.min(hi, Math.max(lo, n));
70 const round = (n: number): number => Math.round(n * 100) / 100;
71
72 /** Where an avatar is at `now`: somewhere along its walk. */
73 export function positionAt(a: Avatar, now: number): { x: number; z: number } {
74 const d = Math.hypot(a.tx - a.x, a.tz - a.z);
75 if (d === 0) return { x: a.x, z: a.z };
76 const f = Math.min(1, (((now - a.at) / 1000) * speed) / d);
77 return { x: a.x + (a.tx - a.x) * f, z: a.z + (a.tz - a.z) * f };
78 }
79
80 export const view = (a: Avatar, now: number): AvatarView => ({
81 id: a.id,
82 author_id: a.author_id,
83 name: a.name,
84 x: round(a.x),
85 z: round(a.z),
86 tx: round(a.tx),
87 tz: round(a.tz),
88 age: now - a.at,
89 emote: a.emote,
90 emoteAge: now - a.emoteAt,
91 say: a.say,
92 sayAge: now - a.sayAt,
93 });
94
95 export const all = (): Avatar[] => [...avatars.values()];
96 export const find = (id: number): Avatar | undefined => avatars.get(id);
97 export const ofAuthor = (authorId: string): Avatar | undefined =>
98 all().find((a) => a.author_id === authorId);
99 export const chatLog = (): readonly Line[] => chat;
100
101 /** Someone steps into the square — or is already there, and stays put. */
102 export function place(authorId: string, name: string, now: number): Avatar {
103 const there = ofAuthor(authorId);
104 if (there !== undefined) {
105 there.seen = now;
106 return there;
107 }
108 const id = nextId++;
109 // Arrivals come through the pantry door and spread out a little.
110 const x = clamp(width * 0.75 + ((id * 7) % 5) - 2, 1, width - 1);
111 const z = clamp(depth * 0.3 + ((id * 3) % 4), 1, depth - 1);
112 const avatar: Avatar = {
113 id,
114 author_id: authorId,
115 name,
116 x,
117 z,
118 tx: x,
119 tz: z,
120 at: now,
121 emote: null,
122 emoteAt: 0,
123 say: null,
124 sayAt: 0,
125 seen: now,
126 };
127 avatars.set(id, avatar);
128 return avatar;
129 }
130
131 /** Set off toward (x, z) from wherever it is now. The square's edge stops it. */
132 export function walk(a: Avatar, x: number, z: number, now: number): void {
133 const here = positionAt(a, now);
134 a.x = here.x;
135 a.z = here.z;
136 a.tx = clamp(x, 0.5, width - 0.5);
137 a.tz = clamp(z, 0.5, depth - 0.5);
138 a.at = now;
139 a.seen = now;
140 }
141
142 export function emote(a: Avatar, name: string, now: number): void {
143 a.emote = name;
144 a.emoteAt = now;
145 a.seen = now;
146 }
147
148 export function say(a: Avatar, text: string, now: number): void {
149 a.say = text;
150 a.sayAt = now;
151 a.seen = now;
152 chat.push({ name: a.name, text, at: new Date(now).toISOString() });
153 if (chat.length > CHAT_KEEP) chat.shift();
154 }
155
156 export const remove = (id: number): boolean => avatars.delete(id);
157
158 /** Whoever has done nothing for a while has wandered off: the ids that went. */
159 export function sweep(now: number): number[] {
160 const gone: number[] = [];
161 for (const a of avatars.values())
162 if (now - a.seen > idleMinutes * 60_000) {
163 avatars.delete(a.id);
164 gone.push(a.id);
165 }
166 return gone;
167 }
src/live.ts +109 −68
@@ -1,10 +1,16 @@
11 /**
2 * Realtime without a framework. Actions `emit` what changed; `GET /events`
3 * streams it to every open page as server-sent events, numbered, with the
4 * last hundred kept so a client that reconnects with `Last-Event-ID` (or
5 * `?since=`) hears what it missed. `static/live.js` turns the stream into
6 * DOM events. The bus is this process's: the terminal is another process,
7 * and its writes show on the next load rather than live.
2 * Realtime without a framework. A bus is a numbered stream of what
3 * changed: `emit(type, data)` sends to everyone listening, and
4 * `events(request)` is its `GET` — server-sent events, with the last few
5 * kept so a client that reconnects with `Last-Event-ID` (or `?since=`)
6 * hears what it missed. `static/live.js` turns the page's stream into DOM
7 * events.
8 *
9 * The app has one page bus (`emit`, `events`: `GET /events`) and makes
10 * more where a stream is its own: the hub's footsteps, and every delve's
11 * ticks, each go only to whoever watches them. A bus is this process's:
12 * the terminal is another process, and its writes show on the next load
13 * rather than live.
814 */
915
1016 export interface LiveEvent {
@@ -13,69 +19,104 @@ export interface LiveEvent {
1319 readonly data: unknown;
1420 }
1521
16 const KEEP = 100;
22 export interface Bus {
23 emit(type: string, data: unknown): LiveEvent;
24 subscribe(listen: (event: LiveEvent) => void): () => void;
25 /** `GET` for this bus: a stream that replays what `since` missed, then follows. */
26 events(request: Request): Response;
27 /** How many are listening right now. */
28 readonly watchers: number;
29 /** Hang up on everyone: the thing they watched is gone. */
30 close(): void;
31 }
32
1733 const HEARTBEAT_MS = 25_000;
18 const kept: LiveEvent[] = [];
19 const listeners = new Set<(event: LiveEvent) => void>();
20 let last = 0;
2134
22 export function emit(type: string, data: unknown): LiveEvent {
23 const event: LiveEvent = { id: ++last, type, data };
24 kept.push(event);
25 if (kept.length > KEEP) kept.shift();
26 for (const listen of listeners) listen(event);
27 return event;
35 /** A bus that keeps its last `keep` events for whoever reconnects. */
36 export function bus(keep = 100): Bus {
37 const kept: LiveEvent[] = [];
38 const listeners = new Set<(event: LiveEvent) => void>();
39 const closers = new Set<() => void>();
40 let last = 0;
41
42 const emit = (type: string, data: unknown): LiveEvent => {
43 const event: LiveEvent = { id: ++last, type, data };
44 kept.push(event);
45 if (kept.length > keep) kept.shift();
46 for (const listen of listeners) listen(event);
47 return event;
48 };
49
50 const subscribe = (listen: (event: LiveEvent) => void): (() => void) => {
51 listeners.add(listen);
52 return () => listeners.delete(listen);
53 };
54
55 const events = (request: Request): Response => {
56 const since = Number(
57 request.headers.get("last-event-id") ??
58 new URL(request.url).searchParams.get("since") ??
59 0,
60 );
61 const encoder = new TextEncoder();
62 let stop = (): void => {};
63 const stream = new ReadableStream<Uint8Array>({
64 start(controller) {
65 const write = (text: string): void => {
66 try {
67 controller.enqueue(encoder.encode(text));
68 } catch {
69 stop();
70 }
71 };
72 const send = (event: LiveEvent): void =>
73 write(
74 `id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`,
75 );
76 const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS);
77 const unsubscribe = subscribe(send);
78 stop = () => {
79 clearInterval(heartbeat);
80 unsubscribe();
81 closers.delete(stop);
82 try {
83 controller.close();
84 } catch {}
85 };
86 closers.add(stop);
87 request.signal.addEventListener("abort", stop);
88 write("retry: 2000\n\n");
89 for (const event of kept)
90 if (event.id > (Number.isFinite(since) ? since : 0)) send(event);
91 },
92 cancel() {
93 stop();
94 },
95 });
96 return new Response(stream, {
97 headers: {
98 "content-type": "text/event-stream",
99 "cache-control": "no-store",
100 "x-accel-buffering": "no",
101 },
102 });
103 };
104
105 return {
106 emit,
107 subscribe,
108 events,
109 get watchers() {
110 return listeners.size;
111 },
112 close() {
113 for (const stop of [...closers]) stop();
114 },
115 };
28116 }
29117
30 export function subscribe(listen: (event: LiveEvent) => void): () => void {
31 listeners.add(listen);
32 return () => listeners.delete(listen);
33 }
34
35 /** `GET /events`: a stream that replays what `since` missed, then follows. */
36 export function events(request: Request): Response {
37 const since = Number(
38 request.headers.get("last-event-id") ??
39 new URL(request.url).searchParams.get("since") ??
40 0,
41 );
42 const encoder = new TextEncoder();
43 let stop = (): void => {};
44 const stream = new ReadableStream<Uint8Array>({
45 start(controller) {
46 const write = (text: string): void => {
47 try {
48 controller.enqueue(encoder.encode(text));
49 } catch {
50 stop();
51 }
52 };
53 const send = (event: LiveEvent): void =>
54 write(
55 `id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`,
56 );
57 const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS);
58 const unsubscribe = subscribe(send);
59 stop = () => {
60 clearInterval(heartbeat);
61 unsubscribe();
62 try {
63 controller.close();
64 } catch {}
65 };
66 request.signal.addEventListener("abort", stop);
67 write("retry: 2000\n\n");
68 for (const event of kept) if (event.id > since) send(event);
69 },
70 cancel() {
71 stop();
72 },
73 });
74 return new Response(stream, {
75 headers: {
76 "content-type": "text/event-stream",
77 "cache-control": "no-store",
78 "x-accel-buffering": "no",
79 },
80 });
81 }
118 /** The page's bus: `GET /events`. */
119 const page = bus();
120 export const emit = page.emit;
121 export const subscribe = page.subscribe;
122 export const events = page.events;
src/mcp.ts +38 −6
@@ -7,8 +7,15 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { addNote, deleteNote, listNotes, type Result } from "./actions.ts";
11 import { title } from "./env.ts";
10 import {
11 addNote,
12 deleteNote,
13 listDelves,
14 listNotes,
15 listRuns,
16 type Result,
17 } from "./actions.ts";
18 import { title, version } from "./env.ts";
1219 import { fields } from "./http.ts";
1320 import type { Who } from "./who.ts";
1421
@@ -24,9 +31,33 @@ interface Tool {
2431 }
2532
2633 export const tools: readonly Tool[] = [
34 {
35 name: "leaderboard",
36 description:
37 "A day's best runs through the daily dungeon (today unless `day`), best first.",
38 inputSchema: {
39 type: "object",
40 properties: {
41 day: { type: "string", description: "YYYY-MM-DD, in UTC." },
42 limit: { type: "integer", minimum: 1, maximum: 100 },
43 },
44 },
45 run: (db, who, args) =>
46 listRuns(db, who, {
47 ...(args.day === undefined ? {} : { day: String(args.day) }),
48 limit: Number(args.limit ?? 20),
49 }),
50 },
51 {
52 name: "list_delves",
53 description:
54 "The tables: every delve going on (or just over), who is at it, and how deep.",
55 inputSchema: { type: "object", properties: {} },
56 run: (db, who) => listDelves(db, who),
57 },
2758 {
2859 name: "list_notes",
29 description: "The notes, newest first.",
60 description: "The graffiti wall, newest first.",
3061 inputSchema: {
3162 type: "object",
3263 properties: {
@@ -44,7 +75,7 @@ export const tools: readonly Tool[] = [
4475 {
4576 name: "add_note",
4677 description:
47 "Add a note as the signed-in person. Anonymous callers cannot.",
78 "Scrawl on the graffiti wall as the signed-in person. Anonymous callers cannot.",
4879 inputSchema: {
4980 type: "object",
5081 properties: { body: { type: "string", maxLength: 500 } },
@@ -54,7 +85,8 @@ export const tools: readonly Tool[] = [
5485 },
5586 {
5687 name: "delete_note",
57 description: "Remove a note by id. Only its author or an admin can.",
88 description:
89 "Scrub graffiti off the wall by id. Only its author or an admin can.",
5890 inputSchema: {
5991 type: "object",
6092 properties: { id: { type: "integer" } },
@@ -89,7 +121,7 @@ export function rpc(
89121 return reply({
90122 protocolVersion: "2025-06-18",
91123 capabilities: { tools: {} },
92 serverInfo: { name: title, version: "1" },
124 serverInfo: { name: title, version },
93125 });
94126 case "ping":
95127 return reply({});
src/openapi.ts +15 −1
@@ -12,12 +12,26 @@
1212
1313 import { contract } from "./contract.ts";
1414 import { description, title, version } from "./env.ts";
15 import { AUDIT_ROW, ERROR, NOTE, routes, type Schema } from "./routes.ts";
15 import {
16 AUDIT_ROW,
17 AVATAR,
18 DELVE,
19 ERROR,
20 HERO,
21 NOTE,
22 routes,
23 RUN,
24 type Schema,
25 } from "./routes.ts";
1626 import { doorOf } from "./security.ts";
1727
1828 // The shapes said once, under components, and referred to wherever they appear.
1929 const NAMED = new Map<Schema, string>([
2030 [NOTE, "Note"],
31 [AVATAR, "Avatar"],
32 [DELVE, "Delve"],
33 [HERO, "Hero"],
34 [RUN, "Run"],
2135 [AUDIT_ROW, "AuditRow"],
2236 [ERROR, "Error"],
2337 ]);
src/page.ts deleted −122
@@ -1,122 +0,0 @@
1 /**
2 * The page, rendered on the server: the first paint is complete HTML, and
3 * `static/live.js` keeps the notes fresh afterwards. When a second page
4 * arrives, move `layout` to `src/pages/layout.ts` and give each page a file.
5 */
6
7 import { canDelete, type Note } from "./actions.ts";
8 import {
9 commit,
10 description,
11 env,
12 publicOrigin,
13 ssh,
14 title,
15 where,
16 } from "./env.ts";
17 import { asset, escape } from "./http.ts";
18 import { token, tokensHref } from "./tokens.ts";
19 import type { Who } from "./who.ts";
20
21 /**
22 * The frame every page shares: the tokens and the look, who you are, and
23 * what a link to the page says when it is shared — a title, a sentence
24 * and the address, for Open Graph and the card a chat app unfurls.
25 */
26 export function layout(body: string, who: Who, path = "/"): string {
27 const door =
28 who.role === "anon"
29 ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>`
30 : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span>
31 <a class="button button-quiet" href="/__forge/signout">Sign out</a>`;
32 return `<!doctype html>
33 <html lang="en">
34 <meta charset="utf-8">
35 <meta name="viewport" content="width=device-width, initial-scale=1">
36 <meta name="color-scheme" content="light dark">
37 <meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)">
38 <meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)">
39 <link rel="icon" href="data:,">
40 <title>${escape(title)}</title>
41 <meta name="description" content="${escape(description)}">
42 <meta property="og:type" content="website">
43 <meta property="og:site_name" content="${escape(title)}">
44 <meta property="og:title" content="${escape(title)}">
45 <meta property="og:description" content="${escape(description)}">
46 <meta property="og:url" content="${escape(publicOrigin + path)}">
47 <meta name="twitter:card" content="summary">
48 <meta name="twitter:title" content="${escape(title)}">
49 <meta name="twitter:description" content="${escape(description)}">
50 <link rel="stylesheet" href="${tokensHref}">
51 <link rel="stylesheet" href="${asset("app.css")}">
52 <script type="module" src="${asset("live.js")}"></script>
53 <body>
54 <div class="page">
55 <header class="head">
56 <div>
57 <h1>${escape(title)}</h1>
58 <p class="muted">${escape(description)}</p>
59 </div>
60 <nav class="row">
61 <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""}
62 ${door}
63 </nav>
64 </header>
65 <main>${body}</main>
66 <footer class="muted">
67 ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`}
68 <p>Also at <a href="/api/notes">/api/notes</a> (<a href="/openapi.json">described</a>) and <code>POST /mcp</code>, with the same rules.</p>
69 </footer>
70 </div>
71 </body>
72 </html>
73 `;
74 }
75
76 export function home(input: {
77 who: Who;
78 notes: readonly Note[];
79 notice?: string | undefined;
80 }): string {
81 const { who, notes, notice } = input;
82 const composer =
83 who.role === "anon"
84 ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=/">Sign in</a> to add a note.</p>`
85 : `<form class="row" method="post" action="/notes">
86 <input class="input" name="body" placeholder="Write a note" aria-label="New note" required maxlength="500" autocomplete="off" autofocus>
87 <button class="button">Add</button>
88 </form>`;
89 const list =
90 notes.length === 0
91 ? `<p class="empty">No notes yet. Notes written here stay in ${escape(where)} data.</p>`
92 : `<ul class="list">${notes.map((note) => item(note, who)).join("")}</ul>`;
93 return layout(
94 `<section class="card">${composer}</section>
95 <section id="notes" data-live="note.added note.removed">${list}</section>
96 ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`,
97 who,
98 );
99 }
100
101 const item = (note: Note, who: Who): string => `<li class="note">
102 <div>
103 <p>${escape(note.body)}</p>
104 <small class="muted">${escape(note.author_name)} · <time datetime="${escape(note.created_at)}">${ago(note.created_at)}</time></small>
105 </div>
106 ${
107 canDelete(who, note)
108 ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Remove this note">Remove</button></form>`
109 : ""
110 }
111 </li>`;
112
113 const ago = (iso: string): string => {
114 const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
115 if (s < 60) return "just now";
116 if (s < 3600) return `${Math.floor(s / 60)}m ago`;
117 if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
118 return new Date(iso).toLocaleDateString("en", {
119 month: "short",
120 day: "numeric",
121 });
122 };
src/pages/delve.ts added +126
@@ -0,0 +1,126 @@
1 /**
2 * `/delve/:id`: one table. The room is drawn by `static/delve.js` from the
3 * ticks the server sends; the page around it — who is at the table, how to
4 * join or leave, how to play, the board to beat — is complete HTML, so a
5 * link to a table says what it is before any script runs.
6 */
7
8 import type { Run } from "../actions.ts";
9 import { game, gameHref } from "../content.ts";
10 import type { DelveView, HeroView } from "../delves.ts";
11 import { publicOrigin } from "../env.ts";
12 import { escape } from "../http.ts";
13 import type { Who } from "../who.ts";
14 import { layout, toast } from "./layout.ts";
15
16 export interface DelvePage {
17 readonly who: Who;
18 readonly delve: DelveView;
19 /** This person's hero at this table, if they have one. */
20 readonly hero?: HeroView | undefined;
21 readonly runs: readonly Run[];
22 readonly simHref: string;
23 readonly notice?: string | undefined;
24 }
25
26 const OUTCOME: Record<string, string> = {
27 victory: "Order up! The Pass is cleared.",
28 wiped: "Burnt to a crisp. The party is down.",
29 timeout: "Kitchen's closed: out of time.",
30 abandoned: "Everyone left the table.",
31 };
32
33 export function delvePage(p: DelvePage): string {
34 const { who, delve, hero } = p;
35 const path = `/delve/${delve.id}`;
36 const room = game.rooms.find((r) => r.id === delve.room);
37 const over = delve.over;
38 const pad =
39 hero === undefined || over !== null
40 ? ""
41 : `<div class="pad">
42 <button class="button button-big" type="button" data-act="attack">Attack</button>
43 <button class="button button-big button-alt" type="button" data-act="dodge">Dodge</button>
44 </div>`;
45 return layout(
46 `<h2>${escape(delve.host_name)}'s table <small class="muted mono">${escape(delve.day)}</small></h2>
47 <section class="stage" aria-label="The dungeon">
48 <div class="viewport" id="delve-view" data-delve="${delve.id}" data-hero="${hero?.id ?? ""}" data-sim="${escape(p.simHref)}" data-content="${gameHref}" data-day="${escape(delve.day)}">
49 <canvas tabindex="0" aria-label="The dungeon. Click or tap to walk, click a monster to fight, shift-click to dodge."></canvas>
50 <div class="tags" aria-hidden="true"></div>
51 <div class="hud">
52 <p data-hud="room">${escape(room?.name ?? delve.room)} · floor ${delve.depth + 1} of ${delve.rooms}</p>
53 <p data-hud="hp">${hero === undefined ? "Watching" : ""}</p>
54 <p data-hud="score">${delve.score} pts</p>
55 <p data-hud="ghost"></p>
56 </div>
57 <div class="over" id="over"${over === null ? " hidden" : ""}>
58 <p class="over-title" data-outcome>${over === null ? "" : escape(OUTCOME[over] ?? over)}</p>
59 <p data-final>${delve.score} points</p>
60 <div class="row wrap">
61 <a class="button button-quiet" href="/">Back to the hub</a>
62 ${who.role === "anon" ? "" : `<form method="post" action="/delves"><button class="button">Delve again</button></form>`}
63 </div>
64 </div>
65 <p class="viewport-note">The dungeon is drawn with JavaScript and WebGL; this browser has not run them.</p>
66 </div>
67 ${pad}
68 <p class="muted" data-hud="status" role="status" aria-live="polite"></p>
69 </section>
70 <div class="grid">
71 ${party(p, path)}
72 <section class="card">
73 <h2>How to cook</h2>
74 <ul class="howto">
75 <li><b>Walk</b>: click or tap the floor, or WASD / arrow keys.</li>
76 <li><b>Fight</b>: click a monster (your chef keeps swinging), or <kbd>Space</kbd> for the nearest.</li>
77 <li><b>Dodge</b>: shift-click or right-click where to roll, or <kbd>K</kbd>. Rolling dodges bites, charges, seeds and spikes.</li>
78 <li><b>Chests</b> open to the touch. <b>Downed?</b> A teammate standing beside you brings you back.</li>
79 <li>Clear a room and the door opens. Five floors, then the Pass.</li>
80 </ul>
81 <p class="muted">${game.monsters.map((m) => `<b>${escape(m.name)}</b>`).join(" · ")}</p>
82 </section>
83 ${board(p)}
84 </div>
85 ${toast(p.notice)}`,
86 who,
87 { path, heading: `${delve.host_name}'s table`, scripts: ["delve.js"] },
88 );
89 }
90
91 function party(p: DelvePage, path: string): string {
92 const { who, delve, hero } = p;
93 const names = delve.party
94 .map((h) => `<li>${escape(h.name)}${h.author_id === delve.author_id ? " <small class=\"muted\">host</small>" : ""}</li>`)
95 .join("");
96 let action = "";
97 if (delve.over === null) {
98 if (who.role === "anon")
99 action = `<p><a href="/__forge/signin?to=${encodeURIComponent(path)}">Sign in</a> to take a seat.</p>`;
100 else if (hero !== undefined)
101 action = `<form method="post" action="/heroes/${hero.id}/leave"><button class="button button-quiet">Leave the table</button></form>`;
102 else if (delve.joinable)
103 action = `<form method="post" action="/delves/${delve.id}/join"><button class="button">Take a seat</button></form>`;
104 else action = `<p class="muted">This table is full. Watch, or start your own.</p>`;
105 }
106 return `<section class="card">
107 <h2>At the table <small class="muted">${delve.party.length} of 4</small></h2>
108 ${names === "" ? `<p class="muted">Nobody left.</p>` : `<ul class="list-plain">${names}</ul>`}
109 ${action}
110 ${delve.over === null ? `<p class="muted">Bring friends: <code class="share">${escape(publicOrigin + path)}</code></p>` : ""}
111 </section>`;
112 }
113
114 function board(p: DelvePage): string {
115 const rows = p.runs
116 .slice(0, 5)
117 .map(
118 (r, i) =>
119 `<li class="item"><span><b>#${i + 1}</b> ${r.crew.map(escape).join(", ")}</span><b class="score">${r.score}</b></li>`,
120 )
121 .join("");
122 return `<section class="card" id="board" data-live="run.recorded">
123 <h2>Score to beat</h2>
124 ${rows === "" ? `<p class="empty">The board is empty today. Be the first.</p>` : `<ol class="list">${rows}</ol>`}
125 </section>`;
126 }
src/pages/hub.ts added +176
@@ -0,0 +1,176 @@
1 /**
2 * `/`: the hub. The square, drawn by `static/hub.js` over a canvas that
3 * never changes size; around it, everything that works without a line of
4 * script — today's dungeon and its board, the open tables, who is in the
5 * square and what they said, and the graffiti wall. Each list is a
6 * `data-live` region, so it stays current as the page's events arrive.
7 */
8
9 import { canDelete, type Note, type Run } from "../actions.ts";
10 import { game, gameHref } from "../content.ts";
11 import type { DelveView, HeroView } from "../delves.ts";
12 import { escape } from "../http.ts";
13 import type { AvatarView, Line } from "../hub.ts";
14 import type { Who } from "../who.ts";
15 import { ago, layout, toast } from "./layout.ts";
16
17 export interface HubPage {
18 readonly who: Who;
19 readonly day: string;
20 readonly plan: readonly string[];
21 readonly avatars: readonly AvatarView[];
22 readonly chat: readonly Line[];
23 readonly delves: readonly DelveView[];
24 readonly runs: readonly Run[];
25 readonly notes: readonly Note[];
26 /** The hero this person is playing right now, if any. */
27 readonly playing?: HeroView | undefined;
28 readonly notice?: string | undefined;
29 }
30
31 const roomName = (id: string): string =>
32 game.rooms.find((r) => r.id === id)?.name ?? id;
33
34 export function hubPage(p: HubPage): string {
35 const { who } = p;
36 const signedIn = who.role !== "anon";
37 return layout(
38 `<section class="stage" aria-label="The hub">
39 <div class="viewport" id="hub-view" data-content="${gameHref}" data-signed-in="${signedIn ? "yes" : "no"}">
40 <canvas tabindex="0" aria-label="The hub square. Click or tap the floor to walk; arrow keys walk when it has focus."></canvas>
41 <div class="tags" aria-hidden="true"></div>
42 <p class="viewport-note">The hub is drawn with JavaScript and WebGL. Everything below works without them.</p>
43 </div>
44 </section>
45 ${controls(p)}
46 <div class="grid">
47 ${tables(p)}
48 ${board(p)}
49 ${square(p)}
50 ${wall(p)}
51 </div>
52 ${toast(p.notice)}`,
53 who,
54 { scripts: ["hub.js"] },
55 );
56 }
57
58 function controls(p: HubPage): string {
59 if (p.who.role === "anon")
60 return `<section class="card">
61 <p><b>Grab an apron, chef.</b> <a href="/__forge/signin?to=/">Sign in</a> to step into the hub, cook through today's dungeon with up to three friends, and get your name on the board. Until then, pull up a stool and watch.</p>
62 </section>`;
63 const back =
64 p.playing === undefined
65 ? ""
66 : `<p class="callout">You have a seat at <a href="/delve/${p.playing.delve_id}">table #${p.playing.delve_id}</a> — get back in there!</p>`;
67 const emotes = game.hub.emotes
68 .map(
69 (e) =>
70 `<button class="button button-quiet" name="emote" value="${escape(e)}">${escape(e)}</button>`,
71 )
72 .join("");
73 return `<section class="card controls">
74 ${back}
75 <form method="post" action="/delves">
76 <button class="button button-big">Start today's delve</button>
77 </form>
78 <form class="row wrap" method="post" action="/hub/emote" data-hub aria-label="Emotes">${emotes}</form>
79 <form class="row" method="post" action="/hub/say" data-hub>
80 <input class="input" name="say" placeholder="Say something to the hub" aria-label="Say something to the hub" required maxlength="140" autocomplete="off">
81 <button class="button">Say</button>
82 </form>
83 <p class="muted" id="hub-status" role="status" aria-live="polite"></p>
84 </section>`;
85 }
86
87 function tables(p: HubPage): string {
88 const open = p.delves.filter((d) => d.over === null);
89 const rows = open
90 .map((d) => {
91 const seated =
92 p.who.role !== "anon" && d.party.some((h) => h.author_id === p.who.id);
93 const join =
94 p.who.role === "anon" || seated || !d.joinable
95 ? ""
96 : `<form method="post" action="/delves/${d.id}/join"><button class="button">Join</button></form>`;
97 return `<li class="item">
98 <div>
99 <p><b>${escape(d.host_name)}'s table</b> · ${escape(roomName(d.room))}</p>
100 <small class="muted">floor ${d.depth + 1} of ${d.rooms} · ${d.party.length} of 4 chefs · ${d.score} pts · ${d.party.map((h) => escape(h.name)).join(", ")}</small>
101 </div>
102 <div class="row">${join}<a class="button button-quiet" href="/delve/${d.id}">${seated ? "Back in" : "Watch"}</a></div>
103 </li>`;
104 })
105 .join("");
106 return `<section class="card" id="tables" data-live="delve.opened delve.changed delve.closed">
107 <h2>Open tables</h2>
108 ${open.length === 0 ? `<p class="empty">No tables open. Start one — friends join from here, four chefs a table.</p>` : `<ul class="list">${rows}</ul>`}
109 </section>`;
110 }
111
112 function board(p: HubPage): string {
113 const rows = p.runs
114 .map(
115 (r, i) => `<li class="item">
116 <div>
117 <p><b>#${i + 1}</b> ${r.crew.map(escape).join(", ")}</p>
118 <small class="muted">${r.outcome === "victory" ? "cleared the Pass" : `reached floor ${r.depth + 1}`} · ${r.kills} down · ${r.gold} gold · ${Math.round(r.ticks / game.rules.tickHz)}s</small>
119 </div>
120 <b class="score">${r.score}</b>
121 </li>`,
122 )
123 .join("");
124 return `<section class="card" id="board" data-live="run.recorded">
125 <h2>Today's menu <small class="muted mono">${escape(p.day)}</small></h2>
126 <p class="muted">Everyone delves the same kitchen today: ${p.plan.map((id) => escape(roomName(id))).join(" → ")}. Beat the best run and your ghost is the one the next party races.</p>
127 ${p.runs.length === 0 ? `<p class="empty">Nobody's on the board yet. First plate out wins.</p>` : `<ol class="list">${rows}</ol>`}
128 </section>`;
129 }
130
131 function square(p: HubPage): string {
132 const names = p.avatars.map((a) => escape(a.name)).join(", ");
133 const lines = p.chat
134 .slice(-12)
135 .map(
136 (l) =>
137 `<li><b>${escape(l.name)}</b> ${escape(l.text)} <small class="muted"><time datetime="${escape(l.at)}">${ago(l.at)}</time></small></li>`,
138 )
139 .join("");
140 return `<section class="card" id="square" data-live="hub.roster hub.chat">
141 <h2>In the hub <small class="muted">${p.avatars.length}</small></h2>
142 <p class="muted">${names === "" ? "Nobody's here yet." : names}</p>
143 ${lines === "" ? "" : `<ul class="chat">${lines}</ul>`}
144 </section>`;
145 }
146
147 function wall(p: HubPage): string {
148 const composer =
149 p.who.role === "anon"
150 ? `<p class="muted">Sign in to leave your mark on the wall.</p>`
151 : `<form class="row" method="post" action="/notes">
152 <input class="input" name="body" placeholder="Scrawl something on the wall" aria-label="Graffiti" required maxlength="500" autocomplete="off">
153 <button class="button">Scrawl</button>
154 </form>`;
155 const list =
156 p.notes.length === 0
157 ? `<p class="empty">A clean wall. Suspicious.</p>`
158 : `<ul class="list">${p.notes.map((n) => scrawl(n, p.who)).join("")}</ul>`;
159 return `<section class="card">
160 <h2>The graffiti wall</h2>
161 ${composer}
162 <div id="notes" data-live="note.added note.removed">${list}</div>
163 </section>`;
164 }
165
166 const scrawl = (note: Note, who: Who): string => `<li class="item">
167 <div>
168 <p class="scrawl">${escape(note.body)}</p>
169 <small class="muted">${escape(note.author_name)} · <time datetime="${escape(note.created_at)}">${ago(note.created_at)}</time></small>
170 </div>
171 ${
172 canDelete(who, note)
173 ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Scrub this off the wall">Scrub</button></form>`
174 : ""
175 }
176 </li>`;
src/pages/layout.ts added +104
@@ -0,0 +1,104 @@
1 /**
2 * The frame every page shares: the tokens and the look, who you are, and
3 * what a link to the page says when it is shared — a title, a sentence
4 * and the address, for Open Graph and the card a chat app unfurls. Each
5 * page is a file beside this one; each renders complete HTML on the
6 * server, and its script only brings it to life.
7 */
8
9 import {
10 commit,
11 description,
12 displayName,
13 env,
14 publicOrigin,
15 ssh,
16 where,
17 } from "../env.ts";
18 import { asset, escape } from "../http.ts";
19 import { token, tokensHref } from "../tokens.ts";
20 import type { Who } from "../who.ts";
21
22 export interface Frame {
23 /** The page's own address, for `og:url` and for signing in back to it. */
24 readonly path?: string;
25 /** What the tab says before the app's name. */
26 readonly heading?: string;
27 /** Scripts under static/ this page runs, as modules. */
28 readonly scripts?: readonly string[];
29 }
30
31 export function layout(body: string, who: Who, frame: Frame = {}): string {
32 const path = frame.path ?? "/";
33 const pageTitle =
34 frame.heading === undefined
35 ? displayName
36 : `${frame.heading} · ${displayName}`;
37 const door =
38 who.role === "anon"
39 ? `<a class="button button-quiet" href="/__forge/signin?to=${encodeURIComponent(path)}">Sign in</a>`
40 : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span>
41 <a class="button button-quiet" href="/__forge/signout">Sign out</a>`;
42 const scripts = ["live.js", ...(frame.scripts ?? [])]
43 .map((s) => `<script type="module" src="${asset(s)}"></script>`)
44 .join("\n");
45 return `<!doctype html>
46 <html lang="en">
47 <meta charset="utf-8">
48 <meta name="viewport" content="width=device-width, initial-scale=1">
49 <meta name="color-scheme" content="light dark">
50 <meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)">
51 <meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)">
52 <link rel="icon" href="data:,">
53 <title>${escape(pageTitle)}</title>
54 <meta name="description" content="${escape(description)}">
55 <meta property="og:type" content="website">
56 <meta property="og:site_name" content="${escape(displayName)}">
57 <meta property="og:title" content="${escape(pageTitle)}">
58 <meta property="og:description" content="${escape(description)}">
59 <meta property="og:url" content="${escape(publicOrigin + path)}">
60 <meta name="twitter:card" content="summary">
61 <meta name="twitter:title" content="${escape(pageTitle)}">
62 <meta name="twitter:description" content="${escape(description)}">
63 <link rel="stylesheet" href="${tokensHref}">
64 <link rel="stylesheet" href="${asset("app.css")}">
65 ${scripts}
66 <body>
67 <div class="page">
68 <header class="head">
69 <div>
70 <h1><a class="home" href="/">${escape(displayName)}</a></h1>
71 <p class="muted">${escape(description)}</p>
72 </div>
73 <nav class="row">
74 <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""}
75 ${door}
76 </nav>
77 </header>
78 <main>${body}</main>
79 <footer class="muted">
80 <p>${ssh === undefined ? "The terminal is off here." : `Terminal: <code>${escape(ssh)}</code>`}</p>
81 <p>Everything here is also at <a href="/openapi.json">the API</a> and <code>POST /mcp</code>, with the same rules.</p>
82 </footer>
83 </div>
84 </body>
85 </html>
86 `;
87 }
88
89 /** A notice from the last request, shown once. */
90 export const toast = (notice: string | undefined): string =>
91 notice === undefined
92 ? ""
93 : `<p class="toast" role="status">${escape(notice)}</p>`;
94
95 export const ago = (iso: string): string => {
96 const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
97 if (s < 60) return "just now";
98 if (s < 3600) return `${Math.floor(s / 60)}m ago`;
99 if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
100 return new Date(iso).toLocaleDateString("en", {
101 month: "short",
102 day: "numeric",
103 });
104 };
src/routes.ts +464 −5
@@ -16,15 +16,38 @@ import { existsSync, readFileSync } from "node:fs";
1616 import {
1717 addNote,
1818 BODY_MAX,
19 commandHero,
1920 deleteNote,
21 deleteRun,
22 enterHub,
2023 getAudit,
24 getAvatar,
25 getDelve,
26 getHero,
2127 getNote,
28 getRun,
29 hubChat,
30 joinDelve,
31 leaveDelve,
32 leaveHub,
2233 listAudit,
34 listAvatars,
35 listDelves,
36 listHeroes,
2337 listNotes,
38 listRuns,
39 openDelve,
40 today,
41 updateAvatar,
2442 updateNote,
43 watchDelve,
44 watchHub,
2545 type Failure,
2646 type Result,
2747 } from "./actions.ts";
48 import { simHref, simJs } from "./assets.ts";
49 import { game, gameJson } from "./content.ts";
50 import { heroOfAuthor } from "./delves.ts";
2851 import { commit, env, forgeUrl, startedAt, version } from "./env.ts";
2952 import {
3053 back,
@@ -38,9 +61,12 @@ import {
3861 staticFile,
3962 text,
4063 } from "./http.ts";
64 import { SAY_MAX } from "./hub.ts";
4165 import { events } from "./live.ts";
4266 import { rpc } from "./mcp.ts";
43 import { home } from "./page.ts";
67 import { delvePage } from "./pages/delve.ts";
68 import { hubPage } from "./pages/hub.ts";
69 import { daySeed, planOf } from "./sim.ts";
4470 import { tokensCss } from "./tokens.ts";
4571 import type { Who } from "./who.ts";
4672
@@ -115,6 +141,114 @@ export const AUDIT_ROW: Schema = {
115141 },
116142 required: ["id", "at", "role", "resource", "verb", "allowed", "outcome"],
117143 };
144 export const AVATAR: Schema = {
145 type: "object",
146 description:
147 "A chef in the hub: walking from (x, z) to (tx, tz), set off `age` ms ago at the hub's speed.",
148 properties: {
149 id: { type: "integer" },
150 author_id: { type: "string" },
151 name: { type: "string" },
152 x: { type: "number" },
153 z: { type: "number" },
154 tx: { type: "number" },
155 tz: { type: "number" },
156 age: { type: "integer" },
157 emote: { type: ["string", "null"] },
158 emoteAge: { type: "integer" },
159 say: { type: ["string", "null"] },
160 sayAge: { type: "integer" },
161 },
162 required: ["id", "author_id", "name", "x", "z", "tx", "tz", "age"],
163 };
164 const AVATAR_CHANGE: Schema = {
165 type: "object",
166 description: "Any of: where to walk (x and z together), an emote, words.",
167 properties: {
168 x: { type: "number" },
169 z: { type: "number" },
170 emote: { enum: [...game.hub.emotes] },
171 say: { type: "string", minLength: 1, maxLength: SAY_MAX },
172 },
173 };
174 export const DELVE: Schema = {
175 type: "object",
176 properties: {
177 id: { type: "integer" },
178 day: { type: "string", format: "date" },
179 author_id: { type: "string" },
180 host_name: { type: "string" },
181 party: {
182 type: "array",
183 maxItems: 4,
184 items: {
185 type: "object",
186 properties: {
187 id: { type: "integer" },
188 author_id: { type: "string" },
189 name: { type: "string" },
190 },
191 },
192 },
193 depth: { type: "integer" },
194 rooms: { type: "integer" },
195 room: { type: "string" },
196 score: { type: "integer" },
197 over: { enum: [null, "victory", "wiped", "timeout", "abandoned"] },
198 joinable: { type: "boolean" },
199 },
200 required: ["id", "day", "author_id", "host_name", "party", "depth", "rooms", "room", "score", "over", "joinable"],
201 };
202 export const HERO: Schema = {
203 type: "object",
204 properties: {
205 id: { type: "integer" },
206 delve_id: { type: "integer" },
207 author_id: { type: "string" },
208 author_name: { type: "string" },
209 },
210 required: ["id", "delve_id", "author_id", "author_name"],
211 };
212 const ORDER: Schema = {
213 type: "object",
214 description:
215 "move: walk to (x, z). attack: fight `target` (a monster's id), or swing where you face. dodge: roll toward (x, z). stop.",
216 properties: {
217 type: { enum: ["move", "attack", "dodge", "stop"] },
218 x: { type: "number" },
219 z: { type: "number" },
220 target: { type: "integer" },
221 },
222 required: ["type"],
223 };
224 export const RUN: Schema = {
225 type: "object",
226 properties: {
227 id: { type: "integer" },
228 day: { type: "string", format: "date" },
229 seed: { type: "integer" },
230 outcome: { enum: ["victory", "wiped", "timeout", "abandoned"] },
231 score: { type: "integer" },
232 depth: { type: "integer" },
233 kills: { type: "integer" },
234 gold: { type: "integer" },
235 ticks: { type: "integer" },
236 crew: { type: "array", items: { type: "string" } },
237 author_id: { type: "string" },
238 author_name: { type: "string" },
239 created_at: { type: "string", format: "date-time" },
240 ghost: {
241 type: "string",
242 description: "Where the lead chef was every 10 ticks: `floor,x*10,z*10;…`. Only on a single run.",
243 },
244 },
245 required: ["id", "day", "seed", "outcome", "score", "depth", "kills", "gold", "ticks", "crew", "author_id", "author_name", "created_at"],
246 };
247 const GONE: Schema = {
248 type: "object",
249 properties: { id: { type: "integer" } },
250 required: ["id"],
251 };
118252 export const ERROR: Schema = {
119253 type: "object",
120254 properties: { error: { type: "string" }, message: { type: "string" } },
@@ -144,7 +278,7 @@ const answer = <T>(result: Result<T>, status = 200): Response =>
144278 STATUS[result.error],
145279 );
146280
147 /** A result for a form: back to the page, with what went wrong if it did. */
281 /** A result for a form: back to the hub, with what went wrong if it did. */
148282 const done = <T>(result: Result<T>): Response =>
149283 result.ok ? back() : back(result.message);
150284
@@ -157,15 +291,61 @@ const OPENAPI = new URL("../contract/openapi.json", import.meta.url);
157291 const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined;
158292
159293 export const routes: readonly Route[] = [
160 // The page. Forms post and come back; the list stays live over /events.
294 // The pages. Forms post and come back; the lists stay live over /events.
161295 {
162296 method: "GET",
163297 path: "/",
164298 handle: (c) => {
165299 const { notice, clear } = readNotice(c.request);
166 const notes = listNotes(c.db, c.who);
300 const day = today();
301 const or = <T>(r: Result<T[]>): T[] => (r.ok ? r.value : []);
302 const chat = hubChat(c.db, c.who);
167303 return html(
168 home({ who: c.who, notes: notes.ok ? notes.value : [], notice }),
304 hubPage({
305 who: c.who,
306 day,
307 plan: planOf(game, daySeed(day)),
308 avatars: or(listAvatars(c.db, c.who)),
309 chat: chat.ok ? chat.value : [],
310 delves: or(listDelves(c.db, c.who)),
311 runs: or(listRuns(c.db, c.who, { day, limit: 10 })),
312 notes: or(listNotes(c.db, c.who, { limit: 50 })),
313 playing:
314 c.who.role === "anon" ? undefined : heroOfAuthor(c.who.id),
315 notice,
316 }),
317 200,
318 clear,
319 );
320 },
321 },
322 {
323 method: "GET",
324 path: "/delve/:id",
325 handle: (c) => {
326 const { notice, clear } = readNotice(c.request);
327 const found = getDelve(c.db, c.who, { id: Number(c.params.id) });
328 if (!found.ok)
329 return found.error === "not_found" || found.error === "invalid"
330 ? back("That table is cleared away. Start a fresh one!")
331 : back(found.message);
332 const delve = found.value;
333 const runs = listRuns(c.db, c.who, { day: delve.day, limit: 5 });
334 return html(
335 delvePage({
336 who: c.who,
337 delve,
338 hero:
339 c.who.role === "anon"
340 ? undefined
341 : (() => {
342 const h = heroOfAuthor(c.who.id);
343 return h?.delve_id === delve.id ? h : undefined;
344 })(),
345 runs: runs.ok ? runs.value : [],
346 simHref,
347 notice,
348 }),
169349 200,
170350 clear,
171351 );
@@ -187,6 +367,82 @@ export const routes: readonly Route[] = [
187367 path: "/notes/:id/delete",
188368 handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })),
189369 },
370 {
371 method: "POST",
372 path: "/hub/say",
373 handle: async (c) => {
374 const form = await readForm(c.request);
375 if (form === undefined) return text("too large", 413);
376 const me = enterHub(c.db, c.who);
377 if (!me.ok) return done(me);
378 return done(
379 updateAvatar(c.db, c.who, {
380 id: me.value.id,
381 say: String(form.get("say") ?? ""),
382 }),
383 );
384 },
385 },
386 {
387 method: "POST",
388 path: "/hub/emote",
389 handle: async (c) => {
390 const form = await readForm(c.request);
391 if (form === undefined) return text("too large", 413);
392 const me = enterHub(c.db, c.who);
393 if (!me.ok) return done(me);
394 return done(
395 updateAvatar(c.db, c.who, {
396 id: me.value.id,
397 emote: String(form.get("emote") ?? ""),
398 }),
399 );
400 },
401 },
402 {
403 method: "POST",
404 path: "/delves",
405 handle: (c) => {
406 const opened = openDelve(c.db, c.who);
407 return opened.ok
408 ? back(undefined, `/delve/${opened.value.id}`)
409 : back(opened.message);
410 },
411 },
412 {
413 method: "POST",
414 path: "/delves/:id/join",
415 handle: (c) => {
416 const id = Number(c.params.id);
417 const joined = joinDelve(c.db, c.who, { delve: id });
418 return joined.ok
419 ? back(undefined, `/delve/${joined.value.delve_id}`)
420 : back(joined.message, Number.isInteger(id) ? `/delve/${id}` : "/");
421 },
422 },
423 {
424 method: "POST",
425 path: "/heroes/:id/leave",
426 handle: (c) => done(leaveDelve(c.db, c.who, { id: Number(c.params.id) })),
427 },
428
429 // The streams: the hub's footsteps and each table's ticks, to whoever may watch.
430 {
431 method: "GET",
432 path: "/hub/events",
433 handle: (c) => {
434 const watched = watchHub(c.db, c.who);
435 return watched.ok ? watched.value.events(c.request) : answer(watched);
436 },
437 },
438 {
439 method: "GET",
440 path: "/delves/:id/events",
441 handle: (c) => {
442 const watched = watchDelve(c.db, c.who, { id: Number(c.params.id) });
443 return watched.ok ? watched.value.events(c.request) : answer(watched);
444 },
445 },
190446
191447 // The JSON API: the same actions, the same identity, plain status codes.
192448 {
@@ -257,6 +513,179 @@ export const routes: readonly Route[] = [
257513 },
258514 },
259515
516 // The hub, as data: who is in the square and where they are walking.
517 {
518 method: "GET",
519 path: "/api/avatars",
520 handle: (c) => answer(listAvatars(c.db, c.who)),
521 api: { summary: "Every chef in the hub.", answer: list(AVATAR) },
522 },
523 {
524 method: "POST",
525 path: "/api/avatars",
526 handle: (c) => answer(enterHub(c.db, c.who), 201),
527 api: {
528 summary: "Step into the hub as yourself (already there, you stay put).",
529 answer: AVATAR,
530 status: 201,
531 },
532 },
533 {
534 method: "GET",
535 path: "/api/avatars/:id",
536 handle: (c) => answer(getAvatar(c.db, c.who, { id: Number(c.params.id) })),
537 api: { summary: "One chef in the hub.", answer: AVATAR },
538 },
539 {
540 method: "PATCH",
541 path: "/api/avatars/:id",
542 handle: async (c) => {
543 const body = await readJson(c.request);
544 if (body === undefined) return json({ error: "too_large" }, 413);
545 const f = fields(body);
546 return answer(
547 updateAvatar(c.db, c.who, {
548 id: Number(c.params.id),
549 x: f.x,
550 z: f.z,
551 emote: f.emote,
552 say: f.say,
553 }),
554 );
555 },
556 api: {
557 summary: "Walk somewhere, emote, or say something.",
558 body: AVATAR_CHANGE,
559 answer: AVATAR,
560 },
561 },
562 {
563 method: "DELETE",
564 path: "/api/avatars/:id",
565 handle: (c) => answer(leaveHub(c.db, c.who, { id: Number(c.params.id) })),
566 api: { summary: "Leave the hub.", answer: GONE },
567 },
568
569 // Delves: today's dungeon, one table per party.
570 {
571 method: "GET",
572 path: "/api/delves",
573 handle: (c) => answer(listDelves(c.db, c.who)),
574 api: { summary: "The tables, newest first.", answer: list(DELVE) },
575 },
576 {
577 method: "POST",
578 path: "/api/delves",
579 handle: (c) => answer(openDelve(c.db, c.who), 201),
580 api: {
581 summary: "Open today's dungeon with yourself at the table.",
582 answer: DELVE,
583 status: 201,
584 },
585 },
586 {
587 method: "GET",
588 path: "/api/delves/:id",
589 handle: (c) => answer(getDelve(c.db, c.who, { id: Number(c.params.id) })),
590 api: {
591 summary: "One table. Its ticks stream at /delves/{id}/events.",
592 answer: DELVE,
593 },
594 },
595 {
596 method: "GET",
597 path: "/api/heroes",
598 handle: (c) => answer(listHeroes(c.db, c.who)),
599 api: { summary: "Every chef at every table.", answer: list(HERO) },
600 },
601 {
602 method: "POST",
603 path: "/api/heroes",
604 handle: async (c) => {
605 const body = await readJson(c.request);
606 if (body === undefined) return json({ error: "too_large" }, 413);
607 return answer(
608 joinDelve(c.db, c.who, { delve: Number(fields(body).delve) }),
609 201,
610 );
611 },
612 api: {
613 summary: "Take a seat at a table (four a table).",
614 body: {
615 type: "object",
616 properties: { delve: { type: "integer" } },
617 required: ["delve"],
618 },
619 answer: HERO,
620 status: 201,
621 },
622 },
623 {
624 method: "GET",
625 path: "/api/heroes/:id",
626 handle: (c) => answer(getHero(c.db, c.who, { id: Number(c.params.id) })),
627 api: { summary: "One chef at a table.", answer: HERO },
628 },
629 {
630 method: "PATCH",
631 path: "/api/heroes/:id",
632 handle: async (c) => {
633 const body = await readJson(c.request);
634 if (body === undefined) return json({ error: "too_large" }, 413);
635 return answer(
636 commandHero(c.db, c.who, {
637 id: Number(c.params.id),
638 order: fields(body),
639 }),
640 );
641 },
642 api: { summary: "Give your chef an order.", body: ORDER, answer: HERO },
643 },
644 {
645 method: "DELETE",
646 path: "/api/heroes/:id",
647 handle: (c) => answer(leaveDelve(c.db, c.who, { id: Number(c.params.id) })),
648 api: {
649 summary: "Leave the table. The last chef out ends the delve.",
650 answer: GONE,
651 },
652 },
653
654 // The board: finished runs, best first, with their ghosts.
655 {
656 method: "GET",
657 path: "/api/runs",
658 handle: (c) => {
659 const day = c.url.searchParams.get("day");
660 return answer(
661 listRuns(c.db, c.who, {
662 ...(day === null ? {} : { day }),
663 limit: whole(c.url.searchParams.get("limit")) ?? 20,
664 }),
665 );
666 },
667 api: {
668 summary: "A day's leaderboard (today unless `day`), best first.",
669 query: {
670 day: { type: "string", format: "date" },
671 limit: { type: "integer", minimum: 1, maximum: 100, default: 20 },
672 },
673 answer: list(RUN),
674 },
675 },
676 {
677 method: "GET",
678 path: "/api/runs/:id",
679 handle: (c) => answer(getRun(c.db, c.who, { id: Number(c.params.id) })),
680 api: { summary: "One run, with its ghost.", answer: RUN },
681 },
682 {
683 method: "DELETE",
684 path: "/api/runs/:id",
685 handle: (c) => answer(deleteRun(c.db, c.who, { id: Number(c.params.id) })),
686 api: { summary: "Strike a run from the board.", answer: GONE },
687 },
688
260689 // Who tried to change what: the audit trail, for whoever the table lets read it.
261690 {
262691 method: "GET",
@@ -396,6 +825,36 @@ export const routes: readonly Route[] = [
396825 }),
397826 },
398827
828 // The game's code and content, linked by content and cached for a year.
829 {
830 method: "GET",
831 path: "/sim.js",
832 handle: (c) =>
833 new Response(simJs, {
834 headers: {
835 "content-type": "text/javascript; charset=utf-8",
836 "cache-control": c.url.searchParams.has("v")
837 ? "public, max-age=31536000, immutable"
838 : "no-cache",
839 ...SECURITY,
840 },
841 }),
842 },
843 {
844 method: "GET",
845 path: "/content.json",
846 handle: (c) =>
847 new Response(gameJson, {
848 headers: {
849 "content-type": "application/json; charset=utf-8",
850 "cache-control": c.url.searchParams.has("v")
851 ? "public, max-age=31536000, immutable"
852 : "no-cache",
853 ...SECURITY,
854 },
855 }),
856 },
857
399858 // What the web expects of a site. Crawlers read the page, not the API;
400859 // a preview is a draft and is read by nobody. Where to report a
401860 // vulnerability is the forge's to say: it runs this app.
src/server.test.ts +100
@@ -132,6 +132,8 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
132132 expect(hello.result.capabilities).toEqual({ tools: {} });
133133 const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
134134 expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([
135 "leaderboard",
136 "list_delves",
135137 "list_notes",
136138 "add_note",
137139 "delete_note",
@@ -339,3 +341,101 @@ test("what the web expects: robots.txt, and security.txt from the forge that run
339341 );
340342 }
341343 });
344
345 test("the hub page: today's dungeon, the tables, the square, and a canvas that holds its size", async () => {
346 const guest = await (await fetch(base + "/")).text();
347 expect(guest).toContain("Today's menu");
348 expect(guest).toContain('id="hub-view"');
349 expect(guest).toContain('data-signed-in="no"');
350 expect(guest).not.toContain('action="/delves"');
351 expect(guest).toMatch(/src="\/static\/hub\.js\?v=[0-9a-f]{8}"/);
352 const signed = await (await api("/", {}, alice)).text();
353 expect(signed).toContain('action="/delves"');
354 expect(signed).toContain('data-signed-in="yes"');
355 expect(signed).toContain('action="/hub/say"');
356 });
357
358 test("start a delve by form, land at its table, and watch its ticks stream", async () => {
359 const cook = as("u31", "cook");
360 const started = await api("/delves", { method: "POST", redirect: "manual" }, cook);
361 expect(started.status).toBe(303);
362 const where = started.headers.get("location")!;
363 expect(where).toMatch(/^\/delve\/\d+$/);
364 const page = await (await api(where, {}, cook)).text();
365 expect(page).toContain("cook's table");
366 expect(page).toMatch(/data-hero="\d+"/);
367 expect(page).toContain('data-act="dodge"');
368 // A stranger may watch, not play.
369 const watching = await (await fetch(base + where)).text();
370 expect(watching).toContain('data-hero=""');
371 expect(watching).not.toContain('data-act="attack"');
372
373 const id = where.split("/").at(-1);
374 const stream = await fetch(`${base}/delves/${id}/events`);
375 expect(stream.headers.get("content-type")).toBe("text/event-stream");
376 const reader = stream.body!.getReader();
377 let buffer = "";
378 while (!/data: .*"type":"tick"/.test(buffer))
379 buffer += new TextDecoder().decode((await reader.read()).value);
380 await reader.cancel();
381 const tick = JSON.parse(/data: (\{"type":"tick".*)\n/.exec(buffer)![1]!);
382 expect(tick.data.heroes[0].name).toBe("cook");
383 expect((await fetch(`${base}/delves/999999/events`)).status).toBe(404);
384 });
385
386 test("the API plays the same game: join, order, leave", async () => {
387 const host = as("u41", "host");
388 const guest = as("u42", "guest");
389 const opened = await api("/api/delves", { method: "POST" }, host);
390 expect(opened.status).toBe(201);
391 const delve = (await opened.json()) as { id: number };
392 expect((await api("/api/heroes", { method: "POST", ...jsonBody({ delve: delve.id }) })).status).toBe(401);
393 const joined = await api("/api/heroes", { method: "POST", ...jsonBody({ delve: delve.id }) }, guest);
394 expect(joined.status).toBe(201);
395 const hero = (await joined.json()) as { id: number; delve_id: number };
396 expect(hero.delve_id).toBe(delve.id);
397 const move = (who: Record<string, string>, body: unknown) =>
398 api(`/api/heroes/${hero.id}`, { method: "PATCH", ...jsonBody(body) }, who);
399 expect((await move(guest, { type: "move", x: 4, z: 5 })).status).toBe(200);
400 expect((await move(guest, { type: "move", x: 400, z: 5 })).status).toBe(422);
401 expect((await move(host, { type: "stop" })).status).toBe(403);
402 const party = (await (await fetch(`${base}/api/delves/${delve.id}`)).json()) as { party: unknown[] };
403 expect(party.party.length).toBe(2);
404 expect((await api(`/api/heroes/${hero.id}`, { method: "DELETE" }, guest)).status).toBe(200);
405 expect((await (await fetch(`${base}/api/runs`)).json())).toBeInstanceOf(Array);
406 });
407
408 test("the hub over the API: enter, walk, emote; a stranger watches", async () => {
409 const walker = as("u51", "walker");
410 const entered = await api("/api/avatars", { method: "POST" }, walker);
411 expect(entered.status).toBe(201);
412 const me = (await entered.json()) as { id: number };
413 const walked = await api(`/api/avatars/${me.id}`, { method: "PATCH", ...jsonBody({ x: 3, z: 4, emote: "wave" }) }, walker);
414 expect(walked.status).toBe(200);
415 expect(await walked.json()).toMatchObject({ tx: 3, tz: 4, emote: "wave" });
416 const all = (await (await fetch(`${base}/api/avatars`)).json()) as { id: number }[];
417 expect(all.map((a) => a.id)).toContain(me.id);
418 const hub = await fetch(`${base}/hub/events`);
419 expect(hub.headers.get("content-type")).toBe("text/event-stream");
420 await hub.body!.cancel();
421 // A form says something without a line of script.
422 const form = new FormData();
423 form.set("say", "yes chef");
424 const said = await api("/hub/say", { method: "POST", body: form, redirect: "manual" }, walker);
425 expect(said.status).toBe(303);
426 expect(await (await fetch(base + "/")).text()).toContain("yes chef");
427 });
428
429 test("the sim and the content are served by content, cached for a year", async () => {
430 const page = await (await api("/delve/1", {}, alice)).text();
431 const sim = /data-sim="(\/sim\.js\?v=[0-9a-f]{8})"/.exec(page)?.[1];
432 const content = /data-content="(\/content\.json\?v=[0-9a-f]{8})"/.exec(page)?.[1];
433 expect(sim).toBeDefined();
434 expect(content).toBeDefined();
435 const js = await fetch(base + sim!);
436 expect(js.headers.get("content-type")).toContain("javascript");
437 expect(js.headers.get("cache-control")).toContain("immutable");
438 expect(await js.text()).toContain("export function step");
439 const data = (await (await fetch(base + content!)).json()) as { monsters: { id: string }[] };
440 expect(data.monsters.map((m) => m.id)).toEqual(["meatball", "baguette", "pepper"]);
441 });
src/server.ts +4
@@ -7,6 +7,7 @@
77 * serves what the forge said; a test calls `serve` with its own.
88 */
99
10 import { sweepHub } from "./actions.ts";
1011 import { openDb } from "./db.ts";
1112 import { dataDir, identityKey, title, where } from "./env.ts";
1213 import { html, text } from "./http.ts";
@@ -23,6 +24,9 @@ export interface ServeOptions {
2324
2425 export function serve(options: ServeOptions) {
2526 const db = openDb(options.dataDir);
27 // Chefs who wandered off are shown out of the hub once a minute. Delves
28 // keep their own clock (src/delves.ts).
29 setInterval(() => sweepHub(), 60_000).unref();
2630 return Bun.serve({
2731 port: options.port,
2832 hostname: options.hostname ?? "127.0.0.1",
src/sim.test.ts added +183
@@ -0,0 +1,183 @@
1 import { expect, test } from "bun:test";
2 import { game } from "./content.ts";
3 import {
4 addHero,
5 type Content,
6 createWorld,
7 daySeed,
8 order,
9 planOf,
10 snapshot,
11 step,
12 trapUp,
13 type World,
14 } from "./sim.ts";
15
16 /** The real rules and cast, in rooms drawn for one test each. */
17 const kitchen = (...maps: string[][]): Content => ({
18 ...game,
19 rules: { ...game.rules, roomsPerRun: maps.length },
20 rooms: maps.map((map, i) => ({
21 id: `r${i}`,
22 name: `Room ${i}`,
23 ...(i === maps.length - 1 ? { final: true } : { depth: [i, i] as [number, number] }),
24 map,
25 })),
26 });
27 const run = (content: Content, world: World, ticks: number) => {
28 const seen: string[] = [];
29 for (let i = 0; i < ticks && world.over === null; i++)
30 for (const h of step(content, world)) seen.push(h.type);
31 return seen;
32 };
33
34 test("a day has one seed, and a seed one plan, ending in a final room", () => {
35 expect(daySeed("2026-09-29")).toBe(daySeed("2026-09-29"));
36 expect(daySeed("2026-09-29")).not.toBe(daySeed("2026-09-30"));
37 const plan = planOf(game, daySeed("2026-09-29"));
38 expect(plan).toEqual(planOf(game, daySeed("2026-09-29")));
39 expect(plan.length).toBe(game.rules.roomsPerRun);
40 expect(game.rooms.find((r) => r.id === plan.at(-1))?.final).toBe(true);
41 expect(new Set(plan).size).toBe(plan.length);
42 });
43
44 test("the same seed and the same orders make the same run", () => {
45 const play = () => {
46 const w = createWorld(game, 1234);
47 addHero(game, w, 1, "a");
48 addHero(game, w, 2, "b");
49 for (let t = 0; t < 600; t++) {
50 if (t % 20 === 0) {
51 const foe = w.monsters[0];
52 if (foe !== undefined) order(w, 1, { type: "attack", target: foe.id });
53 order(w, 2, { type: "move", x: 3 + (t % 7), z: 2 + (t % 5) });
54 }
55 if (t % 45 === 0) order(w, 2, { type: "dodge", x: 7, z: 5 });
56 step(game, w);
57 }
58 return JSON.stringify(snapshot(w));
59 };
60 expect(play()).toBe(play());
61 });
62
63 test("walls stop a chef", () => {
64 const c = kitchen(["#######", "#S...E#", "#######"]);
65 const w = createWorld(c, 1);
66 const hero = addHero(c, w, 1, "a");
67 order(w, 1, { type: "move", x: 1.5, z: -5 });
68 run(c, w, 40);
69 expect(hero.z).toBeGreaterThanOrEqual(1 + c.rules.hero.radius);
70 // The closed exit is a wall too.
71 order(w, 1, { type: "move", x: 9, z: 1.5 });
72 run(c, w, 80);
73 expect(hero.x).toBeLessThan(5);
74 });
75
76 test("melee: target a monster, cut it down, and the exit opens", () => {
77 const c = kitchen(["#########", "#S..m..E#", "#########"], ["#####", "#S.E#", "#####"]);
78 const w = createWorld(c, 1);
79 addHero(c, w, 1, "a");
80 order(w, 1, { type: "attack", target: w.monsters[0]!.id });
81 const seen = run(c, w, 200);
82 expect(seen).toContain("hit");
83 expect(seen).toContain("slain");
84 expect(seen).toContain("open");
85 expect(w.kills).toBe(1);
86 expect(w.score).toBe(game.monsters.find((m) => m.id === "meatball")!.score);
87 });
88
89 test("through the open exit is the next floor; out of the last is victory", () => {
90 const c = kitchen(["######", "#S..E#", "######"], ["######", "#S..E#", "######"]);
91 const w = createWorld(c, 1);
92 addHero(c, w, 1, "a");
93 order(w, 1, { type: "move", x: 4.5, z: 1.5 });
94 expect(run(c, w, 60)).toContain("room");
95 expect(w.depth).toBe(1);
96 order(w, 1, { type: "move", x: 4.5, z: 1.5 });
97 run(c, w, 60);
98 expect(w.over).toBe("victory");
99 expect(w.score).toBe(2 * c.rules.score.room + c.rules.score.victory);
100 });
101
102 test("a dodge rolls through spikes unhurt; walking through them hurts", () => {
103 const c = kitchen(["##########", "#S^^^^^.E#", "##########"]);
104 const walk = createWorld(c, 1);
105 const walker = addHero(c, walk, 1, "a");
106 walker.invuln = 0;
107 walk.tick = 0; // the spikes are up at the start of their period
108 order(walk, 1, { type: "move", x: 7.5, z: 1.5 });
109 expect(run(c, walk, 12)).toContain("hurt");
110
111 const roll = createWorld(c, 1);
112 const roller = addHero(c, roll, 1, "a");
113 roller.invuln = 0;
114 roll.tick = 0;
115 order(roll, 1, { type: "dodge", x: 9, z: 1.5 });
116 expect(run(c, roll, c.rules.hero.dodgeTicks)).not.toContain("hurt");
117 expect(roller.x).toBeGreaterThan(3);
118 expect(trapUp(c, "spikes", 0)).toBe(true);
119 });
120
121 test("the Baguette Knight winds up, charges, and stuns itself on a wall", () => {
122 const c = kitchen(["############", "#S....b....#", "#.........E#", "############"]);
123 const w = createWorld(c, 1);
124 addHero(c, w, 1, "a");
125 const knight = w.monsters[0]!;
126 knight.cd = 0;
127 const states = new Set<string>();
128 for (let t = 0; t < 120; t++) {
129 step(c, w);
130 states.add(knight.state);
131 }
132 expect([...states]).toEqual(expect.arrayContaining(["windup", "charge", "stun"]));
133 });
134
135 test("the Pepper Imp keeps its distance and throws seeds", () => {
136 const c = kitchen(["############", "#S.......p.#", "#.........E#", "############"]);
137 const w = createWorld(c, 1);
138 const hero = addHero(c, w, 1, "a");
139 hero.invuln = 0;
140 w.monsters[0]!.cd = 0;
141 let shots = 0;
142 for (let t = 0; t < 80; t++) {
143 step(c, w);
144 shots = Math.max(shots, w.shots.length);
145 }
146 expect(shots).toBeGreaterThan(0);
147 expect(w.heroes[0]!.hp).toBeLessThan(c.rules.hero.hp);
148 });
149
150 test("a chest opens to the touch and the loot is the seed's", () => {
151 const c = kitchen(["#######", "#SC..E#", "#######"]);
152 const open = (seed: number) => {
153 const w = createWorld(c, seed);
154 addHero(c, w, 1, "a");
155 order(w, 1, { type: "move", x: 2.5, z: 1.5 });
156 const seen = run(c, w, 20);
157 return { seen, loot: w.chests[0]!.open, gold: w.gold };
158 };
159 const first = open(7);
160 expect(first.seen).toContain("loot");
161 expect(game.loot.map((l) => l.id)).toContain(first.loot!);
162 expect(open(7)).toEqual(first);
163 });
164
165 test("a downed chef is revived by a teammate; a party all down is wiped", () => {
166 const c = kitchen(["#######", "#S...E#", "#######"]);
167 const w = createWorld(c, 1);
168 const a = addHero(c, w, 1, "a");
169 addHero(c, w, 2, "b");
170 a.hp = 0;
171 a.downed = true;
172 a.x = w.heroes[1]!.x;
173 a.z = w.heroes[1]!.z;
174 expect(run(c, w, c.rules.hero.revive + 2)).toContain("revived");
175 expect(a.downed).toBe(false);
176 for (const h of w.heroes) {
177 h.downed = true;
178 h.hp = 0;
179 h.x = 1.5 + h.id * 3; // too far apart to help each other
180 }
181 run(c, w, 2);
182 expect(w.over).toBe("wiped");
183 });
src/sim.ts added +834
@@ -0,0 +1,834 @@
1 /**
2 * A delve, as a pure function of time. `step(world)` advances one tick
3 * (1/20 s): heroes follow their orders, monsters their brains, shots fly,
4 * traps bite, chests open, rooms clear. The world is plain data and the
5 * randomness is a seeded generator inside it, so the same seed and the same
6 * orders make the same run on any machine — which is what makes a daily
7 * seed fair and a server tick authoritative.
8 *
9 * Self-contained on purpose: no imports. The server runs it for every
10 * instance, and the page loads the very same file (`/sim.js`, transpiled at
11 * boot) to lay out rooms. No `Math.sin`/`cos` here: engines may round those
12 * differently; `+ - * / sqrt` they may not.
13 */
14
15 // ── Content: what the JSON in content/ holds, checked by src/content.ts ──
16
17 export interface HeroRules {
18 readonly hp: number;
19 readonly radius: number;
20 readonly speed: number;
21 readonly damage: number;
22 readonly reach: number;
23 readonly swing: number;
24 readonly cooldown: number;
25 readonly dodgeSpeed: number;
26 readonly dodgeTicks: number;
27 readonly dodgeCooldown: number;
28 readonly invuln: number;
29 readonly revive: number;
30 }
31 export interface MonsterDef {
32 readonly id: string;
33 readonly name: string;
34 readonly glyph: string;
35 readonly behavior: "chase" | "charge" | "shoot";
36 readonly hp: number;
37 readonly radius: number;
38 readonly speed: number;
39 readonly damage: number;
40 readonly aggro: number;
41 readonly score: number;
42 readonly windup?: number;
43 readonly chargeSpeed?: number;
44 readonly chargeTicks?: number;
45 readonly stun?: number;
46 readonly range?: number;
47 readonly keepAway?: number;
48 readonly cooldown?: number;
49 readonly shotSpeed?: number;
50 }
51 export interface TrapDef {
52 readonly id: string;
53 readonly name: string;
54 readonly glyph: string;
55 readonly damage: number;
56 readonly period: number;
57 readonly active: number;
58 }
59 export interface LootDef {
60 readonly id: string;
61 readonly name: string;
62 readonly weight: number;
63 readonly gold?: number;
64 readonly heal?: number;
65 readonly power?: number;
66 }
67 export interface RoomDef {
68 readonly id: string;
69 readonly name: string;
70 readonly depth?: readonly [number, number];
71 readonly final?: boolean;
72 readonly map: readonly string[];
73 }
74 export interface Content {
75 readonly rules: {
76 readonly tickHz: number;
77 readonly roomsPerRun: number;
78 readonly maxTicks: number;
79 readonly hero: HeroRules;
80 readonly score: { readonly room: number; readonly victory: number };
81 };
82 readonly monsters: readonly MonsterDef[];
83 readonly traps: readonly TrapDef[];
84 readonly loot: readonly LootDef[];
85 readonly rooms: readonly RoomDef[];
86 }
87
88 // ── The world ──
89
90 export interface Hero {
91 readonly id: number;
92 readonly name: string;
93 x: number;
94 z: number;
95 fx: number;
96 fz: number;
97 hp: number;
98 power: number;
99 goal: { x: number; z: number } | null;
100 target: number | null;
101 wantSwing: boolean;
102 wantDodge: { x: number; z: number } | null;
103 swing: number;
104 cooldown: number;
105 dodge: number;
106 dodgeCd: number;
107 dx: number;
108 dz: number;
109 invuln: number;
110 downed: boolean;
111 revive: number;
112 }
113 export interface Monster {
114 readonly id: number;
115 readonly kind: string;
116 x: number;
117 z: number;
118 hp: number;
119 state: "idle" | "windup" | "charge" | "stun";
120 t: number;
121 dx: number;
122 dz: number;
123 cd: number;
124 hurt: number;
125 }
126 export interface Shot {
127 readonly id: number;
128 x: number;
129 z: number;
130 readonly dx: number;
131 readonly dz: number;
132 ttl: number;
133 readonly damage: number;
134 }
135 export interface Chest {
136 readonly id: number;
137 readonly x: number;
138 readonly z: number;
139 open: string | null;
140 }
141 export interface Trap {
142 readonly kind: string;
143 readonly col: number;
144 readonly row: number;
145 }
146 export interface Layout {
147 readonly w: number;
148 readonly h: number;
149 /** Row-major: true where nothing walks. */
150 readonly walls: readonly boolean[];
151 readonly start: { readonly x: number; readonly z: number };
152 readonly exit: { readonly x: number; readonly z: number };
153 readonly monsters: readonly { kind: string; x: number; z: number }[];
154 readonly chests: readonly { x: number; z: number }[];
155 readonly traps: readonly Trap[];
156 }
157 export type Outcome = "victory" | "wiped" | "timeout" | "abandoned";
158 /** What happened this tick, for the renderer and the log. */
159 export type Happening =
160 | { readonly type: "hit"; readonly by: number; readonly on: number; readonly damage: number }
161 | { readonly type: "hurt"; readonly hero: number; readonly damage: number }
162 | { readonly type: "down"; readonly hero: number }
163 | { readonly type: "revived"; readonly hero: number }
164 | { readonly type: "slain"; readonly monster: number; readonly kind: string }
165 | { readonly type: "loot"; readonly hero: number; readonly loot: string }
166 | { readonly type: "open" }
167 | { readonly type: "room"; readonly depth: number; readonly room: string }
168 | { readonly type: "over"; readonly outcome: Outcome };
169
170 export interface World {
171 readonly seed: number;
172 readonly plan: readonly string[];
173 tick: number;
174 depth: number;
175 rng: number;
176 nextId: number;
177 layout: Layout;
178 exitOpen: boolean;
179 heroes: Hero[];
180 monsters: Monster[];
181 shots: Shot[];
182 chests: Chest[];
183 gold: number;
184 kills: number;
185 score: number;
186 over: Outcome | null;
187 happened: Happening[];
188 }
189
190 /** An order from a player. The server checks the shape; the sim trusts it. */
191 export type Order =
192 | { readonly type: "move"; readonly x: number; readonly z: number }
193 | { readonly type: "attack"; readonly target?: number | undefined }
194 | { readonly type: "dodge"; readonly x: number; readonly z: number }
195 | { readonly type: "stop" };
196
197 // ── Randomness: mulberry32, its state kept in the world ──
198
199 /** The next number in [0, 1), advancing `world.rng`. */
200 export function random(world: { rng: number }): number {
201 world.rng = (world.rng + 0x6d2b79f5) | 0;
202 let t = world.rng;
203 t = Math.imul(t ^ (t >>> 15), t | 1);
204 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
205 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
206 }
207
208 /** A day's seed: FNV-1a of `YYYY-MM-DD`, so everyone delves the same dungeon today. */
209 export function daySeed(day: string): number {
210 let h = 0x811c9dc5;
211 for (let i = 0; i < day.length; i++) {
212 h ^= day.charCodeAt(i);
213 h = Math.imul(h, 0x01000193);
214 }
215 return h >>> 0;
216 }
217
218 /** Today, in UTC, as the daily seed spells it. */
219 export const dayOf = (ms: number): string =>
220 new Date(ms).toISOString().slice(0, 10);
221
222 // ── Rooms ──
223
224 /** A room's text map, read: walls, where things start. */
225 export function layoutOf(content: Content, room: RoomDef): Layout {
226 const h = room.map.length;
227 const w = room.map[0]?.length ?? 0;
228 const walls: boolean[] = [];
229 let start = { x: 1.5, z: 1.5 };
230 let exit = { x: w - 1.5, z: h - 1.5 };
231 const monsters: { kind: string; x: number; z: number }[] = [];
232 const chests: { x: number; z: number }[] = [];
233 const traps: Trap[] = [];
234 for (let row = 0; row < h; row++)
235 for (let col = 0; col < w; col++) {
236 const glyph = room.map[row]![col]!;
237 const at = { x: col + 0.5, z: row + 0.5 };
238 walls.push(glyph === "#");
239 if (glyph === "S") start = at;
240 else if (glyph === "E") exit = at;
241 else if (glyph === "C") chests.push(at);
242 else {
243 const monster = content.monsters.find((m) => m.glyph === glyph);
244 if (monster !== undefined) monsters.push({ kind: monster.id, ...at });
245 const trap = content.traps.find((t) => t.glyph === glyph);
246 if (trap !== undefined) traps.push({ kind: trap.id, col, row });
247 }
248 }
249 return { w, h, walls, start, exit, monsters, chests, traps };
250 }
251
252 /** Which rooms a seed delves, in order: one per floor, the last a final room. */
253 export function planOf(content: Content, seed: number): string[] {
254 const state = { rng: seed | 0 };
255 const plan: string[] = [];
256 const floors = content.rules.roomsPerRun;
257 for (let depth = 0; depth < floors - 1; depth++) {
258 const fits = content.rooms.filter(
259 (r) =>
260 r.final !== true &&
261 !plan.includes(r.id) &&
262 (r.depth === undefined || (depth >= r.depth[0] && depth <= r.depth[1])),
263 );
264 const pool = fits.length > 0 ? fits : content.rooms.filter((r) => r.final !== true);
265 plan.push(pool[Math.floor(random(state) * pool.length)]!.id);
266 }
267 const finals = content.rooms.filter((r) => r.final === true);
268 plan.push(finals[Math.floor(random(state) * finals.length)]!.id);
269 return plan;
270 }
271
272 const roomById = (content: Content, id: string): RoomDef => {
273 const room = content.rooms.find((r) => r.id === id);
274 if (room === undefined) throw new Error(`no room ${id}`);
275 return room;
276 };
277 const monsterDef = (content: Content, kind: string): MonsterDef =>
278 content.monsters.find((m) => m.id === kind)!;
279
280 // ── The world, made and changed ──
281
282 export function createWorld(content: Content, seed: number): World {
283 const plan = planOf(content, seed);
284 const world: World = {
285 seed,
286 plan,
287 tick: 0,
288 depth: 0,
289 rng: (seed ^ 0x5bd1e995) | 0,
290 nextId: 1,
291 layout: layoutOf(content, roomById(content, plan[0]!)),
292 exitOpen: false,
293 heroes: [],
294 monsters: [],
295 shots: [],
296 chests: [],
297 gold: 0,
298 kills: 0,
299 score: 0,
300 over: null,
301 happened: [],
302 };
303 enter(content, world, 0);
304 return world;
305 }
306
307 /** Set out a floor: its monsters and chests, every hero at the start. */
308 function enter(content: Content, world: World, depth: number): void {
309 world.depth = depth;
310 world.layout = layoutOf(content, roomById(content, world.plan[depth]!));
311 world.exitOpen = false;
312 world.shots = [];
313 world.monsters = world.layout.monsters.map((m) => ({
314 id: world.nextId++,
315 kind: m.kind,
316 x: m.x,
317 z: m.z,
318 hp: monsterDef(content, m.kind).hp,
319 state: "idle",
320 t: 0,
321 dx: 0,
322 dz: 0,
323 cd: Math.floor(random(world) * 30),
324 hurt: 0,
325 }));
326 world.chests = world.layout.chests.map((c) => ({
327 id: world.nextId++,
328 x: c.x,
329 z: c.z,
330 open: null,
331 }));
332 world.heroes.forEach((hero, i) => place(world, hero, i));
333 }
334
335 const SPOTS = [
336 [0, 0],
337 [0, -0.8],
338 [0, 0.8],
339 [0.8, 0],
340 ] as const;
341 function place(world: World, hero: Hero, i: number): void {
342 const { start } = world.layout;
343 const [ox, oz] = SPOTS[i % SPOTS.length]!;
344 hero.x = start.x + ox;
345 hero.z = start.z + oz;
346 hero.goal = null;
347 hero.target = null;
348 hero.dodge = 0;
349 hero.fx = 1;
350 hero.fz = 0;
351 }
352
353 /** A hero steps in. Whoever joins a delve under way starts at this floor's start. */
354 export function addHero(
355 content: Content,
356 world: World,
357 id: number,
358 name: string,
359 ): Hero {
360 const rules = content.rules.hero;
361 const hero: Hero = {
362 id,
363 name,
364 x: 0,
365 z: 0,
366 fx: 1,
367 fz: 0,
368 hp: rules.hp,
369 power: 0,
370 goal: null,
371 target: null,
372 wantSwing: false,
373 wantDodge: null,
374 swing: 0,
375 cooldown: 0,
376 dodge: 0,
377 dodgeCd: 0,
378 dx: 0,
379 dz: 0,
380 invuln: rules.invuln,
381 downed: false,
382 revive: 0,
383 };
384 place(world, hero, world.heroes.length);
385 world.heroes.push(hero);
386 return hero;
387 }
388
389 export function removeHero(world: World, id: number): void {
390 world.heroes = world.heroes.filter((h) => h.id !== id);
391 }
392
393 /** Take an order. It acts on the next tick. */
394 export function order(world: World, heroId: number, o: Order): void {
395 const hero = world.heroes.find((h) => h.id === heroId);
396 if (hero === undefined || hero.downed || world.over !== null) return;
397 if (o.type === "move") {
398 hero.goal = { x: o.x, z: o.z };
399 hero.target = null;
400 } else if (o.type === "attack") {
401 if (o.target !== undefined && world.monsters.some((m) => m.id === o.target)) {
402 hero.target = o.target;
403 hero.goal = null;
404 } else hero.wantSwing = true;
405 } else if (o.type === "dodge") hero.wantDodge = { x: o.x, z: o.z };
406 else {
407 hero.goal = null;
408 hero.target = null;
409 }
410 }
411
412 // ── Space ──
413
414 const solid = (world: World, col: number, row: number): boolean => {
415 const { w, h, walls, exit } = world.layout;
416 if (col < 0 || row < 0 || col >= w || row >= h) return true;
417 if (walls[row * w + col] === true) return true;
418 return (
419 !world.exitOpen &&
420 col === Math.floor(exit.x) &&
421 row === Math.floor(exit.z)
422 );
423 };
424
425 const blocked = (world: World, x: number, z: number, r: number): boolean =>
426 solid(world, Math.floor(x - r), Math.floor(z - r)) ||
427 solid(world, Math.floor(x + r), Math.floor(z - r)) ||
428 solid(world, Math.floor(x - r), Math.floor(z + r)) ||
429 solid(world, Math.floor(x + r), Math.floor(z + r));
430
431 /** Move a circle by (dx, dz), one axis at a time, stopping at walls. True when a wall stopped it. */
432 function slide(
433 world: World,
434 body: { x: number; z: number },
435 dx: number,
436 dz: number,
437 r: number,
438 ): boolean {
439 let hit = false;
440 if (!blocked(world, body.x + dx, body.z, r)) body.x += dx;
441 else hit = true;
442 if (!blocked(world, body.x, body.z + dz, r)) body.z += dz;
443 else hit = true;
444 return hit;
445 }
446
447 const dist = (a: { x: number; z: number }, b: { x: number; z: number }): number =>
448 Math.sqrt((a.x - b.x) ** 2 + (a.z - b.z) ** 2);
449
450 /** The unit vector from a toward b, or undefined when they meet. */
451 function toward(
452 a: { x: number; z: number },
453 b: { x: number; z: number },
454 ): { x: number; z: number } | undefined {
455 const d = dist(a, b);
456 return d < 1e-6 ? undefined : { x: (b.x - a.x) / d, z: (b.z - a.z) / d };
457 }
458
459 const round = (n: number): number => Math.round(n * 100) / 100;
460
461 // ── One tick ──
462
463 export function step(content: Content, world: World): Happening[] {
464 world.happened = [];
465 if (world.over !== null) return world.happened;
466 world.tick++;
467 const rules = content.rules.hero;
468 for (const hero of world.heroes) stepHero(content, world, hero, rules);
469 for (const monster of world.monsters) stepMonster(content, world, monster);
470 stepShots(world);
471 stepTraps(content, world);
472
473 const slain = world.monsters.filter((m) => m.hp <= 0);
474 for (const m of slain) {
475 world.kills++;
476 world.score += monsterDef(content, m.kind).score;
477 world.happened.push({ type: "slain", monster: m.id, kind: m.kind });
478 }
479 if (slain.length > 0) world.monsters = world.monsters.filter((m) => m.hp > 0);
480 if (!world.exitOpen && world.monsters.length === 0) {
481 world.exitOpen = true;
482 world.happened.push({ type: "open" });
483 }
484
485 // Out through the open door, together: whoever reaches it takes the party.
486 const out =
487 world.exitOpen &&
488 world.heroes.some((h) => !h.downed && dist(h, world.layout.exit) < 0.7);
489 if (out) {
490 world.score += content.rules.score.room;
491 if (world.depth + 1 >= world.plan.length) {
492 world.score += content.rules.score.victory;
493 finish(world, "victory");
494 } else {
495 for (const h of world.heroes)
496 if (h.downed) {
497 h.downed = false;
498 h.hp = 1;
499 h.revive = 0;
500 }
501 enter(content, world, world.depth + 1);
502 world.happened.push({
503 type: "room",
504 depth: world.depth,
505 room: world.plan[world.depth]!,
506 });
507 }
508 }
509
510 if (
511 world.over === null &&
512 world.heroes.length > 0 &&
513 world.heroes.every((h) => h.downed)
514 )
515 finish(world, "wiped");
516 if (world.over === null && world.tick >= content.rules.maxTicks)
517 finish(world, "timeout");
518 return world.happened;
519 }
520
521 export function finish(world: World, outcome: Outcome): void {
522 if (world.over !== null) return;
523 world.over = outcome;
524 world.happened.push({ type: "over", outcome });
525 }
526
527 function stepHero(
528 content: Content,
529 world: World,
530 hero: Hero,
531 rules: HeroRules,
532 ): void {
533 if (hero.cooldown > 0) hero.cooldown--;
534 if (hero.dodgeCd > 0) hero.dodgeCd--;
535 if (hero.invuln > 0) hero.invuln--;
536 if (hero.swing > 0) hero.swing--;
537
538 if (hero.downed) {
539 // A teammate standing close brings a downed chef back.
540 const helped = world.heroes.some(
541 (h) => h !== hero && !h.downed && dist(h, hero) < 1.3,
542 );
543 hero.revive = helped ? hero.revive + 1 : Math.max(0, hero.revive - 1);
544 if (hero.revive >= rules.revive) {
545 hero.downed = false;
546 hero.hp = Math.ceil(rules.hp / 3);
547 hero.revive = 0;
548 hero.invuln = rules.invuln * 2;
549 world.happened.push({ type: "revived", hero: hero.id });
550 }
551 return;
552 }
553
554 if (hero.wantDodge !== null) {
555 if (hero.dodgeCd === 0) {
556 const dir = toward(hero, hero.wantDodge) ?? { x: hero.fx, z: hero.fz };
557 hero.dx = dir.x;
558 hero.dz = dir.z;
559 hero.fx = dir.x;
560 hero.fz = dir.z;
561 hero.dodge = rules.dodgeTicks;
562 hero.dodgeCd = rules.dodgeCooldown;
563 hero.goal = null;
564 }
565 hero.wantDodge = null;
566 }
567 if (hero.dodge > 0) {
568 hero.dodge--;
569 hero.invuln = Math.max(hero.invuln, 1);
570 slide(world, hero, hero.dx * rules.dodgeSpeed, hero.dz * rules.dodgeSpeed, rules.radius);
571 } else if (hero.target !== null) {
572 const foe = world.monsters.find((m) => m.id === hero.target && m.hp > 0);
573 if (foe === undefined) hero.target = null;
574 else {
575 const d = dist(hero, foe);
576 const reach = rules.reach + monsterDef(content, foe.kind).radius * 0.5;
577 const dir = toward(hero, foe);
578 if (dir !== undefined) {
579 hero.fx = dir.x;
580 hero.fz = dir.z;
581 }
582 if (d > reach * 0.9 && dir !== undefined)
583 slide(world, hero, dir.x * rules.speed, dir.z * rules.speed, rules.radius);
584 else if (hero.cooldown === 0) hero.wantSwing = true;
585 }
586 } else if (hero.goal !== null) {
587 const d = dist(hero, hero.goal);
588 const dir = toward(hero, hero.goal);
589 if (dir === undefined || d <= rules.speed) {
590 if (!blocked(world, hero.goal.x, hero.goal.z, rules.radius)) {
591 hero.x = hero.goal.x;
592 hero.z = hero.goal.z;
593 }
594 hero.goal = null;
595 } else {
596 hero.fx = dir.x;
597 hero.fz = dir.z;
598 const before = { x: hero.x, z: hero.z };
599 slide(world, hero, dir.x * rules.speed, dir.z * rules.speed, rules.radius);
600 if (dist(before, hero) < rules.speed * 0.05) hero.goal = null; // stuck on a wall
601 }
602 }
603
604 if (hero.wantSwing) {
605 hero.wantSwing = false;
606 if (hero.cooldown === 0 && hero.dodge === 0) swing(content, world, hero, rules);
607 }
608
609 // Treasure opens to the touch.
610 for (const chest of world.chests)
611 if (chest.open === null && dist(hero, chest) < 0.8) {
612 const loot = roll(content, world);
613 chest.open = loot.id;
614 world.gold += loot.gold ?? 0;
615 world.score += loot.gold ?? 0;
616 hero.hp = Math.min(rules.hp, hero.hp + (loot.heal ?? 0));
617 hero.power += loot.power ?? 0;
618 world.happened.push({ type: "loot", hero: hero.id, loot: loot.id });
619 }
620 }
621
622 function swing(content: Content, world: World, hero: Hero, rules: HeroRules): void {
623 hero.swing = rules.swing;
624 hero.cooldown = rules.cooldown;
625 const damage = rules.damage + hero.power;
626 for (const m of world.monsters) {
627 if (m.hp <= 0) continue;
628 const def = monsterDef(content, m.kind);
629 const d = dist(hero, m);
630 if (d > rules.reach + def.radius) continue;
631 const dir = toward(hero, m);
632 const facing = dir === undefined ? 1 : dir.x * hero.fx + dir.z * hero.fz;
633 if (facing < 0.2 && d > 0.5) continue;
634 m.hp -= damage;
635 m.hurt = 4;
636 if (dir !== undefined) slide(world, m, dir.x * 0.4, dir.z * 0.4, def.radius);
637 world.happened.push({ type: "hit", by: hero.id, on: m.id, damage });
638 }
639 }
640
641 function roll(content: Content, world: World) {
642 const total = content.loot.reduce((sum, l) => sum + l.weight, 0);
643 let pick = random(world) * total;
644 for (const loot of content.loot) {
645 pick -= loot.weight;
646 if (pick < 0) return loot;
647 }
648 return content.loot[content.loot.length - 1]!;
649 }
650
651 function hurt(content: Content, world: World, hero: Hero, damage: number): boolean {
652 if (hero.downed || hero.invuln > 0 || hero.dodge > 0) return false;
653 hero.hp -= damage;
654 hero.invuln = content.rules.hero.invuln;
655 world.happened.push({ type: "hurt", hero: hero.id, damage });
656 if (hero.hp <= 0) {
657 hero.hp = 0;
658 hero.downed = true;
659 hero.goal = null;
660 hero.target = null;
661 hero.revive = 0;
662 world.happened.push({ type: "down", hero: hero.id });
663 }
664 return true;
665 }
666
667 function nearestHero(world: World, m: Monster): Hero | undefined {
668 let best: Hero | undefined;
669 let bestD = Infinity;
670 for (const h of world.heroes) {
671 if (h.downed) continue;
672 const d = dist(h, m);
673 if (d < bestD) {
674 best = h;
675 bestD = d;
676 }
677 }
678 return best;
679 }
680
681 function stepMonster(content: Content, world: World, m: Monster): void {
682 const def = monsterDef(content, m.kind);
683 if (m.hurt > 0) m.hurt--;
684 if (m.cd > 0) m.cd--;
685 const prey = nearestHero(world, m);
686 if (prey === undefined) return;
687 const d = dist(m, prey);
688 const dir = toward(m, prey);
689 const touching = d < def.radius + content.rules.hero.radius;
690
691 if (def.behavior === "chase") {
692 if (d < def.aggro && dir !== undefined && !touching)
693 slide(world, m, dir.x * def.speed, dir.z * def.speed, def.radius);
694 // A bite, then a bounce back: room to breathe, and to swing.
695 if (touching && hurt(content, world, prey, def.damage) && dir !== undefined)
696 slide(world, m, -dir.x * 0.6, -dir.z * 0.6, def.radius);
697 return;
698 }
699
700 if (def.behavior === "charge") {
701 if (m.state === "idle") {
702 if (d < def.aggro && m.cd === 0 && dir !== undefined) {
703 m.state = "windup";
704 m.t = def.windup ?? 15;
705 m.dx = dir.x;
706 m.dz = dir.z;
707 } else if (d < def.aggro * 1.5 && dir !== undefined && !touching)
708 slide(world, m, dir.x * def.speed, dir.z * def.speed, def.radius);
709 } else if (m.state === "windup") {
710 if (--m.t <= 0) {
711 m.state = "charge";
712 m.t = def.chargeTicks ?? 15;
713 }
714 } else if (m.state === "charge") {
715 const speed = def.chargeSpeed ?? 0.3;
716 const wall = slide(world, m, m.dx * speed, m.dz * speed, def.radius);
717 for (const h of world.heroes)
718 if (dist(h, m) < def.radius + content.rules.hero.radius)
719 hurt(content, world, h, def.damage);
720 if (wall) {
721 m.state = "stun";
722 m.t = def.stun ?? 30;
723 } else if (--m.t <= 0) {
724 m.state = "stun";
725 m.t = Math.floor((def.stun ?? 30) / 2);
726 }
727 } else if (--m.t <= 0) {
728 m.state = "idle";
729 m.cd = 20;
730 }
731 if (m.state !== "charge" && touching) hurt(content, world, prey, 1);
732 return;
733 }
734
735 // shoot: keep a distance, and throw seeds.
736 if (d >= def.aggro || dir === undefined) return;
737 const keep = def.keepAway ?? 3;
738 const range = def.range ?? 6;
739 if (d < keep) slide(world, m, -dir.x * def.speed, -dir.z * def.speed, def.radius);
740 else if (d > range) slide(world, m, dir.x * def.speed, dir.z * def.speed, def.radius);
741 if (m.cd === 0 && d <= range + 0.5) {
742 const speed = def.shotSpeed ?? 0.18;
743 world.shots.push({
744 id: world.nextId++,
745 x: m.x,
746 z: m.z,
747 dx: dir.x * speed,
748 dz: dir.z * speed,
749 ttl: Math.ceil((range + 3) / speed),
750 damage: def.damage,
751 });
752 m.cd = def.cooldown ?? 45;
753 }
754 }
755
756 function stepShots(world: World): void {
757 world.shots = world.shots.filter((s) => {
758 s.x += s.dx;
759 s.z += s.dz;
760 s.ttl--;
761 return s.ttl > 0 && !solid(world, Math.floor(s.x), Math.floor(s.z));
762 });
763 }
764
765 function stepTraps(content: Content, world: World): void {
766 // Shots land here too: one pass over the heroes for everything that bites.
767 for (const hero of world.heroes) {
768 // A seed splats on whoever it reaches, unless they are rolling through it.
769 world.shots = world.shots.filter((s) => {
770 if (hero.downed || hero.dodge > 0 || dist(s, hero) > 0.4) return true;
771 hurt(content, world, hero, s.damage);
772 return false;
773 });
774 const col = Math.floor(hero.x);
775 const row = Math.floor(hero.z);
776 for (const trap of world.layout.traps) {
777 if (trap.col !== col || trap.row !== row) continue;
778 const def = content.traps.find((t) => t.id === trap.kind)!;
779 if (world.tick % def.period < def.active) hurt(content, world, hero, def.damage);
780 }
781 }
782 }
783
784 /** Whether a trap is up this tick: for the renderer. */
785 export const trapUp = (content: Content, kind: string, tick: number): boolean => {
786 const def = content.traps.find((t) => t.id === kind);
787 return def !== undefined && tick % def.period < def.active;
788 };
789
790 // ── The wire ──
791
792 /** What a watcher needs to draw a tick: small, rounded, plain. */
793 export function snapshot(world: World) {
794 return {
795 t: world.tick,
796 depth: world.depth,
797 room: world.plan[world.depth]!,
798 rooms: world.plan.length,
799 exit: world.exitOpen,
800 over: world.over,
801 gold: world.gold,
802 kills: world.kills,
803 score: world.score,
804 heroes: world.heroes.map((h) => ({
805 id: h.id,
806 name: h.name,
807 x: round(h.x),
808 z: round(h.z),
809 fx: round(h.fx),
810 fz: round(h.fz),
811 hp: h.hp,
812 power: h.power,
813 swing: h.swing,
814 dodge: h.dodge > 0,
815 hit: h.invuln > 0 && h.dodge === 0,
816 downed: h.downed,
817 revive: h.revive,
818 })),
819 monsters: world.monsters.map((m) => ({
820 id: m.id,
821 kind: m.kind,
822 x: round(m.x),
823 z: round(m.z),
824 hp: m.hp,
825 state: m.state,
826 hurt: m.hurt > 0,
827 fx: round(m.dx),
828 fz: round(m.dz),
829 })),
830 shots: world.shots.map((s) => ({ id: s.id, x: round(s.x), z: round(s.z) })),
831 chests: world.chests.map((c) => ({ id: c.id, x: c.x, z: c.z, open: c.open })),
832 };
833 }
834 export type Snapshot = ReturnType<typeof snapshot>;
static/app.css +217 −1
@@ -15,7 +15,7 @@ body {
1515 font: var(--text-base) / var(--leading-body) var(--font-sans);
1616 }
1717 .page {
18 max-width: 40rem;
18 max-width: 68rem;
1919 margin: 0 auto;
2020 padding: var(--space-5) var(--space-4) var(--space-6);
2121 }
@@ -169,6 +169,222 @@ footer code {
169169 border-color: currentColor;
170170 }
171171
172 /* Headings inside cards, and the name at the top that goes home. */
173 h2 {
174 margin: 0 0 var(--space-3);
175 font-size: var(--text-lg);
176 line-height: var(--leading-tight);
177 }
178 h2 small {
179 font-weight: 400;
180 font-size: var(--text-sm);
181 }
182 main > h2 {
183 margin-bottom: var(--space-3);
184 }
185 .home {
186 color: inherit;
187 text-decoration: none;
188 }
189 .grid {
190 display: grid;
191 grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
192 gap: 0 var(--space-4);
193 align-items: start;
194 }
195 .grid .card,
196 .controls {
197 display: grid;
198 gap: var(--space-3);
199 }
200 .row.wrap {
201 flex-wrap: wrap;
202 }
203 .item {
204 display: flex;
205 justify-content: space-between;
206 align-items: center;
207 gap: var(--space-3);
208 padding: var(--space-3) 0;
209 border-top: 1px solid var(--color-line);
210 }
211 .item:first-child {
212 border-top: 0;
213 }
214 .item p {
215 overflow-wrap: anywhere;
216 }
217 .item small {
218 font-size: var(--text-sm);
219 }
220 .list-plain,
221 .howto {
222 margin: 0;
223 padding-left: var(--space-5);
224 }
225 .howto li + li {
226 margin-top: var(--space-1);
227 }
228 .chat {
229 list-style: none;
230 margin: 0;
231 padding: 0;
232 display: grid;
233 gap: var(--space-1);
234 overflow-wrap: anywhere;
235 }
236 .score {
237 font-size: var(--text-lg);
238 font-variant-numeric: tabular-nums;
239 }
240 .scrawl {
241 font-weight: 600;
242 font-style: italic;
243 }
244 .callout {
245 padding: var(--space-3);
246 border-left: 3px solid var(--color-accent);
247 background: var(--color-bg);
248 }
249 .share {
250 overflow-wrap: anywhere;
251 }
252 kbd {
253 font-family: var(--font-mono);
254 font-size: var(--text-sm);
255 padding: 0 var(--space-1);
256 border: 1px solid var(--color-line);
257 border-radius: 4px;
258 }
259 .button-big {
260 min-height: calc(var(--size-tap) + 8px);
261 padding: 0 var(--space-5);
262 font-size: var(--text-lg);
263 }
264 .button-alt {
265 color: var(--color-fg);
266 background: var(--color-surface);
267 border-color: var(--color-line);
268 }
269
270 /* The stage: a canvas that is its size before a pixel is drawn, so nothing moves. */
271 .stage {
272 margin-bottom: var(--space-5);
273 }
274 .viewport {
275 position: relative;
276 aspect-ratio: 4 / 3;
277 max-height: 72vh;
278 margin: 0 auto;
279 overflow: hidden;
280 background: var(--color-game-sky);
281 border: 1px solid var(--color-line);
282 border-radius: var(--radius-md);
283 }
284 .viewport canvas {
285 display: block;
286 width: 100%;
287 height: 100%;
288 touch-action: none;
289 cursor: crosshair;
290 }
291 .viewport-note {
292 position: absolute;
293 inset: auto var(--space-4) var(--space-4);
294 padding: var(--space-3);
295 text-align: center;
296 color: var(--color-fg);
297 background: var(--color-surface);
298 border-radius: var(--radius-md);
299 }
300 .viewport.ready .viewport-note {
301 display: none;
302 }
303 .tags {
304 position: absolute;
305 inset: 0;
306 pointer-events: none;
307 }
308 .tag {
309 position: absolute;
310 left: 0;
311 top: 0;
312 display: grid;
313 justify-items: center;
314 gap: 2px;
315 font-size: var(--text-sm);
316 line-height: var(--leading-tight);
317 white-space: nowrap;
318 }
319 .tag b {
320 padding: 1px var(--space-2);
321 color: var(--color-fg);
322 background: var(--color-surface);
323 border-radius: var(--radius-pill);
324 }
325 .tag-me b {
326 color: var(--color-accent-fg);
327 background: var(--color-accent);
328 }
329 .tag span {
330 order: -1;
331 max-width: 14rem;
332 padding: var(--space-1) var(--space-2);
333 white-space: normal;
334 overflow-wrap: anywhere;
335 color: var(--color-toast-fg);
336 background: var(--color-toast-bg);
337 border-radius: var(--radius-md);
338 }
339 .hud {
340 position: absolute;
341 top: var(--space-2);
342 left: var(--space-2);
343 right: var(--space-2);
344 display: flex;
345 flex-wrap: wrap;
346 gap: var(--space-1) var(--space-2);
347 pointer-events: none;
348 font-size: var(--text-sm);
349 }
350 .hud p:empty {
351 display: none;
352 }
353 .hud p {
354 padding: 2px var(--space-2);
355 color: var(--color-fg);
356 background: var(--color-surface);
357 border-radius: var(--radius-pill);
358 }
359 .over {
360 position: absolute;
361 inset: 0;
362 display: grid;
363 place-content: center;
364 justify-items: center;
365 gap: var(--space-3);
366 padding: var(--space-4);
367 text-align: center;
368 color: var(--color-fg);
369 background: var(--color-surface);
370 }
371 .over[hidden] {
372 display: none;
373 }
374 .over-title {
375 font-size: var(--text-xl);
376 font-weight: 700;
377 }
378 .pad {
379 display: grid;
380 grid-template-columns: 1fr 1fr;
381 gap: var(--space-3);
382 margin-top: var(--space-3);
383 }
384 .pad .button {
385 justify-content: center;
386 }
387
172388 /* A word from the last request, shown once, gone in a moment. */
173389 .toast {
174390 position: fixed;
static/delve.js added +401
@@ -0,0 +1,401 @@
1 // A delve, drawn. The server runs the world (src/sim.ts, twenty ticks a
2 // second) and sends every other tick down /delves/:id/events; this file
3 // draws between the last two it heard, and sends orders back: click or tap
4 // the floor to walk, a monster to fight it, shift-click or right-click to
5 // dodge; WASD/arrows, Space to attack, K to dodge. The same sim.ts, served
6 // as /sim.js, lays out the rooms here.
7
8 import { compose, createRenderer, drawChef, paint } from "./gl.js";
9
10 const root = document.getElementById("delve-view");
11 const canvas = root?.querySelector("canvas");
12 const renderer = canvas ? createRenderer(canvas) : null;
13 const tags = root?.querySelector(".tags");
14 const hud = (name) => document.querySelector(`[data-hud="${name}"]`);
15 const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
16 const heroId = Number(root?.dataset.hero || 0) || null;
17
18 let sim = null;
19 let game = null;
20 let prev = null;
21 let cur = null;
22 let curAt = 0;
23 let roomMesh = null;
24 let roomId = null;
25 let layout = null;
26 let ghost = null;
27 let marker = null;
28
29 const say = (text) => {
30 const el = hud("status");
31 if (el) el.textContent = text;
32 };
33
34 // ── Orders, paced to the door: a burst, then two a second; the newest wins ──
35
36 let tokens = 16;
37 let refilled = performance.now();
38 let pending = null;
39 let inFlight = false;
40
41 function orderUp(order) {
42 if (heroId === null || cur?.over) return;
43 pending = order;
44 pump();
45 }
46 async function pump() {
47 if (inFlight || pending === null) return;
48 const now = performance.now();
49 tokens = Math.min(16, tokens + ((now - refilled) / 1000) * 2);
50 refilled = now;
51 if (tokens < 1) {
52 setTimeout(pump, ((1 - tokens) / 2) * 1000 + 10);
53 return;
54 }
55 tokens -= 1;
56 const order = pending;
57 pending = null;
58 inFlight = true;
59 try {
60 const r = await fetch(`/api/heroes/${heroId}`, {
61 method: "PATCH",
62 headers: { "content-type": "application/json" },
63 body: JSON.stringify(order),
64 });
65 if (r.status === 429) {
66 tokens = 0;
67 pending ??= order;
68 } else if (!r.ok) say((await r.json().catch(() => null))?.message ?? "");
69 } catch {
70 say("The kitchen went quiet. Reconnecting…");
71 } finally {
72 inFlight = false;
73 if (pending !== null) pump();
74 }
75 }
76
77 // ── What the world looks like between two ticks ──
78
79 const lerp = (a, b, f) => a + (b - a) * f;
80 function blend(list, before, f) {
81 const old = new Map((before ?? []).map((e) => [e.id, e]));
82 return list.map((e) => {
83 const o = old.get(e.id);
84 return o ? { ...e, x: lerp(o.x, e.x, f), z: lerp(o.z, e.z, f) } : e;
85 });
86 }
87 const mine = () => cur?.heroes.find((h) => h.id === heroId);
88
89 function bakeRoom() {
90 const def = game.rooms.find((r) => r.id === cur.room);
91 layout = sim.layoutOf(game, def);
92 const parts = [];
93 for (let row = 0; row < layout.h; row++)
94 for (let col = 0; col < layout.w; col++) {
95 const wall = layout.walls[row * layout.w + col];
96 const near = row === layout.h - 1;
97 parts.push({
98 shape: "box",
99 model: wall
100 ? compose(col + 0.5, 0, row + 0.5, 0, 1, near ? 0.25 : 1.1, 1)
101 : compose(col + 0.5, -0.1, row + 0.5, 0, 1, 0.1, 1),
102 color: paint(wall ? "game-wall" : (col + row) % 2 ? "game-floor" : "game-floor-alt"),
103 });
104 }
105 for (const t of layout.traps)
106 parts.push({
107 shape: "box",
108 model: compose(t.col + 0.5, 0, t.row + 0.5, 0, 0.9, 0.03, 0.9),
109 color: paint("game-spike"),
110 });
111 if (roomMesh) renderer.drop(roomMesh);
112 roomMesh = renderer.bake(parts);
113 roomId = cur.room;
114 hud("room").textContent = `${def.name} · floor ${cur.depth + 1} of ${cur.rooms}`;
115 }
116
117 function frame(now) {
118 requestAnimationFrame(frame);
119 if (!cur || !game || !sim) return;
120 if (roomId !== cur.room) bakeRoom();
121 const f = prev && prev.room === cur.room ? Math.min(1, (now - curAt) / 100) : 1;
122 const heroes = blend(cur.heroes, prev?.heroes, f);
123 const monsters = blend(cur.monsters, prev?.monsters, f);
124 const shots = blend(cur.shots, prev?.shots, f);
125 const t = cur.t + f * 2;
126 const cx = layout.w / 2;
127 renderer.frame([cx, 13, layout.h + 6.5], [cx, 0, layout.h / 2 + 0.3]);
128 renderer.draw(roomMesh);
129
130 // The door, shut until the room is clear.
131 const ex = layout.exit;
132 if (cur.exit) renderer.draw("box", compose(ex.x, 0, ex.z, 0, 0.9, 0.06, 0.9), paint("game-exit"));
133 else renderer.draw("box", compose(ex.x, 0, ex.z, 0, 0.9, 1.3, 0.9), paint("game-door"));
134
135 for (const tr of layout.traps)
136 if (sim.trapUp(game, tr.kind, Math.floor(t)))
137 for (const [dx, dz] of [[-0.2, -0.2], [0.2, -0.2], [-0.2, 0.2], [0.2, 0.2]])
138 renderer.draw("pyramid", compose(tr.col + 0.5 + dx, 0, tr.row + 0.5 + dz, 0, 0.22, 0.45, 0.22), paint("game-spike"));
139
140 for (const c of cur.chests) {
141 renderer.draw("box", compose(c.x, 0, c.z, 0, 0.7, 0.4, 0.5), paint("game-chest"));
142 renderer.draw("box", compose(c.x, 0.4, c.z + (c.open ? -0.25 : 0), 0, 0.72, 0.12, 0.52, c.open ? -1.1 : 0), paint("game-chest"));
143 if (c.open) renderer.draw("gem", compose(c.x, 0.55, c.z, t / 10, 0.3, 0.3, 0.3), paint("game-gold"));
144 }
145
146 for (const m of monsters) drawMonster(m, t);
147 for (const s of shots) renderer.draw("gem", compose(s.x, 0.35, s.z, t / 3, 0.18, 0.18, 0.18), paint("game-seed"));
148
149 if (ghost) {
150 const g = ghostAt(t);
151 if (g) drawChef(renderer, g.x, g.z, g.face, { alpha: 0.35, tint: paint("game-ghost") });
152 }
153 if (marker && now - marker.at < 600)
154 renderer.draw("prism", compose(marker.x, 0.01, marker.z, 0, 0.5, 0.03, 0.5), paint(marker.dodge ? "game-gold" : "game-apron"), 0.7);
155
156 const labels = [];
157 for (const h of heroes) {
158 const face = Math.atan2(h.fx, h.fz);
159 if (h.id === heroId)
160 renderer.draw("prism", compose(h.x, 0.01, h.z, 0, 0.8, 0.02, 0.8), paint("game-apron"), 0.5);
161 const blink = h.hit && !reduced && Math.floor(now / 80) % 2 === 0;
162 drawChef(renderer, h.x, h.z, face, {
163 swing: h.swing,
164 lying: h.downed,
165 lift: h.dodge ? 0.15 : 0,
166 tint: blink ? paint("game-pepper") : undefined,
167 });
168 const at = renderer.project(h.x, 1.5, h.z);
169 if (at) labels.push({ id: h.id, at, h });
170 }
171 drawTags(labels);
172 }
173
174 function drawMonster(m, t) {
175 const hurt = m.hurt ? [1, 1, 1] : null;
176 const face = Math.atan2(m.fx || 0, m.fz || 1);
177 if (m.kind === "meatball") {
178 const bob = reduced ? 0 : Math.abs(Math.sin(t / 3 + m.id)) * 0.12;
179 renderer.draw("gem", compose(m.x, bob, m.z, t / 8, 0.75, 0.7, 0.75), hurt ?? paint("game-meatball"));
180 } else if (m.kind === "baguette") {
181 const shake = m.state === "windup" && !reduced ? Math.sin(t * 3) * 0.06 : 0;
182 const spin = m.state === "stun" ? t / 2 : face;
183 renderer.draw("box", compose(m.x + shake, 0.05, m.z, spin, 0.3, 0.32, 1.05, m.state === "charge" ? 0.2 : 0), hurt ?? paint("game-baguette"));
184 if (m.state === "stun")
185 renderer.draw("gem", compose(m.x, 0.7, m.z, t / 2, 0.18, 0.18, 0.18), paint("game-gold"));
186 } else if (m.kind === "pepper") {
187 const hover = reduced ? 0.15 : 0.15 + Math.sin(t / 4 + m.id) * 0.08;
188 renderer.draw("pyramid", compose(m.x, hover + 0.75, m.z, face, 0.5, 0.75, 0.5, Math.PI), hurt ?? paint("game-pepper"));
189 renderer.draw("box", compose(m.x, hover + 0.75, m.z, face, 0.14, 0.2, 0.14), paint("game-leaf"));
190 } else {
191 renderer.draw("gem", compose(m.x, 0, m.z, 0, 0.6, 0.6, 0.6), hurt ?? paint("game-wall"));
192 }
193 }
194
195 const tagEls = new Map();
196 function drawTags(labels) {
197 const seen = new Set();
198 for (const { id, at, h } of labels) {
199 seen.add(id);
200 let el = tagEls.get(id);
201 if (!el) {
202 el = document.createElement("div");
203 el.className = "tag";
204 el.append(document.createElement("b"), document.createElement("span"));
205 tags.append(el);
206 tagEls.set(id, el);
207 }
208 el.classList.toggle("tag-me", id === heroId);
209 el.firstChild.textContent = h.name;
210 const note = h.downed ? `down · ${Math.round((h.revive / game.rules.hero.revive) * 100)}%` : "";
211 el.lastChild.textContent = note;
212 el.lastChild.hidden = note === "";
213 el.style.transform = `translate(${at.x.toFixed(1)}px, ${at.y.toFixed(1)}px) translate(-50%, -100%)`;
214 }
215 for (const [id, el] of tagEls)
216 if (!seen.has(id)) {
217 el.remove();
218 tagEls.delete(id);
219 }
220 }
221
222 // ── The ghost: today's best run, where its lead chef was, twice a second ──
223
224 function ghostAt(t) {
225 const every = 10;
226 const i = t / every - 1;
227 const a = ghost[Math.floor(i)];
228 const b = ghost[Math.floor(i) + 1] ?? a;
229 if (!a || a[0] !== cur.depth) return null;
230 const f = i - Math.floor(i);
231 const x = lerp(a[1], b[0] === a[0] ? b[1] : a[1], f);
232 const z = lerp(a[2], b[0] === a[0] ? b[2] : a[2], f);
233 const face = Math.atan2(b[1] - a[1], b[2] - a[2]) || 0;
234 return { x, z, face };
235 }
236
237 async function loadGhost(day) {
238 try {
239 const best = await (await fetch(`/api/runs?day=${encodeURIComponent(day)}&limit=1`)).json();
240 if (!Array.isArray(best) || best.length === 0) return;
241 const run = await (await fetch(`/api/runs/${best[0].id}`)).json();
242 if (typeof run.ghost !== "string" || run.ghost === "") return;
243 ghost = run.ghost.split(";").map((s) => {
244 const [d, x, z] = s.split(",").map(Number);
245 return [d, x / 10, z / 10];
246 });
247 hud("ghost").textContent = `Racing the ghost of ${run.author_name} (${run.score})`;
248 } catch {}
249 }
250
251 // ── The HUD ──
252
253 const OUTCOME = {
254 victory: "Order up! The Pass is cleared.",
255 wiped: "Burnt to a crisp. The party is down.",
256 timeout: "Kitchen's closed: out of time.",
257 abandoned: "Everyone left the table.",
258 };
259 function showHud() {
260 const me = mine();
261 hud("score").textContent = `${cur.score} pts · ${cur.gold} gold · ${cur.kills} down`;
262 hud("hp").textContent = me
263 ? `${"♥".repeat(me.hp)}${"♡".repeat(Math.max(0, game.rules.hero.hp - me.hp))}${me.power ? ` · +${me.power} whisk` : ""}`
264 : "Watching";
265 hud("party").textContent = cur.heroes
266 .map((h) => `${h.name} ${h.downed ? "(down)" : `${h.hp}♥`}`)
267 .join(" · ");
268 if (me?.downed && !cur.over) say("You're down! A teammate standing beside you brings you back.");
269 const over = document.getElementById("over");
270 if (cur.over && over?.hidden) {
271 over.hidden = false;
272 over.querySelector("[data-outcome]").textContent = OUTCOME[cur.over] ?? cur.over;
273 over.querySelector("[data-final]").textContent =
274 `${cur.score} points · floor ${cur.depth + 1} of ${cur.rooms} · ${cur.kills} monsters · ${cur.gold} gold`;
275 }
276 }
277
278 // ── Input ──
279
280 function nearestMonster(x, z, within) {
281 let best = null;
282 let d = within;
283 for (const m of cur?.monsters ?? []) {
284 const e = Math.hypot(m.x - x, m.z - z);
285 if (e < d) {
286 d = e;
287 best = m;
288 }
289 }
290 return best;
291 }
292
293 let lastDir = null;
294 function dodgeToward(x, z) {
295 marker = { x, z, at: performance.now(), dodge: true };
296 orderUp({ type: "dodge", x, z });
297 }
298 function attackNearest() {
299 const me = mine();
300 if (!me) return;
301 const m = nearestMonster(me.x, me.z, 4);
302 orderUp(m ? { type: "attack", target: m.id } : { type: "attack" });
303 }
304 function dodgeAhead() {
305 const me = mine();
306 if (!me) return;
307 const [dx, dz] = lastDir ?? [me.fx, me.fz];
308 dodgeToward(me.x + dx * 3, me.z + dz * 3);
309 }
310 const clampTo = (x, z) => ({
311 x: Math.min(layout.w - 0.01, Math.max(0.01, x)),
312 z: Math.min(layout.h - 0.01, Math.max(0.01, z)),
313 });
314
315 const KEYS = { ArrowUp: [0, -1], w: [0, -1], ArrowDown: [0, 1], s: [0, 1],
316 ArrowLeft: [-1, 0], a: [-1, 0], ArrowRight: [1, 0], d: [1, 0] };
317 const held = new Set();
318
319 function listen() {
320 if (heroId === null) return;
321 canvas.addEventListener("contextmenu", (e) => e.preventDefault());
322 canvas.addEventListener("pointerdown", (e) => {
323 if (!layout) return;
324 const rect = canvas.getBoundingClientRect();
325 const g = renderer.ground(e.clientX - rect.left, e.clientY - rect.top);
326 if (!g) return;
327 const p = clampTo(g.x, g.z);
328 if (e.button === 2 || e.shiftKey) return dodgeToward(p.x, p.z);
329 const m = nearestMonster(p.x, p.z, 0.9);
330 marker = { ...p, at: performance.now(), dodge: false };
331 orderUp(m ? { type: "attack", target: m.id } : { type: "move", ...p });
332 });
333 document.addEventListener("keydown", (e) => {
334 if (e.target instanceof HTMLInputElement || e.metaKey || e.ctrlKey) return;
335 const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
336 if (KEYS[k]) {
337 e.preventDefault();
338 held.add(k);
339 steer();
340 } else if (k === " " || k === "j") {
341 e.preventDefault();
342 attackNearest();
343 } else if (k === "k" || k === "Shift") {
344 e.preventDefault();
345 dodgeAhead();
346 }
347 });
348 document.addEventListener("keyup", (e) => {
349 const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
350 if (held.delete(k) && held.size === 0) orderUp({ type: "stop" });
351 });
352 document.querySelector("[data-act=attack]")?.addEventListener("click", attackNearest);
353 document.querySelector("[data-act=dodge]")?.addEventListener("click", dodgeAhead);
354 }
355
356 // Held keys walk a couple of tiles ahead, again while they stay held.
357 let steering = null;
358 function steer() {
359 const me = mine();
360 if (!me || held.size === 0 || !layout) {
361 clearTimeout(steering);
362 steering = null;
363 return;
364 }
365 let dx = 0, dz = 0;
366 for (const k of held) {
367 dx += KEYS[k][0];
368 dz += KEYS[k][1];
369 }
370 const l = Math.hypot(dx, dz);
371 if (l > 0) {
372 lastDir = [dx / l, dz / l];
373 orderUp({ type: "move", ...clampTo(me.x + (dx / l) * 2.5, me.z + (dz / l) * 2.5) });
374 }
375 clearTimeout(steering);
376 steering = setTimeout(steer, 450);
377 }
378
379 async function start() {
380 if (!renderer) return;
381 root.classList.add("ready");
382 [sim, game] = await Promise.all([
383 import(root.dataset.sim),
384 fetch(root.dataset.content).then((r) => r.json()),
385 ]);
386 loadGhost(root.dataset.day);
387 const events = new EventSource(`/delves/${root.dataset.delve}/events`);
388 events.onmessage = (m) => {
389 const { type, data } = JSON.parse(m.data);
390 if (type !== "tick") return;
391 prev = cur;
392 cur = data;
393 curAt = performance.now();
394 showHud();
395 };
396 listen();
397 if (heroId !== null) say("Tap to walk, tap a monster to fight. Dodge rolls through harm.");
398 requestAnimationFrame(frame);
399 }
400
401 start();
static/gl.js added +332
@@ -0,0 +1,332 @@
1 // A low-poly renderer in one file: WebGL 1, flat shading, a handful of
2 // shapes (box, prism, pyramid, gem) and a camera that can point back at
3 // the floor. No library: the whole game draws with what is here.
4 //
5 // Colours are the design tokens': `paint("game-hero")` reads
6 // --color-game-hero from the page, so light and dark come from
7 // design/tokens.json like everything else.
8
9 const VERTEX = `
10 attribute vec3 aPos;
11 attribute vec3 aNormal;
12 attribute vec3 aColor;
13 uniform mat4 uViewProj;
14 uniform mat4 uModel;
15 uniform vec3 uTint;
16 uniform vec3 uLight;
17 varying vec3 vColor;
18 void main() {
19 vec3 n = normalize(mat3(uModel[0].xyz, uModel[1].xyz, uModel[2].xyz) * aNormal);
20 float light = 0.58 + 0.42 * max(dot(n, uLight), 0.0);
21 vColor = aColor * uTint * light;
22 gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
23 }`;
24 const FRAGMENT = `
25 precision mediump float;
26 varying vec3 vColor;
27 uniform float uAlpha;
28 void main() { gl_FragColor = vec4(vColor, uAlpha); }`;
29
30 // ── Colour, from the tokens ──
31
32 const cache = new Map();
33 /** A token's colour as [r, g, b] in 0..1. */
34 export function paint(name) {
35 let rgb = cache.get(name);
36 if (rgb) return rgb;
37 const value = getComputedStyle(document.documentElement)
38 .getPropertyValue(`--color-${name}`)
39 .trim();
40 const hex = value.startsWith("#") ? value.slice(1) : "888888";
41 const full =
42 hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex.slice(0, 6);
43 rgb = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
44 cache.set(name, rgb);
45 return rgb;
46 }
47 // The theme can change under us; the next frame repaints.
48 matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () =>
49 cache.clear(),
50 );
51
52 // ── Shapes: triangles with a normal per face ──
53
54 function face(out, pts, normal) {
55 for (let i = 1; i + 1 < pts.length; i++)
56 for (const p of [pts[0], pts[i], pts[i + 1]]) out.push(p, normal);
57 }
58 const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
59 const cross = (a, b) => [
60 a[1] * b[2] - a[2] * b[1],
61 a[2] * b[0] - a[0] * b[2],
62 a[0] * b[1] - a[1] * b[0],
63 ];
64 const norm = (v) => {
65 const l = Math.hypot(v[0], v[1], v[2]) || 1;
66 return [v[0] / l, v[1] / l, v[2] / l];
67 };
68 const faceNormal = (a, b, c) => norm(cross(sub(b, a), sub(c, a)));
69 function tri(out, a, b, c) {
70 face(out, [a, b, c], faceNormal(a, b, c));
71 }
72
73 /** Unit shapes, standing on y = 0, centred on x and z. */
74 const SHAPES = {
75 box() {
76 const o = [];
77 const v = (x, y, z) => [x - 0.5, y, z - 0.5];
78 face(o, [v(0, 0, 1), v(1, 0, 1), v(1, 1, 1), v(0, 1, 1)], [0, 0, 1]);
79 face(o, [v(1, 0, 0), v(0, 0, 0), v(0, 1, 0), v(1, 1, 0)], [0, 0, -1]);
80 face(o, [v(1, 0, 1), v(1, 0, 0), v(1, 1, 0), v(1, 1, 1)], [1, 0, 0]);
81 face(o, [v(0, 0, 0), v(0, 0, 1), v(0, 1, 1), v(0, 1, 0)], [-1, 0, 0]);
82 face(o, [v(0, 1, 1), v(1, 1, 1), v(1, 1, 0), v(0, 1, 0)], [0, 1, 0]);
83 face(o, [v(0, 0, 0), v(1, 0, 0), v(1, 0, 1), v(0, 0, 1)], [0, -1, 0]);
84 return o;
85 },
86 prism() {
87 const o = [];
88 const ring = [...Array(6)].map((_, i) => {
89 const a = (i / 6) * Math.PI * 2;
90 return [Math.cos(a) * 0.5, Math.sin(a) * 0.5];
91 });
92 for (let i = 0; i < 6; i++) {
93 const [x1, z1] = ring[i];
94 const [x2, z2] = ring[(i + 1) % 6];
95 face(o, [[x1, 0, z1], [x1, 1, z1], [x2, 1, z2], [x2, 0, z2]],
96 faceNormal([x1, 0, z1], [x1, 1, z1], [x2, 1, z2]));
97 }
98 face(o, ring.map(([x, z]) => [x, 1, z]).reverse(), [0, 1, 0]);
99 face(o, ring.map(([x, z]) => [x, 0, z]), [0, -1, 0]);
100 return o;
101 },
102 pyramid() {
103 const o = [];
104 const top = [0, 1, 0];
105 const c = [[-0.5, 0, -0.5], [0.5, 0, -0.5], [0.5, 0, 0.5], [-0.5, 0, 0.5]];
106 for (let i = 0; i < 4; i++) tri(o, c[(i + 1) % 4], c[i], top);
107 face(o, c, [0, -1, 0]);
108 return o;
109 },
110 gem() {
111 const o = [];
112 const t = [0, 1, 0];
113 const b = [0, 0, 0];
114 const r = [[0.5, 0.5, 0], [0, 0.5, 0.5], [-0.5, 0.5, 0], [0, 0.5, -0.5]];
115 for (let i = 0; i < 4; i++) {
116 tri(o, r[(i + 1) % 4], r[i], t);
117 tri(o, r[i], r[(i + 1) % 4], b);
118 }
119 return o;
120 },
121 };
122
123 // ── Matrices, column-major ──
124
125 export function compose(x, y, z, ry = 0, sx = 1, sy = 1, sz = 1, rx = 0) {
126 const cy = Math.cos(ry), sny = Math.sin(ry);
127 const cx = Math.cos(rx), snx = Math.sin(rx);
128 // T · Ry · Rx · S
129 return new Float32Array([
130 cy * sx, 0, -sny * sx, 0,
131 sny * snx * sy, cx * sy, cy * snx * sy, 0,
132 sny * cx * sz, -snx * sz, cy * cx * sz, 0,
133 x, y, z, 1,
134 ]);
135 }
136 function multiply(a, b) {
137 const o = new Float32Array(16);
138 for (let c = 0; c < 4; c++)
139 for (let r = 0; r < 4; r++) {
140 let s = 0;
141 for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
142 o[c * 4 + r] = s;
143 }
144 return o;
145 }
146 function transform(m, p) {
147 const out = [0, 0, 0, 0];
148 for (let r = 0; r < 4; r++)
149 out[r] = m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r];
150 return out;
151 }
152
153 // ── The renderer ──
154
155 export function createRenderer(canvas) {
156 const gl = canvas.getContext("webgl", { antialias: true, alpha: false });
157 if (!gl) return null;
158 const compile = (type, src) => {
159 const s = gl.createShader(type);
160 gl.shaderSource(s, src);
161 gl.compileShader(s);
162 return s;
163 };
164 const program = gl.createProgram();
165 gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX));
166 gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT));
167 gl.linkProgram(program);
168 gl.useProgram(program);
169 const at = (n) => gl.getAttribLocation(program, n);
170 const un = (n) => gl.getUniformLocation(program, n);
171 const aPos = at("aPos"), aNormal = at("aNormal"), aColor = at("aColor");
172 const uViewProj = un("uViewProj"), uModel = un("uModel"), uTint = un("uTint");
173 const uAlpha = un("uAlpha"), uLight = un("uLight");
174 gl.enable(gl.DEPTH_TEST);
175 gl.enable(gl.CULL_FACE);
176 gl.enable(gl.BLEND);
177 gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
178 gl.uniform3fv(uLight, norm([0.4, 1, 0.6]));
179
180 // Interleaved: position, normal, colour.
181 function upload(tris, colorOf) {
182 const data = new Float32Array((tris.length / 2) * 9);
183 for (let i = 0, v = 0; i < tris.length; i += 2, v++) {
184 const [p, n] = [tris[i], tris[i + 1]];
185 const c = colorOf(v);
186 data.set([p[0], p[1], p[2], n[0], n[1], n[2], c[0], c[1], c[2]], v * 9);
187 }
188 const buffer = gl.createBuffer();
189 gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
190 gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
191 return { buffer, count: tris.length / 2 };
192 }
193 const white = () => [1, 1, 1];
194 const meshes = Object.fromEntries(
195 Object.entries(SHAPES).map(([name, make]) => [name, upload(make(), white)]),
196 );
197
198 let viewProj = new Float32Array(16);
199 let camera = null;
200
201 function bind(mesh) {
202 gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
203 gl.enableVertexAttribArray(aPos);
204 gl.enableVertexAttribArray(aNormal);
205 gl.enableVertexAttribArray(aColor);
206 gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 36, 0);
207 gl.vertexAttribPointer(aNormal, 3, gl.FLOAT, false, 36, 12);
208 gl.vertexAttribPointer(aColor, 3, gl.FLOAT, false, 36, 24);
209 }
210 const IDENTITY = compose(0, 0, 0);
211
212 return {
213 /** Size the drawing buffer to the canvas, and look from `eye` at `target`. */
214 frame(eye, target, fov = 0.72) {
215 const dpr = Math.min(2, devicePixelRatio || 1);
216 const w = Math.round(canvas.clientWidth * dpr);
217 const h = Math.round(canvas.clientHeight * dpr);
218 if (canvas.width !== w || canvas.height !== h) {
219 canvas.width = w;
220 canvas.height = h;
221 }
222 gl.viewport(0, 0, w, h);
223 const sky = paint("game-sky");
224 gl.clearColor(sky[0], sky[1], sky[2], 1);
225 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
226 const aspect = w / Math.max(1, h);
227 const f = norm(sub(target, eye));
228 const r = norm(cross(f, [0, 1, 0]));
229 const u = cross(r, f);
230 const view = new Float32Array([
231 r[0], u[0], -f[0], 0,
232 r[1], u[1], -f[1], 0,
233 r[2], u[2], -f[2], 0,
234 -(r[0] * eye[0] + r[1] * eye[1] + r[2] * eye[2]),
235 -(u[0] * eye[0] + u[1] * eye[1] + u[2] * eye[2]),
236 f[0] * eye[0] + f[1] * eye[1] + f[2] * eye[2],
237 1,
238 ]);
239 const near = 0.5, far = 100, t = 1 / Math.tan(fov / 2);
240 const proj = new Float32Array([
241 t / aspect, 0, 0, 0,
242 0, t, 0, 0,
243 0, 0, (far + near) / (near - far), -1,
244 0, 0, (2 * far * near) / (near - far), 0,
245 ]);
246 viewProj = multiply(proj, view);
247 camera = { eye, f, r, u, aspect, tan: Math.tan(fov / 2) };
248 gl.uniformMatrix4fv(uViewProj, false, viewProj);
249 },
250 /** Draw a unit shape. */
251 draw(shape, model, color, alpha = 1) {
252 const mesh = typeof shape === "string" ? meshes[shape] : shape;
253 bind(mesh);
254 gl.uniformMatrix4fv(uModel, false, model ?? IDENTITY);
255 gl.uniform3fv(uTint, color ?? [1, 1, 1]);
256 gl.uniform1f(uAlpha, alpha);
257 if (alpha < 1) gl.depthMask(false);
258 gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
259 if (alpha < 1) gl.depthMask(true);
260 },
261 /** Many shapes baked into one mesh: a room, drawn in one call. */
262 bake(parts) {
263 const tris = [];
264 const colors = [];
265 for (const { shape, model, color } of parts) {
266 const src = SHAPES[shape]();
267 const nm = [model[0], model[1], model[2], model[4], model[5], model[6], model[8], model[9], model[10]];
268 for (let i = 0; i < src.length; i += 2) {
269 const p = transform(model, src[i]);
270 const n = src[i + 1];
271 tris.push([p[0], p[1], p[2]], norm([
272 nm[0] * n[0] + nm[3] * n[1] + nm[6] * n[2],
273 nm[1] * n[0] + nm[4] * n[1] + nm[7] * n[2],
274 nm[2] * n[0] + nm[5] * n[1] + nm[8] * n[2],
275 ]));
276 colors.push(color);
277 }
278 }
279 return upload(tris, (v) => colors[v]);
280 },
281 drop(mesh) {
282 gl.deleteBuffer(mesh.buffer);
283 },
284 /** Where a world point lands on the canvas, in CSS pixels; null behind the eye. */
285 project(x, y, z) {
286 const p = transform(viewProj, [x, y, z]);
287 if (p[3] <= 0) return null;
288 return {
289 x: ((p[0] / p[3] + 1) / 2) * canvas.clientWidth,
290 y: ((1 - p[1] / p[3]) / 2) * canvas.clientHeight,
291 };
292 },
293 /** The floor point under a CSS pixel of the canvas. */
294 ground(px, py) {
295 if (!camera) return null;
296 const nx = (px / canvas.clientWidth) * 2 - 1;
297 const ny = 1 - (py / canvas.clientHeight) * 2;
298 const { eye, f, r, u, aspect, tan } = camera;
299 const d = [0, 1, 2].map(
300 (i) => f[i] + r[i] * nx * tan * aspect + u[i] * ny * tan,
301 );
302 if (d[1] >= -1e-6) return null;
303 const t = -eye[1] / d[1];
304 return { x: eye[0] + d[0] * t, z: eye[2] + d[2] * t };
305 },
306 };
307 }
308
309 /** A chef, in five shapes: whites, apron, hat and puff, and a knife when it swings. */
310 export function drawChef(r, x, z, facing, opts = {}) {
311 const { alpha = 1, swing = 0, lying = false, lift = 0, tint, spin = 0, wave = 0 } = opts;
312 const whites = tint ?? paint("game-hero");
313 const apron = tint ?? paint("game-apron");
314 const ry = facing + spin;
315 const tilt = lying ? Math.PI / 2 : 0;
316 const y = lift;
317 r.draw("box", compose(x, y, z, ry, 0.5, 0.62, 0.36, tilt), whites, alpha);
318 if (!lying) {
319 const fx = Math.sin(ry), fz = Math.cos(ry);
320 r.draw("box", compose(x + fx * 0.19, y + 0.05, z + fz * 0.19, ry, 0.42, 0.42, 0.04), apron, alpha);
321 r.draw("box", compose(x, y + 0.62, z, ry, 0.4, 0.28, 0.32), paint("game-seed"), alpha);
322 r.draw("prism", compose(x, y + 0.9, z, ry, 0.34, 0.26, 0.34), whites, alpha);
323 r.draw("gem", compose(x, y + 1.08, z, ry, 0.5, 0.3, 0.5), whites, alpha);
324 // The knife: resting at the side, sweeping across while it swings.
325 const arc = swing > 0 ? (swing / 6) * 2.4 - 1.2 : 0.9;
326 const side = ry + arc;
327 const kx = x + Math.sin(side) * 0.42, kz = z + Math.cos(side) * 0.42;
328 r.draw("box", compose(kx, y + 0.34 + wave, kz, side, 0.07, 0.07, swing > 0 ? 0.6 : 0.4), paint("game-spike"), alpha);
329 } else {
330 r.draw("prism", compose(x, 0.1, z + 0.5, ry, 0.34, 0.3, 0.34, tilt), whites, alpha);
331 }
332 }
static/hub.js added +228
@@ -0,0 +1,228 @@
1 // The hub, drawn: the square, the furniture from content/hub.json, and
2 // every chef in it, walked to where they are going at the hub's speed.
3 // Nothing streams positions: a click is one PATCH, and /hub/events tells
4 // everyone where that chef set off for. Forms on the page work without
5 // this file; with it, they stay on the page.
6
7 import { compose, createRenderer, drawChef, paint } from "./gl.js";
8
9 const root = document.getElementById("hub-view");
10 const canvas = root?.querySelector("canvas");
11 const renderer = canvas ? createRenderer(canvas) : null;
12 const tags = root?.querySelector(".tags");
13 const status = document.getElementById("hub-status");
14 const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
15
16 const say = (text) => {
17 if (status) status.textContent = text;
18 };
19
20 // ── Talking to the server ──
21
22 async function send(method, path, body) {
23 const response = await fetch(path, {
24 method,
25 headers: { "content-type": "application/json" },
26 body: body === undefined ? undefined : JSON.stringify(body),
27 });
28 if (response.status === 429) {
29 say("Easy, chef — one thing at a time.");
30 return null;
31 }
32 const data = await response.json().catch(() => null);
33 if (!response.ok) {
34 say(data?.message ?? "That did not work.");
35 return null;
36 }
37 return data;
38 }
39
40 // ── The square ──
41
42 const avatars = new Map(); // id → { view, t0 }
43 let me = null;
44 let game = null;
45
46 function learn(view) {
47 avatars.set(view.id, {
48 view,
49 t0: performance.now() - view.age,
50 emoteAt: performance.now() - view.emoteAge,
51 sayAt: performance.now() - view.sayAge,
52 });
53 }
54
55 function where(a, now) {
56 const v = a.view;
57 const d = Math.hypot(v.tx - v.x, v.tz - v.z);
58 if (d === 0) return { x: v.x, z: v.z, moving: false, dir: null };
59 const f = Math.min(1, (((now - a.t0) / 1000) * game.hub.speed) / d);
60 return {
61 x: v.x + (v.tx - v.x) * f,
62 z: v.z + (v.tz - v.z) * f,
63 moving: f < 1,
64 dir: Math.atan2(v.tx - v.x, v.tz - v.z),
65 };
66 }
67
68 let lastSend = 0;
69 async function walkTo(x, z) {
70 if (me === null) return;
71 const now = performance.now();
72 // Two steps a second is the door's pace; a faster finger just waits.
73 if (now - lastSend < 450) return;
74 lastSend = now;
75 const mine = avatars.get(me);
76 if (mine) {
77 const here = where(mine, now);
78 learn({ ...mine.view, x: here.x, z: here.z, tx: x, tz: z, age: 0,
79 emoteAge: now - mine.emoteAt, sayAge: now - mine.sayAt });
80 }
81 const view = await send("PATCH", `/api/avatars/${me}`, { x, z });
82 if (view) learn(view);
83 }
84
85 // ── Drawing ──
86
87 let floor = null;
88 const facing = new Map();
89
90 function bakeFloor() {
91 const { width, depth, props } = game.hub;
92 const parts = [];
93 for (let x = 0; x < width; x++)
94 for (let z = 0; z < depth; z++)
95 parts.push({
96 shape: "box",
97 model: compose(x + 0.5, -0.1, z + 0.5, 0, 1, 0.1, 1),
98 color: paint((x + z) % 2 ? "game-floor" : "game-floor-alt"),
99 });
100 for (const p of props)
101 parts.push({
102 shape: p.shape,
103 model: compose(p.x, 0, p.z, 0, p.w, p.h, p.d),
104 color: paint(p.paint),
105 });
106 floor = renderer.bake(parts);
107 }
108
109 function frame(now) {
110 requestAnimationFrame(frame);
111 if (!game) return;
112 const { width, depth } = game.hub;
113 renderer.frame([width / 2, 15, depth + 7.5], [width / 2, 0, depth / 2 + 0.5]);
114 if (!floor) bakeFloor();
115 renderer.draw(floor);
116 const labels = [];
117 for (const [id, a] of avatars) {
118 const p = where(a, now);
119 const face = p.dir ?? facing.get(id) ?? 0;
120 facing.set(id, face);
121 const since = (now - a.emoteAt) / 1000;
122 const emote = a.view.emote && since < 3 && !reduced ? a.view.emote : null;
123 const bob = p.moving && !reduced ? Math.abs(Math.sin(now / 90)) * 0.08 : 0;
124 drawChef(renderer, p.x, p.z, face, {
125 lift: bob + (emote === "cheer" ? Math.abs(Math.sin(since * 9)) * 0.5 : 0),
126 spin: emote === "dance" ? since * 8 : 0,
127 wave: emote === "wave" ? Math.sin(since * 14) * 0.25 + 0.3 : 0,
128 swing: emote === "taste" ? 3 : 0,
129 });
130 if (emote === "bow")
131 renderer.draw("gem", compose(p.x, 1.5, p.z, since * 3, 0.2, 0.2, 0.2), paint("game-gold"));
132 const at = renderer.project(p.x, 1.55, p.z);
133 if (at) labels.push({ id, at, a, mine: id === me });
134 }
135 drawTags(labels, now);
136 }
137
138 // Name tags and speech bubbles are HTML over the canvas: real text, set
139 // with textContent, never markup.
140 const tagEls = new Map();
141 function drawTags(labels, now) {
142 const seen = new Set();
143 for (const { id, at, a, mine } of labels) {
144 seen.add(id);
145 let el = tagEls.get(id);
146 if (!el) {
147 el = document.createElement("div");
148 el.className = "tag";
149 el.append(document.createElement("b"), document.createElement("span"));
150 tags.append(el);
151 tagEls.set(id, el);
152 }
153 el.classList.toggle("tag-me", mine);
154 el.firstChild.textContent = a.view.name;
155 const talking = a.view.say && now - a.sayAt < 7000;
156 el.lastChild.textContent = talking ? a.view.say : "";
157 el.lastChild.hidden = !talking;
158 el.style.transform = `translate(${at.x.toFixed(1)}px, ${at.y.toFixed(1)}px) translate(-50%, -100%)`;
159 }
160 for (const [id, el] of tagEls)
161 if (!seen.has(id)) {
162 el.remove();
163 tagEls.delete(id);
164 }
165 }
166
167 // ── Input: click or tap to walk; arrows and WASD when the square has focus ──
168
169 const STEPS = { ArrowUp: [0, -2], w: [0, -2], ArrowDown: [0, 2], s: [0, 2],
170 ArrowLeft: [-2, 0], a: [-2, 0], ArrowRight: [2, 0], d: [2, 0] };
171
172 function listen() {
173 canvas.addEventListener("pointerdown", (e) => {
174 const rect = canvas.getBoundingClientRect();
175 const g = renderer.ground(e.clientX - rect.left, e.clientY - rect.top);
176 if (g) walkTo(g.x, g.z);
177 });
178 canvas.addEventListener("keydown", (e) => {
179 const step = STEPS[e.key];
180 if (!step || me === null) return;
181 e.preventDefault();
182 const mine = avatars.get(me);
183 if (!mine) return;
184 const here = where(mine, performance.now());
185 walkTo(here.x + step[0], here.z + step[1]);
186 });
187 // The page's forms, kept on the page.
188 for (const form of document.querySelectorAll("form[data-hub]"))
189 form.addEventListener("submit", async (e) => {
190 if (me === null) return;
191 e.preventDefault();
192 const body = {};
193 const data = new FormData(form, e.submitter);
194 for (const [k, v] of data) body[k] = String(v);
195 const view = await send("PATCH", `/api/avatars/${me}`, body);
196 if (view) {
197 learn(view);
198 form.reset();
199 say("");
200 }
201 });
202 }
203
204 async function start() {
205 if (!renderer) return;
206 root.classList.add("ready");
207 game = await (await fetch(root.dataset.content)).json();
208 const events = new EventSource("/hub/events");
209 events.onmessage = (m) => {
210 const { type, data } = JSON.parse(m.data);
211 if (type === "avatar") learn(data);
212 if (type === "left") avatars.delete(data.id);
213 };
214 const list = await (await fetch("/api/avatars")).json();
215 if (Array.isArray(list)) list.forEach(learn);
216 if (root.dataset.signedIn === "yes") {
217 const mine = await send("POST", "/api/avatars");
218 if (mine) {
219 me = mine.id;
220 learn(mine);
221 say("Tap the floor to walk. Arrow keys work when the square is focused.");
222 }
223 }
224 listen();
225 requestAnimationFrame(frame);
226 }
227
228 start();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs