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

Merged opened this

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

Merged

mason/parley:mason/36-the-turn-and-the-binding-as-pure-arithme → main 2 commits fork

  • Merged. mason/36-the-turn-and-the-binding-as-pure-arithme was merged into main as 1453322.

Review

Sign in to review this change.
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
4848 and the sworn arithmetic from `RULES.md` — a seed and a seat count in, a
4949 charter and charges out; a charge and an accord in, what it pays and what a
5050 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.
5358
5459 Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`,
5560 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.
2929 | ---------------------------------- | ------------------------------------------------------------ |
3030 | `src/actions.ts` | open, sit, leave, read a table — and who may see what |
3131 | `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 |
3234 | `src/when.ts` | time in words: "4h 12m left", "20m ago", a UTC moment |
3335 | `src/result.ts` | data or a named failure — the answer shape every door reads |
3436 | `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.
6668
6769 The game's `seed` is the charges of every seat written another way, so it
6870 never leaves `src/actions.ts` either. What a charter and a charge contain,
69 and what an accord pays, are [RULES.md](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](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`.
7277
7378 ## Who you are
7479
src/board.test.ts +4 −56
@@ -14,6 +14,7 @@ import {
1414 score,
1515 validAccord,
1616 } from "./board.ts";
17 import { a1, a2, ash, brail, CHARTER, cleat, dunnage } from "./example.ts";
1718
1819 const dealt = (seed: string, players: number): Board => {
1920 const r = deal(seed, players);
@@ -41,62 +42,9 @@ const fullPay = (charge: Charge): number =>
4142 positives(charge).reduce((sum, aim) => sum + aim.weight, 0);
4243
4344 // --------------------------------------------- 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.
10048
10149 const pairs = (aims: Aim[]): string[] =>
10250 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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs