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

Learn it in two minutes: a /rules page that teaches the game rather than specifying it #69

Merged opened this

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

Merged

mason/parley:mason/29-learn-it-in-two-minutes-a-rules-page-tha → main 2 commits fork

  • Merged. mason/29-learn-it-in-two-minutes-a-rules-page-tha was merged into main as 5e8ab75.

Review

Sign in to review this change.
12 files changed +539 −38
AGENTS.md +5 −1
@@ -76,7 +76,11 @@ To add a concept:
7676 data in, escaped HTML out, and every one of them shown at `/style` from
7777 a fixture. A page that invents its own markup for a charter, a claim or
7878 an act is a drift; add to `table.ts` instead. (`src/page.ts` is the
79 lobby's body; it renders no document of its own.) A miss is a page too: a
79 lobby's body; it renders no document of its own.) `RULES.md` is the rules
80 of record and `/rules` (`src/pages/rules.ts`) is the two-minute lesson
81 that points at it: every number there is read from the constants and
82 never typed into prose, and a rule it leaves out is a sentence pointing
83 at the record, which wins where the two disagree. A miss is a page too: a
8084 route that finds nothing answers with `notFound` (`src/pages/missing.ts`),
8185 which hands a browser the layout and a way back to the lobby, and a
8286 script the plain body and status it expects.
README.md +15 −4
@@ -4,7 +4,10 @@ A negotiation game played over hours: secret goals, public offers, a deal that b
44
55 The game is *Sworn & Said*, and its rules of record — charter, charges,
66 scoring, the sworn and the said, quorum and the clock — are written once in
7 [RULES.md](RULES.md).
7 [RULES.md](RULES.md). If you have never played, start at `/rules` instead:
8 the game in two minutes, with a real charter, a real charge and what an
9 accord pays it. The record is served beside it at `/rules.md`, and where the
10 two disagree the record wins.
811
912 This is an app: the forge runs it. Production follows the default branch;
1013 every open pull gets a preview at its own address with its own copy of the
@@ -34,12 +37,19 @@ nobody's identity. The fixture is the worked example of RULES.md §10, and
3437 disagrees with the game teaches the wrong thing twice. The table page
3538 renders the same functions from real state.
3639
40 `/rules` teaches the same game those parts play. Every number on it is read
41 from the constants — `SEATS_MIN`, `CHARTER_CLAUSES`, the weights, the
42 quorum, the clock's defaults — and what the example accord pays the example
43 charge is `score()`, run at render time. Nothing numeric is written into its
44 prose, because two copies of the rules is how a game ends up with a wrong
45 one; anything procedural it leaves out is a sentence pointing at the record.
46
3747 | file | what it is |
3848 | ---------------------------------- | ------------------------------------------------------------ |
3949 | `src/actions.ts` | open, sit, leave, read a table — and who may see what |
4050 | `src/board.ts` | the deal, the score and the sworn rule: by RULES.md, no db |
4151 | `src/round.ts` | the turn, the standing offer, the binding and the settlement |
42 | `src/example.ts` | RULES.md §10's worked table, the fixture both are tested on |
52 | `src/example.ts` | RULES.md §10's worked table: the one copy, tested and shown |
4353 | `src/when.ts` | time in words: "4h 12m left", "20m ago", a UTC moment |
4454 | `src/result.ts` | data or a named failure — the answer shape every door reads |
4555 | `src/who.ts` | who is asking, from the forge's signed headers |
@@ -52,6 +62,7 @@ renders the same functions from real state.
5262 | `src/pages/tableview.ts` | `/tables/:id`, one table at its own address |
5363 | `src/pages/missing.ts` | not found, as a page for a browser and a word for a script |
5464 | `src/pages/style.ts` | `/style`, where the look lives |
65 | `src/pages/rules.ts` | `/rules`, the game in two minutes, and `/rules.md` behind it |
5566 | `src/page.ts` | the lobby's body, poured into the layout |
5667 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
5768 | `src/server.ts` | boot: open the database, serve |
@@ -129,8 +140,8 @@ redacted for another. The cost is the back button: going back refetches
129140 rather than restoring instantly. Files under `/static` are the exception —
130141 they are the same for everyone and are cached for a year when linked by
131142 content — and a response that is genuinely public says so at its call site:
132 `/style` reads no table and nobody's identity, so it is `public, max-age=60`
133 and varies on nothing a person carries.
143 `/style`, `/rules` and `/rules.md` read no table and nobody's identity, so
144 they are `public, max-age=60` and vary on nothing a person carries.
134145
135146 ## Any interface
136147
src/example.ts +15 −3
@@ -8,7 +8,10 @@
88 * nine acts, and neither file keeps a second copy that could drift from the
99 * document or from the other.
1010 *
11 * Fixture only: nothing the app serves imports this.
11 * The app serves it too: `/rules` (`src/pages/rules.ts`) teaches from this
12 * table, and `/style` draws its parts from it, so what a reader is shown is
13 * the same worked example the engine is tested against. Nothing here is a
14 * secret — §10 prints these charges itself — and nothing here reads state.
1215 */
1316
1417 import { type Accord, type Aim, type Charge, CLAUSE_POOL, type Clause } from "./board.ts";
@@ -19,7 +22,11 @@ const aim = (pair: string, weight: number): Aim => {
1922 };
2023 const charge = (seat: number, aims: Aim[]): Charge => ({ seat, aims });
2124
22 /** The 7 clauses of §10, in the order §10 lists them. */
25 /**
26 * The 7 clauses of §10, in the order §10 lists them. A clause the pool no
27 * longer holds fails at boot, not at the first request for a page that
28 * draws it.
29 */
2330 export const CHARTER: Clause[] = [
2431 "beacon",
2532 "hold",
@@ -28,7 +35,12 @@ export const CHARTER: Clause[] = [
2835 "name",
2936 "chart",
3037 "salvage",
31 ].map((id) => CLAUSE_POOL.find((clause) => clause.id === id)!);
38 ].map((id) => {
39 const clause = CLAUSE_POOL.find((one) => one.id === id);
40 if (clause === undefined)
41 throw new Error(`example: the pool holds no clause ${id} (RULES §3)`);
42 return clause;
43 });
3244
3345 export const ash = charge(0, [
3446 aim("salvage=to_the_finders", 5),
src/page.ts +3 −1
@@ -35,7 +35,9 @@ export function home(input: {
3535 return layout({
3636 who,
3737 notice,
38 body: `<section class="card">${opener}</section>
38 body: `<section class="card">${opener}
39 <p class="muted teach">Never played? <a href="/rules">The game in two minutes</a> — the charter, your charge, and what a signature costs.</p>
40 </section>
3941 <section id="tables" data-live="table.opened seat.joined seat.left">${list}</section>`,
4042 });
4143 }
src/pages/layout.ts +1
@@ -89,5 +89,6 @@ const door = (who: Who, signInTo: string): string =>
8989
9090 const footer = (): string => `<footer class="muted">
9191 <p>${ssh === undefined ? "The terminal is off." : `Terminal: <code>${escape(ssh)}</code>`}</p>
92 <p>New here? <a href="/rules">The game in two minutes</a>, and <a href="/rules.md">the rules of record</a> behind it.</p>
9293 <p>Also at <a href="/api/tables">/api/tables</a> and <code>POST /mcp</code>, with the same rules. Every part a table is made of is at <a href="/style">/style</a>.</p>
9394 </footer>`;
src/pages/rules.ts added +222
@@ -0,0 +1,222 @@
1 /**
2 * `/rules`: the game in a page, for somebody who has just been dealt a
3 * charge and has two minutes before their turn.
4 *
5 * This page teaches; `RULES.md` specifies, and is the record — where the two
6 * disagree the record wins, and this page says so. Two copies of the rules
7 * is how a game ends up with a wrong one, so nothing numeric is written into
8 * the prose here: every number is read from `src/board.ts`, `src/round.ts`
9 * and `src/actions.ts`, and anything procedural is a sentence that points at
10 * the record, served beside this page at `/rules.md`.
11 *
12 * It shows rather than tells. The charter, the charge, the accord and the
13 * claims below are the worked example of RULES.md §10 (`src/example.ts`,
14 * one copy for the whole repository), drawn with the parts in `./table.ts`
15 * that a real table is drawn with. What an accord pays a charge is
16 * `score()`, not a number typed into a sentence: if the arithmetic of the
17 * game changed, this page would change with it.
18 *
19 * It reads no table, no identity and no clock, so it is the same bytes for
20 * everyone and says so at its call site (`src/routes.ts`).
21 */
22
23 import { readFileSync } from "node:fs";
24 import { join } from "node:path";
25 import { DEADLINE_SECONDS, TURN_SECONDS } from "../actions.ts";
26 import {
27 AIMS_PER_CHARGE,
28 CHARTER_CLAUSES,
29 DREAD_WEIGHT,
30 POSITIVE_AIMS,
31 POSITIVE_WEIGHTS,
32 SEATS_MAX,
33 SEATS_MIN,
34 satisfied,
35 score,
36 } from "../board.ts";
37 import { a2, CHARTER, cleat, dunnage } from "../example.ts";
38 import { quorum, trueWorth } from "../round.ts";
39 import { span } from "../when.ts";
40 import { ANON } from "../who.ts";
41 import { layout } from "./layout.ts";
42 import {
43 aimList,
44 charterGrid,
45 saidClaim,
46 signed,
47 swornClaim,
48 } from "./table.ts";
49
50 /** A number as a word on the page. Written once so no prose spells a digit. */
51 const n = (value: number): string => String(value);
52
53 /** "1, 2, 3 or 5" — the weights a positive aim can be worth, from the pool. */
54 const weights = (): string => {
55 const all = POSITIVE_WEIGHTS.map(n);
56 return `${all.slice(0, -1).join(", ")} or ${all[all.length - 1]}`;
57 };
58
59 /** The settings every clause has: the charter's own, never a typed 3. */
60 const SETTINGS_PER_CLAUSE = CHARTER[0]!.settings.length;
61
62 /** What A₂ pays Cleat's charge: §4's arithmetic, run, never a typed total. */
63 const PAYS = score(cleat, a2);
64
65 /**
66 * The claim Cleat swore to accept A₂ (§10, act 7): the cheapest truth this
67 * accord lets him spend, picked out of the aims it satisfies rather than
68 * copied out of the transcript, so its weight is the charge's own.
69 */
70 const SWORN = {
71 seat: cleat.seat,
72 ...satisfied(cleat, a2).reduce((one, other) =>
73 other.weight < one.weight ? other : one,
74 ),
75 };
76
77 /**
78 * The lie Dunnage told about the Ledger (§10, act 4), as the reveal marks
79 * it. The verdict is `trueWorth` run against his charge — the same function
80 * that judges a real table — so this page cannot teach a verdict the engine
81 * would not reach.
82 */
83 const SAID = {
84 clause: "ledger",
85 setting: "redacted",
86 worth: "none",
87 } as const;
88 const JUDGED = { ...SAID, verdict: trueWorth(dunnage, SAID) === SAID.worth };
89
90 export function rules(): string {
91 return layout({
92 title: "How to play",
93 who: ANON,
94 body: `<article class="lesson">
95 <p class="lead">A wreck, a document, and ${n(SEATS_MIN)} to ${n(SEATS_MAX)} people who each want
96 something different out of it. You argue by offering the whole document at
97 once. When enough of you sign one version it binds, and it pays every seat —
98 signed or not.</p>
99
100 ${lesson(
101 "The charter",
102 `What you are arguing over: ${n(CHARTER_CLAUSES)} clauses, each with exactly
103 ${n(SETTINGS_PER_CLAUSE)} settings. An <b>accord</b> is one setting for every clause, all
104 ${n(CHARTER_CLAUSES)} of them, never a fragment. Here is one.`,
105 charterGrid({
106 clauses: CHARTER,
107 offer: a2,
108 caption: "An accord: one setting for every clause.",
109 }),
110 )}
111
112 ${lesson(
113 "Your charge",
114 `Secret until the game ends: ${n(AIMS_PER_CHARGE)} aims on ${n(AIMS_PER_CHARGE)} different clauses.
115 ${n(POSITIVE_AIMS)} pay you — ${weights()}, three different values — and one is a
116 <b>dread</b> worth ${signed(DREAD_WEIGHT)}, the setting you are paid to prevent. Here is a
117 charge, held against the accord above.`,
118 aimList({
119 aims: cleat.aims,
120 clauses: CHARTER,
121 accord: a2,
122 caption: `That accord pays this charge ${n(PAYS)}.`,
123 }),
124 )}
125
126 ${lesson(
127 "The turn",
128 `Seats act in a fixed order, round and round; nobody is skipped. On your turn
129 you take exactly one act: <b>offer</b> an accord, which becomes the standing offer
130 and clears every acceptance including your own; <b>accept</b> the offer that
131 stands; or <b>pass</b>, always legal.`,
132 )}
133
134 ${lesson(
135 "The sworn: signing costs a truth",
136 `To accept, cite one of your own aims that the accord satisfies and that you
137 have not cited before. The engine checks it against the charge it holds and
138 publishes it forever: clause, setting, weight, seat. A sworn claim cannot be
139 false, and you get at most ${n(POSITIVE_AIMS)} in a game — one per paying aim.`,
140 `<ul class="claims">${swornClaim(SWORN, CHARTER)}</ul>`,
141 )}
142
143 ${lesson(
144 "The said: lying is legal and free",
145 `Any act may also carry <b>said</b> claims: what you tell the table an aim is
146 worth to you. Nobody checks them while the game runs — saying the Ledger is
147 nothing to you is a move, not a fact. At the end each is marked true or
148 false in public, against the charge that said it. This one was a lie.`,
149 `<ul class="claims">${saidClaim(JUDGED, CHARTER)}</ul>`,
150 )}
151
152 ${lesson(
153 "The clock",
154 `A table plays by post. The seat to act has a turn window
155 (${span(TURN_SECONDS.default)} by default); a window that runs out is a real pass, written
156 for you and marked lapsed. The table has a deadline too
157 (${span(DEADLINE_SECONDS.default)} by default), from the moment the last seat fills.
158 Running the clock down is a bargaining position, and nobody has to announce
159 it.`,
160 )}
161
162 ${lesson(
163 "The binding and the reveal",
164 `The standing offer binds the moment acceptances reach quorum — ${quorums()}.
165 Nobody closes the vote; it happens inside the acceptance that reaches it.
166 Every seat is then scored against that accord, signed or silent, and
167 everything opens: every charge, every score, every said claim marked true or
168 false. If the deadline passes with nothing bound the table is impounded —
169 the same reveal, and nobody scores.`,
170 )}
171
172 ${lesson(
173 "The question the game asks",
174 `You cannot accept a deal that pays you nothing — not as a favour, not for a
175 promise. So every signature is information: it tells the table that this
176 accord pays you, on which clause, and exactly how much, and you have only
177 ${n(POSITIVE_AIMS)} such facts to spend. Buying a seat's signature makes them pay the
178 room in something the room will use against you next offer. Holding out
179 keeps you unread, but quorum can settle without you and the accord scores
180 you anyway. That trade — being paid against being read — is the game.`,
181 )}
182
183 <p class="record muted">This page teaches; <a href="/rules.md">the rules of
184 record</a> specify, with every edge case this one leaves out. Where the two
185 disagree, <b>the record wins</b>. Every part of a table, on its own, is at
186 <a href="/style">/style</a>.</p>
187 </article>`,
188 });
189 }
190
191 /** "3 of 4, 4 of 5, 4 of 6" — ⌈n/2⌉+1 at every seat count a table has. */
192 const quorums = (): string =>
193 seatCounts()
194 .map((seats) => `${n(quorum(seats))} of ${n(seats)}`)
195 .join(", ");
196
197 const seatCounts = (): number[] =>
198 Array.from({ length: SEATS_MAX - SEATS_MIN + 1 }, (_, i) => SEATS_MIN + i);
199
200 /**
201 * One section: a heading, a paragraph, and the thing itself when there is
202 * one to show. The headings and sentences are literals of this module and
203 * the components escaped their own input, so nothing here came from a
204 * person.
205 */
206 const lesson = (heading: string, prose: string, shown?: string): string =>
207 `<section class="lesson-part">
208 <h2>${heading}</h2>
209 <p>${prose}</p>
210 ${shown ?? ""}
211 </section>`;
212
213 /**
214 * The record itself, beside the page that points at it: the repository's
215 * `RULES.md`, read once at boot and served as plain text. A link to the
216 * record has to reach the record, and this app knows where its own copy is;
217 * guessing at a repository URL would be inventing one.
218 */
219 export const RECORD: string = readFileSync(
220 join(import.meta.dir, "..", "..", "RULES.md"),
221 "utf8",
222 );
src/pages/style.ts +23 −28
@@ -9,12 +9,18 @@
99 * the real components and not at a drawing of them.
1010 */
1111
12 import { CLAUSE_POOL, type Accord, type Clause } from "../board.ts";
12 import type { Accord, Clause } from "../board.ts";
13 import {
14 a2 as A2,
15 CHARTER as EXAMPLE_CHARTER,
16 cleat,
17 } from "../example.ts";
1318 import { ANON, type Who } from "../who.ts";
1419 import { headerNav, layout } from "./layout.ts";
1520 import {
1621 actBar,
1722 actLog,
23 aimList,
1824 charterGrid,
1925 clockSlot,
2026 offerForm,
@@ -41,34 +47,13 @@ export const TURN_SECONDS = 6 * 3600;
4147 export const DEADLINE_SECONDS = 3 * 86400;
4248
4349 /**
44 * The seven clauses of RULES.md §10, by id. A clause the pool no longer
45 * holds fails at boot rather than at the first request for the page.
50 * The seven clauses of RULES.md §10, and A₂ — the offer standing after act
51 * 5. Both are `src/example.ts`, which is the one copy of that table in this
52 * repository: a fixture written twice is a fixture that drifts, and the
53 * ledger below is only checkable against the charter it was written for.
4654 */
47 export const CHARTER: readonly Clause[] = [
48 "beacon",
49 "hold",
50 "ledger",
51 "pilot",
52 "name",
53 "chart",
54 "salvage",
55 ].map((id) => {
56 const clause = CLAUSE_POOL.find((one) => one.id === id);
57 if (clause === undefined)
58 throw new Error(`style: the pool holds no clause ${id} (RULES §3)`);
59 return clause;
60 });
61
62 /** A₂, the standing offer after act 5. */
63 export const STANDING: Accord = {
64 beacon: "kept",
65 hold: "sealed",
66 ledger: "published",
67 pilot: "reinstated",
68 name: "changed",
69 chart: "locked",
70 salvage: "to_the_finders",
71 };
55 export const CHARTER: readonly Clause[] = EXAMPLE_CHARTER;
56 export const STANDING: Accord = A2;
7257
7358 /**
7459 * The seats after act 8, with A₂ standing. Ash is to act; Brail and Cleat
@@ -240,6 +225,16 @@ const SPECIMENS: readonly Specimen[] = [
240225 clauses: CHARTER,
241226 }),
242227 },
228 {
229 heading: "A charge, open",
230 why: "A charge is secret while a table plays, so this is drawn only where the rules have already opened one: the reveal, and the worked example /rules teaches from. Each aim carries its weight, the dread is marked as one, and against an accord every row says what that accord did to it.",
231 body: aimList({
232 aims: cleat.aims,
233 clauses: CHARTER,
234 accord: STANDING,
235 caption: "Cleat's charge, held against the offer that stands.",
236 }),
237 },
243238 {
244239 heading: "A said claim, three ways",
245240 why: "Said claims are shape-checked and never truth-checked. They are unjudged while the game runs, and marked true or false in public at the reveal.",
src/pages/table.ts +47 −1
@@ -13,7 +13,7 @@
1313 * #20) and the machine-readable instant beside them.
1414 */
1515
16 import type { Accord, Clause } from "../board.ts";
16 import type { Accord, Aim, Clause } from "../board.ts";
1717 import { escape } from "../http.ts";
1818
1919 // ------------------------------------------------------------ the vocabulary
@@ -101,6 +101,52 @@ const clauseRow = (clause: Clause, offered: string | undefined): string =>
101101 .join("")}</ul>
102102 </li>`;
103103
104 /**
105 * A charge, open: the four aims of one seat, each with what it is worth,
106 * and — when an accord is given — whether that accord pays it. The dread is
107 * marked as one, because a negative weight beside three positive ones is the
108 * whole of the arithmetic (RULES §4).
109 *
110 * A charge is secret while a table plays (RULES §4), so this is drawn only
111 * where the rules already opened one: the reveal, and the worked example the
112 * teaching page shows. It reads a charge it was handed and nothing else.
113 */
114 export function aimList(input: {
115 readonly aims: readonly Aim[];
116 readonly clauses: readonly Clause[];
117 readonly accord?: Accord | undefined;
118 readonly caption?: string | undefined;
119 }): string {
120 const { aims, clauses, accord, caption } = input;
121 return `<section class="charge" aria-label="A charge">
122 ${caption === undefined ? "" : `<p class="muted charge-caption">${escape(caption)}</p>`}
123 <ul class="aims">${aims.map((aim) => aimRow(aim, clauses, accord)).join("")}</ul>
124 </section>`;
125 }
126
127 /**
128 * One aim. Every row carries the verdict's space whether or not an accord
129 * was given, so marking a charge against one moves nothing.
130 */
131 export const aimRow = (
132 aim: Aim,
133 clauses: readonly Clause[],
134 accord?: Accord | undefined,
135 ): string => {
136 const dreaded = aim.weight < 0;
137 const met = accord === undefined ? undefined : accord[aim.clause] === aim.setting;
138 return `<li class="aim"${dreaded ? ` data-dread="yes"` : ""}${met === undefined ? "" : ` data-met="${met ? "yes" : "no"}"`}>
139 <span class="claim-kind">${dreaded ? "dread" : "aim"}</span>
140 <span class="claim-what">${escape(title(clauses, aim.clause))} ${escape(words(aim.setting))}</span>
141 <span class="weight">${escape(signed(aim.weight))}</span>
142 ${
143 met === undefined
144 ? ""
145 : `<span class="met">${dreaded ? (met ? "triggered" : "not triggered") : met ? "paid" : "missed"}</span>`
146 }
147 </li>`;
148 };
149
104150 /** The seats in order, each with where it stands and what it has sworn. */
105151 export function seatList(input: {
106152 readonly seats: readonly Seat[];
src/pages/tableview.ts +1
@@ -48,6 +48,7 @@ export function tableView(input: {
4848 }</p>
4949 <p class="muted">${table.seated}/${table.seats_max} seated · ${span(table.turn_seconds)} a turn · ${span(table.deadline_seconds)} in all · ${table.quorum} of ${table.seats_max} bind an accord</p>
5050 ${invite(path)}
51 <p class="muted teach">Never played? <a href="/rules">The game in two minutes</a>.</p>
5152 ${
5253 who.role === "anon"
5354 ? `<p class="muted">Watching needs no account. <a href="/__forge/signin?to=${path}">Sign in</a> to take a seat.</p>`
src/routes.ts +23
@@ -29,6 +29,7 @@ import { events } from "./live.ts";
2929 import { rpc } from "./mcp.ts";
3030 import { home } from "./page.ts";
3131 import { notFound } from "./pages/missing.ts";
32 import { RECORD, rules } from "./pages/rules.ts";
3233 import { style } from "./pages/style.ts";
3334 import { tableView } from "./pages/tableview.ts";
3435 import type { Who } from "./who.ts";
@@ -154,6 +155,28 @@ export const routes: readonly Route[] = [
154155 ),
155156 },
156157
158 // The game in two minutes, and the rules of record it points at. Both
159 // read no table and nobody's identity, so both are the same bytes for
160 // everyone and say so here rather than taking the private floor.
161 {
162 method: "GET",
163 path: "/rules",
164 handle: () =>
165 html(rules(), 200, {
166 "cache-control": "public, max-age=60",
167 vary: "accept-encoding",
168 }),
169 },
170 {
171 method: "GET",
172 path: "/rules.md",
173 handle: () =>
174 text(RECORD, 200, {
175 "cache-control": "public, max-age=60",
176 vary: "accept-encoding",
177 }),
178 },
179
157180 // Every part a table is made of, from a fixture: it reads no table and
158181 // nobody's identity, so it answers the same bytes to everyone and says so
159182 // at its call site — the one page that is not `private, no-store`, and it
src/server.test.ts +105
@@ -3,9 +3,23 @@ import { afterAll, expect, test } from "bun:test";
33 import { mkdtempSync } from "node:fs";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
6 import {
7 AIMS_PER_CHARGE,
8 CHARTER_CLAUSES,
9 DREAD_WEIGHT,
10 POSITIVE_AIMS,
11 POSITIVE_WEIGHTS,
12 SEATS_MAX,
13 SEATS_MIN,
14 score,
15 } from "./board.ts";
16 import { DEADLINE_SECONDS, TURN_SECONDS } from "./actions.ts";
17 import { a2, cleat } from "./example.ts";
618 import { text } from "./http.ts";
719 import { ID_MAX, type LiveEvent, notAnAddress, subscribe } from "./live.ts";
820 import { layout } from "./pages/layout.ts";
21 import { quorum } from "./round.ts";
22 import { span } from "./when.ts";
923 import { serve } from "./server.ts";
1024 import { sign } from "./who.ts";
1125
@@ -568,6 +582,97 @@ test("writes are rationed per person", async () => {
568582 expect(codes).toContain(429);
569583 });
570584
585 test("/rules teaches the game with the numbers the code holds, not with prose", async () => {
586 const response = await fetch(base + "/rules");
587 expect(response.status).toBe(200);
588 const page = await response.text();
589
590 // Every number on the page is one of these constants, rendered. Change a
591 // constant and the page changes with it, or this fails — which is the
592 // only thing standing between one game and two sets of rules.
593 const weights = `${POSITIVE_WEIGHTS.slice(0, -1).join(", ")} or ${POSITIVE_WEIGHTS.at(-1)}`;
594 expect(page).toContain(`${SEATS_MIN} to ${SEATS_MAX} people`);
595 expect(page).toContain(`${CHARTER_CLAUSES} clauses`);
596 expect(page).toContain(`${AIMS_PER_CHARGE} aims on ${AIMS_PER_CHARGE} different clauses`);
597 expect(page).toContain(`${POSITIVE_AIMS} pay you — ${weights}`);
598 expect(page).toContain(`worth −${Math.abs(DREAD_WEIGHT)}`);
599 expect(page).toContain(`at most ${POSITIVE_AIMS} in a game`);
600 // Quorum at every seat count a table has, and the clock's own defaults.
601 for (let seats = SEATS_MIN; seats <= SEATS_MAX; seats++)
602 expect(page).toContain(`${quorum(seats)} of ${seats}`);
603 expect(page).toContain(`(${span(TURN_SECONDS.default)} by default)`);
604 expect(page).toContain(`(${span(DEADLINE_SECONDS.default)} by default)`);
605
606 // It shows rather than tells: the real charter grid with a real accord
607 // marked, a real charge, and what that accord pays it — `score()`, run,
608 // never a number typed into a sentence.
609 expect(page.match(/class="setting"/g)).toHaveLength(CHARTER_CLAUSES * 3);
610 expect(page.match(/data-offered="yes"/g)).toHaveLength(CHARTER_CLAUSES);
611 expect(page.match(/class="aim"/g)).toHaveLength(AIMS_PER_CHARGE);
612 expect(page).toContain(`pays this charge ${score(cleat, a2)}`);
613 expect(page).toContain('data-dread="yes"');
614 // And the two kinds of claim, drawn by the parts a real table is drawn with.
615 expect(page).toContain('data-kind="sworn"');
616 expect(page).toContain('data-kind="said"');
617 expect(page).toContain('data-verdict="false"');
618
619 // It points at the record, and says which wins.
620 expect(page).toContain('<a href="/rules.md">');
621 expect(page).toContain("the record wins");
622 });
623
624 test("/rules is the same page for everyone, on the floor, in milliseconds", async () => {
625 const guest = await (await fetch(base + "/rules")).text();
626 expect(await (await api("/rules", {}, alice)).text()).toBe(guest);
627 expect(await (await api("/rules", {}, root)).text()).toBe(guest);
628 // Nothing inline to execute: the CSP forbids it and the page does not ask.
629 expect(guest).not.toMatch(/<script(?![^>]*\ssrc=)/);
630
631 const floor = await fetch(base + "/rules");
632 expect(floor.headers.get("content-security-policy")).toContain(
633 "default-src 'self'",
634 );
635 expect(floor.headers.get("x-content-type-options")).toBe("nosniff");
636 expect(floor.headers.get("referrer-policy")).toBe(
637 "strict-origin-when-cross-origin",
638 );
639 // Reading nobody, it says its own caching and varies on nothing a person
640 // carries — the same bytes for a guest and an admin may be shared.
641 expect(floor.headers.get("cache-control")).toBe("public, max-age=60");
642 const vary = floor.headers.get("vary")?.toLowerCase() ?? "";
643 for (const name of ["x-forge-identity", "x-forge-signature", "cookie"])
644 expect(vary).not.toContain(name);
645
646 // The fastest of ten warm requests: rendering constants and a fixture is
647 // string work, and a loaded box makes some request slow whatever the code
648 // does, so the slowest says nothing and the fastest is a real floor.
649 const times: number[] = [];
650 for (let i = 0; i < 10; i++) {
651 const started = performance.now();
652 await (await fetch(base + "/rules")).text();
653 times.push(performance.now() - started);
654 }
655 expect(Math.min(...times)).toBeLessThan(50);
656 });
657
658 test("the record is served whole, as text, and everything links to the lesson", async () => {
659 const record = await fetch(base + "/rules.md");
660 expect(record.status).toBe(200);
661 expect(record.headers.get("content-type")).toContain("text/plain");
662 const markdown = await record.text();
663 expect(markdown).toContain("# Sworn & Said — the rules of record");
664 expect(markdown).toContain("## 6. The sworn: accepting costs a truth");
665
666 // One tap from the lobby and from a table, signed in or not.
667 const id = await openTable(alice, "A table to learn at");
668 for (const page of [
669 await (await fetch(base + "/")).text(),
670 await (await api("/", {}, alice)).text(),
671 await (await fetch(base + `/tables/${id}`)).text(),
672 ])
673 expect(page).toContain('href="/rules"');
674 });
675
571676 test("/style shows every part a table is made of, from a fixture", async () => {
572677 const response = await fetch(base + "/style");
573678 expect(response.status).toBe(200);
static/app.css +79
@@ -585,6 +585,85 @@ footer code {
585585 max-width: 36rem;
586586 margin: var(--s1) 0 var(--s3);
587587 font-size: var(--text-sm);
588 }
589
590 /* /rules: the game in two minutes. A column of reading width, each part a
591 heading, a paragraph and the thing itself drawn by the same components a
592 real table is drawn with. */
593 .lesson {
594 max-width: 38rem;
595 }
596 .lesson .lead {
597 font-size: var(--text-lg);
598 margin-bottom: var(--s6);
599 }
600 .lesson-part {
601 margin-bottom: var(--s6);
602 }
603 .lesson-part h2 {
604 font-size: var(--text-lg);
605 margin-bottom: var(--s2);
606 }
607 .lesson-part p {
608 margin-bottom: var(--s3);
609 }
610 .lesson .record {
611 font-size: var(--text-sm);
612 padding-top: var(--s4);
613 border-top: 1px solid var(--line);
614 }
615 /* The one link that takes a first-time reader out of a table and into the
616 rules. Quiet, but never smaller than the text around it. */
617 .teach {
618 margin-top: var(--s3);
619 font-size: var(--text-sm);
620 }
621
622 /* A charge, open: four aims and what an accord does to each. Same rule on
623 the left as a claim, so the vocabulary reads as one family. */
624 .charge-caption {
625 margin-bottom: var(--s2);
626 font-size: var(--text-sm);
627 }
628 .aims {
629 list-style: none;
630 display: grid;
631 gap: var(--s1);
632 margin: 0;
633 padding: 0;
634 }
635 .aim {
636 display: flex;
637 flex-wrap: wrap;
638 align-items: baseline;
639 gap: var(--s2);
640 padding: var(--s2) var(--s3);
641 font-size: var(--text-sm);
642 background: var(--sunk);
643 border-left: 3px solid var(--line);
644 border-radius: 0 var(--radius) var(--radius) 0;
645 }
646 .aim[data-dread="yes"] .weight {
647 color: var(--danger);
648 }
649 .aim[data-met="yes"] {
650 border-left-color: var(--good);
651 }
652 .aim[data-dread="yes"][data-met="yes"] {
653 border-left-color: var(--danger);
654 }
655 .met {
656 margin-left: auto;
657 color: var(--muted);
658 }
659 .aim[data-met="yes"] .met {
660 font-weight: 700;
661 color: var(--good);
662 }
663 .aim[data-dread="yes"][data-met="yes"] .met {
664 color: var(--danger);
665 }
666
588667 /* Time in words (`src/when.ts`). A clock that ticks once a second must not
589668 move anything: the numbers are tabular, so every digit is the same width,
590669 and a slot that is re-rendered holds a width of its own in `ch` — wide

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs