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

Build www: the site that explains Cheffry's Dungeon #2

Merged opened this in answer to

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

Merged

mason/cheffryjames-www:mason/1-build-www-the-site-that-explains-cheffry → main 1 commit fork

  • Merged. mason/1-build-www-the-site-that-explains-cheffry was merged into main as 5487810.

Review

Sign in to review this change.
14 files changed +630 −422
AGENTS.md +7 −4
@@ -30,7 +30,7 @@ preview at its own address, deployed from your branch as you push.
3030
3131 One implementation, many doors. Every operation is a function in
3232 `src/actions.ts` — `(db, who, input)` in, data or a named failure out — and
33 the page (`src/routes.ts`, `src/page.ts`), the JSON API (`src/routes.ts`),
33 the page (`src/routes.ts`, `src/pages/`), the JSON API (`src/routes.ts`),
3434 the MCP server (`src/mcp.ts`) and the terminal (`src/tty.ts`) call it. A
3535 door reads its wire format and shapes the answer; it decides nothing. A
3636 rule in an action holds at every door at once; a rule anywhere else is a
@@ -79,8 +79,11 @@ To add a concept:
7979 and a test in `src/actions.test.ts` for what the table cannot say.
8080 - **A migration** — append to `migrations` in `src/db.ts`. Never edit one
8181 that has run. `bun run src/db.ts --check` shows the version.
82 - **A page** — a row in `src/routes.ts` and a function in `src/page.ts`.
83 With a second page, move them under `src/pages/` and keep one `layout`.
82 - **A page** — a row in `src/routes.ts` and a file in `src/pages/` that
83 renders through `layout` (`src/pages/layout.ts`); add it to `NAV` there
84 if the header should link it. A fact about the game is a field in
85 `src/game.ts`, read from the game's repository, never written from
86 memory; the site's name and words are `src/brand.ts`.
8487 - **An API route** — a row in `src/routes.ts` that calls the action and
8588 answers with `answer(...)`; the status follows the failure. Give it an
8689 `api` entry beside it (a sentence, the body and answer as JSON Schema)
@@ -93,7 +96,7 @@ To add a concept:
9396 - **A terminal command** — a key in `src/tty.ts` that calls the action and
9497 reports on the status line.
9598 - **Something live** — `emit(type, data)` in the action, and
96 `data-live="<types>"` on the region of the page that shows it.
99 a listener on `/events` wherever it is shown.
97100 - **A colour, a space, a size** — a token in `design/tokens.json` (a
98101 colour needs its dark value in `$extensions.mode.dark`), used as
99102 `var(--…)`. `bun run lint:design` refuses a raw `#hex`, `rgb(` or
README.md +30 −8
@@ -1,6 +1,28 @@
11 # www
22
3 The site that explains Cheffry's Dungeon, the game at game.cheffryjames.com
3 The site that explains Cheffry's Dungeon, the game at
4 [game.cheffryjames.com](https://game.cheffryjames.com). It is served at
5 www.cheffryjames.com.
6
7 Two pages, server-rendered, no script, nothing from any other site:
8
9 - `/` — what the game is, what exists in it today, and **Play**.
10 - `/how-to-play` — getting in, the controls, a run, the enemies, the
11 chests, the score and the daily board.
12
13 Every fact the pages state about the game lives in `src/game.ts`, read
14 from the game's own repository (`cheffryjames/cheffry-s-dungeon` on this
15 forge): its `content/*.json` for names and numbers, `src/sim.ts` for the
16 rules of a run, its pages and scripts for the controls. Nothing here is
17 simulated or promised; when the game changes, change `src/game.ts`.
18
19 The branding is a placeholder: the name, tagline and wordmark are
20 `src/brand.ts`, the colours `design/tokens.json`. Replace those two and the
21 site is rebranded.
22
23 The template's notes, its API, MCP server and terminal are still here,
24 unchanged and off the pages: they carry the contract (`contract.json`)
25 the forge audits.
426
527 This is an app: the forge runs it. Production follows the default branch;
628 every open pull gets a preview at its own address with its own copy of the
@@ -29,6 +51,9 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2951
3052 | file | what it is |
3153 | ---------------------------------- | -------------------------------------------------------------- |
54 | `src/pages/` | the site: `layout`, `home`, `how-to-play`, rendered on the server |
55 | `src/game.ts` | what the game is, as data: every fact the pages state |
56 | `src/brand.ts` | the placeholder name, tagline and wordmark |
3257 | `src/actions.ts` | list, add, delete; the audit trail, read |
3358 | `contract.json` | who may do what: roles, and the least role each verb needs |
3459 | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim |
@@ -40,13 +65,12 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
4065 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
4166 | `src/routes.ts` | every path, in one table, each API route described beside it |
4267 | `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 |
4468 | `design/tokens.json`, `src/tokens.ts` | the look as data, light and dark; compiled to `/tokens.css` |
4569 | `src/http.ts` | the floor: headers, body cap, files |
4670 | `src/server.ts` | boot: open the database, serve |
4771 | `src/tty.ts` | the terminal, over the same actions |
4872 | `src/env.ts` | what the forge told this process |
49 | `static/app.css`, `static/live.js` | the look in the tokens' words, and the list that stays current |
73 | `static/app.css` | the look in the tokens' words |
5074
5175 ## What every app stands on
5276
@@ -100,18 +124,16 @@ As an MCP server, for Claude Code or Claude Desktop — `list_notes`,
100124 `add_note`, `delete_note`:
101125
102126 ```sh
103 claude mcp add --transport http __TITLE__ https://<app>/mcp
127 claude mcp add --transport http www https://<app>/mcp
104128 ```
105129
106130 ```json
107131 {
108 "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } }
132 "mcpServers": { "www": { "type": "http", "url": "https://<app>/mcp" } }
109133 }
110134 ```
111135
112 The page stays current over `/events`: a note someone else adds or removes
113 appears without a reload. Without JavaScript it is the same page, one
114 reload behind.
136 A client that wants notes as they change listens on `/events`.
115137
116138 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
117139 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
design/tokens.json +3 −1
@@ -59,6 +59,7 @@
5959 "radius": {
6060 "$type": "dimension",
6161 "md": { "$value": "10px" },
62 "lg": { "$value": "16px" },
6263 "pill": { "$value": "999px" }
6364 },
6465 "size": {
@@ -85,7 +86,8 @@
8586 "$description": "Never below 16px: a phone zooms into a smaller input."
8687 },
8788 "lg": { "$value": "1.125rem" },
88 "xl": { "$value": "1.5rem" }
89 "xl": { "$value": "1.5rem" },
90 "2xl": { "$value": "clamp(2rem, 7vw, 3rem)" }
8991 },
9092 "leading": {
9193 "$type": "number",
src/brand.ts added +13
@@ -0,0 +1,13 @@
1 /**
2 * The site's name and words, placeholder branding in one place: a new
3 * name, tagline or mark is an edit here and in `design/tokens.json`, and
4 * nowhere else.
5 */
6
7 export const brand = {
8 name: "Cheffry's Dungeon",
9 tagline:
10 "A low-poly 3D dungeon crawl in the browser: meet in the hub, gather up to four chefs, and fight through today's kitchen.",
11 /** The wordmark's glyph, drawn as text so there is no image to load. */
12 mark: "CD",
13 } as const;
src/game.ts added +135
@@ -0,0 +1,135 @@
1 /**
2 * What Cheffry's Dungeon is, as plain data — every fact the site states
3 * about the game, in one place. Each one is read from the game's own
4 * repository (`cheffryjames/cheffry-s-dungeon` on this forge, at 5d461ee):
5 * `content/*.json` for the numbers and names, `src/sim.ts` for the rules
6 * of a run, `src/pages/` and `static/*.js` for the controls. When the game
7 * changes, change this file, and nothing here that the game does not do.
8 */
9
10 /** Where the game is played. */
11 export const playUrl = "https://game.cheffryjames.com";
12
13 /** content/rules.json: 20 ticks a second. */
14 const TICK_HZ = 20;
15 const seconds = (ticks: number): number => ticks / TICK_HZ;
16
17 export const party = { max: 4 } as const;
18
19 export const run = {
20 /** rules.roomsPerRun: four drawn by depth, then a final room. */
21 rooms: 5,
22 final: "The Pass",
23 /** rules.maxTicks: 24000 ticks. */
24 minutes: 24000 / TICK_HZ / 60,
25 hearts: 6,
26 /** hero.revive: how long a friend stands beside a downed chef to bring them back. */
27 reviveSeconds: seconds(40),
28 } as const;
29
30 /** content/rooms.json, the non-final ones, in file order. */
31 export const rooms: readonly string[] = [
32 "The Pantry",
33 "The Bakery",
34 "The Spice Rack",
35 "The Cold Room",
36 "The Scullery",
37 ];
38
39 export interface Monster {
40 readonly name: string;
41 readonly brain: string;
42 readonly hp: number;
43 readonly damage: number;
44 readonly score: number;
45 }
46
47 /** content/monsters.json, with what each brain (`src/sim.ts`) does. */
48 export const monsters: readonly Monster[] = [
49 {
50 name: "Meatball Slime",
51 brain: "Chases you once you come within 7 tiles.",
52 hp: 3,
53 damage: 1,
54 score: 20,
55 },
56 {
57 name: "Baguette Knight",
58 brain:
59 "Winds up, then charges in a straight line. After a charge it stands stunned, twice as long if it ran into a wall: that is your opening.",
60 hp: 4,
61 damage: 2,
62 score: 35,
63 },
64 {
65 name: "Pepper Imp",
66 brain:
67 "Keeps about 3 tiles away and shoots pepper seeds from up to 6 tiles off.",
68 hp: 2,
69 damage: 1,
70 score: 30,
71 },
72 ];
73
74 /** content/traps.json. */
75 export const trap = {
76 name: "Skewer Spikes",
77 damage: 1,
78 upSeconds: seconds(16),
79 everySeconds: seconds(50),
80 } as const;
81
82 export interface Loot {
83 readonly name: string;
84 readonly does: string;
85 /** Out of `lootWeight`. */
86 readonly weight: number;
87 }
88
89 /** content/loot.json. */
90 export const loot: readonly Loot[] = [
91 { name: "Pile of Coins", does: "+50 gold", weight: 6 },
92 { name: "Golden Truffle", does: "+150 gold", weight: 2 },
93 { name: "Hearty Soup", does: "2 hearts back", weight: 4 },
94 {
95 name: "Whisk of Power",
96 does: "+1 damage on every swing for the rest of the run",
97 weight: 2,
98 },
99 ];
100 export const lootWeight = loot.reduce((sum, l) => sum + l.weight, 0);
101
102 /** content/rules.json `score`, beside each monster's own. */
103 export const score = { room: 100, victory: 500 } as const;
104
105 /** content/rules.json `hub.emotes`. */
106 export const emotes: readonly string[] = [
107 "wave",
108 "dance",
109 "cheer",
110 "taste",
111 "bow",
112 ];
113
114 export interface Control {
115 readonly act: string;
116 readonly how: string;
117 }
118
119 /** static/delve.js and src/pages/delve.ts. */
120 export const controls: readonly Control[] = [
121 { act: "Walk", how: "Click or tap the floor, or WASD / arrow keys." },
122 {
123 act: "Fight",
124 how: "Click a monster and your chef keeps swinging, or Space / J for the nearest one.",
125 },
126 {
127 act: "Dodge",
128 how: "Shift-click or right-click where to roll, or K / Shift. A roll passes through bites, charges, seeds and spikes.",
129 },
130 { act: "Loot", how: "Walk into a chest to open it." },
131 {
132 act: "Revive",
133 how: "Stand beside a downed friend to bring them back.",
134 },
135 ];
src/http.ts −44
@@ -61,39 +61,6 @@ export const text = (body: string, status = 200): Response =>
6161 headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY },
6262 });
6363
64 /** Back to the page, with a word for it to show once (`readNotice`). */
65 export const back = (notice?: string): Response =>
66 new Response(null, {
67 status: 303,
68 headers: {
69 location: "/",
70 ...SECURITY,
71 ...(notice === undefined
72 ? {}
73 : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }),
74 },
75 });
76
77 /** The notice a redirect left, and the header that clears it. */
78 export function readNotice(request: Request): {
79 notice?: string;
80 clear: Record<string, string>;
81 } {
82 const raw = /(?:^|;\s*)notice=([^;]*)/.exec(
83 request.headers.get("cookie") ?? "",
84 )?.[1];
85 if (raw === undefined) return { clear: {} };
86 return {
87 notice: decodeURIComponent(raw),
88 clear: { "set-cookie": cookie("notice", "", 0) },
89 };
90 }
91
92 // Every cookie the app sets itself is HttpOnly and SameSite=Lax, and Secure
93 // wherever the public address is https.
94 const cookie = (name: string, value: string, maxAge: number): string =>
95 `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
96
9764 // A request body is read up to a cap and no further, counting the bytes as
9865 // they arrive. A declared Content-Length is a claim: a chunked request has
9966 // none, and a lie is free. Undefined means "too large" — refuse it. Past the
@@ -134,17 +101,6 @@ export async function readBody(
134101 return body;
135102 }
136103
137 /** The form fields of a capped body, decoded the way the browser sent them. */
138 export async function readForm(request: Request) {
139 const body = await readBody(request);
140 if (body === undefined) return undefined;
141 return new Request(request.url, {
142 method: "POST",
143 headers: { "content-type": request.headers.get("content-type") ?? "" },
144 body: new Uint8Array(body),
145 }).formData();
146 }
147
148104 /** The JSON of a capped body: `undefined` when too large, `null` when not JSON. */
149105 export async function readJson(request: Request): Promise<unknown> {
150106 const body = await readBody(request);
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/home.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * The home page: what Cheffry's Dungeon is, what exists in it today, and
3 * the way in. Every claim is the game's own (`src/game.ts`).
4 */
5
6 import { brand } from "../brand.ts";
7 import { emotes, monsters, party, rooms, run } from "../game.ts";
8 import { escape } from "../http.ts";
9 import { layout, play } from "./layout.ts";
10
11 export function home(): string {
12 return layout(
13 `<section class="hero">
14 <h1>${escape(brand.name)}</h1>
15 <p class="lede">${escape(brand.tagline)}</p>
16 <div class="row wrap">${play("Play now")}<a class="button button-quiet" href="/how-to-play">How to play</a></div>
17 <p class="muted">Sign in on the game to play. Anyone can walk in and watch.</p>
18 </section>
19
20 <section>
21 <h2>What it is</h2>
22 <div class="cards">
23 <article class="card">
24 <h3>The hub</h3>
25 <p>A shared low-poly square where every signed-in chef stands. Walk around, emote, chat, and scrawl on the graffiti wall. Only positions, emotes and chat cross the wire.</p>
26 </article>
27 <article class="card">
28 <h3>The delve</h3>
29 <p>Open a table or join one: up to ${party.max} chefs a party. Fight through ${run.rooms} rooms of the kitchen's rejects, ending at ${escape(run.final)}, in melee with a dodge roll. The server owns every fight, twenty ticks a second.</p>
30 </article>
31 <article class="card">
32 <h3>The daily board</h3>
33 <p>Everyone gets the same dungeon each UTC day. Finished runs land on the leaderboard with a ghost of their path, and your next party races the best one on screen.</p>
34 </article>
35 </div>
36 </section>
37
38 <section>
39 <h2>What exists today</h2>
40 <p class="muted">Version one, the foundation. This is all of it, nothing promised:</p>
41 <ul class="ticks">
42 <li>An explorable hub with ${emotes.length} emotes (${emotes.map(escape).join(", ")}), chat and the graffiti wall.</li>
43 <li>Melee combat with a dodge roll that passes through hits.</li>
44 <li>Three enemies with three different brains: ${monsters.map((m) => `<b>${escape(m.name)}</b>`).join(", ")}.</li>
45 <li>Skewer Spikes in the floor, and loot chests.</li>
46 <li>Party play for up to ${party.max}, with downed chefs revived by a friend.</li>
47 <li>${rooms.length} rooms and a final one, dealt from the day's seed.</li>
48 <li>Daily seeded runs, a leaderboard and ghosts.</li>
49 </ul>
50 </section>
51
52 <section>
53 <h2>How it is built</h2>
54 <p>Small on purpose. The server runs each delve as a small authoritative instance on a fixed tick, and clients only send orders: move, attack, dodge, stop. Rooms, monsters, traps and loot are plain data files, so a new room is an edit rather than code. The scenes are drawn by a small hand-written WebGL renderer, in low poly, which keeps downloads and bandwidth tiny.</p>
55 </section>
56
57 <section class="card cta">
58 <h2>Today's kitchen is open</h2>
59 <p>Grab an apron and ${party.max - 1} friends.</p>
60 ${play()}
61 </section>`,
62 { path: "/" },
63 );
64 }
src/pages/how-to-play.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * How to play: getting in, the hub, the controls, a run from the first
3 * room to the last, what is in the dungeon and how a score is made. Every
4 * number is the game's own (`src/game.ts`).
5 */
6
7 import {
8 controls,
9 emotes,
10 loot,
11 lootWeight,
12 monsters,
13 party,
14 rooms,
15 run,
16 score,
17 trap,
18 } from "../game.ts";
19 import { escape } from "../http.ts";
20 import { layout, play } from "./layout.ts";
21
22 const hearts = (n: number): string => `${n} ${n === 1 ? "heart" : "hearts"}`;
23
24 export function howToPlay(): string {
25 return layout(
26 `<section class="hero">
27 <h1>How to play</h1>
28 <p class="lede">Meet in the hub, sit at a table with up to ${party.max - 1} friends, and cook your way through the day's ${run.rooms} rooms.</p>
29 </section>
30
31 <section>
32 <h2>Getting in</h2>
33 <ol class="steps">
34 <li>Open the game and sign in. Without signing in you can still watch the hub, the open tables and the board.</li>
35 <li>You step into the hub with an apron on. Tap the floor or use the arrow keys to walk; ${emotes.map((e) => `<b>${escape(e)}</b>`).join(", ")} are your emotes, and there is chat and a graffiti wall.</li>
36 <li>Open a table, or join one that is open. A table seats ${party.max} chefs; anyone else can watch.</li>
37 </ol>
38 </section>
39
40 <section>
41 <h2>Controls in a delve</h2>
42 <dl class="keys">
43 ${controls.map((c) => `<dt>${escape(c.act)}</dt><dd>${escape(c.how)}</dd>`).join("")}
44 </dl>
45 </section>
46
47 <section>
48 <h2>A run</h2>
49 <ul class="ticks">
50 <li>Every chef starts with ${hearts(run.hearts)}.</li>
51 <li>A room's exit opens once every monster in it is down; whoever steps through takes the whole party to the next room.</li>
52 <li>${run.rooms - 1} rooms are dealt from ${rooms.map(escape).join(", ")}, deeper ones later. The last room is always ${escape(run.final)}.</li>
53 <li>At zero hearts a chef is downed, not gone: a friend standing beside them for ${run.reviveSeconds} seconds brings them back, and anyone still down when the party leaves a room gets up in the next one with 1 heart.</li>
54 <li>The run ends when ${escape(run.final)} is cleared, when every chef is down at once, or after ${run.minutes} minutes.</li>
55 </ul>
56 </section>
57
58 <section>
59 <h2>The kitchen's rejects</h2>
60 <div class="cards">
61 ${monsters
62 .map(
63 (m) => `<article class="card">
64 <h3>${escape(m.name)}</h3>
65 <p>${escape(m.brain)}</p>
66 <p class="muted">${m.hp} hit points · hits for ${hearts(m.damage)} · ${m.score} points</p>
67 </article>`,
68 )
69 .join("")}
70 <article class="card">
71 <h3>${escape(trap.name)}</h3>
72 <p>Spikes in the floor, up for ${trap.upSeconds} of every ${trap.everySeconds} seconds. Roll across them.</p>
73 <p class="muted">hits for ${hearts(trap.damage)}, unless you are dodging</p>
74 </article>
75 </div>
76 </section>
77
78 <section>
79 <h2>Chests</h2>
80 <p>Each chest holds one of these:</p>
81 <ul class="ticks">
82 ${loot.map((l) => `<li><b>${escape(l.name)}</b>: ${escape(l.does)} <span class="muted">(${Math.round((l.weight / lootWeight) * 100)}%)</span></li>`).join("")}
83 </ul>
84 </section>
85
86 <section>
87 <h2>Score and the daily board</h2>
88 <ul class="ticks">
89 <li>Points for every monster down, ${score.room} for every room cleared, gold from chests, and ${score.victory} more for clearing ${escape(run.final)}.</li>
90 <li>Everyone gets the same dungeon on the same UTC day. The hub shows the day's rooms before you go in.</li>
91 <li>A finished run goes on the day's board with a ghost of its path. Your next party races the best run's ghost on screen.</li>
92 </ul>
93 </section>
94
95 <section class="card cta">
96 <h2>Ready?</h2>
97 ${play()}
98 </section>`,
99 {
100 path: "/how-to-play",
101 title: "How to play",
102 description: `Controls, the three enemies, chests, and how a run of ${run.rooms} rooms is scored in Cheffry's Dungeon.`,
103 },
104 );
105 }
src/pages/layout.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * The frame every page shares: the tokens and the look, the wordmark and
3 * the way around, the one Play button, and what a link to the page says
4 * when it is shared — a title, a sentence and the address, for Open Graph
5 * and the card a chat app unfurls. No script: every page is complete HTML.
6 */
7
8 import { brand } from "../brand.ts";
9 import { commit, env, publicOrigin, where } from "../env.ts";
10 import { playUrl } from "../game.ts";
11 import { asset, escape } from "../http.ts";
12 import { token, tokensHref } from "../tokens.ts";
13
14 export interface PageMeta {
15 /** The page's own address, for `og:url` and to mark where you are. */
16 readonly path: string;
17 /** The page's name, before the site's; the home page has none. */
18 readonly title?: string;
19 /** The sentence a search result or a shared link shows. */
20 readonly description?: string;
21 }
22
23 /** The pages the header links to, in order. */
24 const NAV: readonly { readonly path: string; readonly label: string }[] = [
25 { path: "/", label: "The game" },
26 { path: "/how-to-play", label: "How to play" },
27 ];
28
29 /** The one call to action, the same on every page. */
30 export const play = (label = "Play"): string =>
31 `<a class="button button-play" href="${escape(playUrl)}">${escape(label)}</a>`;
32
33 export function layout(body: string, meta: PageMeta): string {
34 const title =
35 meta.title === undefined ? brand.name : `${meta.title} · ${brand.name}`;
36 const description = meta.description ?? brand.tagline;
37 const nav = NAV.map(
38 (n) =>
39 `<a href="${n.path}"${n.path === meta.path ? ' aria-current="page"' : ""}>${escape(n.label)}</a>`,
40 ).join("");
41 return `<!doctype html>
42 <html lang="en">
43 <meta charset="utf-8">
44 <meta name="viewport" content="width=device-width, initial-scale=1">
45 <meta name="color-scheme" content="light dark">
46 <meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)">
47 <meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)">
48 <link rel="icon" href="data:,">
49 <title>${escape(title)}</title>
50 <meta name="description" content="${escape(description)}">
51 <meta property="og:type" content="website">
52 <meta property="og:site_name" content="${escape(brand.name)}">
53 <meta property="og:title" content="${escape(title)}">
54 <meta property="og:description" content="${escape(description)}">
55 <meta property="og:url" content="${escape(publicOrigin + meta.path)}">
56 <meta name="twitter:card" content="summary">
57 <meta name="twitter:title" content="${escape(title)}">
58 <meta name="twitter:description" content="${escape(description)}">
59 <link rel="stylesheet" href="${tokensHref}">
60 <link rel="stylesheet" href="${asset("app.css")}">
61 <body>
62 <a class="skip" href="#main">Skip to the page</a>
63 <div class="page">
64 <header class="head">
65 <a class="brand" href="/"><span class="mark" aria-hidden="true">${escape(brand.mark)}</span>${escape(brand.name)}</a>
66 <nav class="nav" aria-label="Site">${nav}${play()}</nav>
67 </header>
68 <main id="main">${body}</main>
69 <footer class="muted">
70 <p>${escape(brand.name)} is played at <a href="${escape(playUrl)}">${escape(new URL(playUrl).host)}</a>, in the browser: nothing to install.</p>
71 ${env === "production" ? "" : `<p><span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? ` <span class="mono">${escape(commit)}</span>` : ""}</p>`}
72 </footer>
73 </div>
74 </body>
75 </html>
76 `;
77 }
src/routes.ts +8 −37
@@ -27,20 +27,18 @@ import {
2727 } from "./actions.ts";
2828 import { commit, env, forgeUrl, startedAt, version } from "./env.ts";
2929 import {
30 back,
3130 fields,
3231 html,
3332 json,
34 readForm,
3533 readJson,
36 readNotice,
3734 SECURITY,
3835 staticFile,
3936 text,
4037 } from "./http.ts";
4138 import { events } from "./live.ts";
4239 import { rpc } from "./mcp.ts";
43 import { home } from "./page.ts";
40 import { home } from "./pages/home.ts";
41 import { howToPlay } from "./pages/how-to-play.ts";
4442 import { tokensCss } from "./tokens.ts";
4543 import type { Who } from "./who.ts";
4644
@@ -144,9 +142,9 @@ const answer = <T>(result: Result<T>, status = 200): Response =>
144142 STATUS[result.error],
145143 );
146144
147 /** A result for a form: back to the page, with what went wrong if it did. */
148 const done = <T>(result: Result<T>): Response =>
149 result.ok ? back() : back(result.message);
145 /** A page: the same HTML for everyone, so a browser may keep it a minute. */
146 const page = (body: string): Response =>
147 html(body, 200, { "cache-control": "public, max-age=60" });
150148
151149 /** A whole number from the query, or undefined. */
152150 const whole = (value: string | null): number | undefined =>
@@ -157,36 +155,9 @@ const OPENAPI = new URL("../contract/openapi.json", import.meta.url);
157155 const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined;
158156
159157 export const routes: readonly Route[] = [
160 // The page. Forms post and come back; the list stays live over /events.
161 {
162 method: "GET",
163 path: "/",
164 handle: (c) => {
165 const { notice, clear } = readNotice(c.request);
166 const notes = listNotes(c.db, c.who);
167 return html(
168 home({ who: c.who, notes: notes.ok ? notes.value : [], notice }),
169 200,
170 clear,
171 );
172 },
173 },
174 {
175 method: "POST",
176 path: "/notes",
177 handle: async (c) => {
178 const form = await readForm(c.request);
179 if (form === undefined) return text("too large", 413);
180 return done(
181 addNote(c.db, c.who, { body: String(form.get("body") ?? "") }),
182 );
183 },
184 },
185 {
186 method: "POST",
187 path: "/notes/:id/delete",
188 handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })),
189 },
158 // The pages: complete HTML, the same for everyone, no script.
159 { method: "GET", path: "/", handle: () => page(home()) },
160 { method: "GET", path: "/how-to-play", handle: () => page(howToPlay()) },
190161
191162 // The JSON API: the same actions, the same identity, plain status codes.
192163 {
src/server.test.ts +51 −40
@@ -48,41 +48,55 @@ const rpc = (message: unknown, who: Record<string, string> = {}) =>
4848 (r) => r.json() as Promise<any>,
4949 );
5050
51 test("the page knows who you are and offers the right door", async () => {
52 const guest = await (await fetch(base + "/")).text();
53 expect(guest).toContain("<h1>");
54 expect(guest).toContain("/__forge/signin?to=/");
55 expect(guest).not.toContain('action="/notes"');
56 const signed = await (await api("/", {}, alice)).text();
57 expect(signed).toContain("<b>alice</b>");
58 expect(signed).toContain("/__forge/signout");
59 expect(signed).toContain('action="/notes"');
51 test("the home page explains the game and leads to Play", async () => {
52 const response = await fetch(base + "/");
53 expect(response.status).toBe(200);
54 const page = await response.text();
55 expect(page).toContain("<h1>Cheffry&#39;s Dungeon</h1>");
56 expect(page).toContain('href="https://game.cheffryjames.com"');
57 expect(page).toContain('class="button button-play"');
58 for (const name of ["Meatball Slime", "Baguette Knight", "Pepper Imp"])
59 expect(page).toContain(name);
60 expect(page).toContain('href="/how-to-play"');
61 expect(page).toContain('aria-current="page"');
6062 });
6163
62 test("a form post adds as the person; a guest is sent to sign in", async () => {
63 const form = new FormData();
64 form.set("body", "hello from a form");
65 const refused = await api("/notes", {
66 method: "POST",
67 body: form,
68 redirect: "manual",
69 });
70 expect(refused.status).toBe(303);
71 expect(refused.headers.get("set-cookie")).toContain("notice=");
72 expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax");
73 expect(await (await fetch(base + "/")).text()).not.toContain(
74 "hello from a form",
64 test("how to play says the controls, the enemies, the chests and the score", async () => {
65 const response = await fetch(base + "/how-to-play");
66 expect(response.status).toBe(200);
67 const page = await response.text();
68 expect(page).toContain("<title>How to play · Cheffry&#39;s Dungeon</title>");
69 expect(page).toContain(
70 'property="og:url" content="http://localhost/how-to-play"',
7571 );
76 const posted = await api(
77 "/notes",
78 { method: "POST", body: form, redirect: "manual" },
79 alice,
80 );
81 expect(posted.status).toBe(303);
82 expect(posted.headers.get("set-cookie")).toBeNull();
83 const page = await (await api("/", {}, alice)).text();
84 expect(page).toContain("hello from a form");
85 expect(page).toContain("alice ·");
72 for (const words of [
73 "Dodge",
74 "Skewer Spikes",
75 "Whisk of Power",
76 "The Pass",
77 "6 hearts",
78 "20 minutes",
79 ])
80 expect([words, page.includes(words)]).toEqual([words, true]);
81 expect(page).toContain('href="https://game.cheffryjames.com"');
82 });
83
84 test("the pages run no script, and link nothing but this site and the game", async () => {
85 for (const path of ["/", "/how-to-play"]) {
86 const page = await (await fetch(base + path)).text();
87 expect(page).not.toContain("<script");
88 const hrefs = [...page.matchAll(/(?:href|src)="([^"]*)"/g)].map(
89 (m) => m[1]!,
90 );
91 for (const href of hrefs)
92 expect([
93 href,
94 href.startsWith("/") ||
95 href.startsWith("#") ||
96 href.startsWith("data:") ||
97 href === "https://game.cheffryjames.com",
98 ]).toEqual([href, true]);
99 }
86100 });
87101
88102 test("the JSON API is the same actions with the same identity", async () => {
@@ -229,11 +243,10 @@ test("stands on the security floor", async () => {
229243
230244 test("a body is capped by the bytes read, whatever it claims", async () => {
231245 const response = await api(
232 "/notes",
246 "/api/notes",
233247 {
234248 method: "POST",
235 headers: { "content-type": "application/x-www-form-urlencoded" },
236 body: "body=" + "x".repeat(1024 * 1024),
249 ...jsonBody({ body: "x".repeat(1024 * 1024) }),
237250 },
238251 alice,
239252 );
@@ -247,15 +260,13 @@ test("HEAD sees the app up", async () => {
247260 });
248261
249262 test("refuses a cross-site post", async () => {
250 const form = new FormData();
251 form.set("body", "from another site");
252263 const posted = await api(
253 "/notes",
264 "/api/notes",
254265 {
255266 method: "POST",
256 body: form,
257 redirect: "manual",
267 body: JSON.stringify({ body: "from another site" }),
258268 headers: {
269 "content-type": "application/json",
259270 origin: "https://evil.example",
260271 "sec-fetch-site": "cross-site",
261272 },
static/app.css +137 −98
@@ -15,27 +15,29 @@ body {
1515 font: var(--text-base) / var(--leading-body) var(--font-sans);
1616 }
1717 .page {
18 max-width: 40rem;
18 max-width: 52rem;
1919 margin: 0 auto;
20 padding: var(--space-5) var(--space-4) var(--space-6);
20 padding: var(--space-4) var(--space-4) var(--space-6);
2121 }
22 .head {
23 display: flex;
24 flex-wrap: wrap;
25 justify-content: space-between;
26 align-items: flex-start;
27 gap: var(--space-3) var(--space-4);
28 margin-bottom: var(--space-5);
29 }
30 h1 {
31 margin: 0;
32 font-size: var(--text-xl);
22 h1,
23 h2,
24 h3 {
25 margin: 0 0 var(--space-3);
3326 font-weight: 650;
3427 letter-spacing: -0.01em;
3528 line-height: var(--leading-tight);
3629 }
30 h1 {
31 font-size: var(--text-2xl);
32 }
33 h2 {
34 font-size: var(--text-xl);
35 }
36 h3 {
37 font-size: var(--text-lg);
38 }
3739 p {
38 margin: 0;
40 margin: 0 0 var(--space-3);
3941 }
4042 a {
4143 color: var(--color-accent);
@@ -54,79 +56,141 @@ code,
5456 .muted {
5557 color: var(--color-muted);
5658 }
57 .you {
59 main > section {
60 margin-bottom: var(--space-6);
61 }
62
63 /* Past the header for whoever arrives by keyboard. */
64 .skip {
65 position: absolute;
66 left: var(--space-2);
67 top: -100px;
68 padding: var(--space-2) var(--space-3);
69 background: var(--color-surface);
70 border-radius: var(--radius-md);
71 }
72 .skip:focus {
73 top: var(--space-2);
74 }
75
76 /* The header: the wordmark, the way around, Play. */
77 .head {
78 display: flex;
79 flex-wrap: wrap;
80 justify-content: space-between;
81 align-items: center;
82 gap: var(--space-2) var(--space-4);
83 margin-bottom: var(--space-6);
84 }
85 .brand {
86 display: inline-flex;
87 align-items: center;
88 gap: var(--space-2);
89 min-height: var(--size-tap);
90 font-weight: 700;
91 color: var(--color-fg);
92 text-decoration: none;
93 }
94 .mark {
95 display: inline-grid;
96 place-items: center;
97 width: 2rem;
98 height: 2rem;
99 font-size: var(--text-sm);
100 color: var(--color-accent-fg);
101 background: var(--color-accent);
102 border-radius: var(--radius-md);
103 }
104 .nav {
105 display: flex;
106 flex-wrap: wrap;
107 align-items: center;
108 gap: var(--space-1) var(--space-3);
109 }
110 .nav a:not(.button) {
111 display: inline-flex;
112 align-items: center;
113 min-height: var(--size-tap);
114 padding: 0 var(--space-1);
58115 color: var(--color-muted);
59 white-space: nowrap;
116 text-decoration: none;
117 }
118 .nav a:not(.button):hover,
119 .nav a[aria-current="page"] {
120 color: var(--color-fg);
121 }
122 .nav a[aria-current="page"] {
123 text-decoration: underline;
124 }
125
126 /* The opening of a page. */
127 .hero {
128 padding: var(--space-5) 0;
129 }
130 .lede {
131 max-width: 36rem;
132 font-size: var(--text-lg);
133 }
134 .hero .row {
135 margin: var(--space-5) 0 var(--space-3);
60136 }
61137
62138 /* Blocks. */
63139 .card {
64140 background: var(--color-surface);
65141 border: 1px solid var(--color-line);
66 border-radius: var(--radius-md);
142 border-radius: var(--radius-lg);
67143 padding: var(--space-4);
68 margin-bottom: var(--space-5);
144 }
145 .card > :last-child {
146 margin-bottom: 0;
147 }
148 .cards {
149 display: grid;
150 grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
151 gap: var(--space-4);
152 }
153 .cta {
154 text-align: center;
155 padding: var(--space-6) var(--space-4);
69156 }
70157 .row {
71158 display: flex;
72159 align-items: center;
73160 gap: var(--space-2);
74161 }
75 .list {
76 list-style: none;
162 .wrap {
163 flex-wrap: wrap;
164 }
165 .ticks,
166 .steps {
77167 margin: 0;
78 padding: 0;
168 padding-left: var(--space-5);
79169 }
80 .note {
81 display: flex;
82 justify-content: space-between;
83 align-items: flex-start;
84 gap: var(--space-3);
85 padding: var(--space-3) 0;
86 border-top: 1px solid var(--color-line);
170 .ticks li,
171 .steps li {
172 margin-bottom: var(--space-2);
87173 }
88 .note:first-child {
89 border-top: 0;
174 .keys {
175 display: grid;
176 grid-template-columns: max-content 1fr;
177 gap: var(--space-2) var(--space-4);
178 margin: 0;
90179 }
91 .note p {
92 overflow-wrap: anywhere;
180 .keys dt {
181 font-weight: 650;
93182 }
94 .note small {
95 font-size: var(--text-sm);
96 }
97 .empty {
98 padding: var(--space-6) var(--space-4);
99 text-align: center;
100 color: var(--color-muted);
101 border: 1px dashed var(--color-line);
102 border-radius: var(--radius-md);
183 .keys dd {
184 margin: 0;
103185 }
104186 footer {
105187 margin-top: var(--space-6);
188 padding-top: var(--space-4);
189 border-top: 1px solid var(--color-line);
106190 font-size: var(--text-sm);
107 display: grid;
108 gap: var(--space-1);
109 }
110 footer code {
111 overflow-wrap: anywhere;
112191 }
113192
114193 /* Controls: at least 44px on a side, keyboard and mouse alike. */
115 .input {
116 flex: 1;
117 min-width: 0;
118 min-height: var(--size-tap);
119 padding: 0 var(--space-3);
120 font: inherit;
121 font-size: max(16px, var(--text-base));
122 color: inherit;
123 background: var(--color-bg);
124 border: 1px solid var(--color-line);
125 border-radius: var(--radius-md);
126 }
127 .input::placeholder {
128 color: var(--color-muted);
129 }
130194 .button {
131195 min-height: var(--size-tap);
132196 padding: 0 var(--space-4);
@@ -141,58 +205,36 @@ footer code {
141205 text-decoration: none;
142206 display: inline-flex;
143207 align-items: center;
208 justify-content: center;
144209 }
145210 .button:hover {
146211 filter: brightness(1.08);
147212 }
213 .button-play {
214 padding: 0 var(--space-5);
215 }
216 .hero .button-play,
217 .cta .button-play {
218 font-size: var(--text-lg);
219 min-height: calc(var(--size-tap) * 1.25);
220 }
148221 .button-quiet {
149 color: var(--color-muted);
222 color: var(--color-fg);
150223 background: transparent;
151 padding: 0 var(--space-3);
224 border-color: var(--color-line);
152225 }
153226 .button-quiet:hover {
154 color: var(--color-fg);
155 border-color: var(--color-line);
227 border-color: var(--color-muted);
156228 filter: none;
157229 }
158230 .pill {
159231 display: inline-block;
160232 padding: 0.1rem 0.6rem;
161 font-size: var(--text-sm);
162233 font-weight: 600;
163 color: var(--color-muted);
164234 border: 1px solid var(--color-line);
165235 border-radius: var(--radius-pill);
166236 }
167 .pill[data-env="production"] {
168 color: var(--color-accent);
169 border-color: currentColor;
170 }
171237
172 /* A word from the last request, shown once, gone in a moment. */
173 .toast {
174 position: fixed;
175 left: 50%;
176 bottom: var(--space-5);
177 transform: translateX(-50%);
178 max-width: calc(100% - 2 * var(--space-4));
179 padding: var(--space-3) var(--space-4);
180 color: var(--color-toast-fg);
181 background: var(--color-toast-bg);
182 border-radius: var(--radius-pill);
183 box-shadow: 0 8px 24px var(--color-shadow);
184 animation: toast 6s forwards;
185 }
186 @keyframes toast {
187 0%,
188 85% {
189 opacity: 1;
190 }
191 100% {
192 opacity: 0;
193 visibility: hidden;
194 }
195 }
196238 /* Motion is a courtesy: whoever asked for less gets none. */
197239 @media (prefers-reduced-motion: reduce) {
198240 *,
@@ -203,7 +245,4 @@ footer code {
203245 transition-duration: 0.01ms !important;
204246 scroll-behavior: auto !important;
205247 }
206 .toast {
207 animation: none;
208 }
209248 }
static/live.js deleted −68
@@ -1,68 +0,0 @@
1 // The page stays current without a reload. /events is a stream of what
2 // changed; each event becomes `live` and `live:<type>` on `document`, and
3 // any element with data-live="<types>" is refetched and swapped when one of
4 // its types arrives — server-rendered, so the page never renders twice.
5 // Without this file the page is the same page, one reload behind.
6
7 let since = "";
8 let tries = 0;
9 let source;
10
11 function connect() {
12 source = new EventSource(since ? `/events?since=${since}` : "/events");
13 source.onopen = () => {
14 if (tries > 0) refresh(); // Reconnected: whatever was missed, catch up.
15 tries = 0;
16 };
17 source.onmessage = (message) => {
18 since = message.lastEventId;
19 const { type, data } = JSON.parse(message.data);
20 document.dispatchEvent(new CustomEvent("live", { detail: { type, data } }));
21 document.dispatchEvent(new CustomEvent(`live:${type}`, { detail: data }));
22 };
23 // The browser retries a dropped connection itself; a refused one it gives
24 // up on, and then we try again, later each time.
25 source.onerror = () => {
26 if (source.readyState !== EventSource.CLOSED) return;
27 source.close();
28 setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++));
29 };
30 }
31
32 let refreshing = false;
33 let again = false;
34 async function refresh(types) {
35 const regions = [...document.querySelectorAll("[data-live]")].filter(
36 (el) =>
37 types === undefined ||
38 el.dataset.live.split(" ").some((t) => types.includes(t)),
39 );
40 if (regions.length === 0) return;
41 if (refreshing) {
42 again = true;
43 return;
44 }
45 refreshing = true;
46 try {
47 const response = await fetch(location.href, {
48 headers: { accept: "text/html" },
49 });
50 const fresh = new DOMParser().parseFromString(
51 await response.text(),
52 "text/html",
53 );
54 for (const el of regions) {
55 const next = fresh.getElementById(el.id);
56 if (next) el.replaceWith(next);
57 }
58 } finally {
59 refreshing = false;
60 if (again) {
61 again = false;
62 refresh();
63 }
64 }
65 }
66
67 document.addEventListener("live", (event) => refresh([event.detail.type]));
68 connect();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs