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
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/29-learn-it-in-two-minutes-a-rules-page-tha → main 2 commits fork
12 files
changed +539
−38
AGENTS.md +5 −1
| @@ -76,7 +76,11 @@ To add a concept: | ||
| 76 | 76 | data in, escaped HTML out, and every one of them shown at `/style` from |
| 77 | 77 | a fixture. A page that invents its own markup for a charter, a claim or |
| 78 | 78 | 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 | |
| 80 | 84 | route that finds nothing answers with `notFound` (`src/pages/missing.ts`), |
| 81 | 85 | which hands a browser the layout and a way back to the lobby, and a |
| 82 | 86 | 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 | ||
| 4 | 4 | |
| 5 | 5 | The game is *Sworn & Said*, and its rules of record — charter, charges, |
| 6 | 6 | scoring, the sworn and the said, quorum and the clock — are written once in |
| 7 | [](RULES.md). | |
| 7 | [](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. | |
| 8 | 11 | |
| 9 | 12 | This is an app: the forge runs it. Production follows the default branch; |
| 10 | 13 | 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 | ||
| 34 | 37 | disagrees with the game teaches the wrong thing twice. The table page |
| 35 | 38 | renders the same functions from real state. |
| 36 | 39 | |
| 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 | ||
| 37 | 47 | | file | what it is | |
| 38 | 48 | | ---------------------------------- | ------------------------------------------------------------ | |
| 39 | 49 | | `src/actions.ts` | open, sit, leave, read a table — and who may see what | |
| 40 | 50 | | `src/board.ts` | the deal, the score and the sworn rule: by RULES.md, no db | |
| 41 | 51 | | `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 | | |
| 43 | 53 | | `src/when.ts` | time in words: "4h 12m left", "20m ago", a UTC moment | |
| 44 | 54 | | `src/result.ts` | data or a named failure — the answer shape every door reads | |
| 45 | 55 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| @@ -52,6 +62,7 @@ renders the same functions from real state. | ||
| 52 | 62 | | `src/pages/tableview.ts` | `/tables/:id`, one table at its own address | |
| 53 | 63 | | `src/pages/missing.ts` | not found, as a page for a browser and a word for a script | |
| 54 | 64 | | `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 | | |
| 55 | 66 | | `src/page.ts` | the lobby's body, poured into the layout | |
| 56 | 67 | | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files | |
| 57 | 68 | | `src/server.ts` | boot: open the database, serve | |
| @@ -129,8 +140,8 @@ redacted for another. The cost is the back button: going back refetches | ||
| 129 | 140 | rather than restoring instantly. Files under `/static` are the exception — |
| 130 | 141 | they are the same for everyone and are cached for a year when linked by |
| 131 | 142 | 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. | |
| 134 | 145 | |
| 135 | 146 | ## Any interface |
| 136 | 147 |
src/example.ts +15 −3
| @@ -8,7 +8,10 @@ | ||
| 8 | 8 | * nine acts, and neither file keeps a second copy that could drift from the |
| 9 | 9 | * document or from the other. |
| 10 | 10 | * |
| 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. | |
| 12 | 15 | */ |
| 13 | 16 | |
| 14 | 17 | 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 => { | ||
| 19 | 22 | }; |
| 20 | 23 | const charge = (seat: number, aims: Aim[]): Charge => ({ seat, aims }); |
| 21 | 24 | |
| 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 | */ | |
| 23 | 30 | export const CHARTER: Clause[] = [ |
| 24 | 31 | "beacon", |
| 25 | 32 | "hold", |
| @@ -28,7 +35,12 @@ export const CHARTER: Clause[] = [ | ||
| 28 | 35 | "name", |
| 29 | 36 | "chart", |
| 30 | 37 | "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 | }); | |
| 32 | 44 | |
| 33 | 45 | export const ash = charge(0, [ |
| 34 | 46 | aim("salvage=to_the_finders", 5), |
src/page.ts +3 −1
| @@ -35,7 +35,9 @@ export function home(input: { | ||
| 35 | 35 | return layout({ |
| 36 | 36 | who, |
| 37 | 37 | 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> | |
| 39 | 41 | <section id="tables" data-live="table.opened seat.joined seat.left">${list}</section>`, |
| 40 | 42 | }); |
| 41 | 43 | } |
src/pages/layout.ts +1
| @@ -89,5 +89,6 @@ const door = (who: Who, signInTo: string): string => | ||
| 89 | 89 | |
| 90 | 90 | const footer = (): string => `<footer class="muted"> |
| 91 | 91 | <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> | |
| 92 | 93 | <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> |
| 93 | 94 | </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 @@ | ||
| 9 | 9 | * the real components and not at a drawing of them. |
| 10 | 10 | */ |
| 11 | 11 | |
| 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"; | |
| 13 | 18 | import { ANON, type Who } from "../who.ts"; |
| 14 | 19 | import { headerNav, layout } from "./layout.ts"; |
| 15 | 20 | import { |
| 16 | 21 | actBar, |
| 17 | 22 | actLog, |
| 23 | aimList, | |
| 18 | 24 | charterGrid, |
| 19 | 25 | clockSlot, |
| 20 | 26 | offerForm, |
| @@ -41,34 +47,13 @@ export const TURN_SECONDS = 6 * 3600; | ||
| 41 | 47 | export const DEADLINE_SECONDS = 3 * 86400; |
| 42 | 48 | |
| 43 | 49 | /** |
| 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. | |
| 46 | 54 | */ |
| 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; | |
| 72 | 57 | |
| 73 | 58 | /** |
| 74 | 59 | * The seats after act 8, with A₂ standing. Ash is to act; Brail and Cleat |
| @@ -240,6 +225,16 @@ const SPECIMENS: readonly Specimen[] = [ | ||
| 240 | 225 | clauses: CHARTER, |
| 241 | 226 | }), |
| 242 | 227 | }, |
| 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 | }, | |
| 243 | 238 | { |
| 244 | 239 | heading: "A said claim, three ways", |
| 245 | 240 | 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 @@ | ||
| 13 | 13 | * #20) and the machine-readable instant beside them. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | import type { Accord, Clause } from "../board.ts"; | |
| 16 | import type { Accord, Aim, Clause } from "../board.ts"; | |
| 17 | 17 | import { escape } from "../http.ts"; |
| 18 | 18 | |
| 19 | 19 | // ------------------------------------------------------------ the vocabulary |
| @@ -101,6 +101,52 @@ const clauseRow = (clause: Clause, offered: string | undefined): string => | ||
| 101 | 101 | .join("")}</ul> |
| 102 | 102 | </li>`; |
| 103 | 103 | |
| 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 | ||
| 104 | 150 | /** The seats in order, each with where it stands and what it has sworn. */ |
| 105 | 151 | export function seatList(input: { |
| 106 | 152 | readonly seats: readonly Seat[]; |
src/pages/tableview.ts +1
| @@ -48,6 +48,7 @@ export function tableView(input: { | ||
| 48 | 48 | }</p> |
| 49 | 49 | <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> |
| 50 | 50 | ${invite(path)} |
| 51 | <p class="muted teach">Never played? <a href="/rules">The game in two minutes</a>.</p> | |
| 51 | 52 | ${ |
| 52 | 53 | who.role === "anon" |
| 53 | 54 | ? `<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"; | ||
| 29 | 29 | import { rpc } from "./mcp.ts"; |
| 30 | 30 | import { home } from "./page.ts"; |
| 31 | 31 | import { notFound } from "./pages/missing.ts"; |
| 32 | import { RECORD, rules } from "./pages/rules.ts"; | |
| 32 | 33 | import { style } from "./pages/style.ts"; |
| 33 | 34 | import { tableView } from "./pages/tableview.ts"; |
| 34 | 35 | import type { Who } from "./who.ts"; |
| @@ -154,6 +155,28 @@ export const routes: readonly Route[] = [ | ||
| 154 | 155 | ), |
| 155 | 156 | }, |
| 156 | 157 | |
| 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 | ||
| 157 | 180 | // Every part a table is made of, from a fixture: it reads no table and |
| 158 | 181 | // nobody's identity, so it answers the same bytes to everyone and says so |
| 159 | 182 | // 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"; | ||
| 3 | 3 | import { mkdtempSync } from "node:fs"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | 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"; | |
| 6 | 18 | import { text } from "./http.ts"; |
| 7 | 19 | import { ID_MAX, type LiveEvent, notAnAddress, subscribe } from "./live.ts"; |
| 8 | 20 | import { layout } from "./pages/layout.ts"; |
| 21 | import { quorum } from "./round.ts"; | |
| 22 | import { span } from "./when.ts"; | |
| 9 | 23 | import { serve } from "./server.ts"; |
| 10 | 24 | import { sign } from "./who.ts"; |
| 11 | 25 | |
| @@ -568,6 +582,97 @@ test("writes are rationed per person", async () => { | ||
| 568 | 582 | expect(codes).toContain(429); |
| 569 | 583 | }); |
| 570 | 584 | |
| 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 | ||
| 571 | 676 | test("/style shows every part a table is made of, from a fixture", async () => { |
| 572 | 677 | const response = await fetch(base + "/style"); |
| 573 | 678 | expect(response.status).toBe(200); |
static/app.css +79
| @@ -585,6 +585,85 @@ footer code { | ||
| 585 | 585 | max-width: 36rem; |
| 586 | 586 | margin: var() 0 var(); |
| 587 | 587 | font-size: var(); |
| 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(); | |
| 598 | margin-bottom: var(); | |
| 599 | } | |
| 600 | .lesson-part { | |
| 601 | margin-bottom: var(); | |
| 602 | } | |
| 603 | .lesson-part h2 { | |
| 604 | font-size: var(); | |
| 605 | margin-bottom: var(); | |
| 606 | } | |
| 607 | .lesson-part p { | |
| 608 | margin-bottom: var(); | |
| 609 | } | |
| 610 | .lesson .record { | |
| 611 | font-size: var(); | |
| 612 | padding-top: var(); | |
| 613 | border-top: 1px solid var(); | |
| 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(); | |
| 619 | font-size: var(); | |
| 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(); | |
| 626 | font-size: var(); | |
| 627 | } | |
| 628 | .aims { | |
| 629 | list-style: none; | |
| 630 | display: grid; | |
| 631 | gap: var(); | |
| 632 | margin: 0; | |
| 633 | padding: 0; | |
| 634 | } | |
| 635 | .aim { | |
| 636 | display: flex; | |
| 637 | flex-wrap: wrap; | |
| 638 | align-items: baseline; | |
| 639 | gap: var(); | |
| 640 | padding: var() var(); | |
| 641 | font-size: var(); | |
| 642 | background: var(); | |
| 643 | border-left: 3px solid var(); | |
| 644 | border-radius: 0 var() var() 0; | |
| 645 | } | |
| 646 | .aim[data-dread="yes"] .weight { | |
| 647 | color: var(); | |
| 648 | } | |
| 649 | .aim[data-met="yes"] { | |
| 650 | border-left-color: var(); | |
| 651 | } | |
| 652 | .aim[data-dread="yes"][data-met="yes"] { | |
| 653 | border-left-color: var(); | |
| 654 | } | |
| 655 | .met { | |
| 656 | margin-left: auto; | |
| 657 | color: var(); | |
| 658 | } | |
| 659 | .aim[data-met="yes"] .met { | |
| 660 | font-weight: 700; | |
| 661 | color: var(); | |
| 662 | } | |
| 663 | .aim[data-dread="yes"][data-met="yes"] .met { | |
| 664 | color: var(); | |
| 665 | } | |
| 666 | ||
| 588 | 667 | /* Time in words (`src/when.ts`). A clock that ticks once a second must not |
| 589 | 668 | move anything: the numbers are tabular, so every digit is the same width, |
| 590 | 669 | and a slot that is re-rendered holds a width of its own in `ch` — wide |
new above
offline