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
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/26-what-an-accord-pays-you-satisfied-citabl → main 1 commit fork
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 | ||
| 33 | 33 | rule in an action holds at every door at once; a rule anywhere else is a |
| 34 | 34 | bug waiting for the door you forgot. |
| 35 | 35 | |
| 36 | A rule of the game is not an action. `src/board.ts` is the deal and the | |
| 37 | score from `RULES.md` — a seed and a seat count in, a charter and charges | |
| 38 | out, no database, no clock, no `Math.random`, so it can be checked by hand | |
| 39 | and tested against a fixture. Rules of that kind go in a pure module an | |
| 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 | |
| 40 | 41 | action calls; both speak the same named failures, from `src/result.ts`. |
| 41 | 42 | |
| 42 | 43 | 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. | ||
| 28 | 28 | | file | what it is | |
| 29 | 29 | | ---------------------------------- | ------------------------------------------------------------ | |
| 30 | 30 | | `src/actions.ts` | list, add, delete — and who may | |
| 31 | | `src/board.ts` | the deal and the score: a table from a seed, by RULES.md | | |
| 31 | | `src/board.ts` | the deal, the score and the sworn rule: by RULES.md, no db | | |
| 32 | 32 | | `src/result.ts` | data or a named failure — the answer shape every door reads | |
| 33 | 33 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 34 | 34 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
src/board.test.ts +273 −45
| @@ -1,14 +1,18 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | 3 | type Accord, |
| 4 | type Aim, | |
| 4 | 5 | type Board, |
| 5 | 6 | type Charge, |
| 7 | citable, | |
| 6 | 8 | CLAUSE_POOL, |
| 7 | 9 | type Clause, |
| 8 | 10 | deal, |
| 9 | 11 | dread, |
| 10 | 12 | positives, |
| 13 | satisfied, | |
| 11 | 14 | score, |
| 15 | validAccord, | |
| 12 | 16 | } from "./board.ts"; |
| 13 | 17 | |
| 14 | 18 | const dealt = (seed: string, players: number): Board => { |
| @@ -36,6 +40,67 @@ function accords(clauses: Clause[]): Accord[] { | ||
| 36 | 40 | const fullPay = (charge: Charge): number => |
| 37 | 41 | positives(charge).reduce((sum, aim) => sum + aim.weight, 0); |
| 38 | 42 | |
| 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 | ||
| 39 | 104 | test("an empty seed and a seat count outside 4–6 are refused by name", () => { |
| 40 | 105 | for (const [seed, players] of [ |
| 41 | 106 | ["", 4], |
| @@ -170,51 +235,6 @@ test("the same seed and count deal byte-identical tables", () => { | ||
| 170 | 235 | }); |
| 171 | 236 | |
| 172 | 237 | test("score pays the satisfied positives and docks the dread (RULES.md §10)", () => { |
| 173 | const aim = (pair: string, weight: number) => { | |
| 174 | const [clause, setting] = pair.split("=") as [string, string]; | |
| 175 | return { clause, setting, weight }; | |
| 176 | }; | |
| 177 | const charge = (seat: number, aims: ReturnType<typeof aim>[]): Charge => ({ | |
| 178 | seat, | |
| 179 | aims, | |
| 180 | }); | |
| 181 | // The four charges of the worked example, written out by hand. | |
| 182 | const ash = charge(0, [ | |
| 183 | aim("salvage=to_the_finders", 5), | |
| 184 | aim("beacon=kept", 3), | |
| 185 | aim("ledger=redacted", 1), | |
| 186 | aim("pilot=put_ashore", -3), | |
| 187 | ]); | |
| 188 | const brail = charge(1, [ | |
| 189 | aim("ledger=published", 5), | |
| 190 | aim("hold=opened", 2), | |
| 191 | aim("name=changed", 1), | |
| 192 | aim("salvage=to_the_crown", -3), | |
| 193 | ]); | |
| 194 | const cleat = charge(2, [ | |
| 195 | aim("pilot=reinstated", 3), | |
| 196 | aim("chart=shared", 2), | |
| 197 | aim("salvage=to_the_finders", 1), | |
| 198 | aim("beacon=scuttled", -3), | |
| 199 | ]); | |
| 200 | const dunnage = charge(3, [ | |
| 201 | aim("beacon=kept", 5), | |
| 202 | aim("hold=sealed", 3), | |
| 203 | aim("name=kept", 2), | |
| 204 | aim("ledger=redacted", -3), | |
| 205 | ]); | |
| 206 | ||
| 207 | const a1: Accord = { | |
| 208 | beacon: "kept", | |
| 209 | hold: "sealed", | |
| 210 | ledger: "redacted", | |
| 211 | pilot: "reinstated", | |
| 212 | name: "kept", | |
| 213 | chart: "locked", | |
| 214 | salvage: "to_the_finders", | |
| 215 | }; | |
| 216 | const a2: Accord = { ...a1, ledger: "published", name: "changed" }; | |
| 217 | ||
| 218 | 238 | // The accord of record binds every seat, signed or not. |
| 219 | 239 | expect([ash, brail, cleat, dunnage].map((c) => score(c, a2))).toEqual([ |
| 220 | 240 | 8, 6, 4, 8, |
| @@ -232,6 +252,214 @@ test("score pays the satisfied positives and docks the dread (RULES.md §10)", ( | ||
| 232 | 252 | expect(score(dunnage, { ...a1, name: "kept", ledger: "burned" })).toBe(10); |
| 233 | 253 | }); |
| 234 | 254 | |
| 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 | ||
| 235 | 463 | test("the deal reads no clock and no dice", async () => { |
| 236 | 464 | const source = await Bun.file(new URL("./board.ts", import.meta.url)).text(); |
| 237 | 465 | // 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 @@ | ||
| 1 | 1 | /** |
| 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). | |
| 3 | 4 | * |
| 4 | 5 | * A table's charter and its secret charges are a function of the seed and |
| 5 | 6 | * the seat count and nothing else: plain data in, plain data out, no |
| @@ -31,6 +32,12 @@ export interface Charge { | ||
| 31 | 32 | readonly aims: readonly Aim[]; |
| 32 | 33 | } |
| 33 | 34 | |
| 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 | ||
| 34 | 41 | /** One setting for every clause of the charter — all 7, never a fragment. */ |
| 35 | 42 | export type Accord = Readonly<Record<string, string>>; |
| 36 | 43 | |
| @@ -136,6 +143,135 @@ export const positives = (charge: Charge): Aim[] => | ||
| 136 | 143 | export const dread = (charge: Charge): Aim => |
| 137 | 144 | charge.aims.find((aim) => aim.weight === DREAD_WEIGHT)!; |
| 138 | 145 | |
| 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 | ||
| 139 | 275 | // ---------------------------------------------------------------- the deal |
| 140 | 276 | |
| 141 | 277 | /** |
new above
offline