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

What an accord pays you: satisfied, citable and a validated accord, the pure half of the sworn rule in src/board.ts #35

Merged opened this

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

Merged

mason/parley:mason/26-what-an-accord-pays-you-satisfied-citabl → main 1 commit fork

  • Merged. mason/26-what-an-accord-pays-you-satisfied-citabl was merged into main as 72aa6a7.

Review

Sign in to review this change.
4 files changed +416 −51
AGENTS.md +5 −4
@@ -33,10 +33,11 @@ door reads its wire format and shapes the answer; it decides nothing. A
3333 rule in an action holds at every door at once; a rule anywhere else is a
3434 bug waiting for the door you forgot.
3535
36 A rule of the game is not an action. `src/board.ts` is the deal and the
37 score from `RULES.md` — a seed and a seat count in, a charter and charges
38 out, no database, no clock, no `Math.random`, so it can be checked by hand
39 and tested against a fixture. Rules of that kind go in a pure module an
36 A rule of the game is not an action. `src/board.ts` is the deal, the score
37 and the sworn arithmetic from `RULES.md` — a seed and a seat count in, a
38 charter and charges out; a charge and an accord in, what it pays and what a
39 seat may swear for it out. No database, no clock, no `Math.random`, so it
40 can be checked by hand and tested against a fixture. Rules of that kind go in a pure module an
4041 action calls; both speak the same named failures, from `src/result.ts`.
4142
4243 Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`,
README.md +1 −1
@@ -28,7 +28,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2828 | file | what it is |
2929 | ---------------------------------- | ------------------------------------------------------------ |
3030 | `src/actions.ts` | list, add, delete — and who may |
31 | `src/board.ts` | the deal and the score: a table from a seed, by RULES.md |
31 | `src/board.ts` | the deal, the score and the sworn rule: by RULES.md, no db |
3232 | `src/result.ts` | data or a named failure — the answer shape every door reads |
3333 | `src/who.ts` | who is asking, from the forge's signed headers |
3434 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
src/board.test.ts +273 −45
@@ -1,14 +1,18 @@
11 import { expect, test } from "bun:test";
22 import {
33 type Accord,
4 type Aim,
45 type Board,
56 type Charge,
7 citable,
68 CLAUSE_POOL,
79 type Clause,
810 deal,
911 dread,
1012 positives,
13 satisfied,
1114 score,
15 validAccord,
1216 } from "./board.ts";
1317
1418 const dealt = (seed: string, players: number): Board => {
@@ -36,6 +40,67 @@ function accords(clauses: Clause[]): Accord[] {
3640 const fullPay = (charge: Charge): number =>
3741 positives(charge).reduce((sum, aim) => sum + aim.weight, 0);
3842
43 // --------------------------------------------- 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" };
100
101 const pairs = (aims: Aim[]): string[] =>
102 aims.map((one) => `${one.clause}=${one.setting}:${one.weight}`);
103
39104 test("an empty seed and a seat count outside 4–6 are refused by name", () => {
40105 for (const [seed, players] of [
41106 ["", 4],
@@ -170,51 +235,6 @@ test("the same seed and count deal byte-identical tables", () => {
170235 });
171236
172237 test("score pays the satisfied positives and docks the dread (RULES.md §10)", () => {
173 const aim = (pair: string, weight: number) => {
174 const [clause, setting] = pair.split("=") as [string, string];
175 return { clause, setting, weight };
176 };
177 const charge = (seat: number, aims: ReturnType<typeof aim>[]): Charge => ({
178 seat,
179 aims,
180 });
181 // The four charges of the worked example, written out by hand.
182 const ash = charge(0, [
183 aim("salvage=to_the_finders", 5),
184 aim("beacon=kept", 3),
185 aim("ledger=redacted", 1),
186 aim("pilot=put_ashore", -3),
187 ]);
188 const brail = charge(1, [
189 aim("ledger=published", 5),
190 aim("hold=opened", 2),
191 aim("name=changed", 1),
192 aim("salvage=to_the_crown", -3),
193 ]);
194 const cleat = charge(2, [
195 aim("pilot=reinstated", 3),
196 aim("chart=shared", 2),
197 aim("salvage=to_the_finders", 1),
198 aim("beacon=scuttled", -3),
199 ]);
200 const dunnage = charge(3, [
201 aim("beacon=kept", 5),
202 aim("hold=sealed", 3),
203 aim("name=kept", 2),
204 aim("ledger=redacted", -3),
205 ]);
206
207 const a1: Accord = {
208 beacon: "kept",
209 hold: "sealed",
210 ledger: "redacted",
211 pilot: "reinstated",
212 name: "kept",
213 chart: "locked",
214 salvage: "to_the_finders",
215 };
216 const a2: Accord = { ...a1, ledger: "published", name: "changed" };
217
218238 // The accord of record binds every seat, signed or not.
219239 expect([ash, brail, cleat, dunnage].map((c) => score(c, a2))).toEqual([
220240 8, 6, 4, 8,
@@ -232,6 +252,214 @@ test("score pays the satisfied positives and docks the dread (RULES.md §10)", (
232252 expect(score(dunnage, { ...a1, name: "kept", ledger: "burned" })).toBe(10);
233253 });
234254
255 test("an accord is one setting for every clause of this charter (RULES.md §3)", () => {
256 const r = validAccord(CHARTER, a1);
257 expect(r.ok).toBe(true);
258 if (!r.ok) throw new Error(r.message);
259 // Canonical: the charter's clauses, in the charter's order, whatever order
260 // they arrived in, so the same accord stores and compares identically.
261 expect(Object.keys(r.value)).toEqual(CHARTER.map((clause) => clause.id));
262 const shuffledIn = Object.fromEntries(Object.entries(a1).reverse()) as Accord;
263 expect(validAccord(CHARTER, shuffledIn)).toEqual(r);
264
265 const refused = (accord: unknown): string => {
266 const bad = validAccord(CHARTER, accord);
267 expect(bad.ok).toBe(false);
268 if (bad.ok) throw new Error("accepted an accord it should have refused");
269 expect(bad.error).toBe("invalid");
270 return bad.message;
271 };
272 const { ledger: _dropped, ...missing } = a1;
273 expect(refused(missing)).toBe("The accord leaves The Ledger unset.");
274 expect(refused({ ...a1, ledger: 7 })).toBe(
275 "The accord leaves The Ledger unset.",
276 );
277 expect(refused({ ...a1, ledger: "shredded" })).toBe(
278 "The accord sets The Ledger to a setting it does not have.",
279 );
280 // A setting of another clause is still not this clause's setting.
281 expect(refused({ ...a1, ledger: "sealed" })).toBe(
282 "The accord sets The Ledger to a setting it does not have.",
283 );
284 // An extra key: a clause of the pool that this table is not arguing over,
285 // and a key that is no clause at all — which is never echoed back.
286 expect(refused({ ...a1, wreck: "raised" })).toBe(
287 "The accord names The Wreck, which is not on this charter.",
288 );
289 expect(refused({ ...a1, "<script>": "raised" })).toBe(
290 "The accord names a clause that is not on this charter.",
291 );
292 for (const notAnAccord of [null, undefined, "beacon=kept", 7, [a1]])
293 expect(refused(notAnAccord)).toBe(
294 "An accord is one setting for every clause.",
295 );
296 });
297
298 test("satisfied is the positive aims this accord pays, in charter order (§4)", () => {
299 // Ash holds salvage first and beacon second; the charter holds beacon first.
300 expect(pairs(satisfied(ash, a2))).toEqual([
301 "beacon=kept:3",
302 "salvage=to_the_finders:5",
303 ]);
304 // A clause set to another setting pays nothing: A₂ publishes the Ledger
305 // and Ash is paid for it redacted.
306 expect(pairs(satisfied(ash, a1))).toEqual([
307 "beacon=kept:3",
308 "ledger=redacted:1",
309 "salvage=to_the_finders:5",
310 ]);
311 expect(pairs(satisfied(brail, a2))).toEqual([
312 "ledger=published:5",
313 "name=changed:1",
314 ]);
315 expect(satisfied(brail, a1)).toEqual([]);
316 // Satisfied is the same arithmetic score() pays, less the dread.
317 for (const seat of [ash, brail, cleat, dunnage])
318 for (const accord of [a1, a2]) {
319 const paid = satisfied(seat, accord).reduce(
320 (s, one) => s + one.weight,
321 0,
322 );
323 const docked =
324 accord[dread(seat).clause] === dread(seat).setting ? -3 : 0;
325 expect(paid + docked).toBe(score(seat, accord));
326 }
327 });
328
329 test("the dread is never satisfied, never citable and never a positive (§4, §6)", () => {
330 // A₁ puts the Pilot ashore: Ash's dread is triggered and pays nothing.
331 const triggered: Accord = { ...a1, pilot: "put_ashore" };
332 expect(score(ash, triggered)).toBe(3 + 1 + 5 - 3);
333 expect(pairs(satisfied(ash, triggered))).not.toContain("pilot=put_ashore:-3");
334 expect(pairs(citable(ash, triggered, []).aims)).not.toContain(
335 "pilot=put_ashore:-3",
336 );
337 // An accord that triggers the dread and satisfies nothing else is still
338 // nothing to swear to: −3 is not a citation.
339 const only: Accord = {
340 beacon: "sold",
341 hold: "sealed",
342 ledger: "burned",
343 pilot: "put_ashore",
344 name: "kept",
345 chart: "locked",
346 salvage: "split_evenly",
347 };
348 expect(score(ash, only)).toBe(-3);
349 expect(satisfied(ash, only)).toEqual([]);
350 expect(citable(ash, only, []).reason).toBe("nothing_satisfied");
351 for (const seat of [ash, brail, cleat, dunnage])
352 expect(positives(seat)).not.toContainEqual(dread(seat));
353 });
354
355 test("citable is §6's fresh, true and satisfied — the worked transcript (§10)", () => {
356 // Act 3: Cleat accepts A₁ with nothing sworn — both his satisfied aims are
357 // secret and he may pick either. Charter order, so the Pilot comes first.
358 expect(citable(cleat, a1, [])).toEqual({
359 reason: "citable",
360 aims: [
361 { clause: "pilot", setting: "reinstated", weight: 3 },
362 { clause: "salvage", setting: "to_the_finders", weight: 1 },
363 ],
364 refusal: null,
365 });
366 // Act 6: Brail accepts A₂. Said is not sworn — he has said the Ledger is
367 // large and it is still citable.
368 expect(pairs(citable(brail, a2, []).aims)).toEqual([
369 "ledger=published:5",
370 "name=changed:1",
371 ]);
372 // Act 7: Cleat accepts A₂. His Pilot aim was sworn in an earlier act, so
373 // only the Salvage is left — secrets first leaves him one legal citation.
374 const sworn = citable(cleat, a2, [
375 { clause: "pilot", setting: "reinstated" },
376 ]);
377 expect(sworn.reason).toBe("citable");
378 expect(pairs(sworn.aims)).toEqual(["salvage=to_the_finders:1"]);
379 // Act 9: the third acceptance, the one that binds. Ash accepts her own
380 // offer having sworn nothing; §6 must allow it.
381 expect(pairs(citable(ash, a2, []).aims)).toEqual([
382 "beacon=kept:3",
383 "salvage=to_the_finders:5",
384 ]);
385
386 // A sworn claim on a clause this accord sets to another setting is no
387 // constraint at all: Cleat swore the Pilot reinstated, and an accord that
388 // pays him nothing else is still unacceptable rather than "already sworn".
389 const elsewhere: Accord = {
390 ...a2,
391 pilot: "paid_off",
392 salvage: "to_the_crown",
393 };
394 expect(
395 citable(cleat, elsewhere, [{ clause: "pilot", setting: "reinstated" }]),
396 ).toEqual({
397 reason: "nothing_satisfied",
398 aims: [],
399 refusal: "This accord satisfies none of your aims.",
400 });
401 // Nor does a claim naming this clause at a setting he never swore.
402 expect(
403 citable(cleat, a2, [{ clause: "salvage", setting: "to_the_crown" }]).reason,
404 ).toBe("citable");
405
406 // All three positives sworn: an accord that satisfies every one of them is
407 // still unacceptable, and for the other reason. Honesty is a finite purse.
408 const everything: Accord = { ...a2, hold: "opened" };
409 expect(pairs(satisfied(brail, everything))).toEqual([
410 "hold=opened:2",
411 "ledger=published:5",
412 "name=changed:1",
413 ]);
414 expect(citable(brail, everything, positives(brail))).toEqual({
415 reason: "already_sworn",
416 aims: [],
417 refusal: "You have already sworn every aim this accord satisfies.",
418 });
419 // Two of three sworn is still a signature: the third is left.
420 expect(
421 pairs(
422 citable(brail, everything, [
423 { clause: "hold", setting: "opened" },
424 { clause: "name", setting: "changed" },
425 ]).aims,
426 ),
427 ).toEqual(["ledger=published:5"]);
428 });
429
430 test("a refusal leaks nothing: no aim, no seat, no accord detail (§6)", () => {
431 // Every sentence these functions can produce, gathered from the functions
432 // themselves rather than written out twice.
433 const refusals = [
434 citable(cleat, { ...a2, pilot: "paid_off", salvage: "to_the_crown" }, [])
435 .refusal!,
436 citable(brail, { ...a2, hold: "opened" }, positives(brail)).refusal!,
437 ];
438 expect(refusals).toEqual([
439 "This accord satisfies none of your aims.",
440 "You have already sworn every aim this accord satisfies.",
441 ]);
442 for (const sentence of refusals) {
443 expect(sentence).toMatch(/^[A-Z][ ,;'a-zA-Z]+\.$/);
444 // It names nothing on the board and nobody at the table.
445 for (const clause of CLAUSE_POOL) {
446 expect(sentence).not.toContain(clause.id);
447 expect(sentence).not.toContain(clause.title);
448 for (const setting of clause.settings)
449 expect(sentence).not.toContain(setting);
450 }
451 for (const word of ["seat", "weight", "sworn by", "0", "1", "2", "3", "5"])
452 expect(sentence).not.toContain(word);
453 }
454 // A refused accord names a clause of the charter — which the caller wrote
455 // and already holds — and never a seat, a weight or an aim.
456 const bad = validAccord(CHARTER, { ...a1, ledger: "shredded" });
457 expect(bad.ok).toBe(false);
458 if (!bad.ok)
459 for (const word of ["seat", "aim", "weight", "sworn", "shredded"])
460 expect(bad.message).not.toContain(word);
461 });
462
235463 test("the deal reads no clock and no dice", async () => {
236464 const source = await Bun.file(new URL("./board.ts", import.meta.url)).text();
237465 // A boolean, not the file: a failure here should name the call, not dump 300 lines.
src/board.ts +137 −1
@@ -1,5 +1,6 @@
11 /**
2 * The deal, and what an accord pays (RULES.md §2, §3, §4).
2 * The deal, what an accord pays, and what swearing to one costs
3 * (RULES.md §2, §3, §4, §6).
34 *
45 * A table's charter and its secret charges are a function of the seed and
56 * the seat count and nothing else: plain data in, plain data out, no
@@ -31,6 +32,12 @@ export interface Charge {
3132 readonly aims: readonly Aim[];
3233 }
3334
35 /** What a citation names: a clause and a setting. A sworn claim is one of these, verified. */
36 export interface Cite {
37 readonly clause: string;
38 readonly setting: string;
39 }
40
3441 /** One setting for every clause of the charter — all 7, never a fragment. */
3542 export type Accord = Readonly<Record<string, string>>;
3643
@@ -136,6 +143,135 @@ export const positives = (charge: Charge): Aim[] =>
136143 export const dread = (charge: Charge): Aim =>
137144 charge.aims.find((aim) => aim.weight === DREAD_WEIGHT)!;
138145
146 // --------------------------------------------------------------- the sworn
147
148 /**
149 * Why a seat can or cannot swear anything for this accord (RULES.md §6).
150 * Empty has two meanings and a caller must be able to tell them apart to
151 * write the right refusal, so the reason travels with the aims.
152 */
153 export type CitableReason = "citable" | "nothing_satisfied" | "already_sworn";
154
155 /**
156 * What §6 lets a seat swear for this accord, right now.
157 *
158 * One record rather than two functions: a door asks once — the page to draw
159 * the choice of citations, the action to check the one it was given — and
160 * the arithmetic is walked once for both. `reason` decides the rest: `aims`
161 * is non-empty exactly when it is `"citable"`, and `refusal` is the sentence
162 * to answer with exactly when it is not.
163 */
164 export interface Citable {
165 readonly reason: CitableReason;
166 /** The satisfied positive aims not already sworn, in charter order. */
167 readonly aims: Aim[];
168 /** The refusal §6 allows, or null when there is nothing to refuse. */
169 readonly refusal: string | null;
170 }
171
172 /** The two refusals of §6, written once so every door says the same words. */
173 const REFUSAL: Record<Exclude<CitableReason, "citable">, string> = {
174 nothing_satisfied: "This accord satisfies none of your aims.",
175 already_sworn: "You have already sworn every aim this accord satisfies.",
176 };
177
178 /**
179 * Is this an accord of *this* charter (RULES.md §3)? Exactly one setting for
180 * every clause: no unknown clause, no unknown setting, no missing clause, no
181 * extra key.
182 *
183 * The boundary for an accord off the wire, so the input is `unknown` and the
184 * answer is a named failure or a fresh accord holding the charter's clauses
185 * in the charter's order — canonical, so the same accord stores and compares
186 * byte-identically wherever it came from.
187 *
188 * A refusal names the clause at fault by its title. It never echoes the
189 * caller's text back: a key that is no clause at all is refused without
190 * repeating it, and a clause of the pool that is off this charter is named
191 * from the pool, never from the input.
192 */
193 export function validAccord(
194 clauses: readonly Clause[],
195 accord: unknown,
196 ): Result<Accord> {
197 if (typeof accord !== "object" || accord === null || Array.isArray(accord))
198 return fail("invalid", "An accord is one setting for every clause.");
199 const raw = accord as Record<string, unknown>;
200
201 const settled: Record<string, string> = {};
202 for (const clause of clauses) {
203 const setting = Object.hasOwn(raw, clause.id) ? raw[clause.id] : undefined;
204 if (typeof setting !== "string")
205 return fail("invalid", `The accord leaves ${clause.title} unset.`);
206 if (!clause.settings.includes(setting))
207 return fail(
208 "invalid",
209 `The accord sets ${clause.title} to a setting it does not have.`,
210 );
211 settled[clause.id] = setting;
212 }
213
214 const onCharter = new Set(clauses.map((clause) => clause.id));
215 for (const key of Object.keys(raw)) {
216 if (onCharter.has(key)) continue;
217 const off = CLAUSE_POOL.find((clause) => clause.id === key);
218 return fail(
219 "invalid",
220 off
221 ? `The accord names ${off.title}, which is not on this charter.`
222 : "The accord names a clause that is not on this charter.",
223 );
224 }
225 return ok(settled);
226 }
227
228 /**
229 * The positive aims of this charge that this accord pays (RULES.md §4), in
230 * charter order — the order of the accord's own clauses, which `validAccord`
231 * makes the charter's.
232 *
233 * The dread is never here: it is satisfied on the same test but it is not a
234 * positive, and §6 spends positives only.
235 */
236 export function satisfied(charge: Charge, accord: Accord): Aim[] {
237 const order = Object.keys(accord);
238 return positives(charge)
239 .filter((aim) => accord[aim.clause] === aim.setting)
240 .sort((a, b) => order.indexOf(a.clause) - order.indexOf(b.clause));
241 }
242
243 /**
244 * What this seat may swear to accept this accord (RULES.md §6): the aims it
245 * satisfies that the seat has not sworn before, given the citations already
246 * sworn. Fresh, true and satisfied — secrets first is then the caller's
247 * choice among these, and there is no other legal citation.
248 *
249 * `sworn` is this seat's own sworn claims. Claims of other seats are not an
250 * input: nothing here reads a charge but the one it was given.
251 */
252 export function citable(
253 charge: Charge,
254 accord: Accord,
255 sworn: readonly Cite[],
256 ): Citable {
257 const paid = satisfied(charge, accord);
258 if (paid.length === 0)
259 return {
260 reason: "nothing_satisfied",
261 aims: [],
262 refusal: REFUSAL.nothing_satisfied,
263 };
264 const aims = paid.filter(
265 (aim) =>
266 !sworn.some(
267 (claim) => claim.clause === aim.clause && claim.setting === aim.setting,
268 ),
269 );
270 if (aims.length === 0)
271 return { reason: "already_sworn", aims, refusal: REFUSAL.already_sworn };
272 return { reason: "citable", aims, refusal: null };
273 }
274
139275 // ---------------------------------------------------------------- the deal
140276
141277 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs