Public: anyone can read this; 1 person and the crew can change it.
Build cheffry-s-dungeon #2
Check · Review · Land · Live done — Landed and live in production.
cheffry-james/cheffry-s-dungeon:cheffry-james/1-build-cheffry-s-dungeon → main 4 commits fork
38 files
changed +6364
−253
.gitignore +1
| @@ -2,3 +2,4 @@ node_modules/ | ||
| 2 | 2 | *.db |
| 3 | 3 | *.db-wal |
| 4 | 4 | *.db-shm |
| 5 | data/ |
README.md +61 −9
| @@ -20,6 +20,46 @@ bun run contract:emit # rewrite contract/opena | ||
| 20 | 20 | it: the preview comes up the moment the app answers, and says `live`, or |
| 21 | 21 | `serving · tests failed` the moment the suite disagrees. |
| 22 | 22 | |
| 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 | ||
| 23 | 63 | ## The shape |
| 24 | 64 | |
| 25 | 65 | 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. | ||
| 29 | 69 | |
| 30 | 70 | | file | what it is | |
| 31 | 71 | | ---------------------------------- | -------------------------------------------------------------- | |
| 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 | | |
| 33 | 77 | | `contract.json` | who may do what: roles, and the least role each verb needs | |
| 34 | 78 | | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim | |
| 35 | 79 | | `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. | ||
| 40 | 84 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | |
| 41 | 85 | | `src/routes.ts` | every path, in one table, each API route described beside it | |
| 42 | 86 | | `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 | | |
| 44 | 88 | | `design/tokens.json`, `src/tokens.ts` | the look as data, light and dark; compiled to `/tokens.css` | |
| 45 | 89 | | `src/http.ts` | the floor: headers, body cap, files | |
| 46 | 90 | | `src/server.ts` | boot: open the database, serve | |
| 47 | 91 | | `src/tty.ts` | the terminal, over the same actions | |
| 48 | 92 | | `src/env.ts` | what the forge told this process | |
| 49 | 93 | | `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 | | |
| 50 | 96 | |
| 51 | 97 | ## What every app stands on |
| 52 | 98 | |
| @@ -78,9 +124,10 @@ The forge says who is asking on every request it proxies, and the app | ||
| 78 | 124 | verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: |
| 79 | 125 | |
| 80 | 126 | - **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. | |
| 84 | 131 | |
| 85 | 132 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 86 | 133 | `/__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 | ||
| 96 | 143 | curl -X DELETE https://<app>/api/notes/1 |
| 97 | 144 | |
| 98 | 145 | |
| 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`: | |
| 101 | 153 | |
| 102 | 154 | sh |
| 103 | claude mcp add --transport http __TITLE__ https://<app>/mcp | |
| 155 | claude mcp add --transport http cheffry-s-dungeon https://<app>/mcp | |
| 104 | 156 | |
| 105 | 157 | |
| 106 | 158 | json |
| 107 | 159 | { |
| 108 | "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } } | |
| 160 | "mcpServers": { "cheffry-s-dungeon": { "type": "http", "url": "https://<app>/mcp" } } | |
| 109 | 161 | } |
| 110 | 162 | |
| 111 | 163 |
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 @@ | ||
| 16 | 16 | "list": "anon" |
| 17 | 17 | } |
| 18 | 18 | }, |
| 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 | }, | |
| 19 | 59 | "audit": { |
| 20 | 60 | "api": "/api/audit", |
| 21 | 61 | "can": { |
contract/openapi.json +970 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | "info": { |
| 4 | 4 | "title": "cheffry-s-dungeon", |
| 5 | 5 | "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." | |
| 7 | 7 | }, |
| 8 | 8 | "paths": { |
| 9 | 9 | "/api/notes": { |
| @@ -258,6 +258,748 @@ | ||
| 258 | 258 | } |
| 259 | 259 | } |
| 260 | 260 | }, |
| 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 | }, | |
| 261 | 1003 | "/api/audit": { |
| 262 | 1004 | "get": { |
| 263 | 1005 | "summary": "The audit trail, newest first; `before` pages back by id.", |
| @@ -498,6 +1240,233 @@ | ||
| 498 | 1240 | "updated_at" |
| 499 | 1241 | ] |
| 500 | 1242 | }, |
| 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 | }, | |
| 501 | 1470 | "AuditRow": { |
| 502 | 1471 | "type": "object", |
| 503 | 1472 | "properties": { |
design/tokens.json +259 −35
| @@ -3,63 +3,267 @@ | ||
| 3 | 3 | "color": { |
| 4 | 4 | "$type": "color", |
| 5 | 5 | "bg": { |
| 6 | "$value": "#f6f6f3", | |
| 7 | "$extensions": { "mode": { "dark": "#131312" } } | |
| 6 | "$value": "#f7f1e6", | |
| 7 | "$extensions": { | |
| 8 | "mode": { | |
| 9 | "dark": "#17120f" | |
| 10 | } | |
| 11 | } | |
| 8 | 12 | }, |
| 9 | 13 | "surface": { |
| 10 | "$value": "#ffffff", | |
| 11 | "$extensions": { "mode": { "dark": "#1d1d1b" } } | |
| 14 | "$value": "#fffaf2", | |
| 15 | "$extensions": { | |
| 16 | "mode": { | |
| 17 | "dark": "#221b16" | |
| 18 | } | |
| 19 | } | |
| 12 | 20 | }, |
| 13 | 21 | "fg": { |
| 14 | "$value": "#1c1c1a", | |
| 15 | "$extensions": { "mode": { "dark": "#ecece7" } } | |
| 22 | "$value": "#221a14", | |
| 23 | "$extensions": { | |
| 24 | "mode": { | |
| 25 | "dark": "#f1e8dc" | |
| 26 | } | |
| 27 | } | |
| 16 | 28 | }, |
| 17 | 29 | "muted": { |
| 18 | "$value": "#6b6b65", | |
| 19 | "$extensions": { "mode": { "dark": "#9a9a93" } } | |
| 30 | "$value": "#6b5a4b", | |
| 31 | "$extensions": { | |
| 32 | "mode": { | |
| 33 | "dark": "#ab9c8d" | |
| 34 | } | |
| 35 | } | |
| 20 | 36 | }, |
| 21 | 37 | "line": { |
| 22 | "$value": "#e3e3dd", | |
| 23 | "$extensions": { "mode": { "dark": "#2c2c29" } } | |
| 38 | "$value": "#e6dccb", | |
| 39 | "$extensions": { | |
| 40 | "mode": { | |
| 41 | "dark": "#352b23" | |
| 42 | } | |
| 43 | } | |
| 24 | 44 | }, |
| 25 | 45 | "accent": { |
| 26 | "$value": "#2f5bea", | |
| 27 | "$extensions": { "mode": { "dark": "#7c96ff" } } | |
| 46 | "$value": "#b3261e", | |
| 47 | "$extensions": { | |
| 48 | "mode": { | |
| 49 | "dark": "#ff8a65" | |
| 50 | } | |
| 51 | } | |
| 28 | 52 | }, |
| 29 | 53 | "accent-fg": { |
| 30 | 54 | "$value": "#ffffff", |
| 31 | "$extensions": { "mode": { "dark": "#0f1a4d" } } | |
| 55 | "$extensions": { | |
| 56 | "mode": { | |
| 57 | "dark": "#2a0e05" | |
| 58 | } | |
| 59 | } | |
| 32 | 60 | }, |
| 33 | 61 | "danger": { |
| 34 | "$value": "#c2410c", | |
| 35 | "$extensions": { "mode": { "dark": "#fb923c" } } | |
| 62 | "$value": "#b3261e", | |
| 63 | "$extensions": { | |
| 64 | "mode": { | |
| 65 | "dark": "#ff8a65" | |
| 66 | } | |
| 67 | } | |
| 36 | 68 | }, |
| 37 | 69 | "toast-bg": { |
| 38 | "$value": "#1c1c1a", | |
| 39 | "$extensions": { "mode": { "dark": "#ecece7" } } | |
| 70 | "$value": "#221a14", | |
| 71 | "$extensions": { | |
| 72 | "mode": { | |
| 73 | "dark": "#f1e8dc" | |
| 74 | } | |
| 75 | } | |
| 40 | 76 | }, |
| 41 | 77 | "toast-fg": { |
| 42 | "$value": "#f6f6f3", | |
| 43 | "$extensions": { "mode": { "dark": "#131312" } } | |
| 78 | "$value": "#f7f1e6", | |
| 79 | "$extensions": { | |
| 80 | "mode": { | |
| 81 | "dark": "#17120f" | |
| 82 | } | |
| 83 | } | |
| 44 | 84 | }, |
| 45 | 85 | "shadow": { |
| 46 | 86 | "$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 | } | |
| 48 | 236 | } |
| 49 | 237 | }, |
| 50 | 238 | "space": { |
| 51 | 239 | "$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 | } | |
| 58 | 258 | }, |
| 59 | 259 | "radius": { |
| 60 | 260 | "$type": "dimension", |
| 61 | "md": { "$value": "10px" }, | |
| 62 | "pill": { "$value": "999px" } | |
| 261 | "md": { | |
| 262 | "$value": "10px" | |
| 263 | }, | |
| 264 | "pill": { | |
| 265 | "$value": "999px" | |
| 266 | } | |
| 63 | 267 | }, |
| 64 | 268 | "size": { |
| 65 | 269 | "$type": "dimension", |
| @@ -71,25 +275,45 @@ | ||
| 71 | 275 | "font": { |
| 72 | 276 | "$type": "fontFamily", |
| 73 | 277 | "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 | ] | |
| 75 | 284 | }, |
| 76 | 285 | "mono": { |
| 77 | "$value": ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"] | |
| 286 | "$value": [ | |
| 287 | "ui-monospace", | |
| 288 | "SFMono-Regular", | |
| 289 | "Menlo", | |
| 290 | "monospace" | |
| 291 | ] | |
| 78 | 292 | } |
| 79 | 293 | }, |
| 80 | 294 | "text": { |
| 81 | 295 | "$type": "dimension", |
| 82 | "sm": { "$value": "0.875rem" }, | |
| 296 | "sm": { | |
| 297 | "$value": "0.875rem" | |
| 298 | }, | |
| 83 | 299 | "base": { |
| 84 | 300 | "$value": "1rem", |
| 85 | 301 | "$description": "Never below 16px: a phone zooms into a smaller input." |
| 86 | 302 | }, |
| 87 | "lg": { "$value": "1.125rem" }, | |
| 88 | "xl": { "$value": "1.5rem" } | |
| 303 | "lg": { | |
| 304 | "$value": "1.125rem" | |
| 305 | }, | |
| 306 | "xl": { | |
| 307 | "$value": "1.5rem" | |
| 308 | } | |
| 89 | 309 | }, |
| 90 | 310 | "leading": { |
| 91 | 311 | "$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 | } | |
| 94 | 318 | } |
| 95 | 319 | } |
package.json +2 −1
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "cheffry-s-dungeon", |
| 3 | "displayName": "Cheffry's Dungeon", | |
| 3 | 4 | "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.", | |
| 5 | 6 | "private": true, |
| 6 | 7 | "type": "module", |
| 7 | 8 | "scripts": { |
src/actions.ts +527 −1
| @@ -8,8 +8,14 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { record, type AuditRow } from "./audit.ts"; |
| 11 | import { game } from "./content.ts"; | |
| 11 | 12 | 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"; | |
| 13 | 19 | import type { Who } from "./who.ts"; |
| 14 | 20 | |
| 15 | 21 | export interface Note { |
| @@ -269,3 +275,523 @@ export function getAudit( | ||
| 269 | 275 | return fail("invalid", "An audit id is a whole number."); |
| 270 | 276 | return fail("not_found", "No such audit row."); |
| 271 | 277 | } |
| 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"; | ||
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { |
| 12 | 12 | addNote, |
| 13 | commandHero, | |
| 13 | 14 | deleteNote, |
| 15 | deleteRun, | |
| 16 | enterHub, | |
| 14 | 17 | getAudit, |
| 18 | getAvatar, | |
| 19 | getDelve, | |
| 20 | getHero, | |
| 15 | 21 | getNote, |
| 22 | getRun, | |
| 23 | joinDelve, | |
| 24 | leaveDelve, | |
| 25 | leaveHub, | |
| 16 | 26 | listAudit, |
| 27 | listAvatars, | |
| 28 | listDelves, | |
| 29 | listHeroes, | |
| 17 | 30 | listNotes, |
| 31 | listRuns, | |
| 32 | openDelve, | |
| 33 | today, | |
| 34 | updateAvatar, | |
| 18 | 35 | updateNote, |
| 19 | 36 | } from "./actions.ts"; |
| 20 | 37 | import { record } from "./audit.ts"; |
| 21 | 38 | import { actingAs, identities, table, type Verb } from "./can.ts"; |
| 22 | 39 | import { contract } from "./contract.ts"; |
| 23 | 40 | import { openDb } from "./db.ts"; |
| 41 | import * as kitchen from "./delves.ts"; | |
| 42 | import * as hub from "./hub.ts"; | |
| 24 | 43 | import type { Who } from "./who.ts"; |
| 25 | 44 | |
| 26 | 45 | const db = openDb(mkdtempSync(join(tmpdir(), "app-contract-"))); |
| @@ -53,6 +72,24 @@ const auditRow = (): number => { | ||
| 53 | 72 | .id; |
| 54 | 73 | }; |
| 55 | 74 | |
| 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 | ||
| 56 | 93 | /** One door per verb an action serves: did it let `who` through, on `row`? */ |
| 57 | 94 | const doors: Record< |
| 58 | 95 | string, |
| @@ -66,6 +103,39 @@ const doors: Record< | ||
| 66 | 103 | updateNote(db, who, { id: noteBy(authorId), body: "changed" }).ok, |
| 67 | 104 | delete: (who, authorId) => deleteNote(db, who, { id: noteBy(authorId) }).ok, |
| 68 | 105 | }, |
| 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 | }, | |
| 69 | 139 | // The trail is written by the app itself, never through a door: there |
| 70 | 140 | // is no action that creates, changes or removes a row of it. |
| 71 | 141 | audit: { |
src/db.ts +19
| @@ -38,6 +38,25 @@ export const migrations: string[] = [ | ||
| 38 | 38 | allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)), |
| 39 | 39 | outcome TEXT NOT NULL |
| 40 | 40 | )`, |
| 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)`, | |
| 41 | 60 | ]; |
| 42 | 61 | |
| 43 | 62 | 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 @@ | ||
| 7 | 7 | import pkg from "../package.json"; |
| 8 | 8 | |
| 9 | 9 | 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; | |
| 10 | 13 | /** The app's own version (package.json): what `/healthz` and `/openapi.json` say. */ |
| 11 | 14 | export const version: string = pkg.version ?? "0.0.0"; |
| 12 | 15 | /** 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 => | ||
| 61 | 61 | headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY }, |
| 62 | 62 | }); |
| 63 | 63 | |
| 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 => | |
| 66 | 66 | new Response(null, { |
| 67 | 67 | status: 303, |
| 68 | 68 | headers: { |
| 69 | location: "/", | |
| 69 | location: to.startsWith("/") && !to.startsWith("//") ? to : "/", | |
| 70 | 70 | ...SECURITY, |
| 71 | 71 | ...(notice === undefined |
| 72 | 72 | ? {} |
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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 8 | 14 | */ |
| 9 | 15 | |
| 10 | 16 | export interface LiveEvent { |
| @@ -13,69 +19,104 @@ export interface LiveEvent { | ||
| 13 | 19 | readonly data: unknown; |
| 14 | 20 | } |
| 15 | 21 | |
| 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 | ||
| 17 | 33 | const HEARTBEAT_MS = 25_000; |
| 18 | const kept: LiveEvent[] = []; | |
| 19 | const listeners = new Set<(event: LiveEvent) => void>(); | |
| 20 | let last = 0; | |
| 21 | 34 | |
| 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 | }; | |
| 28 | 116 | } |
| 29 | 117 | |
| 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 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 12 | 19 | import { fields } from "./http.ts"; |
| 13 | 20 | import type { Who } from "./who.ts"; |
| 14 | 21 | |
| @@ -24,9 +31,33 @@ interface Tool { | ||
| 24 | 31 | } |
| 25 | 32 | |
| 26 | 33 | 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 | }, | |
| 27 | 58 | { |
| 28 | 59 | name: "list_notes", |
| 29 | description: "The notes, newest first.", | |
| 60 | description: "The graffiti wall, newest first.", | |
| 30 | 61 | inputSchema: { |
| 31 | 62 | type: "object", |
| 32 | 63 | properties: { |
| @@ -44,7 +75,7 @@ export const tools: readonly Tool[] = [ | ||
| 44 | 75 | { |
| 45 | 76 | name: "add_note", |
| 46 | 77 | 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.", | |
| 48 | 79 | inputSchema: { |
| 49 | 80 | type: "object", |
| 50 | 81 | properties: { body: { type: "string", maxLength: 500 } }, |
| @@ -54,7 +85,8 @@ export const tools: readonly Tool[] = [ | ||
| 54 | 85 | }, |
| 55 | 86 | { |
| 56 | 87 | 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.", | |
| 58 | 90 | inputSchema: { |
| 59 | 91 | type: "object", |
| 60 | 92 | properties: { id: { type: "integer" } }, |
| @@ -89,7 +121,7 @@ export function rpc( | ||
| 89 | 121 | return reply({ |
| 90 | 122 | protocolVersion: "2025-06-18", |
| 91 | 123 | capabilities: { tools: {} }, |
| 92 | serverInfo: { name: title, version: "1" }, | |
| 124 | serverInfo: { name: title, version }, | |
| 93 | 125 | }); |
| 94 | 126 | case "ping": |
| 95 | 127 | return reply({}); |
src/openapi.ts +15 −1
| @@ -12,12 +12,26 @@ | ||
| 12 | 12 | |
| 13 | 13 | import { contract } from "./contract.ts"; |
| 14 | 14 | 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"; | |
| 16 | 26 | import { doorOf } from "./security.ts"; |
| 17 | 27 | |
| 18 | 28 | // The shapes said once, under components, and referred to wherever they appear. |
| 19 | 29 | const NAMED = new Map<Schema, string>([ |
| 20 | 30 | [NOTE, "Note"], |
| 31 | [AVATAR, "Avatar"], | |
| 32 | [DELVE, "Delve"], | |
| 33 | [HERO, "Hero"], | |
| 34 | [RUN, "Run"], | |
| 21 | 35 | [AUDIT_ROW, "AuditRow"], |
| 22 | 36 | [ERROR, "Error"], |
| 23 | 37 | ]); |
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"; | ||
| 16 | 16 | import { |
| 17 | 17 | addNote, |
| 18 | 18 | BODY_MAX, |
| 19 | commandHero, | |
| 19 | 20 | deleteNote, |
| 21 | deleteRun, | |
| 22 | enterHub, | |
| 20 | 23 | getAudit, |
| 24 | getAvatar, | |
| 25 | getDelve, | |
| 26 | getHero, | |
| 21 | 27 | getNote, |
| 28 | getRun, | |
| 29 | hubChat, | |
| 30 | joinDelve, | |
| 31 | leaveDelve, | |
| 32 | leaveHub, | |
| 22 | 33 | listAudit, |
| 34 | listAvatars, | |
| 35 | listDelves, | |
| 36 | listHeroes, | |
| 23 | 37 | listNotes, |
| 38 | listRuns, | |
| 39 | openDelve, | |
| 40 | today, | |
| 41 | updateAvatar, | |
| 24 | 42 | updateNote, |
| 43 | watchDelve, | |
| 44 | watchHub, | |
| 25 | 45 | type Failure, |
| 26 | 46 | type Result, |
| 27 | 47 | } from "./actions.ts"; |
| 48 | import { simHref, simJs } from "./assets.ts"; | |
| 49 | import { game, gameJson } from "./content.ts"; | |
| 50 | import { heroOfAuthor } from "./delves.ts"; | |
| 28 | 51 | import { commit, env, forgeUrl, startedAt, version } from "./env.ts"; |
| 29 | 52 | import { |
| 30 | 53 | back, |
| @@ -38,9 +61,12 @@ import { | ||
| 38 | 61 | staticFile, |
| 39 | 62 | text, |
| 40 | 63 | } from "./http.ts"; |
| 64 | import { SAY_MAX } from "./hub.ts"; | |
| 41 | 65 | import { events } from "./live.ts"; |
| 42 | 66 | 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"; | |
| 44 | 70 | import { tokensCss } from "./tokens.ts"; |
| 45 | 71 | import type { Who } from "./who.ts"; |
| 46 | 72 | |
| @@ -115,6 +141,114 @@ export const AUDIT_ROW: Schema = { | ||
| 115 | 141 | }, |
| 116 | 142 | required: ["id", "at", "role", "resource", "verb", "allowed", "outcome"], |
| 117 | 143 | }; |
| 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 | }; | |
| 118 | 252 | export const ERROR: Schema = { |
| 119 | 253 | type: "object", |
| 120 | 254 | properties: { error: { type: "string" }, message: { type: "string" } }, |
| @@ -144,7 +278,7 @@ const answer = <T>(result: Result<T>, status = 200): Response => | ||
| 144 | 278 | STATUS[result.error], |
| 145 | 279 | ); |
| 146 | 280 | |
| 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. */ | |
| 148 | 282 | const done = <T>(result: Result<T>): Response => |
| 149 | 283 | result.ok ? back() : back(result.message); |
| 150 | 284 | |
| @@ -157,15 +291,61 @@ const OPENAPI = new URL("../contract/openapi.json", import.meta.url); | ||
| 157 | 291 | const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined; |
| 158 | 292 | |
| 159 | 293 | 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. | |
| 161 | 295 | { |
| 162 | 296 | method: "GET", |
| 163 | 297 | path: "/", |
| 164 | 298 | handle: (c) => { |
| 165 | 299 | 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); | |
| 167 | 303 | 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 | }), | |
| 169 | 349 | 200, |
| 170 | 350 | clear, |
| 171 | 351 | ); |
| @@ -187,6 +367,82 @@ export const routes: readonly Route[] = [ | ||
| 187 | 367 | path: "/notes/:id/delete", |
| 188 | 368 | handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })), |
| 189 | 369 | }, |
| 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 | }, | |
| 190 | 446 | |
| 191 | 447 | // The JSON API: the same actions, the same identity, plain status codes. |
| 192 | 448 | { |
| @@ -257,6 +513,179 @@ export const routes: readonly Route[] = [ | ||
| 257 | 513 | }, |
| 258 | 514 | }, |
| 259 | 515 | |
| 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 | ||
| 260 | 689 | // Who tried to change what: the audit trail, for whoever the table lets read it. |
| 261 | 690 | { |
| 262 | 691 | method: "GET", |
| @@ -396,6 +825,36 @@ export const routes: readonly Route[] = [ | ||
| 396 | 825 | }), |
| 397 | 826 | }, |
| 398 | 827 | |
| 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 | ||
| 399 | 858 | // What the web expects of a site. Crawlers read the page, not the API; |
| 400 | 859 | // a preview is a draft and is read by nobody. Where to report a |
| 401 | 860 | // 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 () => | ||
| 132 | 132 | expect(hello.result.capabilities).toEqual({ tools: {} }); |
| 133 | 133 | const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); |
| 134 | 134 | expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([ |
| 135 | "leaderboard", | |
| 136 | "list_delves", | |
| 135 | 137 | "list_notes", |
| 136 | 138 | "add_note", |
| 137 | 139 | "delete_note", |
| @@ -339,3 +341,101 @@ test("what the web expects: robots.txt, and security.txt from the forge that run | ||
| 339 | 341 | ); |
| 340 | 342 | } |
| 341 | 343 | }); |
| 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 @@ | ||
| 7 | 7 | * serves what the forge said; a test calls `serve` with its own. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { sweepHub } from "./actions.ts"; | |
| 10 | 11 | import { openDb } from "./db.ts"; |
| 11 | 12 | import { dataDir, identityKey, title, where } from "./env.ts"; |
| 12 | 13 | import { html, text } from "./http.ts"; |
| @@ -23,6 +24,9 @@ export interface ServeOptions { | ||
| 23 | 24 | |
| 24 | 25 | export function serve(options: ServeOptions) { |
| 25 | 26 | 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(); | |
| 26 | 30 | return Bun.serve({ |
| 27 | 31 | port: options.port, |
| 28 | 32 | 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 { | ||
| 15 | 15 | font: var() / var() var(); |
| 16 | 16 | } |
| 17 | 17 | .page { |
| 18 | max-width: 40rem; | |
| 18 | max-width: 68rem; | |
| 19 | 19 | margin: 0 auto; |
| 20 | 20 | padding: var() var() var(); |
| 21 | 21 | } |
| @@ -169,6 +169,222 @@ footer code { | ||
| 169 | 169 | border-color: currentColor; |
| 170 | 170 | } |
| 171 | 171 | |
| 172 | /* Headings inside cards, and the name at the top that goes home. */ | |
| 173 | h2 { | |
| 174 | margin: 0 0 var(); | |
| 175 | font-size: var(); | |
| 176 | line-height: var(); | |
| 177 | } | |
| 178 | h2 small { | |
| 179 | font-weight: 400; | |
| 180 | font-size: var(); | |
| 181 | } | |
| 182 | main > h2 { | |
| 183 | margin-bottom: var(); | |
| 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(); | |
| 193 | align-items: start; | |
| 194 | } | |
| 195 | .grid .card, | |
| 196 | .controls { | |
| 197 | display: grid; | |
| 198 | gap: var(); | |
| 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(); | |
| 208 | padding: var() 0; | |
| 209 | border-top: 1px solid var(); | |
| 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(); | |
| 219 | } | |
| 220 | .list-plain, | |
| 221 | .howto { | |
| 222 | margin: 0; | |
| 223 | padding-left: var(); | |
| 224 | } | |
| 225 | .howto li + li { | |
| 226 | margin-top: var(); | |
| 227 | } | |
| 228 | .chat { | |
| 229 | list-style: none; | |
| 230 | margin: 0; | |
| 231 | padding: 0; | |
| 232 | display: grid; | |
| 233 | gap: var(); | |
| 234 | overflow-wrap: anywhere; | |
| 235 | } | |
| 236 | .score { | |
| 237 | font-size: var(); | |
| 238 | font-variant-numeric: tabular-nums; | |
| 239 | } | |
| 240 | .scrawl { | |
| 241 | font-weight: 600; | |
| 242 | font-style: italic; | |
| 243 | } | |
| 244 | .callout { | |
| 245 | padding: var(); | |
| 246 | border-left: 3px solid var(); | |
| 247 | background: var(); | |
| 248 | } | |
| 249 | .share { | |
| 250 | overflow-wrap: anywhere; | |
| 251 | } | |
| 252 | kbd { | |
| 253 | font-family: var(); | |
| 254 | font-size: var(); | |
| 255 | padding: 0 var(); | |
| 256 | border: 1px solid var(); | |
| 257 | border-radius: 4px; | |
| 258 | } | |
| 259 | .button-big { | |
| 260 | min-height: calc(var() + 8px); | |
| 261 | padding: 0 var(); | |
| 262 | font-size: var(); | |
| 263 | } | |
| 264 | .button-alt { | |
| 265 | color: var(); | |
| 266 | background: var(); | |
| 267 | border-color: var(); | |
| 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(); | |
| 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(); | |
| 281 | border: 1px solid var(); | |
| 282 | border-radius: var(); | |
| 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() var(); | |
| 294 | padding: var(); | |
| 295 | text-align: center; | |
| 296 | color: var(); | |
| 297 | background: var(); | |
| 298 | border-radius: var(); | |
| 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(); | |
| 316 | line-height: var(); | |
| 317 | white-space: nowrap; | |
| 318 | } | |
| 319 | .tag b { | |
| 320 | padding: 1px var(); | |
| 321 | color: var(); | |
| 322 | background: var(); | |
| 323 | border-radius: var(); | |
| 324 | } | |
| 325 | .tag-me b { | |
| 326 | color: var(); | |
| 327 | background: var(); | |
| 328 | } | |
| 329 | .tag span { | |
| 330 | order: -1; | |
| 331 | max-width: 14rem; | |
| 332 | padding: var() var(); | |
| 333 | white-space: normal; | |
| 334 | overflow-wrap: anywhere; | |
| 335 | color: var(); | |
| 336 | background: var(); | |
| 337 | border-radius: var(); | |
| 338 | } | |
| 339 | .hud { | |
| 340 | position: absolute; | |
| 341 | top: var(); | |
| 342 | left: var(); | |
| 343 | right: var(); | |
| 344 | display: flex; | |
| 345 | flex-wrap: wrap; | |
| 346 | gap: var() var(); | |
| 347 | pointer-events: none; | |
| 348 | font-size: var(); | |
| 349 | } | |
| 350 | .hud p:empty { | |
| 351 | display: none; | |
| 352 | } | |
| 353 | .hud p { | |
| 354 | padding: 2px var(); | |
| 355 | color: var(); | |
| 356 | background: var(); | |
| 357 | border-radius: var(); | |
| 358 | } | |
| 359 | .over { | |
| 360 | position: absolute; | |
| 361 | inset: 0; | |
| 362 | display: grid; | |
| 363 | place-content: center; | |
| 364 | justify-items: center; | |
| 365 | gap: var(); | |
| 366 | padding: var(); | |
| 367 | text-align: center; | |
| 368 | color: var(); | |
| 369 | background: var(); | |
| 370 | } | |
| 371 | .over[hidden] { | |
| 372 | display: none; | |
| 373 | } | |
| 374 | .over-title { | |
| 375 | font-size: var(); | |
| 376 | font-weight: 700; | |
| 377 | } | |
| 378 | .pad { | |
| 379 | display: grid; | |
| 380 | grid-template-columns: 1fr 1fr; | |
| 381 | gap: var(); | |
| 382 | margin-top: var(); | |
| 383 | } | |
| 384 | .pad .button { | |
| 385 | justify-content: center; | |
| 386 | } | |
| 387 | ||
| 172 | 388 | /* A word from the last request, shown once, gone in a moment. */ |
| 173 | 389 | .toast { |
| 174 | 390 | 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(); |
new above
offline