Public: anyone can read this; the crew can change it.
The turn and the binding as pure arithmetic: src/round.ts, before the ledger writes a row #44
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/36-the-turn-and-the-binding-as-pure-arithme → main 2 commits fork
6 files
changed +961
−61
AGENTS.md +7 −2
| @@ -48,8 +48,13 @@ A rule of the game is not an action. `src/board.ts` is the deal, the score | ||
| 48 | 48 | and the sworn arithmetic from `RULES.md` — a seed and a seat count in, a |
| 49 | 49 | charter and charges out; a charge and an accord in, what it pays and what a |
| 50 | 50 | seat may swear for it out. No database, no clock, no `Math.random`, so it |
| 51 | can be checked by hand and tested against a fixture. Rules of that kind go in a pure module an | |
| 52 | action calls; both speak the same named failures, from `src/result.ts`. | |
| 51 | can be checked by hand and tested against a fixture. `src/round.ts` is the | |
| 52 | other half — the acts as plain data in, and whose turn it is, what stands, | |
| 53 | whether it binds, what a seat may do and what the settlement is out — so an | |
| 54 | action that plays a turn is a transaction, not a rules engine. Rules of that | |
| 55 | kind go in a pure module an action calls; both speak the same named | |
| 56 | failures, from `src/result.ts`, and `src/example.ts` is the worked table of | |
| 57 | `RULES.md` §10 that both are tested against. | |
| 53 | 58 | |
| 54 | 59 | Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`, |
| 55 | 60 | verified by signature, never by a cookie the app invents. An action that |
README.md +8 −3
| @@ -29,6 +29,8 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 29 | 29 | | ---------------------------------- | ------------------------------------------------------------ | |
| 30 | 30 | | `src/actions.ts` | open, sit, leave, read a table — and who may see what | |
| 31 | 31 | | `src/board.ts` | the deal, the score and the sworn rule: by RULES.md, no db | |
| 32 | | `src/round.ts` | the turn, the standing offer, the binding and the settlement | | |
| 33 | | `src/example.ts` | RULES.md §10's worked table, the fixture both are tested on | | |
| 32 | 34 | | `src/when.ts` | time in words: "4h 12m left", "20m ago", a UTC moment | |
| 33 | 35 | | `src/result.ts` | data or a named failure — the answer shape every door reads | |
| 34 | 36 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| @@ -66,9 +68,12 @@ that reads a table redacts it by viewer, so no door can leak it. | ||
| 66 | 68 | |
| 67 | 69 | The game's `seed` is the charges of every seat written another way, so it |
| 68 | 70 | never leaves `src/actions.ts` either. What a charter and a charge contain, |
| 69 | and what an accord pays, are [](RULES.md) and `src/board.ts`; | |
| 70 | dealing them onto a table and playing turns arrive with the turn engine, so | |
| 71 | a table today is opened, sat at, left and read, and stays `open`. | |
| 71 | and what an accord pays, are [](RULES.md) and `src/board.ts`; whose | |
| 72 | turn it is, what stands, when it binds and what the settlement comes to are | |
| 73 | `src/round.ts`, which reads the acts as plain data and writes nothing. | |
| 74 | Dealing them onto a table and writing acts to the ledger arrive with the | |
| 75 | turn engine, so a table today is opened, sat at, left and read, and stays | |
| 76 | `open`. | |
| 72 | 77 | |
| 73 | 78 | ## Who you are |
| 74 | 79 |
src/board.test.ts +4 −56
| @@ -14,6 +14,7 @@ import { | ||
| 14 | 14 | score, |
| 15 | 15 | validAccord, |
| 16 | 16 | } from "./board.ts"; |
| 17 | import { a1, a2, ash, brail, CHARTER, cleat, dunnage } from "./example.ts"; | |
| 17 | 18 | |
| 18 | 19 | const dealt = (seed: string, players: number): Board => { |
| 19 | 20 | const r = deal(seed, players); |
| @@ -41,62 +42,9 @@ const fullPay = (charge: Charge): number => | ||
| 41 | 42 | positives(charge).reduce((sum, aim) => sum + aim.weight, 0); |
| 42 | 43 | |
| 43 | 44 | // --------------------------------------------- RULES.md §10, written by hand |
| 44 | // The worked example, in one copy: the charter, the four charges and the two | |
| 45 | // accords of the transcript. §4's arithmetic and §6's sworn rule are checked | |
| 46 | // against the same table, because that is the table a reader can check. | |
| 47 | ||
| 48 | const aim = (pair: string, weight: number): Aim => { | |
| 49 | const [clause, setting] = pair.split("=") as [string, string]; | |
| 50 | return { clause, setting, weight }; | |
| 51 | }; | |
| 52 | const charge = (seat: number, aims: Aim[]): Charge => ({ seat, aims }); | |
| 53 | ||
| 54 | const ash = charge(0, [ | |
| 55 | aim("salvage=to_the_finders", 5), | |
| 56 | aim("beacon=kept", 3), | |
| 57 | aim("ledger=redacted", 1), | |
| 58 | aim("pilot=put_ashore", -3), | |
| 59 | ]); | |
| 60 | const brail = charge(1, [ | |
| 61 | aim("ledger=published", 5), | |
| 62 | aim("hold=opened", 2), | |
| 63 | aim("name=changed", 1), | |
| 64 | aim("salvage=to_the_crown", -3), | |
| 65 | ]); | |
| 66 | const cleat = charge(2, [ | |
| 67 | aim("pilot=reinstated", 3), | |
| 68 | aim("chart=shared", 2), | |
| 69 | aim("salvage=to_the_finders", 1), | |
| 70 | aim("beacon=scuttled", -3), | |
| 71 | ]); | |
| 72 | const dunnage = charge(3, [ | |
| 73 | aim("beacon=kept", 5), | |
| 74 | aim("hold=sealed", 3), | |
| 75 | aim("name=kept", 2), | |
| 76 | aim("ledger=redacted", -3), | |
| 77 | ]); | |
| 78 | ||
| 79 | /** The 7 clauses of §10, in the order §10 lists them. */ | |
| 80 | const CHARTER: Clause[] = [ | |
| 81 | "beacon", | |
| 82 | "hold", | |
| 83 | "ledger", | |
| 84 | "pilot", | |
| 85 | "name", | |
| 86 | "chart", | |
| 87 | "salvage", | |
| 88 | ].map((id) => CLAUSE_POOL.find((clause) => clause.id === id)!); | |
| 89 | ||
| 90 | const a1: Accord = { | |
| 91 | beacon: "kept", | |
| 92 | hold: "sealed", | |
| 93 | ledger: "redacted", | |
| 94 | pilot: "reinstated", | |
| 95 | name: "kept", | |
| 96 | chart: "locked", | |
| 97 | salvage: "to_the_finders", | |
| 98 | }; | |
| 99 | const a2: Accord = { ...a1, ledger: "published", name: "changed" }; | |
| 45 | // The worked example is `src/example.ts`, in one copy, because `round.test.ts` | |
| 46 | // replays the same table. §4's arithmetic and §6's sworn rule are checked | |
| 47 | // against it, because that is the table a reader can check. | |
| 100 | 48 | |
| 101 | 49 | const pairs = (aims: Aim[]): string[] => |
| 102 | 50 | aims.map((one) => `${one.clause}=${one.setting}:${one.weight}`); |
src/example.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** | |
| 2 | * RULES.md §10's worked example, as data, in one copy. | |
| 3 | * | |
| 4 | * The charter, the four charges and the two accords of the transcript are | |
| 5 | * written by hand there so the arithmetic can be checked by hand; they are | |
| 6 | * written by hand here so more than one module can be checked against the | |
| 7 | * same table. `src/board.test.ts` scores it, `src/round.test.ts` replays its | |
| 8 | * nine acts, and neither file keeps a second copy that could drift from the | |
| 9 | * document or from the other. | |
| 10 | * | |
| 11 | * Fixture only: nothing the app serves imports this. | |
| 12 | */ | |
| 13 | ||
| 14 | import { type Accord, type Aim, type Charge, CLAUSE_POOL, type Clause } from "./board.ts"; | |
| 15 | ||
| 16 | const aim = (pair: string, weight: number): Aim => { | |
| 17 | const [clause, setting] = pair.split("=") as [string, string]; | |
| 18 | return { clause, setting, weight }; | |
| 19 | }; | |
| 20 | const charge = (seat: number, aims: Aim[]): Charge => ({ seat, aims }); | |
| 21 | ||
| 22 | /** The 7 clauses of §10, in the order §10 lists them. */ | |
| 23 | export const CHARTER: Clause[] = [ | |
| 24 | "beacon", | |
| 25 | "hold", | |
| 26 | "ledger", | |
| 27 | "pilot", | |
| 28 | "name", | |
| 29 | "chart", | |
| 30 | "salvage", | |
| 31 | ].map((id) => CLAUSE_POOL.find((clause) => clause.id === id)!); | |
| 32 | ||
| 33 | export const ash = charge(0, [ | |
| 34 | aim("salvage=to_the_finders", 5), | |
| 35 | aim("beacon=kept", 3), | |
| 36 | aim("ledger=redacted", 1), | |
| 37 | aim("pilot=put_ashore", -3), | |
| 38 | ]); | |
| 39 | export const brail = charge(1, [ | |
| 40 | aim("ledger=published", 5), | |
| 41 | aim("hold=opened", 2), | |
| 42 | aim("name=changed", 1), | |
| 43 | aim("salvage=to_the_crown", -3), | |
| 44 | ]); | |
| 45 | export const cleat = charge(2, [ | |
| 46 | aim("pilot=reinstated", 3), | |
| 47 | aim("chart=shared", 2), | |
| 48 | aim("salvage=to_the_finders", 1), | |
| 49 | aim("beacon=scuttled", -3), | |
| 50 | ]); | |
| 51 | export const dunnage = charge(3, [ | |
| 52 | aim("beacon=kept", 5), | |
| 53 | aim("hold=sealed", 3), | |
| 54 | aim("name=kept", 2), | |
| 55 | aim("ledger=redacted", -3), | |
| 56 | ]); | |
| 57 | ||
| 58 | /** The four charges, by seat: Ash 0, Brail 1, Cleat 2, Dunnage 3. */ | |
| 59 | export const charges: Charge[] = [ash, brail, cleat, dunnage]; | |
| 60 | ||
| 61 | /** A₁, act 1: the Ledger redacted and the Name kept. */ | |
| 62 | export const a1: Accord = { | |
| 63 | beacon: "kept", | |
| 64 | hold: "sealed", | |
| 65 | ledger: "redacted", | |
| 66 | pilot: "reinstated", | |
| 67 | name: "kept", | |
| 68 | chart: "locked", | |
| 69 | salvage: "to_the_finders", | |
| 70 | }; | |
| 71 | ||
| 72 | /** A₂, act 5: Ash spends her own +1 to buy Brail. The accord that binds. */ | |
| 73 | export const a2: Accord = { ...a1, ledger: "published", name: "changed" }; |
src/round.test.ts added +404
| @@ -0,0 +1,404 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { type Accord, CLAUSE_POOL, positives } from "./board.ts"; | |
| 3 | import { a1, a2, ash, brail, CHARTER, charges, cleat, dunnage } from "./example.ts"; | |
| 4 | import { | |
| 5 | type Act, | |
| 6 | binds, | |
| 7 | ending, | |
| 8 | mayAct, | |
| 9 | quorum, | |
| 10 | type Round, | |
| 11 | type Said, | |
| 12 | settlement, | |
| 13 | standing, | |
| 14 | sworn, | |
| 15 | trueWorth, | |
| 16 | whoseTurn, | |
| 17 | } from "./round.ts"; | |
| 18 | ||
| 19 | // ------------------------------------------ RULES.md §10, act by act | |
| 20 | // The nine acts of the worked example, against the charter and charges of | |
| 21 | // `src/example.ts`. The transcript is the test: every number below is in the | |
| 22 | // document, and a reader can check it by hand. | |
| 23 | ||
| 24 | const TICK = 600_000; // ten minutes between acts, so a time is a time. | |
| 25 | const START = Date.parse("2026-09-14T09:00:00.000Z"); | |
| 26 | const when = (act: number): string => new Date(START + act * TICK).toISOString(); | |
| 27 | ||
| 28 | const offer = (seat: number, accord: Accord, act: number): Act => ({ | |
| 29 | seat, | |
| 30 | kind: "offer", | |
| 31 | accord, | |
| 32 | at: when(act), | |
| 33 | }); | |
| 34 | const accept = (seat: number, pair: string, act: number): Act => { | |
| 35 | const [clause, setting] = pair.split("=") as [string, string]; | |
| 36 | return { seat, kind: "accept", cite: { clause, setting }, at: when(act) }; | |
| 37 | }; | |
| 38 | const pass = (seat: number, act: number): Act => ({ | |
| 39 | seat, | |
| 40 | kind: "pass", | |
| 41 | at: when(act), | |
| 42 | }); | |
| 43 | ||
| 44 | /** The ledger of §10, acts 1–9, newest last. */ | |
| 45 | const LEDGER: Act[] = [ | |
| 46 | offer(0, a1, 1), // 1 Ash offers A₁ | |
| 47 | pass(1, 2), // 2 Brail passes | |
| 48 | accept(2, "pilot=reinstated", 3), // 3 Cleat swears the Pilot | |
| 49 | pass(3, 4), // 4 Dunnage passes | |
| 50 | offer(0, a2, 5), // 5 Ash offers A₂: Cleat's acceptance is cleared | |
| 51 | accept(1, "name=changed", 6), // 6 Brail swears the cheap truth | |
| 52 | accept(2, "salvage=to_the_finders", 7), // 7 Cleat swears what is left | |
| 53 | pass(3, 8), // 8 Dunnage holds out | |
| 54 | accept(0, "beacon=kept", 9), // 9 Ash accepts her own offer: quorum | |
| 55 | ]; | |
| 56 | ||
| 57 | /** The said claims of §10, each on the act that carried it. */ | |
| 58 | const SAID: Said[] = [ | |
| 59 | { seat: 0, clause: "ledger", setting: "redacted", worth: "small", act: 0 }, | |
| 60 | { seat: 1, clause: "ledger", setting: "published", worth: "large", act: 1 }, | |
| 61 | { seat: 3, clause: "ledger", setting: "redacted", worth: "none", act: 3 }, | |
| 62 | ]; | |
| 63 | ||
| 64 | const SEATS = [{ seat: 0 }, { seat: 1 }, { seat: 2 }, { seat: 3 }]; | |
| 65 | ||
| 66 | /** The table after the first `acts` of the transcript, at `now`. */ | |
| 67 | const table = (acts: number, extra: Partial<Round> = {}): Round => ({ | |
| 68 | charter: CHARTER, | |
| 69 | seats: SEATS, | |
| 70 | acts: LEDGER.slice(0, acts), | |
| 71 | said: SAID.filter((claim) => claim.act! < acts), | |
| 72 | now: when(acts + 1), | |
| 73 | ...extra, | |
| 74 | }); | |
| 75 | ||
| 76 | const pairs = (aims: readonly { clause: string; setting: string }[]): string[] => | |
| 77 | aims.map((one) => `${one.clause}=${one.setting}`); | |
| 78 | ||
| 79 | /** A₂ stands, Brail has accepted it, and the turn has come round to him again. */ | |
| 80 | const ACCEPTED_ALREADY = table(0, { | |
| 81 | acts: [ | |
| 82 | offer(0, a2, 1), | |
| 83 | accept(1, "name=changed", 2), | |
| 84 | pass(2, 3), | |
| 85 | pass(3, 4), | |
| 86 | pass(0, 5), | |
| 87 | ], | |
| 88 | said: [], | |
| 89 | }); | |
| 90 | ||
| 91 | /** | |
| 92 | * Brail has sworn all three of his positives, one per offer of an accord | |
| 93 | * that satisfies every one of them, and the turn is his again: §6's purse is | |
| 94 | * empty even though the accord pays him in full. | |
| 95 | */ | |
| 96 | const EVERYTHING: Accord = { ...a2, hold: "opened" }; | |
| 97 | const PURSE_SPENT = table(0, { | |
| 98 | acts: [ | |
| 99 | ...positives(brail).flatMap((aim, round) => [ | |
| 100 | offer(0, EVERYTHING, round * 4 + 1), | |
| 101 | accept(1, `${aim.clause}=${aim.setting}`, round * 4 + 2), | |
| 102 | pass(2, round * 4 + 3), | |
| 103 | pass(3, round * 4 + 4), | |
| 104 | ]), | |
| 105 | offer(0, EVERYTHING, 13), | |
| 106 | ], | |
| 107 | said: [], | |
| 108 | }); | |
| 109 | ||
| 110 | test("quorum is ⌈n/2⌉+1: 3 of 4, 4 of 5, 4 of 6 (§2)", () => { | |
| 111 | expect([4, 5, 6].map(quorum)).toEqual([3, 4, 4]); | |
| 112 | }); | |
| 113 | ||
| 114 | test("the turn is the act count around the seat order, and stops at the end (§5)", () => { | |
| 115 | // Nine acts, four seats: 0 1 2 3 0 1 2 3 0, and then the table has settled. | |
| 116 | const turns = Array.from({ length: 10 }, (_, acts) => whoseTurn(table(acts))); | |
| 117 | expect(turns).toEqual([0, 1, 2, 3, 0, 1, 2, 3, 0, null]); | |
| 118 | }); | |
| 119 | ||
| 120 | test("the standing offer is the latest offer, and a new offer clears its acceptances (§5)", () => { | |
| 121 | // Before anyone offers there is nothing standing. | |
| 122 | expect(standing(table(0))).toBeNull(); | |
| 123 | ||
| 124 | const after = (acts: number) => { | |
| 125 | const stood = standing(table(acts)); | |
| 126 | return stood === null | |
| 127 | ? null | |
| 128 | : { | |
| 129 | // A₁ redacts the Ledger; A₂ publishes it. The accord itself is a | |
| 130 | // fresh canonical object, so it is compared by what it says. | |
| 131 | offered: stood.accord.ledger === "redacted" ? "A₁" : "A₂", | |
| 132 | by: stood.by, | |
| 133 | accepted: stood.accepted.map((one) => one.seat), | |
| 134 | }; | |
| 135 | }; | |
| 136 | expect(Array.from({ length: 9 }, (_, i) => after(i + 1))).toEqual([ | |
| 137 | { offered: "A₁", by: 0, accepted: [] }, // 1 Ash offers | |
| 138 | { offered: "A₁", by: 0, accepted: [] }, // 2 a pass changes nothing | |
| 139 | { offered: "A₁", by: 0, accepted: [2] }, // 3 Cleat accepts | |
| 140 | { offered: "A₁", by: 0, accepted: [2] }, // 4 a pass changes nothing | |
| 141 | { offered: "A₂", by: 0, accepted: [] }, // 5 A₂ clears Cleat's acceptance | |
| 142 | { offered: "A₂", by: 0, accepted: [1] }, // 6 Brail accepts | |
| 143 | { offered: "A₂", by: 0, accepted: [1, 2] }, // 7 Cleat accepts again | |
| 144 | { offered: "A₂", by: 0, accepted: [1, 2] }, // 8 a pass changes nothing | |
| 145 | { offered: "A₂", by: 0, accepted: [1, 2, 0] }, // 9 Ash accepts her own offer | |
| 146 | ]); | |
| 147 | // The accord that stands is canonical for this charter, whatever order it | |
| 148 | // arrived in: the clauses of the charter, in the charter's order. | |
| 149 | const backwards = Object.fromEntries( | |
| 150 | Object.entries(a2).reverse(), | |
| 151 | ) as Accord; | |
| 152 | const stood = standing(table(9, { acts: [offer(0, backwards, 1)] }))!; | |
| 153 | expect(Object.keys(stood.accord)).toEqual(CHARTER.map((c) => c.id)); | |
| 154 | ||
| 155 | // Sworn claims are public forever: a new offer clears acceptances, not truths. | |
| 156 | expect(pairs(sworn(table(9), 2))).toEqual([ | |
| 157 | "pilot=reinstated", | |
| 158 | "salvage=to_the_finders", | |
| 159 | ]); | |
| 160 | }); | |
| 161 | ||
| 162 | test("the third acceptance is the one that binds, and not the second (§9)", () => { | |
| 163 | for (let acts = 0; acts < 9; acts++) expect(binds(table(acts))).toBeNull(); | |
| 164 | const bound = binds(table(9))!; | |
| 165 | expect(bound.accord).toEqual(a2); | |
| 166 | expect(bound.by).toBe(0); | |
| 167 | expect(bound.seats).toEqual([1, 2, 0]); | |
| 168 | expect(bound.act).toBe(8); // act 9 of the transcript, index 8 of the ledger | |
| 169 | expect(bound.at).toBe(when(9)); | |
| 170 | expect(ending(table(9))).toEqual({ how: "settled", at: when(9) }); | |
| 171 | }); | |
| 172 | ||
| 173 | test("settlement scores every seat, ranks with ties, and judges what was said (§7, §9)", () => { | |
| 174 | expect(settlement(table(8), charges)).toBeNull(); // still being played | |
| 175 | ||
| 176 | const end = settlement(table(9), charges)!; | |
| 177 | expect(end.how).toBe("settled"); | |
| 178 | expect(end.accord).toEqual(a2); | |
| 179 | expect(end.scores.map((one) => one.score)).toEqual([8, 6, 4, 8]); | |
| 180 | // Ash and Dunnage tied at 8, Brail 6, Cleat 4: a shared rank and no tie-break. | |
| 181 | expect(end.ranking).toEqual([ | |
| 182 | { seat: 0, score: 8, rank: 1 }, | |
| 183 | { seat: 3, score: 8, rank: 1 }, | |
| 184 | { seat: 1, score: 6, rank: 3 }, | |
| 185 | { seat: 2, score: 4, rank: 4 }, | |
| 186 | ]); | |
| 187 | // Dunnage never signed a thing and tied for first. | |
| 188 | expect(sworn(table(9), 3)).toEqual([]); | |
| 189 | ||
| 190 | // Two true claims and the lie that moved the Ledger. | |
| 191 | expect( | |
| 192 | end.claims.map((one) => [one.seat, one.worth, one.truth, one.verdict]), | |
| 193 | ).toEqual([ | |
| 194 | [0, "small", "small", true], | |
| 195 | [1, "large", "large", true], | |
| 196 | [3, "none", "dread", false], | |
| 197 | ]); | |
| 198 | }); | |
| 199 | ||
| 200 | test("a true worth is the aim at exactly that setting, and nothing else (§7)", () => { | |
| 201 | // Every row of §7's table, on Dunnage: +5, +3, +2, the dread, and nothing. | |
| 202 | expect(trueWorth(dunnage, { clause: "beacon", setting: "kept" })).toBe("large"); | |
| 203 | expect(trueWorth(dunnage, { clause: "hold", setting: "sealed" })).toBe("large"); | |
| 204 | expect(trueWorth(dunnage, { clause: "name", setting: "kept" })).toBe("small"); | |
| 205 | expect(trueWorth(ash, { clause: "ledger", setting: "redacted" })).toBe("small"); | |
| 206 | expect(trueWorth(dunnage, { clause: "ledger", setting: "redacted" })).toBe( | |
| 207 | "dread", | |
| 208 | ); | |
| 209 | // An aim on that clause at another setting is worth none at the setting said. | |
| 210 | expect(trueWorth(brail, { clause: "ledger", setting: "redacted" })).toBe("none"); | |
| 211 | expect(trueWorth(brail, { clause: "chart", setting: "locked" })).toBe("none"); | |
| 212 | }); | |
| 213 | ||
| 214 | test("an acceptance before any offer counts for nothing (§5)", () => { | |
| 215 | const early: Act[] = [ | |
| 216 | accept(0, "beacon=kept", 1), // there is nothing standing to accept | |
| 217 | accept(1, "ledger=published", 2), | |
| 218 | offer(2, a2, 3), | |
| 219 | accept(3, "beacon=kept", 4), | |
| 220 | ]; | |
| 221 | const round = table(0, { acts: early, said: [] }); | |
| 222 | const stood = standing(round)!; | |
| 223 | expect(stood.by).toBe(2); | |
| 224 | expect(stood.accepted.map((one) => one.seat)).toEqual([3]); | |
| 225 | // One acceptance of the offer that stands, and quorum is three. | |
| 226 | expect(binds(round)).toBeNull(); | |
| 227 | expect(whoseTurn(round)).toBe(0); | |
| 228 | }); | |
| 229 | ||
| 230 | test("a seat may accept the standing offer once, and may accept its own offer (§5)", () => { | |
| 231 | // Act 9: Ash accepts A₂, which she offered herself, and §6 allows it. | |
| 232 | const her = mayAct(table(8), 0, ash); | |
| 233 | expect(her.turn).toBe(true); | |
| 234 | expect(her.accept.may).toBe(true); | |
| 235 | expect(pairs(her.cite)).toEqual(["beacon=kept", "salvage=to_the_finders"]); | |
| 236 | ||
| 237 | // Act 7: Cleat's Pilot aim was sworn on act 3, so only the Salvage is left. | |
| 238 | const again = mayAct(table(6), 2, cleat); | |
| 239 | expect(pairs(again.cite)).toEqual(["salvage=to_the_finders"]); | |
| 240 | ||
| 241 | // A seat that already accepted what stands has nothing left to do but pass. | |
| 242 | const held = mayAct(ACCEPTED_ALREADY, 1, brail); | |
| 243 | expect(held.turn).toBe(true); | |
| 244 | expect(held.accept).toEqual({ | |
| 245 | may: false, | |
| 246 | refusal: "You have already accepted the standing offer.", | |
| 247 | }); | |
| 248 | expect(held.offer.may).toBe(true); | |
| 249 | expect(held.pass.may).toBe(true); | |
| 250 | expect(held.cite).toEqual([]); | |
| 251 | }); | |
| 252 | ||
| 253 | test("mayAct refuses the seat that is not there, not up, or has nothing to pay (§5, §6)", () => { | |
| 254 | const first = table(0); | |
| 255 | expect(mayAct(first, 0, ash).offer.may).toBe(true); | |
| 256 | // Nothing has been offered, so there is nothing to accept — and a pass is legal. | |
| 257 | expect(mayAct(first, 0, ash).accept).toEqual({ | |
| 258 | may: false, | |
| 259 | refusal: "There is nothing to accept yet.", | |
| 260 | }); | |
| 261 | expect(mayAct(first, 0, ash).pass.may).toBe(true); | |
| 262 | ||
| 263 | const waiting = mayAct(first, 1, brail); | |
| 264 | expect(waiting.turn).toBe(false); | |
| 265 | expect([waiting.offer, waiting.accept, waiting.pass]).toEqual([ | |
| 266 | { may: false, refusal: "It is not your turn." }, | |
| 267 | { may: false, refusal: "It is not your turn." }, | |
| 268 | { may: false, refusal: "It is not your turn." }, | |
| 269 | ]); | |
| 270 | ||
| 271 | // Act 2: A₁ satisfies nothing of Brail's, so he cannot accept it at all. | |
| 272 | expect(mayAct(table(1), 1, brail).accept).toEqual({ | |
| 273 | may: false, | |
| 274 | refusal: "This accord satisfies none of your aims.", | |
| 275 | }); | |
| 276 | // Every positive already sworn is the other refusal §6 allows. | |
| 277 | expect(sworn(PURSE_SPENT, 1).length).toBe(3); | |
| 278 | expect(mayAct(PURSE_SPENT, 1, brail).accept).toEqual({ | |
| 279 | may: false, | |
| 280 | refusal: "You have already sworn every aim this accord satisfies.", | |
| 281 | }); | |
| 282 | ||
| 283 | // A seat that holds no place at this table, and a seat given up: the order | |
| 284 | // keeps its place (§5), so the turn still names it, and nobody may play it. | |
| 285 | const nobody = mayAct(first, 4, { seat: 4, aims: [] }); | |
| 286 | expect(nobody.turn).toBe(false); | |
| 287 | expect(nobody.offer.refusal).toBe("You hold no seat at this table."); | |
| 288 | const gone = table(1, { | |
| 289 | seats: [{ seat: 0 }, { seat: 1, left: true }, { seat: 2 }, { seat: 3 }], | |
| 290 | }); | |
| 291 | expect(whoseTurn(gone)).toBe(1); | |
| 292 | expect(mayAct(gone, 1, brail).pass).toEqual({ | |
| 293 | may: false, | |
| 294 | refusal: "That seat was given up.", | |
| 295 | }); | |
| 296 | ||
| 297 | // A settled table takes no act at any door (§5). | |
| 298 | const done = mayAct(table(9), 0, ash); | |
| 299 | expect(done.turn).toBe(false); | |
| 300 | expect(done.offer.refusal).toBe("This table has settled."); | |
| 301 | }); | |
| 302 | ||
| 303 | test("the deadline with nothing bound impounds: every seat scores zero (§8)", () => { | |
| 304 | const deadline = when(20); | |
| 305 | const late = table(8, { deadline, now: when(21) }); | |
| 306 | expect(ending(late)).toEqual({ how: "impounded", at: deadline }); | |
| 307 | expect(whoseTurn(late)).toBeNull(); | |
| 308 | expect(mayAct(late, 0, ash).pass).toEqual({ | |
| 309 | may: false, | |
| 310 | refusal: "This table was impounded.", | |
| 311 | }); | |
| 312 | ||
| 313 | const end = settlement(late, charges)!; | |
| 314 | expect(end.accord).toBeNull(); | |
| 315 | expect(end.scores.map((one) => one.score)).toEqual([0, 0, 0, 0]); | |
| 316 | // Nobody wins an impound and nobody loses it: one rank, shared. | |
| 317 | expect(end.ranking.map((one) => one.rank)).toEqual([1, 1, 1, 1]); | |
| 318 | // Everything is revealed exactly as a binding reveals it. | |
| 319 | expect(end.claims.map((one) => one.verdict)).toEqual([true, true, false]); | |
| 320 | ||
| 321 | // A deadline still ahead impounds nothing, and a binding beats the deadline: | |
| 322 | // the table settled before it, so the deadline never arrives. | |
| 323 | expect(ending(table(8, { deadline, now: when(9) }))).toBeNull(); | |
| 324 | expect(ending(table(9, { deadline, now: when(21) }))).toEqual({ | |
| 325 | how: "settled", | |
| 326 | at: when(9), | |
| 327 | }); | |
| 328 | }); | |
| 329 | ||
| 330 | test("an offer that is not an accord of this charter is no offer (§3)", () => { | |
| 331 | const short = { ...a2 } as Record<string, string>; | |
| 332 | delete short.ledger; | |
| 333 | const round = table(0, { | |
| 334 | acts: [offer(0, a1, 1), offer(1, short as Accord, 2), pass(2, 3)], | |
| 335 | said: [], | |
| 336 | }); | |
| 337 | // A₁ still stands: the ledger is read past a row the boundary should never | |
| 338 | // have written, rather than standing on it. | |
| 339 | expect(standing(round)!.accord).toEqual(a1); | |
| 340 | expect(standing(round)!.by).toBe(0); | |
| 341 | }); | |
| 342 | ||
| 343 | test("a refusal leaks nothing: no aim, no seat, no accord detail (§6)", () => { | |
| 344 | const at = (round: Round, seat: number, charge = charges[seat]!) => | |
| 345 | mayAct(round, seat, charge); | |
| 346 | const refusals = [ | |
| 347 | at(table(0), 1).offer.refusal!, | |
| 348 | at(table(0), 0).accept.refusal!, | |
| 349 | at(table(1), 1).accept.refusal!, | |
| 350 | at(PURSE_SPENT, 1).accept.refusal!, | |
| 351 | at(ACCEPTED_ALREADY, 1).accept.refusal!, | |
| 352 | at(table(9), 0).offer.refusal!, | |
| 353 | at(table(8, { deadline: when(20), now: when(21) }), 0).offer.refusal!, | |
| 354 | at(table(0), 4, { seat: 4, aims: [] }).offer.refusal!, | |
| 355 | at( | |
| 356 | table(1, { | |
| 357 | seats: [{ seat: 0 }, { seat: 1, left: true }, { seat: 2 }, { seat: 3 }], | |
| 358 | }), | |
| 359 | 1, | |
| 360 | ).pass.refusal!, | |
| 361 | ]; | |
| 362 | expect(refusals).toEqual([ | |
| 363 | "It is not your turn.", | |
| 364 | "There is nothing to accept yet.", | |
| 365 | "This accord satisfies none of your aims.", | |
| 366 | "You have already sworn every aim this accord satisfies.", | |
| 367 | "You have already accepted the standing offer.", | |
| 368 | "This table has settled.", | |
| 369 | "This table was impounded.", | |
| 370 | "You hold no seat at this table.", | |
| 371 | "That seat was given up.", | |
| 372 | ]); | |
| 373 | // Every one of them is a constant of the module: nothing is built from the | |
| 374 | // round, so nothing can carry a piece of it out. The ids are not checked — | |
| 375 | // `hold` and `name` are ordinary English as well as clauses — but a | |
| 376 | // refusal that named the board would name it as the table does. | |
| 377 | for (const sentence of refusals) { | |
| 378 | expect(sentence).toMatch(/^[A-Z][ ,;'a-zA-Z]+\.$/); | |
| 379 | for (const clause of CLAUSE_POOL) { | |
| 380 | expect(sentence).not.toContain(clause.title); | |
| 381 | for (const setting of clause.settings) | |
| 382 | expect(sentence).not.toContain(setting); | |
| 383 | } | |
| 384 | // No number at all: not a seat's ordinal, not a weight, not a tally of | |
| 385 | // who has accepted. The shape regex above forbids a digit; these are the | |
| 386 | // words a leak would arrive in. | |
| 387 | for (const word of ["weight", "sworn by", "ordinal"]) | |
| 388 | expect(sentence).not.toContain(word); | |
| 389 | } | |
| 390 | }); | |
| 391 | ||
| 392 | test("another seat's charge is a bug, not an answer", () => { | |
| 393 | expect(() => mayAct(table(0), 0, brail)).toThrow(); | |
| 394 | expect(() => settlement(table(9), [ash, brail, cleat])).toThrow(); | |
| 395 | }); | |
| 396 | ||
| 397 | test("the round reads no database, no clock and no dice", async () => { | |
| 398 | const source = await Bun.file(new URL("./round.ts", import.meta.url)).text(); | |
| 399 | for (const forbidden of ["Math.random(", "Date.now(", "bun:sqlite"]) | |
| 400 | expect({ forbidden, present: source.includes(forbidden) }).toEqual({ | |
| 401 | forbidden, | |
| 402 | present: false, | |
| 403 | }); | |
| 404 | }); |
src/round.ts added +465
| @@ -0,0 +1,465 @@ | ||
| 1 | /** | |
| 2 | * The turn, the standing offer, the binding and the settlement | |
| 3 | * (RULES.md §5, §7, §8, §9) — as arithmetic on the acts. | |
| 4 | * | |
| 5 | * A table's public facts go in as plain data and answers come out: whose | |
| 6 | * turn it is, what stands, who has signed it, whether it binds, what a seat | |
| 7 | * may do next, and what the end of the table looks like. No database, no | |
| 8 | * clock of its own, no `Math.random` — `now` is handed in like everything | |
| 9 | * else — so every rule here can be checked by hand and replayed in a test | |
| 10 | * against RULES.md §10. | |
| 11 | * | |
| 12 | * Nothing is stored twice. The standing offer is the latest `offer` act, its | |
| 13 | * acceptances are the `accept` acts after it, and the turn is the act count | |
| 14 | * modulo the seat count: derived, so there is no pointer, no counter and no | |
| 15 | * cached tally that can disagree with the ledger that is the record. A row | |
| 16 | * written twice is a bug waiting; a row read twice is arithmetic. | |
| 17 | * | |
| 18 | * `src/board.ts` is the other pure half — the deal, the score and what a | |
| 19 | * seat may swear — and this module calls it rather than restating it. | |
| 20 | */ | |
| 21 | ||
| 22 | import { | |
| 23 | type Accord, | |
| 24 | type Aim, | |
| 25 | type Charge, | |
| 26 | citable, | |
| 27 | type Cite, | |
| 28 | type Clause, | |
| 29 | score, | |
| 30 | validAccord, | |
| 31 | } from "./board.ts"; | |
| 32 | ||
| 33 | /** The three things a turn can be (RULES.md §5). */ | |
| 34 | export type ActKind = "offer" | "accept" | "pass"; | |
| 35 | ||
| 36 | /** | |
| 37 | * One turn, as the ledger holds it. This is the shape `act` rows match: a | |
| 38 | * seat, a kind, the accord an offer carries, the citation an acceptance | |
| 39 | * pays with, and when it happened. Ids, authorship and storage belong to | |
| 40 | * the table that keeps it, not to the rules that read it. | |
| 41 | */ | |
| 42 | export interface Act { | |
| 43 | /** The ordinal of the seat that acted. */ | |
| 44 | readonly seat: number; | |
| 45 | readonly kind: ActKind; | |
| 46 | /** An `offer` carries an accord. Checked against the charter again here. */ | |
| 47 | readonly accord?: Accord; | |
| 48 | /** An `accept` carries the aim it swore (§6), verified when it was written. */ | |
| 49 | readonly cite?: Cite; | |
| 50 | /** When it was acted, as an ISO-8601 UTC string. */ | |
| 51 | readonly at: string; | |
| 52 | } | |
| 53 | ||
| 54 | /** One seat, in its place in the order. Names and identities are a door's business. */ | |
| 55 | export interface Seat { | |
| 56 | /** Its place in the order, 0-based (§5). */ | |
| 57 | readonly seat: number; | |
| 58 | /** True when the seat was given up: the order keeps its place and the clock plays it out. */ | |
| 59 | readonly left?: boolean; | |
| 60 | } | |
| 61 | ||
| 62 | /** What a said claim is worth to the seat that said it (§7). */ | |
| 63 | export type Worth = "none" | "small" | "large" | "dread"; | |
| 64 | ||
| 65 | /** One said claim: cheap, unverified, judged in public at the reveal (§7). */ | |
| 66 | export interface Said { | |
| 67 | readonly seat: number; | |
| 68 | readonly clause: string; | |
| 69 | readonly setting: string; | |
| 70 | /** | |
| 71 | * What the seat declared it is worth to them. Not checked, ever, until | |
| 72 | * the reveal. | |
| 73 | */ | |
| 74 | readonly worth: Worth; | |
| 75 | /** | |
| 76 | * The act it was carried on, as an index into `acts`. Carried through, | |
| 77 | * never read by the arithmetic. | |
| 78 | */ | |
| 79 | readonly act?: number; | |
| 80 | } | |
| 81 | ||
| 82 | /** Everything the rules of the turn read: a table's public facts, as plain data. */ | |
| 83 | export interface Round { | |
| 84 | /** The 7 clauses this table is arguing over (§3). */ | |
| 85 | readonly charter: readonly Clause[]; | |
| 86 | /** Every seat, in the order they play (§5). */ | |
| 87 | readonly seats: readonly Seat[]; | |
| 88 | /** | |
| 89 | * Every turn taken, newest last. An act the table made for itself is not | |
| 90 | * a turn and is not here. | |
| 91 | */ | |
| 92 | readonly acts: readonly Act[]; | |
| 93 | /** Every said claim those acts carried, in the order they were said (§7). */ | |
| 94 | readonly said?: readonly Said[]; | |
| 95 | /** | |
| 96 | * When the table is impounded if nothing has bound (§8), ISO-8601. The | |
| 97 | * turn window is the clock's, not this module's. | |
| 98 | */ | |
| 99 | readonly deadline?: string; | |
| 100 | /** Now, ISO-8601: handed in, never read from a clock here. */ | |
| 101 | readonly now: string; | |
| 102 | } | |
| 103 | ||
| 104 | /** An acceptance of the standing offer, in the order it was made. */ | |
| 105 | export interface Acceptance { | |
| 106 | readonly seat: number; | |
| 107 | /** The aim it swore (§6), when the ledger recorded one. */ | |
| 108 | readonly cite?: Cite; | |
| 109 | readonly at: string; | |
| 110 | /** Its place in the ledger, as an index into `acts`. */ | |
| 111 | readonly act: number; | |
| 112 | } | |
| 113 | ||
| 114 | /** The latest offer and the acceptances that came after it (§5). */ | |
| 115 | export interface Standing { | |
| 116 | /** The accord, canonical for this charter. */ | |
| 117 | readonly accord: Accord; | |
| 118 | /** The seat that offered it. */ | |
| 119 | readonly by: number; | |
| 120 | readonly at: string; | |
| 121 | /** Its place in the ledger, as an index into `acts`. */ | |
| 122 | readonly act: number; | |
| 123 | /** The acceptances of *this* offer, oldest first. A new offer clears them. */ | |
| 124 | readonly accepted: readonly Acceptance[]; | |
| 125 | } | |
| 126 | ||
| 127 | /** The moment quorum was reached: the accord of record and the act that bound it (§9). */ | |
| 128 | export interface Binding { | |
| 129 | readonly accord: Accord; | |
| 130 | /** The seat that offered it. */ | |
| 131 | readonly by: number; | |
| 132 | /** The seats whose acceptances reached the quorum, oldest first. */ | |
| 133 | readonly seats: readonly number[]; | |
| 134 | /** The acceptance that reached it, as an index into `acts`. */ | |
| 135 | readonly act: number; | |
| 136 | readonly at: string; | |
| 137 | } | |
| 138 | ||
| 139 | /** How a table ended, and when (§8, §9). */ | |
| 140 | export interface Ending { | |
| 141 | readonly how: "settled" | "impounded"; | |
| 142 | readonly at: string; | |
| 143 | } | |
| 144 | ||
| 145 | /** One seat's result: its score against the accord of record, and where that puts it. */ | |
| 146 | export interface Score { | |
| 147 | readonly seat: number; | |
| 148 | readonly score: number; | |
| 149 | /** 1 is first. Seats tied on score share a rank and the next rank is skipped (§9). */ | |
| 150 | readonly rank: number; | |
| 151 | } | |
| 152 | ||
| 153 | /** A said claim with its verdict, computed once, at the reveal (§7). */ | |
| 154 | export interface Judged extends Said { | |
| 155 | /** What it was really worth to the seat that said it. */ | |
| 156 | readonly truth: Worth; | |
| 157 | /** True when what was said is what was true. A lie is legal and is recorded as one. */ | |
| 158 | readonly verdict: boolean; | |
| 159 | } | |
| 160 | ||
| 161 | /** The end of the table, the same shape whether it bound or was impounded (§8, §9). */ | |
| 162 | export interface Settlement extends Ending { | |
| 163 | /** The accord of record, or null when the deadline passed with nothing bound. */ | |
| 164 | readonly accord: Accord | null; | |
| 165 | /** Every seat, in seat order. */ | |
| 166 | readonly scores: readonly Score[]; | |
| 167 | /** The same scores, best first, ties in seat order. There is no tie-break. */ | |
| 168 | readonly ranking: readonly Score[]; | |
| 169 | /** Every said claim, in the order it was said, each marked true or false. */ | |
| 170 | readonly claims: readonly Judged[]; | |
| 171 | } | |
| 172 | ||
| 173 | /** Whether one act is allowed, and the sentence to show when it is not. */ | |
| 174 | export interface Allowed { | |
| 175 | readonly may: boolean; | |
| 176 | /** The refusal to show, or null when there is nothing to refuse. */ | |
| 177 | readonly refusal: string | null; | |
| 178 | } | |
| 179 | ||
| 180 | /** What a seat may do right now — the question every door asks before it draws a button. */ | |
| 181 | export interface MayAct { | |
| 182 | readonly seat: number; | |
| 183 | /** Whether the turn is this seat's. */ | |
| 184 | readonly turn: boolean; | |
| 185 | readonly offer: Allowed; | |
| 186 | readonly accept: Allowed; | |
| 187 | readonly pass: Allowed; | |
| 188 | /** | |
| 189 | * What §6 lets this seat swear to accept the standing offer, in charter | |
| 190 | * order. Empty unless `accept.may`. | |
| 191 | */ | |
| 192 | readonly cite: readonly Aim[]; | |
| 193 | } | |
| 194 | ||
| 195 | /** Every refusal this module can give, written once so every door says the same words. */ | |
| 196 | const REFUSAL = { | |
| 197 | no_seat: "You hold no seat at this table.", | |
| 198 | settled: "This table has settled.", | |
| 199 | impounded: "This table was impounded.", | |
| 200 | left: "That seat was given up.", | |
| 201 | not_your_turn: "It is not your turn.", | |
| 202 | nothing_to_accept: "There is nothing to accept yet.", | |
| 203 | already_accepted: "You have already accepted the standing offer.", | |
| 204 | } as const; | |
| 205 | ||
| 206 | /** | |
| 207 | * ⌈n/2⌉+1 (§2): 3 of 4, 4 of 5, 4 of 6. It follows from the seat count, so | |
| 208 | * no host chooses it. | |
| 209 | */ | |
| 210 | export const quorum = (seats: number): number => Math.ceil(seats / 2) + 1; | |
| 211 | ||
| 212 | /** | |
| 213 | * The seat to act: after `k` acts it is the seat `k mod n` places into the | |
| 214 | * order (§5), and null once the table has ended. | |
| 215 | * | |
| 216 | * A seat that has left is still named. The order never changes and no seat | |
| 217 | * is ever skipped; a seat that will not play is passed for it by the clock, | |
| 218 | * which writes a real act and moves the turn on like any other. | |
| 219 | */ | |
| 220 | export function whoseTurn(round: Round): number | null { | |
| 221 | const order = round.seats; | |
| 222 | if (order.length === 0 || ending(round) !== null) return null; | |
| 223 | return order[round.acts.length % order.length]!.seat; | |
| 224 | } | |
| 225 | ||
| 226 | /** | |
| 227 | * The latest offer and the acceptances that came after it (§5). | |
| 228 | * | |
| 229 | * A new offer clears every acceptance, including the offerer's own, and an | |
| 230 | * acceptance made before any offer counts for nothing: it is bound to the | |
| 231 | * offer that stood when it was made, and none did. | |
| 232 | */ | |
| 233 | export const standing = (round: Round): Standing | null => play(round).standing; | |
| 234 | ||
| 235 | /** | |
| 236 | * The moment the standing offer reached quorum, and the act it reached it on | |
| 237 | * (§9), or null while nothing has bound. Binding is not a step anyone takes; | |
| 238 | * it happens inside the acceptance that reaches it. | |
| 239 | */ | |
| 240 | export const binds = (round: Round): Binding | null => play(round).binding; | |
| 241 | ||
| 242 | /** | |
| 243 | * What this seat has sworn, oldest first (§6): the citations its own | |
| 244 | * acceptances paid with, whatever accord stood at the time. A sworn claim is | |
| 245 | * public forever and is never cleared by a later offer. | |
| 246 | */ | |
| 247 | export const sworn = (round: Round, seat: number): Cite[] => | |
| 248 | round.acts | |
| 249 | .filter((act) => act.kind === "accept" && act.seat === seat && act.cite) | |
| 250 | .map((act) => act.cite!); | |
| 251 | ||
| 252 | /** | |
| 253 | * How the table ended, and when: `settled` at the acceptance that bound | |
| 254 | * (§9), `impounded` when the deadline passed with nothing bound (§8), null | |
| 255 | * while it is still being played. | |
| 256 | */ | |
| 257 | export function ending(round: Round): Ending | null { | |
| 258 | const binding = binds(round); | |
| 259 | if (binding !== null) return { how: "settled", at: binding.at }; | |
| 260 | if (round.deadline !== undefined && at(round.now) >= at(round.deadline)) | |
| 261 | return { how: "impounded", at: round.deadline }; | |
| 262 | return null; | |
| 263 | } | |
| 264 | ||
| 265 | /** | |
| 266 | * The end of the table, or null while it is still being played (§8, §9). | |
| 267 | * | |
| 268 | * Every seat is scored against the accord of record whether it signed or | |
| 269 | * not, present or lapsed; the ranking is by score alone, and seats tied on | |
| 270 | * score share a rank. The impound is the same function with no accord: every | |
| 271 | * seat scores 0, and every charge and verdict is revealed exactly as a | |
| 272 | * binding reveals them. | |
| 273 | * | |
| 274 | * `charges` is every seat's charge — the secret the table kept until now. | |
| 275 | * A seat of this round without one is a caller's bug, not a rule. | |
| 276 | */ | |
| 277 | export function settlement( | |
| 278 | round: Round, | |
| 279 | charges: readonly Charge[], | |
| 280 | ): Settlement | null { | |
| 281 | const end = ending(round); | |
| 282 | if (end === null) return null; | |
| 283 | const accord = binds(round)?.accord ?? null; | |
| 284 | const held = (seat: number): Charge => { | |
| 285 | const charge = charges.find((one) => one.seat === seat); | |
| 286 | if (charge === undefined) | |
| 287 | throw new Error(`round: seat ${seat} has no charge to settle`); | |
| 288 | return charge; | |
| 289 | }; | |
| 290 | ||
| 291 | const ranking = ranked( | |
| 292 | round.seats.map((place) => ({ | |
| 293 | seat: place.seat, | |
| 294 | score: accord === null ? 0 : score(held(place.seat), accord), | |
| 295 | })), | |
| 296 | ); | |
| 297 | const claims = (round.said ?? []).map((said): Judged => { | |
| 298 | const truth = trueWorth(held(said.seat), said); | |
| 299 | return { ...said, truth, verdict: truth === said.worth }; | |
| 300 | }); | |
| 301 | return { | |
| 302 | ...end, | |
| 303 | accord, | |
| 304 | scores: [...ranking].sort((a, b) => a.seat - b.seat), | |
| 305 | ranking, | |
| 306 | claims, | |
| 307 | }; | |
| 308 | } | |
| 309 | ||
| 310 | /** | |
| 311 | * What a said claim is really worth to the charge that said it (§7): the | |
| 312 | * dread sitting exactly there, a positive of 3 or 5, a positive of 1 or 2, | |
| 313 | * or nothing at all. An aim on that clause at another setting is `none` at | |
| 314 | * the setting stated — "the Ledger redacted is nothing to me" is true for a | |
| 315 | * seat whose Ledger aim is `published`. | |
| 316 | */ | |
| 317 | export function trueWorth(charge: Charge, claim: Cite): Worth { | |
| 318 | const aim = charge.aims.find( | |
| 319 | (one) => one.clause === claim.clause && one.setting === claim.setting, | |
| 320 | ); | |
| 321 | if (aim === undefined) return "none"; | |
| 322 | if (aim.weight < 0) return "dread"; | |
| 323 | return aim.weight >= 3 ? "large" : "small"; | |
| 324 | } | |
| 325 | ||
| 326 | /** | |
| 327 | * May this seat offer, accept or pass — and when not, the sentence to show. | |
| 328 | * | |
| 329 | * One answer for every door: the page draws the buttons it allows, the | |
| 330 | * action checks the act it was given, and the terminal prints the refusal. | |
| 331 | * `charge` is the caller's own charge, which §6 needs to know whether there | |
| 332 | * is a truth left to pay with; handing in another seat's is a bug, and is | |
| 333 | * refused as one rather than answered. | |
| 334 | */ | |
| 335 | export function mayAct(round: Round, seat: number, charge: Charge): MayAct { | |
| 336 | if (charge.seat !== seat) | |
| 337 | throw new Error( | |
| 338 | `round: seat ${seat} was handed seat ${charge.seat}'s charge`, | |
| 339 | ); | |
| 340 | ||
| 341 | const place = round.seats.find((one) => one.seat === seat); | |
| 342 | if (place === undefined) return nothing(seat, REFUSAL.no_seat); | |
| 343 | const end = ending(round); | |
| 344 | if (end !== null) return nothing(seat, REFUSAL[end.how]); | |
| 345 | if (place.left) return nothing(seat, REFUSAL.left); | |
| 346 | if (whoseTurn(round) !== seat) return nothing(seat, REFUSAL.not_your_turn); | |
| 347 | ||
| 348 | const yes: Allowed = { may: true, refusal: null }; | |
| 349 | const offer = play(round).standing; | |
| 350 | const accept = ((): { allowed: Allowed; cite: Aim[] } => { | |
| 351 | if (offer === null) | |
| 352 | return { | |
| 353 | allowed: { may: false, refusal: REFUSAL.nothing_to_accept }, | |
| 354 | cite: [], | |
| 355 | }; | |
| 356 | if (offer.accepted.some((one) => one.seat === seat)) | |
| 357 | return { | |
| 358 | allowed: { may: false, refusal: REFUSAL.already_accepted }, | |
| 359 | cite: [], | |
| 360 | }; | |
| 361 | // §6, in `board.ts`: true, satisfied, fresh, and secrets first. | |
| 362 | const may = citable(charge, offer.accord, sworn(round, seat)); | |
| 363 | return { | |
| 364 | allowed: { may: may.reason === "citable", refusal: may.refusal }, | |
| 365 | cite: may.aims, | |
| 366 | }; | |
| 367 | })(); | |
| 368 | ||
| 369 | return { | |
| 370 | seat, | |
| 371 | turn: true, | |
| 372 | offer: yes, | |
| 373 | accept: accept.allowed, | |
| 374 | pass: yes, | |
| 375 | cite: accept.cite, | |
| 376 | }; | |
| 377 | } | |
| 378 | ||
| 379 | // ------------------------------------------------------------- the walk | |
| 380 | ||
| 381 | /** | |
| 382 | * One walk of the ledger, for everything derived from it: the offer that | |
| 383 | * stands now with its acceptances, and the first moment acceptances of one | |
| 384 | * offer reached the quorum. | |
| 385 | * | |
| 386 | * An offer act whose accord is not an accord of this charter is not an | |
| 387 | * offer — the boundary refuses one before it is written (§3), and a ledger | |
| 388 | * that somehow holds one is read past rather than believed. What stands is | |
| 389 | * therefore always canonical: the charter's clauses in the charter's order, | |
| 390 | * which is the order `satisfied` and `citable` report their aims in. | |
| 391 | */ | |
| 392 | function play(round: Round): { | |
| 393 | standing: Standing | null; | |
| 394 | binding: Binding | null; | |
| 395 | } { | |
| 396 | const need = quorum(round.seats.length); | |
| 397 | let offer: Omit<Standing, "accepted"> | null = null; | |
| 398 | let accepted: Acceptance[] = []; | |
| 399 | let binding: Binding | null = null; | |
| 400 | ||
| 401 | for (let index = 0; index < round.acts.length; index++) { | |
| 402 | const act = round.acts[index]!; | |
| 403 | if (act.kind === "offer") { | |
| 404 | const accord = validAccord(round.charter, act.accord); | |
| 405 | if (!accord.ok) continue; | |
| 406 | offer = { accord: accord.value, by: act.seat, at: act.at, act: index }; | |
| 407 | accepted = []; | |
| 408 | continue; | |
| 409 | } | |
| 410 | // Nothing to accept before the first offer, and a seat accepts once. | |
| 411 | if (act.kind !== "accept" || offer === null) continue; | |
| 412 | if (accepted.some((one) => one.seat === act.seat)) continue; | |
| 413 | accepted.push({ seat: act.seat, cite: act.cite, at: act.at, act: index }); | |
| 414 | if (binding === null && accepted.length >= need) | |
| 415 | binding = { | |
| 416 | accord: offer.accord, | |
| 417 | by: offer.by, | |
| 418 | seats: accepted.map((one) => one.seat), | |
| 419 | act: index, | |
| 420 | at: act.at, | |
| 421 | }; | |
| 422 | } | |
| 423 | return { | |
| 424 | standing: offer === null ? null : { ...offer, accepted }, | |
| 425 | binding, | |
| 426 | }; | |
| 427 | } | |
| 428 | ||
| 429 | // ------------------------------------------------------------ the plumbing | |
| 430 | ||
| 431 | /** Best first, ties in seat order and sharing a rank, and the next rank skipped (§9). */ | |
| 432 | function ranked(scores: readonly Omit<Score, "rank">[]): Score[] { | |
| 433 | const order = [...scores].sort( | |
| 434 | (a, b) => b.score - a.score || a.seat - b.seat, | |
| 435 | ); | |
| 436 | let rank = 0; | |
| 437 | let previous: number | null = null; | |
| 438 | return order.map((one, place) => { | |
| 439 | if (previous === null || one.score !== previous) { | |
| 440 | rank = place + 1; | |
| 441 | previous = one.score; | |
| 442 | } | |
| 443 | return { ...one, rank }; | |
| 444 | }); | |
| 445 | } | |
| 446 | ||
| 447 | /** A seat that may do nothing, and the one sentence that says why. */ | |
| 448 | const nothing = (seat: number, refusal: string): MayAct => ({ | |
| 449 | seat, | |
| 450 | turn: false, | |
| 451 | offer: { may: false, refusal }, | |
| 452 | accept: { may: false, refusal }, | |
| 453 | pass: { may: false, refusal }, | |
| 454 | cite: [], | |
| 455 | }); | |
| 456 | ||
| 457 | /** | |
| 458 | * An ISO time as epoch milliseconds. Times come from the ledger and the | |
| 459 | * caller, never from a clock here. | |
| 460 | */ | |
| 461 | function at(time: string): number { | |
| 462 | const ms = Date.parse(time); | |
| 463 | if (Number.isNaN(ms)) throw new Error("round: that is not a time"); | |
| 464 | return ms; | |
| 465 | } |
new above
offline