Public: anyone can read this; the crew can change it.
The deal: a charter and four secret charges from a seed, in src/board.ts #24
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/6-the-deal-a-charter-and-four-secret-charg → main 2 commits fork
7 files
changed +568
−11
AGENTS.md +6
| @@ -33,6 +33,12 @@ door reads its wire format and shapes the answer; it decides nothing. A | ||
| 33 | 33 | rule in an action holds at every door at once; a rule anywhere else is a |
| 34 | 34 | bug waiting for the door you forgot. |
| 35 | 35 | |
| 36 | A rule of the game is not an action. `src/board.ts` is the deal and the | |
| 37 | score from `RULES.md` — a seed and a seat count in, a charter and charges | |
| 38 | out, no database, no clock, no `Math.random`, so it can be checked by hand | |
| 39 | and tested against a fixture. Rules of that kind go in a pure module an | |
| 40 | action calls; both speak the same named failures, from `src/result.ts`. | |
| 41 | ||
| 36 | 42 | Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`, |
| 37 | 43 | verified by signature, never by a cookie the app invents. An action that |
| 38 | 44 | needs a person checks `who.role`; ownership is a column (`author_id`) and |
README.md +2
| @@ -28,6 +28,8 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 28 | 28 | | file | what it is | |
| 29 | 29 | | ---------------------------------- | ------------------------------------------------------------ | |
| 30 | 30 | | `src/actions.ts` | list, add, delete — and who may | |
| 31 | | `src/board.ts` | the deal and the score: a table from a seed, by RULES.md | | |
| 32 | | `src/result.ts` | data or a named failure — the answer shape every door reads | | |
| 31 | 33 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 32 | 34 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 33 | 35 | | `src/live.ts` | the in-process bus and `GET /events` | |
RULES.md +5
| @@ -309,6 +309,11 @@ with its verdict. | ||
| 309 | 309 | A four-seat table. Seed `tidewater`, seats Ash (0), Brail (1), Cleat (2), |
| 310 | 310 | Dunnage (3). Quorum is ⌈4/2⌉+1 = **3**. |
| 311 | 311 | |
| 312 | The charter and charges below are **written by hand** so the arithmetic can | |
| 313 | be checked by hand. They are what a deal looks like, not the literal output | |
| 314 | of `deal("tidewater", 4)` — that table is recorded as a fixture in | |
| 315 | `src/board.test.ts`, which also checks `score()` against the numbers here. | |
| 316 | ||
| 312 | 317 | **The charter** (7 of the 12): |
| 313 | 318 | |
| 314 | 319 | | clause | settings | |
src/actions.ts +3 −11
| @@ -8,8 +8,11 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { emit } from "./live.ts"; |
| 11 | import { fail, type Failure, ok, type Result } from "./result.ts"; | |
| 11 | 12 | import type { Who } from "./who.ts"; |
| 12 | 13 | |
| 14 | export type { Failure, Result }; | |
| 15 | ||
| 13 | 16 | export interface Note { |
| 14 | 17 | readonly id: number; |
| 15 | 18 | readonly body: string; |
| @@ -19,17 +22,6 @@ export interface Note { | ||
| 19 | 22 | readonly updated_at: string; |
| 20 | 23 | } |
| 21 | 24 | |
| 22 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; | |
| 23 | export type Result<T> = | |
| 24 | | { ok: true; value: T } | |
| 25 | | { ok: false; error: Failure; message: string }; | |
| 26 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| 27 | const fail = <T>(error: Failure, message: string): Result<T> => ({ | |
| 28 | ok: false, | |
| 29 | error, | |
| 30 | message, | |
| 31 | }); | |
| 32 | ||
| 33 | 25 | export const BODY_MAX = 500; |
| 34 | 26 | |
| 35 | 27 | export function listNotes( |
src/board.test.ts added +243
| @@ -0,0 +1,243 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { | |
| 3 | type Accord, | |
| 4 | type Board, | |
| 5 | type Charge, | |
| 6 | CLAUSE_POOL, | |
| 7 | type Clause, | |
| 8 | deal, | |
| 9 | dread, | |
| 10 | positives, | |
| 11 | score, | |
| 12 | } from "./board.ts"; | |
| 13 | ||
| 14 | const dealt = (seed: string, players: number): Board => { | |
| 15 | const r = deal(seed, players); | |
| 16 | if (!r.ok) throw new Error(r.message); | |
| 17 | return r.value; | |
| 18 | }; | |
| 19 | ||
| 20 | /** A few dozen tables, every seat count, to test what must hold for all of them. */ | |
| 21 | function* tables(count = 40): Generator<Board> { | |
| 22 | for (let i = 0; i < count; i++) | |
| 23 | for (const players of [4, 5, 6]) yield dealt(`table-${i}`, players); | |
| 24 | } | |
| 25 | ||
| 26 | /** Every accord over a charter: 3^7 = 2187 of them, so tension can be brute-forced. */ | |
| 27 | function accords(clauses: Clause[]): Accord[] { | |
| 28 | let all: Accord[] = [{}]; | |
| 29 | for (const clause of clauses) | |
| 30 | all = all.flatMap((accord) => | |
| 31 | clause.settings.map((setting) => ({ ...accord, [clause.id]: setting })), | |
| 32 | ); | |
| 33 | return all; | |
| 34 | } | |
| 35 | ||
| 36 | const fullPay = (charge: Charge): number => | |
| 37 | positives(charge).reduce((sum, aim) => sum + aim.weight, 0); | |
| 38 | ||
| 39 | test("an empty seed and a seat count outside 4–6 are refused by name", () => { | |
| 40 | for (const [seed, players] of [ | |
| 41 | ["", 4], | |
| 42 | [" ", 4], | |
| 43 | ["x".repeat(65), 4], | |
| 44 | [undefined as unknown as string, 4], | |
| 45 | ["tidewater", 3], | |
| 46 | ["tidewater", 7], | |
| 47 | ["tidewater", 4.5], | |
| 48 | ["tidewater", Number.NaN], | |
| 49 | ] as const) { | |
| 50 | const r = deal(seed, players); | |
| 51 | expect(r.ok).toBe(false); | |
| 52 | if (!r.ok) expect(r.error).toBe("invalid"); | |
| 53 | } | |
| 54 | expect(deal(" tidewater ", 4).ok).toBe(true); | |
| 55 | for (const players of [4, 5, 6]) expect(deal("t", players).ok).toBe(true); | |
| 56 | }); | |
| 57 | ||
| 58 | test("the charter is 7 clauses of the pool, each with exactly 3 settings", () => { | |
| 59 | expect(CLAUSE_POOL.length).toBe(12); | |
| 60 | expect(new Set(CLAUSE_POOL.map((c) => c.id)).size).toBe(12); | |
| 61 | for (const clause of CLAUSE_POOL) { | |
| 62 | expect(clause.settings.length).toBe(3); | |
| 63 | expect(new Set(clause.settings).size).toBe(3); | |
| 64 | } | |
| 65 | for (const board of tables()) { | |
| 66 | expect(board.clauses.length).toBe(7); | |
| 67 | expect(new Set(board.clauses.map((c) => c.id)).size).toBe(7); | |
| 68 | for (const clause of board.clauses) | |
| 69 | expect(CLAUSE_POOL).toContainEqual(clause); | |
| 70 | } | |
| 71 | }); | |
| 72 | ||
| 73 | test("a charge is 4 aims on 4 distinct clauses: three distinct weights and a dread", () => { | |
| 74 | for (const board of tables()) { | |
| 75 | const onCharter = new Map(board.clauses.map((c) => [c.id, c])); | |
| 76 | board.charges.forEach((charge, seat) => { | |
| 77 | expect(charge.seat).toBe(seat); | |
| 78 | expect(charge.aims.length).toBe(4); | |
| 79 | expect(new Set(charge.aims.map((aim) => aim.clause)).size).toBe(4); | |
| 80 | for (const aim of charge.aims) { | |
| 81 | const clause = onCharter.get(aim.clause); | |
| 82 | expect(clause).toBeDefined(); | |
| 83 | expect(clause!.settings).toContain(aim.setting); | |
| 84 | } | |
| 85 | const weights = positives(charge).map((aim) => aim.weight); | |
| 86 | expect(weights.length).toBe(3); | |
| 87 | expect(new Set(weights).size).toBe(3); | |
| 88 | for (const weight of weights) expect([1, 2, 3, 5]).toContain(weight); | |
| 89 | expect(dread(charge).weight).toBe(-3); | |
| 90 | expect(fullPay(charge)).toBeGreaterThanOrEqual(6); | |
| 91 | expect(fullPay(charge)).toBeLessThanOrEqual(10); | |
| 92 | }); | |
| 93 | } | |
| 94 | }); | |
| 95 | ||
| 96 | test("no two seats are dealt the same charge", () => { | |
| 97 | const key = (charge: Charge) => | |
| 98 | charge.aims | |
| 99 | .map((aim) => `${aim.clause}=${aim.setting}:${aim.weight}`) | |
| 100 | .sort() | |
| 101 | .join(" "); | |
| 102 | for (const board of tables(200)) { | |
| 103 | const keys = board.charges.map(key); | |
| 104 | expect(new Set(keys).size).toBe(board.charges.length); | |
| 105 | } | |
| 106 | }); | |
| 107 | ||
| 108 | test("the deal is worth arguing over: seats collide and no accord pays everyone", () => { | |
| 109 | for (const board of tables(12)) { | |
| 110 | // At least two seats hold aims that cannot both be satisfied, and on at | |
| 111 | // least two different clauses, so the table is not one clause of haggling. | |
| 112 | const contested = new Set<string>(); | |
| 113 | for (const one of board.charges) | |
| 114 | for (const other of board.charges) | |
| 115 | for (const mine of positives(one)) | |
| 116 | for (const theirs of positives(other)) | |
| 117 | if ( | |
| 118 | one.seat !== other.seat && | |
| 119 | mine.clause === theirs.clause && | |
| 120 | mine.setting !== theirs.setting | |
| 121 | ) | |
| 122 | contested.add(mine.clause); | |
| 123 | expect(contested.size).toBeGreaterThanOrEqual(2); | |
| 124 | ||
| 125 | // Brute force: of all 2187 accords, none satisfies every seat's positive | |
| 126 | // aims, and none is dominant — every accord leaves a seat short. | |
| 127 | const all = accords(board.clauses); | |
| 128 | expect(all.length).toBe(2187); | |
| 129 | for (const accord of all) { | |
| 130 | const short = board.charges.filter( | |
| 131 | (charge) => score(charge, accord) < fullPay(charge), | |
| 132 | ); | |
| 133 | expect(short.length).toBeGreaterThan(0); | |
| 134 | for (const charge of board.charges) { | |
| 135 | expect(score(charge, accord)).toBeGreaterThanOrEqual(-3); | |
| 136 | expect(score(charge, accord)).toBeLessThanOrEqual(10); | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | }); | |
| 141 | ||
| 142 | test("the same seed and count deal byte-identical tables", () => { | |
| 143 | const render = (board: Board) => | |
| 144 | [ | |
| 145 | `charter ${board.clauses.map((c) => c.id).join(" ")}`, | |
| 146 | ...board.charges.map( | |
| 147 | (charge) => | |
| 148 | `seat ${charge.seat} ${charge.aims | |
| 149 | .map((aim) => `${aim.clause}=${aim.setting}:${aim.weight}`) | |
| 150 | .join(" ")}`, | |
| 151 | ), | |
| 152 | ].join("\n"); | |
| 153 | ||
| 154 | // Recorded fixture: what `deal("tidewater", 4)` has always dealt. | |
| 155 | const TIDEWATER = [ | |
| 156 | "charter name salvage wreck hold pilot ledger sounding", | |
| 157 | "seat 0 ledger=redacted:5 pilot=paid_off:1 wreck=stripped:2 salvage=split_evenly:-3", | |
| 158 | "seat 1 hold=opened:2 ledger=published:3 pilot=reinstated:1 name=changed:-3", | |
| 159 | "seat 2 sounding=lost:1 name=struck:2 pilot=reinstated:3 salvage=split_evenly:-3", | |
| 160 | "seat 3 name=kept:2 wreck=left:5 ledger=burned:1 pilot=paid_off:-3", | |
| 161 | ].join("\n"); | |
| 162 | ||
| 163 | expect(render(dealt("tidewater", 4))).toBe(TIDEWATER); | |
| 164 | expect(JSON.stringify(dealt("tidewater", 4))).toBe( | |
| 165 | JSON.stringify(dealt("tidewater", 4)), | |
| 166 | ); | |
| 167 | expect(render(dealt(" tidewater ", 4))).toBe(TIDEWATER); | |
| 168 | expect(render(dealt("tidewater", 5))).not.toBe(TIDEWATER); | |
| 169 | expect(render(dealt("tidewate", 4))).not.toBe(TIDEWATER); | |
| 170 | }); | |
| 171 | ||
| 172 | test("score pays the satisfied positives and docks the dread (RULES.md §10)", () => { | |
| 173 | const aim = (pair: string, weight: number) => { | |
| 174 | const [clause, setting] = pair.split("=") as [string, string]; | |
| 175 | return { clause, setting, weight }; | |
| 176 | }; | |
| 177 | const charge = (seat: number, aims: ReturnType<typeof aim>[]): Charge => ({ | |
| 178 | seat, | |
| 179 | aims, | |
| 180 | }); | |
| 181 | // The four charges of the worked example, written out by hand. | |
| 182 | const ash = charge(0, [ | |
| 183 | aim("salvage=to_the_finders", 5), | |
| 184 | aim("beacon=kept", 3), | |
| 185 | aim("ledger=redacted", 1), | |
| 186 | aim("pilot=put_ashore", -3), | |
| 187 | ]); | |
| 188 | const brail = charge(1, [ | |
| 189 | aim("ledger=published", 5), | |
| 190 | aim("hold=opened", 2), | |
| 191 | aim("name=changed", 1), | |
| 192 | aim("salvage=to_the_crown", -3), | |
| 193 | ]); | |
| 194 | const cleat = charge(2, [ | |
| 195 | aim("pilot=reinstated", 3), | |
| 196 | aim("chart=shared", 2), | |
| 197 | aim("salvage=to_the_finders", 1), | |
| 198 | aim("beacon=scuttled", -3), | |
| 199 | ]); | |
| 200 | const dunnage = charge(3, [ | |
| 201 | aim("beacon=kept", 5), | |
| 202 | aim("hold=sealed", 3), | |
| 203 | aim("name=kept", 2), | |
| 204 | aim("ledger=redacted", -3), | |
| 205 | ]); | |
| 206 | ||
| 207 | const a1: Accord = { | |
| 208 | beacon: "kept", | |
| 209 | hold: "sealed", | |
| 210 | ledger: "redacted", | |
| 211 | pilot: "reinstated", | |
| 212 | name: "kept", | |
| 213 | chart: "locked", | |
| 214 | salvage: "to_the_finders", | |
| 215 | }; | |
| 216 | const a2: Accord = { ...a1, ledger: "published", name: "changed" }; | |
| 217 | ||
| 218 | // The accord of record binds every seat, signed or not. | |
| 219 | expect([ash, brail, cleat, dunnage].map((c) => score(c, a2))).toEqual([ | |
| 220 | 8, 6, 4, 8, | |
| 221 | ]); | |
| 222 | // A₁ pays Dunnage 5+3+2−3 = 7: his dread sits on `ledger=redacted`. | |
| 223 | expect(score(dunnage, a1)).toBe(7); | |
| 224 | expect(score(cleat, a1)).toBe(4); | |
| 225 | // Nothing satisfied is nothing paid; a dread alone is −3. | |
| 226 | const nothing: Accord = { ...a2, beacon: "sold", salvage: "split_evenly" }; | |
| 227 | expect(score(ash, nothing)).toBe(0); | |
| 228 | expect(score(cleat, { ...nothing, beacon: "scuttled", pilot: "paid_off" })).toBe( | |
| 229 | -3, | |
| 230 | ); | |
| 231 | // Best possible: 2+3+5 with the dread untriggered. | |
| 232 | expect(score(dunnage, { ...a1, name: "kept", ledger: "burned" })).toBe(10); | |
| 233 | }); | |
| 234 | ||
| 235 | test("the deal reads no clock and no dice", async () => { | |
| 236 | const source = await Bun.file(new URL("./board.ts", import.meta.url)).text(); | |
| 237 | // A boolean, not the file: a failure here should name the call, not dump 300 lines. | |
| 238 | for (const forbidden of ["Math.random(", "Date.now(", "bun:sqlite"]) | |
| 239 | expect({ forbidden, present: source.includes(forbidden) }).toEqual({ | |
| 240 | forbidden, | |
| 241 | present: false, | |
| 242 | }); | |
| 243 | }); |
src/board.ts added +289
| @@ -0,0 +1,289 @@ | ||
| 1 | /** | |
| 2 | * The deal, and what an accord pays (RULES.md §2, §3, §4). | |
| 3 | * | |
| 4 | * A table's charter and its secret charges are a function of the seed and | |
| 5 | * the seat count and nothing else: plain data in, plain data out, no | |
| 6 | * database, no clock, no `Math.random`. The same seed and the same count | |
| 7 | * always give byte-identical output, so a table is reproducible, a bug is | |
| 8 | * reportable as a seed, and every rule that reads a charge can be checked | |
| 9 | * by hand. | |
| 10 | */ | |
| 11 | ||
| 12 | import { fail, ok, type Result } from "./result.ts"; | |
| 13 | ||
| 14 | /** One question on the charter, with exactly 3 settings. */ | |
| 15 | export interface Clause { | |
| 16 | readonly id: string; | |
| 17 | readonly title: string; | |
| 18 | readonly settings: readonly [string, string, string]; | |
| 19 | } | |
| 20 | ||
| 21 | /** What a seat wants, and what it is worth: a positive weight, or the dread's −3. */ | |
| 22 | export interface Aim { | |
| 23 | readonly clause: string; | |
| 24 | readonly setting: string; | |
| 25 | readonly weight: number; | |
| 26 | } | |
| 27 | ||
| 28 | /** A seat's secret hand: 4 aims on 4 distinct clauses, three positive and one dread. */ | |
| 29 | export interface Charge { | |
| 30 | readonly seat: number; | |
| 31 | readonly aims: readonly Aim[]; | |
| 32 | } | |
| 33 | ||
| 34 | /** One setting for every clause of the charter — all 7, never a fragment. */ | |
| 35 | export type Accord = Readonly<Record<string, string>>; | |
| 36 | ||
| 37 | export interface Board { | |
| 38 | readonly clauses: Clause[]; | |
| 39 | readonly charges: Charge[]; | |
| 40 | } | |
| 41 | ||
| 42 | // The fixed numbers (RULES.md §2), named once. | |
| 43 | export const SEATS_MIN = 4; | |
| 44 | export const SEATS_MAX = 6; | |
| 45 | export const CHARTER_CLAUSES = 7; | |
| 46 | export const AIMS_PER_CHARGE = 4; | |
| 47 | export const POSITIVE_AIMS = 3; | |
| 48 | export const POSITIVE_WEIGHTS = [1, 2, 3, 5] as const; | |
| 49 | export const DREAD_WEIGHT = -3; | |
| 50 | export const SEED_MAX = 64; | |
| 51 | ||
| 52 | /** The pool of 12 (RULES.md §3). Flavour only: the mechanics are symmetric across every clause and setting. */ | |
| 53 | export const CLAUSE_POOL: readonly Clause[] = [ | |
| 54 | { id: "beacon", title: "The Beacon", settings: ["kept", "sold", "scuttled"] }, | |
| 55 | { id: "hold", title: "The Hold", settings: ["opened", "sealed", "flooded"] }, | |
| 56 | { | |
| 57 | id: "ledger", | |
| 58 | title: "The Ledger", | |
| 59 | settings: ["published", "redacted", "burned"], | |
| 60 | }, | |
| 61 | { | |
| 62 | id: "pilot", | |
| 63 | title: "The Pilot", | |
| 64 | settings: ["reinstated", "paid_off", "put_ashore"], | |
| 65 | }, | |
| 66 | { id: "name", title: "The Name", settings: ["kept", "changed", "struck"] }, | |
| 67 | { id: "chart", title: "The Chart", settings: ["shared", "sold", "locked"] }, | |
| 68 | { | |
| 69 | id: "salvage", | |
| 70 | title: "The Salvage", | |
| 71 | settings: ["split_evenly", "to_the_finders", "to_the_crown"], | |
| 72 | }, | |
| 73 | { id: "wreck", title: "The Wreck", settings: ["raised", "stripped", "left"] }, | |
| 74 | { | |
| 75 | id: "crew", | |
| 76 | title: "The Crew", | |
| 77 | settings: ["signed_on", "paid_out", "turned_loose"], | |
| 78 | }, | |
| 79 | { | |
| 80 | id: "bond", | |
| 81 | title: "The Bond", | |
| 82 | settings: ["honoured", "renegotiated", "broken"], | |
| 83 | }, | |
| 84 | { | |
| 85 | id: "anchorage", | |
| 86 | title: "The Anchorage", | |
| 87 | settings: ["held", "leased", "abandoned"], | |
| 88 | }, | |
| 89 | { | |
| 90 | id: "sounding", | |
| 91 | title: "The Sounding", | |
| 92 | settings: ["filed", "amended", "lost"], | |
| 93 | }, | |
| 94 | ]; | |
| 95 | ||
| 96 | /** | |
| 97 | * Deal a table: 7 clauses of the pool, and one charge per seat. | |
| 98 | * | |
| 99 | * Refuses a seed that is not 1–64 characters or a seat count outside 4–6 | |
| 100 | * with a named failure — the boundary is here, because this is the only | |
| 101 | * place a caller's seed becomes a game. | |
| 102 | */ | |
| 103 | export function deal(seed: string, players: number): Result<Board> { | |
| 104 | if (typeof seed !== "string") | |
| 105 | return fail("invalid", "A seed is a line of text."); | |
| 106 | const word = seed.trim(); | |
| 107 | if (word.length === 0 || word.length > SEED_MAX) | |
| 108 | return fail("invalid", `A seed is 1 to ${SEED_MAX} characters.`); | |
| 109 | if (!Number.isInteger(players) || players < SEATS_MIN || players > SEATS_MAX) | |
| 110 | return fail("invalid", `A table seats ${SEATS_MIN} to ${SEATS_MAX}.`); | |
| 111 | ||
| 112 | const rng = prng(`${word}:${players}`); | |
| 113 | const clauses = shuffled(rng, CLAUSE_POOL).slice(0, CHARTER_CLAUSES); | |
| 114 | const charges = drawCharges(rng, clauses, players); | |
| 115 | assertWorthArguing(charges); | |
| 116 | return ok({ clauses, charges }); | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * What an accord pays a charge (RULES.md §4): the sum of the satisfied | |
| 121 | * positive aims, less 3 when the dread's setting is the one chosen. A | |
| 122 | * dread is a negative weight, so that is one sum. | |
| 123 | */ | |
| 124 | export function score(charge: Charge, accord: Accord): number { | |
| 125 | let total = 0; | |
| 126 | for (const aim of charge.aims) | |
| 127 | if (accord[aim.clause] === aim.setting) total += aim.weight; | |
| 128 | return total; | |
| 129 | } | |
| 130 | ||
| 131 | /** The three aims a seat is paid for. */ | |
| 132 | export const positives = (charge: Charge): Aim[] => | |
| 133 | charge.aims.filter((aim) => aim.weight > 0); | |
| 134 | ||
| 135 | /** The one aim a seat is paid to prevent. */ | |
| 136 | export const dread = (charge: Charge): Aim => | |
| 137 | charge.aims.find((aim) => aim.weight === DREAD_WEIGHT)!; | |
| 138 | ||
| 139 | // ---------------------------------------------------------------- the deal | |
| 140 | ||
| 141 | /** | |
| 142 | * One charge per seat, then two forced disagreements: seat 1 is set against | |
| 143 | * an earlier seat, and seat 2 against another on a different clause, so | |
| 144 | * every table is argued over on at least two clauses and no accord can pay | |
| 145 | * everybody in full. Charges that come out identical are redrawn. | |
| 146 | */ | |
| 147 | function drawCharges(rng: Rng, clauses: Clause[], players: number): Charge[] { | |
| 148 | const byId = new Map(clauses.map((clause) => [clause.id, clause])); | |
| 149 | for (let attempt = 0; attempt < 32; attempt++) { | |
| 150 | const hands = Array.from({ length: players }, () => drawHand(rng, clauses)); | |
| 151 | const contested: string[] = []; | |
| 152 | for (const seat of [1, 2]) { | |
| 153 | const against = rng.int(seat); | |
| 154 | const clause = contest(rng, hands, seat, against, contested, byId); | |
| 155 | if (clause !== null) contested.push(clause); | |
| 156 | } | |
| 157 | const charges = hands.map((aims, seat) => ({ seat, aims })); | |
| 158 | if (contested.length === 2 && allDifferent(charges)) return charges; | |
| 159 | } | |
| 160 | // Unreachable: a redraw is only needed when two seats come out identical. | |
| 161 | throw new Error("board: no table worth arguing over after 32 draws"); | |
| 162 | } | |
| 163 | ||
| 164 | /** 4 aims on 4 distinct clauses: three distinct positive weights, then the dread. */ | |
| 165 | function drawHand(rng: Rng, clauses: Clause[]): Aim[] { | |
| 166 | const drawn = shuffled(rng, clauses).slice(0, AIMS_PER_CHARGE); | |
| 167 | const weights = shuffled(rng, POSITIVE_WEIGHTS).slice(0, POSITIVE_AIMS); | |
| 168 | return drawn.map((clause, i) => ({ | |
| 169 | clause: clause.id, | |
| 170 | setting: rng.of(clause.settings), | |
| 171 | weight: i < POSITIVE_AIMS ? weights[i]! : DREAD_WEIGHT, | |
| 172 | })); | |
| 173 | } | |
| 174 | ||
| 175 | /** | |
| 176 | * Put `seat` in the way of `against`: a positive aim of theirs on a clause | |
| 177 | * the other seat is paid for, at a different setting. Returns the clause | |
| 178 | * they now disagree over, or null when there is none to take. | |
| 179 | */ | |
| 180 | function contest( | |
| 181 | rng: Rng, | |
| 182 | hands: Aim[][], | |
| 183 | seat: number, | |
| 184 | against: number, | |
| 185 | taken: string[], | |
| 186 | byId: Map<string, Clause>, | |
| 187 | ): string | null { | |
| 188 | const mine = hands[seat]!; | |
| 189 | const dreaded = mine.find((aim) => aim.weight === DREAD_WEIGHT)!.clause; | |
| 190 | const wanted = shuffled( | |
| 191 | rng, | |
| 192 | hands[against]!.filter( | |
| 193 | (aim) => | |
| 194 | aim.weight > 0 && !taken.includes(aim.clause) && aim.clause !== dreaded, | |
| 195 | ), | |
| 196 | ); | |
| 197 | const theirs = wanted[0]; | |
| 198 | if (theirs === undefined) return null; | |
| 199 | ||
| 200 | const clause = byId.get(theirs.clause)!; | |
| 201 | const otherwise = clause.settings.filter((s) => s !== theirs.setting); | |
| 202 | const setting = rng.of(otherwise); | |
| 203 | const held = mine.findIndex((aim) => aim.clause === theirs.clause); | |
| 204 | const moved = held >= 0 ? held : rng.int(POSITIVE_AIMS); | |
| 205 | mine[moved] = { clause: theirs.clause, setting, weight: mine[moved]!.weight }; | |
| 206 | return theirs.clause; | |
| 207 | } | |
| 208 | ||
| 209 | /** The three things RULES.md §3 says the code asserts about a deal. */ | |
| 210 | function assertWorthArguing(charges: Charge[]): void { | |
| 211 | const clash = charges.some((one) => | |
| 212 | charges.some( | |
| 213 | (other) => | |
| 214 | other.seat !== one.seat && | |
| 215 | positives(one).some((aim) => | |
| 216 | positives(other).some( | |
| 217 | (mine) => mine.clause === aim.clause && mine.setting !== aim.setting, | |
| 218 | ), | |
| 219 | ), | |
| 220 | ), | |
| 221 | ); | |
| 222 | // Positive aims within a charge are on distinct clauses, so one clash is | |
| 223 | // also the proof that no single accord satisfies every seat's positives. | |
| 224 | if (!clash) throw new Error("board: dealt a table nobody would argue over"); | |
| 225 | if (!allDifferent(charges)) throw new Error("board: two seats, one charge"); | |
| 226 | for (const charge of charges) { | |
| 227 | const weights = positives(charge).map((aim) => aim.weight); | |
| 228 | const clauses = new Set(charge.aims.map((aim) => aim.clause)); | |
| 229 | if ( | |
| 230 | charge.aims.length !== AIMS_PER_CHARGE || | |
| 231 | clauses.size !== AIMS_PER_CHARGE || | |
| 232 | weights.length !== POSITIVE_AIMS || | |
| 233 | new Set(weights).size !== POSITIVE_AIMS | |
| 234 | ) | |
| 235 | throw new Error(`board: seat ${charge.seat} is not a charge`); | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 239 | const key = (charge: Charge): string => | |
| 240 | charge.aims | |
| 241 | .map((aim) => `${aim.clause}=${aim.setting}:${aim.weight}`) | |
| 242 | .sort() | |
| 243 | .join(" "); | |
| 244 | ||
| 245 | const allDifferent = (charges: Charge[]): boolean => | |
| 246 | new Set(charges.map(key)).size === charges.length; | |
| 247 | ||
| 248 | // ------------------------------------------------------------ the randomness | |
| 249 | ||
| 250 | interface Rng { | |
| 251 | /** A whole number in [0, n). */ | |
| 252 | int(n: number): number; | |
| 253 | /** One of the items. */ | |
| 254 | of<T>(items: readonly T[]): T; | |
| 255 | } | |
| 256 | ||
| 257 | /** | |
| 258 | * A seeded pseudo-random number generator, written here so the deal owes | |
| 259 | * nothing to a dependency or to the platform: FNV-1a over the seed for the | |
| 260 | * state, mulberry32 for the stream, 32-bit integer arithmetic throughout, | |
| 261 | * so the same seed gives the same sequence everywhere, forever. | |
| 262 | */ | |
| 263 | function prng(seed: string): Rng { | |
| 264 | let state = 0x811c9dc5; | |
| 265 | for (let i = 0; i < seed.length; i++) { | |
| 266 | state = (state ^ seed.charCodeAt(i)) >>> 0; | |
| 267 | state = Math.imul(state, 0x01000193) >>> 0; | |
| 268 | } | |
| 269 | const next = (): number => { | |
| 270 | state = (state + 0x6d2b79f5) >>> 0; | |
| 271 | let t = Math.imul(state ^ (state >>> 15), 1 | state); | |
| 272 | t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; | |
| 273 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| 274 | }; | |
| 275 | const int = (n: number): number => Math.floor(next() * n); | |
| 276 | return { int, of: (items) => items[int(items.length)]! }; | |
| 277 | } | |
| 278 | ||
| 279 | /** Fisher–Yates, from the seeded stream. */ | |
| 280 | function shuffled<T>(rng: Rng, items: readonly T[]): T[] { | |
| 281 | const out = items.slice(); | |
| 282 | for (let i = out.length - 1; i > 0; i--) { | |
| 283 | const j = rng.int(i + 1); | |
| 284 | const swap = out[i]!; | |
| 285 | out[i] = out[j]!; | |
| 286 | out[j] = swap; | |
| 287 | } | |
| 288 | return out; | |
| 289 | } |
src/result.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | /** | |
| 2 | * The answer shape every operation gives: data, or a failure with a name a | |
| 3 | * door can turn into a status and a sentence. It lives on its own so pure | |
| 4 | * modules (`src/board.ts`) speak the same vocabulary as the actions without | |
| 5 | * dragging the database in behind them. | |
| 6 | */ | |
| 7 | ||
| 8 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; | |
| 9 | ||
| 10 | export type Result<T> = | |
| 11 | | { ok: true; value: T } | |
| 12 | | { ok: false; error: Failure; message: string }; | |
| 13 | ||
| 14 | export const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| 15 | ||
| 16 | export const fail = <T>(error: Failure, message: string): Result<T> => ({ | |
| 17 | ok: false, | |
| 18 | error, | |
| 19 | message, | |
| 20 | }); |
new above
offline