Public: anyone can read this; the crew can change it.
The turn: offer, accept, pass — and whose move it is #50
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/8-the-turn-offer-accept-pass-and-whose-mov → main 5 commits fork 1 thread · 1 unresolved
8 files
changed +1101
−40
README.md +41 −9
| @@ -46,7 +46,7 @@ one; anything procedural it leaves out is a sentence pointing at the record. | ||
| 46 | 46 | |
| 47 | 47 | | file | what it is | |
| 48 | 48 | | ---------------------------------- | ------------------------------------------------------------ | |
| 49 | | `src/actions.ts` | open, sit, leave, read a table — and who may see what | | |
| 49 | | `src/actions.ts` | open, sit, start, offer, accept, pass — and who may see what | | |
| 50 | 50 | | `src/board.ts` | the deal, the score and the sworn rule: by RULES.md, no db | |
| 51 | 51 | | `src/round.ts` | the turn, the standing offer, the binding and the settlement | |
| 52 | 52 | | `src/example.ts` | RULES.md §10's worked table: the one copy, tested and shown | |
| @@ -93,7 +93,9 @@ a guest reads it like anyone else, redacted the same way, and signing in is | ||
| 93 | 93 | how you sit down. The seats fill in as people arrive without a reload |
| 94 | 94 | (`data-live="seat.joined seat.left table.started"`), and the first paint is |
| 95 | 95 | the whole page. Nothing of the game itself — the charter, the turn, the |
| 96 | offer, the reveal — is on it yet; that arrives with the turn engine. | |
| 96 | offer, the reveal — is on that page yet: the turn is played over the JSON | |
| 97 | API today, and the page grows the charter, the act log and the three buttons | |
| 98 | next. | |
| 97 | 99 | |
| 98 | 100 | Following a stale link is a page too, not four words on a white screen: any |
| 99 | 101 | address a browser asks for HTML on answers with the app's own layout, a |
| @@ -113,9 +115,24 @@ never leaves `src/actions.ts` either. What a charter and a charge contain, | ||
| 113 | 115 | and what an accord pays, are [](RULES.md) and `src/board.ts`; whose |
| 114 | 116 | turn it is, what stands, when it binds and what the settlement comes to are |
| 115 | 117 | `src/round.ts`, which reads the acts as plain data and writes nothing. |
| 116 | Dealing them onto a table and writing acts to the ledger arrive with the | |
| 117 | turn engine, so a table today is opened, sat at, left and read, and stays | |
| 118 | `open`. | |
| 118 | ||
| 119 | The host **starts** the table once four to six seats are taken: that deals | |
| 120 | the charter and every seat's charge from a seed the app makes and never | |
| 121 | shows, fixes the order of the seats for good, sets the quorum for the seats | |
| 122 | that actually took, and starts the clock. From then on the seat whose turn | |
| 123 | it is takes exactly one act — **offer** an accord, **accept** the one that | |
| 124 | stands, or **pass** — and the ledger of those acts is the whole state of | |
| 125 | the game. Whose turn it is, what stands and who has signed it are derived | |
| 126 | from that ledger on every read, never stored a second time: the turn is the | |
| 127 | act count modulo the seat count, the standing offer is the latest `offer`, | |
| 128 | and its acceptances are the `accept` acts after it. A new offer therefore | |
| 129 | clears every acceptance by existing, and there is no pointer or tally that | |
| 130 | can fall out of step with the record. | |
| 131 | ||
| 132 | A table today is opened, sat at, left, started and played. Scoring the | |
| 133 | accord that reaches quorum, the reveal and the clock that passes for a seat | |
| 134 | that never played are still to come; until then a table that binds stops | |
| 135 | taking acts and waits for its settlement. | |
| 119 | 136 | |
| 120 | 137 | ## Who you are |
| 121 | 138 | |
| @@ -155,11 +172,26 @@ curl -X POST https://<app>/api/tables -H 'content-type: application/json' \ | ||
| 155 | 172 | -d '{"title":"The Wreck of the Fathom","seats_max":5}' |
| 156 | 173 | curl -X POST https://<app>/api/tables/1/join |
| 157 | 174 | curl -X POST https://<app>/api/tables/1/leave |
| 175 | ||
| 176 | # The host deals the table and starts the clock, then the turn goes round. | |
| 177 | curl -X POST https://<app>/api/tables/1/start | |
| 178 | curl -X POST https://<app>/api/tables/1/offer -H 'content-type: application/json' \ | |
| 179 | -d '{"accord":{"beacon":"kept","hold":"sealed","ledger":"redacted","pilot":"reinstated","name":"kept","chart":"locked","salvage":"to_the_finders"}}' | |
| 180 | curl -X POST https://<app>/api/tables/1/accept | |
| 181 | curl -X POST https://<app>/api/tables/1/pass | |
| 158 | 182 | |
| 159 | 183 | |
| 184 | An accord is one setting for every clause of *that* table's charter, which | |
| 185 | `GET /api/tables/1` gives you. Accepting costs a truth (RULES.md §6): the | |
| 186 | answer swears the first of your own aims the accord satisfies, or the one | |
| 187 | you name — `-d '{"cite":{"clause":"name","setting":"changed"}}'` — out of | |
| 188 | those it lets you swear. | |
| 189 | ||
| 160 | 190 | A refusal answers with its name and the status that matches it: |
| 161 | `signin_required` 401, `forbidden` 403, `not_found` 404, `conflict` 409 | |
| 162 | (the table was in no state to take the request) and `invalid` 422. | |
| 191 | `signin_required` 401, `forbidden` 403 (no seat of yours here), | |
| 192 | `not_found` 404, `conflict` 409 (the table was in no state to take the | |
| 193 | request: not your turn, nothing to accept, already accepted), `invalid` | |
| 194 | 422 and `rate_limited` 429. | |
| 163 | 195 | |
| 164 | 196 | As an MCP server, for Claude Code or Claude Desktop — `list_tables`, |
| 165 | 197 | `table_state`, `open_table`, `join_table`, `leave_table`: |
| @@ -174,8 +206,8 @@ claude mcp add --transport http __TITLE__ https://<app>/mcp | ||
| 174 | 206 | } |
| 175 | 207 | |
| 176 | 208 | |
| 177 | The page stays current over `/events`: a table someone else opens, sits at | |
| 178 | or leaves appears without a reload. Without JavaScript it is the same page, | |
| 209 | The page stays current over `/events`: a table someone else opens, sits at, | |
| 210 | leaves or starts appears without a reload. Without JavaScript it is the same page, | |
| 179 | 211 | one reload behind. |
| 180 | 212 | |
| 181 | 213 | Times are said the same way everywhere — "4h 12m left", "20m ago", "14 Sep |
src/actions.test.ts +423
| @@ -4,18 +4,24 @@ import { mkdtempSync } from "node:fs"; | ||
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { |
| 7 | acceptOffer, | |
| 7 | 8 | canJoin, |
| 8 | 9 | canLeave, |
| 9 | 10 | joinTable, |
| 10 | 11 | leaveTable, |
| 11 | 12 | listTables, |
| 13 | makeOffer, | |
| 12 | 14 | openTable, |
| 15 | passTurn, | |
| 13 | 16 | quorumFor, |
| 17 | startTable, | |
| 14 | 18 | tableState, |
| 15 | 19 | TITLE_MAX, |
| 16 | 20 | type TableView, |
| 17 | 21 | } from "./actions.ts"; |
| 18 | 22 | import { openDb } from "./db.ts"; |
| 23 | import { a1, a2, CHARTER, charges } from "./example.ts"; | |
| 24 | import { subscribe } from "./live.ts"; | |
| 19 | 25 | import type { Who } from "./who.ts"; |
| 20 | 26 | |
| 21 | 27 | const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); |
| @@ -25,6 +31,7 @@ const bob: Who = { role: "user", id: "u2", name: "bob" }; | ||
| 25 | 31 | const carol: Who = { role: "user", id: "u3", name: "carol" }; |
| 26 | 32 | const dave: Who = { role: "user", id: "u4", name: "dave" }; |
| 27 | 33 | const erin: Who = { role: "user", id: "u5", name: "erin" }; |
| 34 | const frank: Who = { role: "user", id: "u6", name: "frank" }; | |
| 28 | 35 | const root: Who = { role: "admin", id: "u0", name: "root" }; |
| 29 | 36 | |
| 30 | 37 | /** A table opened by `host`, or the test's own failure. */ |
| @@ -277,6 +284,422 @@ test("a table is read by whole-number id, and a missing one is not found", () => | ||
| 277 | 284 | expect(listTables(db, anon, { limit: "5" }).ok).toBe(true); |
| 278 | 285 | }); |
| 279 | 286 | |
| 287 | // ---------------------------------------------------------------- the turn | |
| 288 | ||
| 289 | /** A table `host` opened with `players` seated at it, still open. */ | |
| 290 | const filled = (host: Who, players: Who[], seats_max = 4): TableView => { | |
| 291 | const table = open(host, { seats_max }); | |
| 292 | for (const player of players) | |
| 293 | expect(joinTable(db, player, { id: table.id }).ok).toBe(true); | |
| 294 | return table; | |
| 295 | }; | |
| 296 | ||
| 297 | /** | |
| 298 | * A four-seat table playing RULES.md §10's board: Ash 0, Brail 1, Cleat 2, | |
| 299 | * Dunnage 3 — alice, bob, carol, dave. | |
| 300 | * | |
| 301 | * §10 says outright that its charter and charges are written by hand rather | |
| 302 | * than dealt, so the table is started through the action (which fixes the | |
| 303 | * order, the quorum and the clock) and then given that board. Every act | |
| 304 | * below is the transcript's, and the arithmetic it leans on is the | |
| 305 | * document's rather than a fourth copy of a charter. | |
| 306 | */ | |
| 307 | const table10 = (): number => { | |
| 308 | const table = filled(alice, [bob, carol, dave]); | |
| 309 | const started = startTable(db, alice, { id: table.id }); | |
| 310 | if (!started.ok) throw new Error(started.message); | |
| 311 | db.query("UPDATE game SET charter_json = ?2 WHERE id = ?1").run( | |
| 312 | table.id, | |
| 313 | JSON.stringify(CHARTER), | |
| 314 | ); | |
| 315 | for (const charge of charges) | |
| 316 | db.query( | |
| 317 | "UPDATE seat SET charge_json = ?3 WHERE game_id = ?1 AND ordinal = ?2", | |
| 318 | ).run(table.id, charge.seat, JSON.stringify(charge)); | |
| 319 | return table.id; | |
| 320 | }; | |
| 321 | ||
| 322 | /** The seat ids of a table, in the order they play. */ | |
| 323 | const order = (id: number): number[] => | |
| 324 | at(id, anon).seats.map((seat) => seat.id); | |
| 325 | ||
| 326 | test("a table is dealt and started by its host, with four to six seats, once", () => { | |
| 327 | const table = filled(alice, [bob, carol], 6); | |
| 328 | const thin = startTable(db, alice, { id: table.id }); | |
| 329 | expect(thin.ok).toBe(false); | |
| 330 | if (!thin.ok) { | |
| 331 | expect(thin.error).toBe("conflict"); | |
| 332 | expect(thin.message).toContain("3 are taken"); | |
| 333 | } | |
| 334 | expect(joinTable(db, dave, { id: table.id }).ok).toBe(true); | |
| 335 | ||
| 336 | const stranger = startTable(db, erin, { id: table.id }); | |
| 337 | expect(stranger.ok).toBe(false); | |
| 338 | if (!stranger.ok) { | |
| 339 | expect(stranger.error).toBe("forbidden"); | |
| 340 | expect(stranger.message).toContain("Only the host"); | |
| 341 | } | |
| 342 | expect(startTable(db, anon, { id: table.id }).ok).toBe(false); | |
| 343 | ||
| 344 | const started = startTable(db, alice, { id: table.id }); | |
| 345 | expect(started.ok).toBe(true); | |
| 346 | if (!started.ok) return; | |
| 347 | const state = started.value; | |
| 348 | expect(state.status).toBe("playing"); | |
| 349 | expect(state.started_at).not.toBeNull(); | |
| 350 | // Seven clauses of three settings, public from the moment they are dealt. | |
| 351 | expect(state.charter?.length).toBe(7); | |
| 352 | expect(state.charter?.every((clause) => clause.settings.length === 3)).toBe( | |
| 353 | true, | |
| 354 | ); | |
| 355 | // The quorum is for the seats that took, not for the seats that were laid. | |
| 356 | expect(state.quorum).toBe(quorumFor(4)); | |
| 357 | // The order is fixed: 0…n−1, with the gaps closed, and never moves again. | |
| 358 | expect(state.seats.map((seat) => seat.ordinal)).toEqual([0, 1, 2, 3]); | |
| 359 | expect(state.turn).toBe(state.seats[0]!.id); | |
| 360 | expect(state.your_turn).toBe(true); | |
| 361 | expect(at(table.id, bob).your_turn).toBe(false); | |
| 362 | ||
| 363 | // Every seat is dealt a charge, and no seat is dealt anyone else's. | |
| 364 | expect(seatOf(state, "alice").charge).not.toBeNull(); | |
| 365 | expect(seatOf(state, "bob").charge).toBeNull(); | |
| 366 | expect(seatOf(at(table.id, bob), "bob").charge).not.toBeNull(); | |
| 367 | // The seed names every charge, so it never leaves the action. | |
| 368 | for (const viewer of [anon, alice, root]) | |
| 369 | expect(JSON.stringify(at(table.id, viewer))).not.toContain("seed"); | |
| 370 | ||
| 371 | const again = startTable(db, alice, { id: table.id }); | |
| 372 | expect(again.ok).toBe(false); | |
| 373 | if (!again.ok) { | |
| 374 | expect(again.error).toBe("conflict"); | |
| 375 | expect(again.message).toContain("already playing"); | |
| 376 | } | |
| 377 | const missing = startTable(db, alice, { id: 9_999 }); | |
| 378 | expect(missing.ok).toBe(false); | |
| 379 | if (!missing.ok) expect(missing.error).toBe("not_found"); | |
| 380 | }); | |
| 381 | ||
| 382 | test("an admin may start a table that is not theirs", () => { | |
| 383 | const table = filled(alice, [bob, carol, dave]); | |
| 384 | expect(startTable(db, root, { id: table.id }).ok).toBe(true); | |
| 385 | }); | |
| 386 | ||
| 387 | test("the seats a table starts with are renumbered 0…n−1, gaps closed", () => { | |
| 388 | // People came and went: five seats were taken, two were given up, and one | |
| 389 | // more was taken after them, so the order on the table is 0, 2, 4, 5. | |
| 390 | const table = open(alice, { seats_max: 6 }); | |
| 391 | for (const player of [bob, carol, dave, erin]) | |
| 392 | expect(joinTable(db, player, { id: table.id }).ok).toBe(true); | |
| 393 | for (const player of [bob, dave]) | |
| 394 | expect(leaveTable(db, player, { id: table.id }).ok).toBe(true); | |
| 395 | expect(joinTable(db, frank, { id: table.id }).ok).toBe(true); | |
| 396 | expect(at(table.id, anon).seats.map((seat) => seat.ordinal)).toEqual([ | |
| 397 | 0, 2, 4, 5, | |
| 398 | ]); | |
| 399 | ||
| 400 | const started = startTable(db, alice, { id: table.id }); | |
| 401 | expect(started.ok).toBe(true); | |
| 402 | if (!started.ok) return; | |
| 403 | const state = started.value; | |
| 404 | // The gaps are closed and nobody's place changes hands: the four who | |
| 405 | // stayed keep the order they sat down in. | |
| 406 | expect(state.seats.map((seat) => seat.ordinal)).toEqual([0, 1, 2, 3]); | |
| 407 | expect(state.seats.map((seat) => seat.player_name)).toEqual([ | |
| 408 | "alice", | |
| 409 | "carol", | |
| 410 | "erin", | |
| 411 | "frank", | |
| 412 | ]); | |
| 413 | expect(state.turn).toBe(state.seats[0]!.id); | |
| 414 | ||
| 415 | // And the turn walks the survivors, because it is the act count modulo | |
| 416 | // the seat count: after two acts it is the third seat's. | |
| 417 | expect(passTurn(db, alice, { id: table.id }).ok).toBe(true); | |
| 418 | expect(passTurn(db, carol, { id: table.id }).ok).toBe(true); | |
| 419 | expect(at(table.id, anon).turn).toBe(state.seats[2]!.id); | |
| 420 | expect(at(table.id, erin).your_turn).toBe(true); | |
| 421 | }); | |
| 422 | ||
| 423 | test("a seat's acts are rationed per table, before any rule is read", () => { | |
| 424 | // `taking()` spends a token before it judges the act, so a hand faster | |
| 425 | // than a person's runs the ration down even on turns that are not its | |
| 426 | // own — and the refusal is the same named failure at every door. | |
| 427 | const id = table10(); | |
| 428 | const said = Array.from({ length: 21 }, () => { | |
| 429 | const act = passTurn(db, bob, { id }); | |
| 430 | return act.ok ? "ok" : act.error; | |
| 431 | }); | |
| 432 | expect(said.slice(0, 20)).toEqual( | |
| 433 | Array.from({ length: 20 }, () => "conflict"), | |
| 434 | ); | |
| 435 | expect(said[20]).toBe("rate_limited"); | |
| 436 | const refused = passTurn(db, bob, { id }); | |
| 437 | expect(refused.ok).toBe(false); | |
| 438 | if (!refused.ok) expect(refused.message).toContain("too fast"); | |
| 439 | // The bucket is this seat's at this table and nobody else's: the seat | |
| 440 | // whose turn it is still takes it, and bob still acts at another table. | |
| 441 | expect(at(id, alice).your_turn).toBe(true); | |
| 442 | expect(passTurn(db, alice, { id }).ok).toBe(true); | |
| 443 | const elsewhere = passTurn(db, bob, { id: table10() }); | |
| 444 | expect(elsewhere.ok).toBe(false); | |
| 445 | if (!elsewhere.ok) expect(elsewhere.error).toBe("conflict"); | |
| 446 | }); | |
| 447 | ||
| 448 | test("an accord is exactly one setting for every clause of this charter", () => { | |
| 449 | const id = table10(); | |
| 450 | const { salvage: _dropped, ...missing } = a1; | |
| 451 | for (const [accord, says] of [ | |
| 452 | [missing, "leaves The Salvage unset"], | |
| 453 | [{ ...a1, beacon: "sunk" }, "a setting it does not have"], | |
| 454 | [{ ...a1, wreck: "raised" }, "The Wreck, which is not on this charter"], | |
| 455 | [{ ...a1, nonsense: "yes" }, "not on this charter"], | |
| 456 | [{ ...a1, beacon: 3 }, "leaves The Beacon unset"], | |
| 457 | ["an accord, honest", "one setting for every clause"], | |
| 458 | [[a1], "one setting for every clause"], | |
| 459 | [null, "one setting for every clause"], | |
| 460 | ] as const) { | |
| 461 | const offered = makeOffer(db, alice, { id, accord }); | |
| 462 | expect(offered.ok).toBe(false); | |
| 463 | if (!offered.ok) { | |
| 464 | expect(offered.error).toBe("invalid"); | |
| 465 | expect(offered.message).toContain(says); | |
| 466 | } | |
| 467 | } | |
| 468 | // Nothing refused was written: the ledger is still empty and it is still | |
| 469 | // the same seat's turn. | |
| 470 | expect(at(id, anon).acts).toEqual([]); | |
| 471 | expect(at(id, alice).your_turn).toBe(true); | |
| 472 | expect(makeOffer(db, alice, { id, accord: a1 }).ok).toBe(true); | |
| 473 | // What is stored is the canonical form: this charter's clauses, in order. | |
| 474 | expect(Object.keys(at(id, anon).acts[0]!.accord as object)).toEqual( | |
| 475 | CHARTER.map((clause) => clause.id), | |
| 476 | ); | |
| 477 | }); | |
| 478 | ||
| 479 | test("the turn goes round the table, one act at a time, and §10 plays out", () => { | |
| 480 | const id = table10(); | |
| 481 | const seats = order(id); | |
| 482 | const turnAfter = (acts: number): void => { | |
| 483 | const state = at(id, anon); | |
| 484 | expect(state.acts.length).toBe(acts); | |
| 485 | expect(state.turn).toBe(seats[acts % 4]!); | |
| 486 | }; | |
| 487 | turnAfter(0); | |
| 488 | ||
| 489 | const heard: string[] = []; | |
| 490 | const stop = subscribe((event) => heard.push(event.type)); | |
| 491 | ||
| 492 | // 1. Ash offers A₁. | |
| 493 | expect(makeOffer(db, alice, { id, accord: a1 }).ok).toBe(true); | |
| 494 | turnAfter(1); | |
| 495 | expect(heard).toEqual(["act.made", "turn.changed"]); | |
| 496 | stop(); | |
| 497 | expect(at(id, anon).standing).toEqual({ | |
| 498 | accord: a1, | |
| 499 | by: seats[0]!, | |
| 500 | at: at(id, anon).acts[0]!.at, | |
| 501 | acceptances: [], | |
| 502 | }); | |
| 503 | ||
| 504 | // 2. Brail cannot accept A₁ at all — it satisfies none of his aims — so | |
| 505 | // he passes. §6: a seat with nothing to swear cannot sign. | |
| 506 | const cannot = acceptOffer(db, bob, { id }); | |
| 507 | expect(cannot.ok).toBe(false); | |
| 508 | if (!cannot.ok) { | |
| 509 | expect(cannot.error).toBe("conflict"); | |
| 510 | expect(cannot.message).toContain("satisfies none of your aims"); | |
| 511 | } | |
| 512 | expect(passTurn(db, bob, { id }).ok).toBe(true); | |
| 513 | turnAfter(2); | |
| 514 | ||
| 515 | // 3. Cleat accepts, and swears the loud truth: pilot=reinstated, the | |
| 516 | // first the charter offers. | |
| 517 | expect(acceptOffer(db, carol, { id }).ok).toBe(true); | |
| 518 | turnAfter(3); | |
| 519 | expect(at(id, anon).acts[2]!.cite).toEqual({ | |
| 520 | clause: "pilot", | |
| 521 | setting: "reinstated", | |
| 522 | }); | |
| 523 | expect(at(id, anon).standing?.acceptances).toEqual([seats[2]!]); | |
| 524 | ||
| 525 | // 4. Dunnage passes. | |
| 526 | expect(passTurn(db, dave, { id }).ok).toBe(true); | |
| 527 | turnAfter(4); | |
| 528 | ||
| 529 | // 5. Ash offers A₂ and buys Brail with her own +1. It clears Cleat's | |
| 530 | // acceptance; his sworn claim stands, in the log, forever. | |
| 531 | expect(makeOffer(db, alice, { id, accord: a2 }).ok).toBe(true); | |
| 532 | turnAfter(5); | |
| 533 | const cleared = at(id, anon); | |
| 534 | expect(cleared.standing?.accord).toEqual(a2); | |
| 535 | expect(cleared.standing?.acceptances).toEqual([]); | |
| 536 | expect(cleared.acts[2]!.cite).toEqual({ | |
| 537 | clause: "pilot", | |
| 538 | setting: "reinstated", | |
| 539 | }); | |
| 540 | ||
| 541 | // 6. Brail accepts and chooses the cheap truth of the two he could swear. | |
| 542 | expect( | |
| 543 | acceptOffer(db, bob, { id, cite: { clause: "name", setting: "changed" } }) | |
| 544 | .ok, | |
| 545 | ).toBe(true); | |
| 546 | turnAfter(6); | |
| 547 | expect(at(id, anon).acts[5]!.cite).toEqual({ | |
| 548 | clause: "name", | |
| 549 | setting: "changed", | |
| 550 | }); | |
| 551 | ||
| 552 | // 7. Cleat accepts again: pilot is sworn and no longer citable, so the | |
| 553 | // secret one is what is left. | |
| 554 | expect(acceptOffer(db, carol, { id }).ok).toBe(true); | |
| 555 | turnAfter(7); | |
| 556 | expect(at(id, anon).acts[6]!.cite).toEqual({ | |
| 557 | clause: "salvage", | |
| 558 | setting: "to_the_finders", | |
| 559 | }); | |
| 560 | ||
| 561 | // 8. Dunnage passes, holding out. | |
| 562 | expect(passTurn(db, dave, { id }).ok).toBe(true); | |
| 563 | turnAfter(8); | |
| 564 | expect(at(id, anon).standing?.acceptances).toEqual([seats[1]!, seats[2]!]); | |
| 565 | ||
| 566 | // 9. Ash accepts her own offer, swearing the cheaper of her two secrets. | |
| 567 | // Third acceptance: quorum, and A₂ binds. | |
| 568 | expect(acceptOffer(db, alice, { id }).ok).toBe(true); | |
| 569 | const bound = at(id, anon); | |
| 570 | expect(bound.acts.length).toBe(9); | |
| 571 | expect(bound.acts[8]!.cite).toEqual({ clause: "beacon", setting: "kept" }); | |
| 572 | expect(bound.standing?.acceptances).toEqual([ | |
| 573 | seats[1]!, | |
| 574 | seats[2]!, | |
| 575 | seats[0]!, | |
| 576 | ]); | |
| 577 | // Bound: there is no next turn, and no door takes another act. | |
| 578 | expect(bound.turn).toBeNull(); | |
| 579 | expect(at(id, alice).your_turn).toBe(false); | |
| 580 | const tenth = passTurn(db, bob, { id }); | |
| 581 | expect(tenth.ok).toBe(false); | |
| 582 | if (!tenth.ok) expect(tenth.message).toContain("settled"); | |
| 583 | // The whole log is public, newest last, and nobody's charge is in it. | |
| 584 | expect(bound.acts.map((act) => act.kind)).toEqual([ | |
| 585 | "offer", | |
| 586 | "pass", | |
| 587 | "accept", | |
| 588 | "pass", | |
| 589 | "offer", | |
| 590 | "accept", | |
| 591 | "accept", | |
| 592 | "pass", | |
| 593 | "accept", | |
| 594 | ]); | |
| 595 | }); | |
| 596 | ||
| 597 | test("a seat accepts the standing offer once, however far round it comes", () => { | |
| 598 | const id = table10(); | |
| 599 | const seats = order(id); | |
| 600 | expect(makeOffer(db, alice, { id, accord: a1 }).ok).toBe(true); | |
| 601 | expect(passTurn(db, bob, { id }).ok).toBe(true); | |
| 602 | expect(acceptOffer(db, carol, { id }).ok).toBe(true); | |
| 603 | expect(passTurn(db, dave, { id }).ok).toBe(true); | |
| 604 | expect(passTurn(db, alice, { id }).ok).toBe(true); | |
| 605 | expect(passTurn(db, bob, { id }).ok).toBe(true); | |
| 606 | ||
| 607 | const twice = acceptOffer(db, carol, { id }); | |
| 608 | expect(twice.ok).toBe(false); | |
| 609 | if (!twice.ok) { | |
| 610 | expect(twice.error).toBe("conflict"); | |
| 611 | expect(twice.message).toContain("already accepted"); | |
| 612 | } | |
| 613 | expect(at(id, anon).standing?.acceptances).toEqual([seats[2]!]); | |
| 614 | // Passing is always legal, so the turn is never stuck on a seat that | |
| 615 | // cannot sign. | |
| 616 | expect(passTurn(db, carol, { id }).ok).toBe(true); | |
| 617 | }); | |
| 618 | ||
| 619 | test("an acceptance is bound to the offer that stood when it was made", () => { | |
| 620 | const id = table10(); | |
| 621 | const seats = order(id); | |
| 622 | // The action will not write this: there is nothing to accept before the | |
| 623 | // first offer. It is written here to prove the derivation reads a ledger | |
| 624 | // that holds one as counting for nothing. | |
| 625 | db.query( | |
| 626 | "INSERT INTO act (game_id, seat_id, kind) VALUES (?1, ?2, 'accept')", | |
| 627 | ).run(id, seats[0]!); | |
| 628 | expect(at(id, anon).standing).toBeNull(); | |
| 629 | // One act taken, so the turn has moved on: the second seat acts. | |
| 630 | expect(at(id, bob).your_turn).toBe(true); | |
| 631 | expect(makeOffer(db, bob, { id, accord: a1 }).ok).toBe(true); | |
| 632 | expect(at(id, anon).standing?.acceptances).toEqual([]); | |
| 633 | }); | |
| 634 | ||
| 635 | test("an act is refused by name: no seat, not your turn, not playing", () => { | |
| 636 | const id = table10(); | |
| 637 | const homeless = makeOffer(db, erin, { id, accord: a1 }); | |
| 638 | expect(homeless.ok).toBe(false); | |
| 639 | if (!homeless.ok) { | |
| 640 | expect(homeless.error).toBe("forbidden"); | |
| 641 | expect(homeless.message).toContain("hold no seat"); | |
| 642 | } | |
| 643 | const early = passTurn(db, bob, { id }); | |
| 644 | expect(early.ok).toBe(false); | |
| 645 | if (!early.ok) { | |
| 646 | expect(early.error).toBe("conflict"); | |
| 647 | expect(early.message).toBe("It is not your turn."); | |
| 648 | } | |
| 649 | const nothing = acceptOffer(db, alice, { id }); | |
| 650 | expect(nothing.ok).toBe(false); | |
| 651 | if (!nothing.ok) expect(nothing.message).toContain("nothing to accept"); | |
| 652 | ||
| 653 | expect(makeOffer(db, anon, { id, accord: a1 }).ok).toBe(false); | |
| 654 | const gone = passTurn(db, alice, { id: 9_999 }); | |
| 655 | expect(gone.ok).toBe(false); | |
| 656 | if (!gone.ok) expect(gone.error).toBe("not_found"); | |
| 657 | ||
| 658 | // A table nobody has started is not a table anyone can act at. | |
| 659 | const waiting = filled(alice, [bob, carol, dave]); | |
| 660 | const soon = passTurn(db, alice, { id: waiting.id }); | |
| 661 | expect(soon.ok).toBe(false); | |
| 662 | if (!soon.ok) { | |
| 663 | expect(soon.error).toBe("conflict"); | |
| 664 | expect(soon.message).toContain("has not started"); | |
| 665 | } | |
| 666 | expect(at(waiting.id, alice).turn).toBeNull(); | |
| 667 | expect(at(waiting.id, alice).your_turn).toBe(false); | |
| 668 | }); | |
| 669 | ||
| 670 | test("a citation is the seat's choice among what §6 allows, and nothing else", () => { | |
| 671 | const id = table10(); | |
| 672 | expect(makeOffer(db, alice, { id, accord: a1 }).ok).toBe(true); | |
| 673 | expect(passTurn(db, bob, { id }).ok).toBe(true); | |
| 674 | // Cleat may swear pilot=reinstated or salvage=to_the_finders, and nothing | |
| 675 | // else: not another seat's aim, not an unsatisfied one of his own, not a | |
| 676 | // clause this accord does not set that way. | |
| 677 | for (const cite of [ | |
| 678 | { clause: "chart", setting: "shared" }, // his, satisfied by no accord here | |
| 679 | { clause: "ledger", setting: "published" }, // Brail's, and not in A₁ | |
| 680 | { clause: "pilot", setting: "paid_off" }, // the right clause, the wrong setting | |
| 681 | { clause: "pilot" }, // not a citation at all | |
| 682 | "pilot=reinstated", | |
| 683 | ]) { | |
| 684 | const wrong = acceptOffer(db, carol, { id, cite }); | |
| 685 | expect(wrong.ok).toBe(false); | |
| 686 | if (!wrong.ok) { | |
| 687 | expect(wrong.error).toBe("invalid"); | |
| 688 | expect(wrong.message).toBe("That is not an aim this accord lets you swear."); | |
| 689 | } | |
| 690 | } | |
| 691 | expect( | |
| 692 | acceptOffer(db, carol, { | |
| 693 | id, | |
| 694 | cite: { clause: "salvage", setting: "to_the_finders" }, | |
| 695 | }).ok, | |
| 696 | ).toBe(true); | |
| 697 | expect(at(id, anon).acts[2]!.cite).toEqual({ | |
| 698 | clause: "salvage", | |
| 699 | setting: "to_the_finders", | |
| 700 | }); | |
| 701 | }); | |
| 702 | ||
| 280 | 703 | test("the schema keeps the promises the actions rest on", () => { |
| 281 | 704 | const table = open(alice, { seats_max: 4 }); |
| 282 | 705 | const seats = (db as Database) |
src/actions.ts +391 −23
| @@ -17,8 +17,28 @@ | ||
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | 19 | import type { Database } from "bun:sqlite"; |
| 20 | import { randomUUID } from "node:crypto"; | |
| 21 | import { | |
| 22 | type Accord, | |
| 23 | type Charge, | |
| 24 | type Cite, | |
| 25 | type Clause, | |
| 26 | deal, | |
| 27 | validAccord, | |
| 28 | } from "./board.ts"; | |
| 29 | import { allow } from "./http.ts"; | |
| 20 | 30 | import { emit } from "./live.ts"; |
| 21 | 31 | import { fail, type Failure, ok, type Result } from "./result.ts"; |
| 32 | import { | |
| 33 | type ActKind, | |
| 34 | mayAct, | |
| 35 | type MayAct, | |
| 36 | quorum, | |
| 37 | type Round, | |
| 38 | standing as whatStands, | |
| 39 | type Act as Turn, | |
| 40 | whoseTurn, | |
| 41 | } from "./round.ts"; | |
| 22 | 42 | import type { Who } from "./who.ts"; |
| 23 | 43 | |
| 24 | 44 | export type { Failure, Result }; |
| @@ -33,6 +53,7 @@ interface GameRow { | ||
| 33 | 53 | readonly host_id: string; |
| 34 | 54 | readonly host_name: string; |
| 35 | 55 | readonly seed: string | null; |
| 56 | readonly charter_json: string | null; | |
| 36 | 57 | readonly seats_max: number; |
| 37 | 58 | readonly quorum: number; |
| 38 | 59 | readonly turn_seconds: number; |
| @@ -60,6 +81,7 @@ interface ActRow { | ||
| 60 | 81 | readonly seat_id: number | null; |
| 61 | 82 | readonly kind: string; |
| 62 | 83 | readonly accord_json: string | null; |
| 84 | readonly cite_json: string | null; | |
| 63 | 85 | readonly at: string; |
| 64 | 86 | } |
| 65 | 87 | |
| @@ -80,6 +102,8 @@ export interface Act { | ||
| 80 | 102 | readonly seat_id: number | null; |
| 81 | 103 | readonly kind: string; |
| 82 | 104 | readonly accord: unknown | null; |
| 105 | /** What an acceptance swore (§6). Public forever, and never cleared. */ | |
| 106 | readonly cite: Cite | null; | |
| 83 | 107 | readonly at: string; |
| 84 | 108 | } |
| 85 | 109 | |
| @@ -103,10 +127,31 @@ export interface Table { | ||
| 103 | 127 | readonly your_seat: number | null; |
| 104 | 128 | } |
| 105 | 129 | |
| 130 | /** The offer that stands, and who has signed it. A new offer replaces the whole record. */ | |
| 131 | export interface Standing { | |
| 132 | /** The accord, canonical for this charter. */ | |
| 133 | readonly accord: Accord; | |
| 134 | /** The seat that offered it, by seat id. */ | |
| 135 | readonly by: number; | |
| 136 | readonly at: string; | |
| 137 | /** The seats that have accepted *this* offer, oldest first, by seat id. */ | |
| 138 | readonly acceptances: readonly number[]; | |
| 139 | } | |
| 140 | ||
| 106 | 141 | /** A table with everything a player reads to take a turn. */ |
| 107 | 142 | export interface TableView extends Table { |
| 108 | /** The public clauses being argued over, dealt from the seed when play starts (#8). */ | |
| 109 | readonly charter: unknown | null; | |
| 143 | /** The public clauses being argued over, dealt from the seed when play starts. */ | |
| 144 | readonly charter: readonly Clause[] | null; | |
| 145 | /** | |
| 146 | * The seat to act, by seat id — the same id `your_seat` carries, so | |
| 147 | * `turn === your_seat` is exactly `your_turn`. Null before the table starts | |
| 148 | * and once it has ended. | |
| 149 | */ | |
| 150 | readonly turn: number | null; | |
| 151 | readonly your_turn: boolean; | |
| 152 | /** The latest offer with its acceptances, or null while nothing stands. */ | |
| 153 | readonly standing: Standing | null; | |
| 154 | /** The whole public log, newest last. */ | |
| 110 | 155 | readonly acts: readonly Act[]; |
| 111 | 156 | } |
| 112 | 157 | |
| @@ -132,9 +177,10 @@ const LIST_LIMIT = { min: 1, max: 100, default: 50 } as const; | ||
| 132 | 177 | /** |
| 133 | 178 | * How many acceptances bind an accord: ceil(n/2)+1 — 3 of 4, 4 of 5, 4 of 6 |
| 134 | 179 | * (RULES.md §2). It follows from the seat count, so no host chooses it; the |
| 135 | * column stores what the rule says for the table's size. | |
| 180 | * column stores what the rule says for the table's size. The rule itself is | |
| 181 | * `src/round.ts`'s, which is where the binding reads it. | |
| 136 | 182 | */ |
| 137 | export const quorumFor = (seats: number): number => Math.ceil(seats / 2) + 1; | |
| 183 | export { quorum as quorumFor } from "./round.ts"; | |
| 138 | 184 | |
| 139 | 185 | // ---------------------------------------------------------------- reading |
| 140 | 186 | |
| @@ -210,7 +256,8 @@ export function openTable( | ||
| 210 | 256 | if (deadline_seconds < 2 * turn_seconds) |
| 211 | 257 | return fail("invalid", "A deadline is at least two turn windows long."); |
| 212 | 258 | // Not the host's to choose: the rule says what a quorum is for this size. |
| 213 | const quorum = quorumFor(seats_max); | |
| 259 | // It is set again when play starts, for the seats that actually took. | |
| 260 | const seatsQuorum = quorum(seats_max); | |
| 214 | 261 | |
| 215 | 262 | // Opening a table is sitting down at it: the host takes the first seat in |
| 216 | 263 | // the same transaction, so a table is never open with nobody at it. |
| @@ -225,7 +272,7 @@ export function openTable( | ||
| 225 | 272 | who.id, |
| 226 | 273 | who.name, |
| 227 | 274 | seats_max, |
| 228 | quorum, | |
| 275 | seatsQuorum, | |
| 229 | 276 | turn_seconds, |
| 230 | 277 | deadline_seconds, |
| 231 | 278 | )!; |
| @@ -289,6 +336,249 @@ export function leaveTable( | ||
| 289 | 336 | return ok(state); |
| 290 | 337 | } |
| 291 | 338 | |
| 339 | // ---------------------------------------------------------------- the turn | |
| 340 | ||
| 341 | /** | |
| 342 | * Deal the table and start the clock: the host's one irreversible act. | |
| 343 | * | |
| 344 | * The order of the seats is fixed here — ordinals may have left gaps while | |
| 345 | * people came and went, and from now on they are 0…n−1 and never move again, | |
| 346 | * because the turn is the act count modulo the seat count and nothing else. | |
| 347 | * The seed is made here and never shown: it names every seat's charge, so it | |
| 348 | * is as secret as the charges are, and the charter it deals is stored beside | |
| 349 | * it because the charter is public and the seed is not. | |
| 350 | */ | |
| 351 | export function startTable( | |
| 352 | db: Database, | |
| 353 | who: Who, | |
| 354 | input: { id: unknown }, | |
| 355 | ): Result<TableView> { | |
| 356 | if (who.role === "anon") return fail("signin_required", "Sign in to play."); | |
| 357 | const game = find(db, input.id); | |
| 358 | if (game === undefined) return fail("not_found", "No such table."); | |
| 359 | if (who.role !== "admin" && game.host_id !== who.id) | |
| 360 | return fail("forbidden", "Only the host can start this table."); | |
| 361 | if (game.status !== "open") | |
| 362 | return fail( | |
| 363 | "conflict", | |
| 364 | game.status === "playing" | |
| 365 | ? "This table is already playing." | |
| 366 | : "This table is over.", | |
| 367 | ); | |
| 368 | const seats = seatRows(db, game.id); | |
| 369 | if (seats.length < SEATS.min || seats.length > SEATS.max) | |
| 370 | return fail( | |
| 371 | "conflict", | |
| 372 | `A table starts with ${SEATS.min} to ${SEATS.max} seats; ${seats.length} ${seats.length === 1 ? "is" : "are"} taken.`, | |
| 373 | ); | |
| 374 | // The deal is `src/board.ts`'s and its refusals are its own. | |
| 375 | const seed = randomUUID(); | |
| 376 | const board = deal(seed, seats.length); | |
| 377 | if (!board.ok) return fail(board.error, board.message); | |
| 378 | ||
| 379 | const started = db.transaction(() => { | |
| 380 | // Two passes: every seat parks on a number no seat can hold, and then | |
| 381 | // takes its place. A seat moving into a place another seat is leaving | |
| 382 | // would break `UNIQUE (game_id, ordinal)` on the way past — which | |
| 383 | // `seatRows` makes unreachable by handing them over in ascending order, | |
| 384 | // so the target of each move is already free. That is why no test can | |
| 385 | // make this pass matter: it is what keeps the renumbering independent | |
| 386 | // of the order the seats arrive in, and the order is one `ORDER BY` | |
| 387 | // away from changing. `startTable` is the host's one irreversible act | |
| 388 | // and a throw here is a 500 in the middle of it. | |
| 389 | seats.forEach((seat, i) => | |
| 390 | db | |
| 391 | .query("UPDATE seat SET ordinal = ?2 WHERE id = ?1") | |
| 392 | .run(seat.id, -(i + 1)), | |
| 393 | ); | |
| 394 | seats.forEach((seat, i) => | |
| 395 | db | |
| 396 | .query("UPDATE seat SET ordinal = ?2, charge_json = ?3 WHERE id = ?1") | |
| 397 | .run(seat.id, i, JSON.stringify(board.value.charges[i])), | |
| 398 | ); | |
| 399 | return db | |
| 400 | .query<GameRow, [number, string, string, number]>( | |
| 401 | `UPDATE game SET status = 'playing', seed = ?2, charter_json = ?3, quorum = ?4, | |
| 402 | started_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1 RETURNING *`, | |
| 403 | ) | |
| 404 | .get( | |
| 405 | game.id, | |
| 406 | seed, | |
| 407 | JSON.stringify(board.value.clauses), | |
| 408 | quorum(seats.length), | |
| 409 | )!; | |
| 410 | })(); | |
| 411 | ||
| 412 | const state = view(db, started, who); | |
| 413 | emit("table.started", { table: started.id }); | |
| 414 | emit("turn.changed", { table: started.id }); | |
| 415 | return ok(state); | |
| 416 | } | |
| 417 | ||
| 418 | /** | |
| 419 | * Offer an accord (§5). It becomes the standing offer and clears every | |
| 420 | * acceptance of the last one, including the offerer's own — which costs | |
| 421 | * nothing to do here, because acceptances are not stored: they are the | |
| 422 | * `accept` acts after the latest `offer`, and this row is now the latest. | |
| 423 | */ | |
| 424 | export function makeOffer( | |
| 425 | db: Database, | |
| 426 | who: Who, | |
| 427 | input: { id: unknown; accord: unknown }, | |
| 428 | ): Result<TableView> { | |
| 429 | const turn = taking(db, who, input.id); | |
| 430 | if (!turn.ok) return turn; | |
| 431 | const { game, seat, may, round } = turn.value; | |
| 432 | if (!may.offer.may) return fail("conflict", may.offer.refusal!); | |
| 433 | // The boundary: an accord is one setting for every clause of *this* | |
| 434 | // table's charter, and what is written down is the canonical form of it. | |
| 435 | const accord = validAccord(round.charter, input.accord); | |
| 436 | if (!accord.ok) return fail(accord.error, accord.message); | |
| 437 | return written(db, game, seat, who, "offer", accord.value, null); | |
| 438 | } | |
| 439 | ||
| 440 | /** | |
| 441 | * Accept the standing offer (§5), which costs a truth (§6): a positive aim | |
| 442 | * of your own that this accord satisfies and that you have not sworn before. | |
| 443 | * `cite` names which — the choice is a move — and naming none swears the | |
| 444 | * first the charter offers. A seat accepts an offer once; the offerer may | |
| 445 | * spend a later turn of their own accepting their own. | |
| 446 | */ | |
| 447 | export function acceptOffer( | |
| 448 | db: Database, | |
| 449 | who: Who, | |
| 450 | input: { id: unknown; cite?: unknown }, | |
| 451 | ): Result<TableView> { | |
| 452 | const turn = taking(db, who, input.id); | |
| 453 | if (!turn.ok) return turn; | |
| 454 | const { game, seat, may } = turn.value; | |
| 455 | if (!may.accept.may) return fail("conflict", may.accept.refusal!); | |
| 456 | // `may.cite` is §6's own list, in charter order, and is never empty here. | |
| 457 | // Unsaid is the first of it; said is matched against it, and a citation | |
| 458 | // this module cannot read is not a citation. | |
| 459 | const asked = input.cite ?? undefined; | |
| 460 | const named = cited(asked); | |
| 461 | const swear = | |
| 462 | asked === undefined | |
| 463 | ? may.cite[0]! | |
| 464 | : named === undefined | |
| 465 | ? undefined | |
| 466 | : may.cite.find( | |
| 467 | (aim) => | |
| 468 | aim.clause === named.clause && aim.setting === named.setting, | |
| 469 | ); | |
| 470 | // Nothing about any other seat, and nothing about an aim the caller does | |
| 471 | // not hold: a refusal never leaks (§6). | |
| 472 | if (swear === undefined) | |
| 473 | return fail("invalid", "That is not an aim this accord lets you swear."); | |
| 474 | return written(db, game, seat, who, "accept", null, { | |
| 475 | clause: swear.clause, | |
| 476 | setting: swear.setting, | |
| 477 | }); | |
| 478 | } | |
| 479 | ||
| 480 | /** Pass (§5). Always legal on your own turn, and says nothing. */ | |
| 481 | export function passTurn( | |
| 482 | db: Database, | |
| 483 | who: Who, | |
| 484 | input: { id: unknown }, | |
| 485 | ): Result<TableView> { | |
| 486 | const turn = taking(db, who, input.id); | |
| 487 | if (!turn.ok) return turn; | |
| 488 | const { game, seat, may } = turn.value; | |
| 489 | if (!may.pass.may) return fail("conflict", may.pass.refusal!); | |
| 490 | return written(db, game, seat, who, "pass", null, null); | |
| 491 | } | |
| 492 | ||
| 493 | /** Everything the three acts share, gathered once before any of them writes. */ | |
| 494 | interface Taking { | |
| 495 | readonly game: GameRow; | |
| 496 | readonly seat: SeatRow; | |
| 497 | readonly round: Round; | |
| 498 | /** What §5 and §6 allow this seat right now. The only rule this module reads. */ | |
| 499 | readonly may: MayAct; | |
| 500 | } | |
| 501 | ||
| 502 | /** | |
| 503 | * The boundary every act passes: signed in, a table, a table being played, a | |
| 504 | * seat of your own, a rate you could have taken by hand, and a charge to | |
| 505 | * play it with. What happens next is `src/round.ts`'s to say, and `may` | |
| 506 | * carries its answer and its words. | |
| 507 | */ | |
| 508 | function taking(db: Database, who: Who, id: unknown): Result<Taking> { | |
| 509 | if (who.role === "anon") return fail("signin_required", "Sign in to play."); | |
| 510 | const game = find(db, id); | |
| 511 | if (game === undefined) return fail("not_found", "No such table."); | |
| 512 | if (game.status !== "playing") | |
| 513 | return fail( | |
| 514 | "conflict", | |
| 515 | game.status === "open" | |
| 516 | ? "This table has not started." | |
| 517 | : game.status === "settled" | |
| 518 | ? "This table has settled." | |
| 519 | : "This table was impounded.", | |
| 520 | ); | |
| 521 | const seats = seatRows(db, game.id); | |
| 522 | const seat = seats.find((one) => one.player_id === who.id); | |
| 523 | if (seat === undefined) | |
| 524 | return fail("forbidden", "You hold no seat at this table."); | |
| 525 | // Rationed per seat per table, here rather than at one door: the terminal | |
| 526 | // and the MCP server do not pass through the server's own bucket, and an | |
| 527 | // agent on its turn should not be able to spend it faster than it thought. | |
| 528 | if (!allow(`act:${game.id}:${who.id}`)) | |
| 529 | return fail("rate_limited", "That was too fast. Take your turn again in a moment."); | |
| 530 | const held = parse(seat.charge_json) as Charge | null; | |
| 531 | if (held === null || !Array.isArray(held.aims)) | |
| 532 | return fail("conflict", "This table has not been dealt."); | |
| 533 | // The seat's own charge, under its own ordinal: §6 reads nobody else's. | |
| 534 | const charge: Charge = { seat: seat.ordinal, aims: held.aims }; | |
| 535 | const round = roundOf(game, seats, actRows(db, game.id)); | |
| 536 | return ok({ | |
| 537 | game, | |
| 538 | seat, | |
| 539 | round, | |
| 540 | may: mayAct(round, seat.ordinal, charge), | |
| 541 | }); | |
| 542 | } | |
| 543 | ||
| 544 | /** One act, one row, and the two things every open page is told about it. */ | |
| 545 | function written( | |
| 546 | db: Database, | |
| 547 | game: GameRow, | |
| 548 | seat: SeatRow, | |
| 549 | who: Who, | |
| 550 | kind: ActKind, | |
| 551 | accord: Accord | null, | |
| 552 | cite: Cite | null, | |
| 553 | ): Result<TableView> { | |
| 554 | db.query<ActRow, [number, number, string, string | null, string | null]>( | |
| 555 | `INSERT INTO act (game_id, seat_id, kind, accord_json, cite_json) | |
| 556 | VALUES (?1, ?2, ?3, ?4, ?5)`, | |
| 557 | ).run( | |
| 558 | game.id, | |
| 559 | seat.id, | |
| 560 | kind, | |
| 561 | accord === null ? null : JSON.stringify(accord), | |
| 562 | cite === null ? null : JSON.stringify(cite), | |
| 563 | ); | |
| 564 | const state = view(db, game, who); | |
| 565 | // Both events name the table and the seat and say nothing else: what the | |
| 566 | // act was, and whose turn it is now, are the viewer's to refetch — and the | |
| 567 | // turn is nobody's once an accord binds, which is not an address at all. | |
| 568 | emit("act.made", { table: game.id, seat: seat.id }); | |
| 569 | emit("turn.changed", { table: game.id }); | |
| 570 | return ok(state); | |
| 571 | } | |
| 572 | ||
| 573 | /** A citation off the wire: a clause and a setting, or nothing we can read. */ | |
| 574 | const cited = (value: unknown): Cite | undefined => { | |
| 575 | if (typeof value !== "object" || value === null) return undefined; | |
| 576 | const { clause, setting } = value as Record<string, unknown>; | |
| 577 | if (typeof clause !== "string" || typeof setting !== "string") | |
| 578 | return undefined; | |
| 579 | return { clause, setting }; | |
| 580 | }; | |
| 581 | ||
| 292 | 582 | // -------------------------------------------------------------- the rules |
| 293 | 583 | // The page draws its buttons from these, so "can I?" is answered once and |
| 294 | 584 | // the action's refusal and the button's absence can never disagree. |
| @@ -344,28 +634,101 @@ function table(game: GameRow, seats: readonly SeatRow[], who: Who): Table { | ||
| 344 | 634 | }; |
| 345 | 635 | } |
| 346 | 636 | |
| 347 | /** The whole table as `who` may see it: seats, the act log, and the charter. */ | |
| 637 | /** | |
| 638 | * The whole table as `who` may see it: seats, the act log, the charter, and | |
| 639 | * everything the turn is — whose it is, what stands and who has signed it. | |
| 640 | * | |
| 641 | * Nothing of that is stored: `src/round.ts` derives it from the acts, and | |
| 642 | * this function only puts the answers in the vocabulary a door speaks (seat | |
| 643 | * ids, not the ordinals the rules count in). | |
| 644 | */ | |
| 348 | 645 | function view(db: Database, game: GameRow, who: Who): TableView { |
| 646 | const seats = seatRows(db, game.id); | |
| 647 | const acts = actRows(db, game.id); | |
| 648 | const seen = table(game, seats, who); | |
| 649 | const round = roundOf(game, seats, acts); | |
| 650 | const idOf = new Map(seats.map((seat) => [seat.ordinal, seat.id])); | |
| 651 | // A turn belongs to a table being played: an open table has an order but | |
| 652 | // nobody to act, which is the status column's word and not the ledger's. | |
| 653 | const playing = game.status === "playing"; | |
| 654 | const ordinal = playing ? whoseTurn(round) : null; | |
| 655 | const turn = ordinal === null ? null : (idOf.get(ordinal) ?? null); | |
| 656 | const stands = playing ? whatStands(round) : null; | |
| 349 | 657 | return { |
| 350 | ...table(game, seatRows(db, game.id), who), | |
| 351 | // Dealt from the seed when play starts; until then there is nothing to | |
| 352 | // argue over yet (#8 fills this in through src/board.ts). | |
| 353 | charter: null, | |
| 354 | acts: db | |
| 355 | .query<ActRow, [number]>( | |
| 356 | "SELECT * FROM act WHERE game_id = ?1 ORDER BY id", | |
| 357 | ) | |
| 358 | .all(game.id) | |
| 359 | .map((act) => ({ | |
| 360 | id: act.id, | |
| 361 | seat_id: act.seat_id, | |
| 362 | kind: act.kind, | |
| 363 | accord: parse(act.accord_json), | |
| 364 | at: act.at, | |
| 365 | })), | |
| 658 | ...seen, | |
| 659 | charter: (parse(game.charter_json) as Clause[] | null) ?? null, | |
| 660 | turn, | |
| 661 | your_turn: turn !== null && turn === seen.your_seat, | |
| 662 | standing: | |
| 663 | stands === null | |
| 664 | ? null | |
| 665 | : { | |
| 666 | accord: stands.accord, | |
| 667 | by: idOf.get(stands.by)!, | |
| 668 | at: stands.at, | |
| 669 | acceptances: stands.accepted.map((one) => idOf.get(one.seat)!), | |
| 670 | }, | |
| 671 | acts: acts.map((act) => ({ | |
| 672 | id: act.id, | |
| 673 | seat_id: act.seat_id, | |
| 674 | kind: act.kind, | |
| 675 | accord: parse(act.accord_json), | |
| 676 | cite: (parse(act.cite_json) as Cite | null) ?? null, | |
| 677 | at: act.at, | |
| 678 | })), | |
| 366 | 679 | }; |
| 367 | 680 | } |
| 368 | 681 | |
| 682 | /** | |
| 683 | * The table's public facts in the shape the rules read (`src/round.ts`): the | |
| 684 | * charter, the seats as ordinals, and the acts that were turns. | |
| 685 | * | |
| 686 | * A row the rules do not read is left out rather than guessed at — an act | |
| 687 | * with no seat is the table's own and is not a turn (§5), and the deadline | |
| 688 | * exists only once the clock started. Neither is stored twice: the deadline | |
| 689 | * is `started_at` plus the table's own window, in seconds, as it always was. | |
| 690 | */ | |
| 691 | function roundOf( | |
| 692 | game: GameRow, | |
| 693 | seats: readonly SeatRow[], | |
| 694 | acts: readonly ActRow[], | |
| 695 | now = Date.now(), | |
| 696 | ): Round { | |
| 697 | const ordinal = new Map(seats.map((seat) => [seat.id, seat.ordinal])); | |
| 698 | const deadline = | |
| 699 | game.started_at === null | |
| 700 | ? undefined | |
| 701 | : new Date( | |
| 702 | Date.parse(game.started_at) + game.deadline_seconds * 1000, | |
| 703 | ).toISOString(); | |
| 704 | return { | |
| 705 | charter: (parse(game.charter_json) as Clause[] | null) ?? [], | |
| 706 | seats: seats.map((seat) => ({ seat: seat.ordinal })), | |
| 707 | acts: acts.flatMap((act): Turn[] => { | |
| 708 | const seat = act.seat_id === null ? undefined : ordinal.get(act.seat_id); | |
| 709 | if (seat === undefined || !TURNS.has(act.kind)) return []; | |
| 710 | return [ | |
| 711 | { | |
| 712 | seat, | |
| 713 | kind: act.kind as ActKind, | |
| 714 | ...(act.accord_json === null | |
| 715 | ? {} | |
| 716 | : { accord: parse(act.accord_json) as Accord }), | |
| 717 | ...(act.cite_json === null | |
| 718 | ? {} | |
| 719 | : { cite: parse(act.cite_json) as Cite }), | |
| 720 | at: act.at, | |
| 721 | }, | |
| 722 | ]; | |
| 723 | }), | |
| 724 | ...(deadline === undefined ? {} : { deadline }), | |
| 725 | now: new Date(now).toISOString(), | |
| 726 | }; | |
| 727 | } | |
| 728 | ||
| 729 | /** The three things a turn can be (§5). Anything else in the log is not one. */ | |
| 730 | const TURNS: ReadonlySet<string> = new Set<ActKind>(["offer", "accept", "pass"]); | |
| 731 | ||
| 369 | 732 | // ------------------------------------------------------------ the plumbing |
| 370 | 733 | |
| 371 | 734 | const find = (db: Database, id: unknown): GameRow | undefined => { |
| @@ -380,6 +743,11 @@ const find = (db: Database, id: unknown): GameRow | undefined => { | ||
| 380 | 743 | ); |
| 381 | 744 | }; |
| 382 | 745 | |
| 746 | const actRows = (db: Database, gameId: number): ActRow[] => | |
| 747 | db | |
| 748 | .query<ActRow, [number]>("SELECT * FROM act WHERE game_id = ?1 ORDER BY id") | |
| 749 | .all(gameId); | |
| 750 | ||
| 383 | 751 | const seatRows = (db: Database, gameId: number): SeatRow[] => |
| 384 | 752 | db |
| 385 | 753 | .query<SeatRow, [number]>( |
src/db.ts +11
| @@ -80,6 +80,17 @@ export const migrations: string[] = [ | ||
| 80 | 80 | |
| 81 | 81 | // The demo the app was born from. Its rows were never anyone's game. |
| 82 | 82 | `DROP TABLE note`, |
| 83 | ||
| 84 | // What the table is arguing over, dealt from the seed when play starts and | |
| 85 | // public from that moment: the 7 clauses, as JSON, in the order they were | |
| 86 | // dealt. The seed beside it stays secret — it names every seat's charge — | |
| 87 | // so the charter is stored rather than re-dealt on every read. | |
| 88 | `ALTER TABLE game ADD COLUMN charter_json TEXT`, | |
| 89 | ||
| 90 | // The truth an acceptance paid with (RULES.md §6): a clause and a setting, | |
| 91 | // verified against the accepting seat's charge before the row was written, | |
| 92 | // and public forever after. Null on an offer and on a pass. | |
| 93 | `ALTER TABLE act ADD COLUMN cite_json TEXT`, | |
| 83 | 94 | ]; |
| 84 | 95 | |
| 85 | 96 | export function openDb(dir = dataDir): Database { |
src/page.ts +1 −1
| @@ -38,7 +38,7 @@ export function home(input: { | ||
| 38 | 38 | body: `<section class="card">${opener} |
| 39 | 39 | <p class="muted teach">Never played? <a href="/rules">The game in two minutes</a> — the charter, your charge, and what a signature costs.</p> |
| 40 | 40 | </section> |
| 41 | <section id="tables" data-live="table.opened seat.joined seat.left">${list}</section>`, | |
| 41 | <section id="tables" data-live="table.opened seat.joined seat.left table.started">${list}</section>`, | |
| 42 | 42 | }); |
| 43 | 43 | } |
| 44 | 44 |
src/result.ts +6 −1
| @@ -15,7 +15,12 @@ export type Failure = | ||
| 15 | 15 | * to take it: a seat at a full table, a second seat at the same one, a |
| 16 | 16 | * table that is already playing. |
| 17 | 17 | */ |
| 18 | | "conflict"; | |
| 18 | | "conflict" | |
| 19 | /** | |
| 20 | * The caller is writing faster than a person could. Every door rations, so | |
| 21 | * the refusal is a named result rather than one server's answer. | |
| 22 | */ | |
| 23 | | "rate_limited"; | |
| 19 | 24 | |
| 20 | 25 | export type Result<T> = |
| 21 | 26 | | { ok: true; value: T } |
src/routes.ts +46 −2
| @@ -6,13 +6,17 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { |
| 9 | acceptOffer, | |
| 10 | type Failure, | |
| 9 | 11 | joinTable, |
| 10 | 12 | leaveTable, |
| 11 | 13 | listTables, |
| 14 | makeOffer, | |
| 12 | 15 | openTable, |
| 13 | tableState, | |
| 14 | type Failure, | |
| 16 | passTurn, | |
| 15 | 17 | type Result, |
| 18 | startTable, | |
| 19 | tableState, | |
| 16 | 20 | } from "./actions.ts"; |
| 17 | 21 | import { |
| 18 | 22 | back, |
| @@ -54,6 +58,7 @@ const STATUS: Record<Failure, number> = { | ||
| 54 | 58 | not_found: 404, |
| 55 | 59 | invalid: 422, |
| 56 | 60 | conflict: 409, |
| 61 | rate_limited: 429, | |
| 57 | 62 | }; |
| 58 | 63 | |
| 59 | 64 | /** A result as JSON: the value, or `{ error, message }` with the matching status. */ |
| @@ -240,6 +245,45 @@ export const routes: readonly Route[] = [ | ||
| 240 | 245 | handle: (c) => answer(leaveTable(c.db, c.who, { id: c.params.id })), |
| 241 | 246 | }, |
| 242 | 247 | |
| 248 | // The turn: one act per call, and the status follows the failure the | |
| 249 | // action named — 409 for a turn that is not yours, 422 for an accord that | |
| 250 | // is not this charter's, 429 for a hand that is too fast. | |
| 251 | { | |
| 252 | method: "POST", | |
| 253 | path: "/api/tables/:id/start", | |
| 254 | handle: (c) => answer(startTable(c.db, c.who, { id: c.params.id })), | |
| 255 | }, | |
| 256 | { | |
| 257 | method: "POST", | |
| 258 | path: "/api/tables/:id/offer", | |
| 259 | handle: async (c) => { | |
| 260 | const body = await readJson(c.request); | |
| 261 | if (body === undefined) return json({ error: "too_large" }, 413); | |
| 262 | return answer( | |
| 263 | makeOffer(c.db, c.who, { | |
| 264 | id: c.params.id, | |
| 265 | accord: fields(body).accord, | |
| 266 | }), | |
| 267 | ); | |
| 268 | }, | |
| 269 | }, | |
| 270 | { | |
| 271 | method: "POST", | |
| 272 | path: "/api/tables/:id/accept", | |
| 273 | handle: async (c) => { | |
| 274 | const body = await readJson(c.request); | |
| 275 | if (body === undefined) return json({ error: "too_large" }, 413); | |
| 276 | return answer( | |
| 277 | acceptOffer(c.db, c.who, { id: c.params.id, cite: fields(body).cite }), | |
| 278 | ); | |
| 279 | }, | |
| 280 | }, | |
| 281 | { | |
| 282 | method: "POST", | |
| 283 | path: "/api/tables/:id/pass", | |
| 284 | handle: (c) => answer(passTurn(c.db, c.who, { id: c.params.id })), | |
| 285 | }, | |
| 286 | ||
| 243 | 287 | // Live events, and the MCP server (the tools are the actions too). |
| 244 | 288 | { method: "GET", path: "/events", handle: (c) => events(c.request) }, |
| 245 | 289 | { |
src/server.test.ts +182 −4
| @@ -15,7 +15,7 @@ import { | ||
| 15 | 15 | } from "./board.ts"; |
| 16 | 16 | import { DEADLINE_SECONDS, TURN_SECONDS } from "./actions.ts"; |
| 17 | 17 | import { a2, cleat } from "./example.ts"; |
| 18 | import { text } from "./http.ts"; | |
| 18 | import { allow, text } from "./http.ts"; | |
| 19 | 19 | import { ID_MAX, type LiveEvent, notAnAddress, subscribe } from "./live.ts"; |
| 20 | 20 | import { layout } from "./pages/layout.ts"; |
| 21 | 21 | import { quorum } from "./round.ts"; |
| @@ -43,6 +43,9 @@ const as = ( | ||
| 43 | 43 | }; |
| 44 | 44 | const alice = as("u1", "alice"); |
| 45 | 45 | const bob = as("u2", "bob"); |
| 46 | const carol = as("u3", "carol"); | |
| 47 | const dave = as("u4", "dave"); | |
| 48 | const erin = as("u5", "erin"); | |
| 46 | 49 | const root = as("u0", "root", "admin"); |
| 47 | 50 | const jsonBody = (body: unknown): RequestInit => ({ |
| 48 | 51 | headers: { "content-type": "application/json" }, |
| @@ -321,6 +324,161 @@ test("the JSON API is the same actions with the same identity", async () => { | ||
| 321 | 324 | expect((await api("/api/tables?limit=0", {}, alice)).status).toBe(422); |
| 322 | 325 | }); |
| 323 | 326 | |
| 327 | /** One table's state, as whoever is asking sees it. */ | |
| 328 | const stateOf = (id: number, who: Record<string, string>) => | |
| 329 | api(`/api/tables/${id}`, {}, who).then((r) => r.json() as Promise<any>); | |
| 330 | ||
| 331 | test("the turn is played over the API, and the status follows the failure", async () => { | |
| 332 | const id = await openTable(alice, "The Turning Table"); | |
| 333 | for (const player of [bob, carol, dave]) | |
| 334 | expect( | |
| 335 | (await api(`/api/tables/${id}/join`, { method: "POST" }, player)).status, | |
| 336 | ).toBe(200); | |
| 337 | ||
| 338 | // Nobody acts at a table that has not been dealt, and nobody but the host | |
| 339 | // deals it. | |
| 340 | expect( | |
| 341 | (await api(`/api/tables/${id}/pass`, { method: "POST" }, alice)).status, | |
| 342 | ).toBe(409); | |
| 343 | expect( | |
| 344 | (await api(`/api/tables/${id}/start`, { method: "POST" }, bob)).status, | |
| 345 | ).toBe(403); | |
| 346 | const started = await api(`/api/tables/${id}/start`, { method: "POST" }, alice); | |
| 347 | expect(started.status).toBe(200); | |
| 348 | const table = (await started.json()) as any; | |
| 349 | expect(table.status).toBe("playing"); | |
| 350 | expect(table.charter).toHaveLength(7); | |
| 351 | expect(table.turn).toBe(table.seats[0].id); | |
| 352 | expect(table.your_turn).toBe(true); | |
| 353 | expect( | |
| 354 | (await api(`/api/tables/${id}/start`, { method: "POST" }, alice)).status, | |
| 355 | ).toBe(409); | |
| 356 | ||
| 357 | // An accord of this charter, bent to buy the second seat: the test asks | |
| 358 | // that seat what it is playing for, which is the one door that tells it. | |
| 359 | const charter = table.charter as { id: string; settings: string[] }[]; | |
| 360 | const mine = await stateOf(id, bob); | |
| 361 | const aim = mine.seats | |
| 362 | .find((seat: any) => seat.player_name === "bob") | |
| 363 | .charge.aims.find((one: any) => one.weight > 0); | |
| 364 | const accord = Object.fromEntries( | |
| 365 | charter.map((clause) => [ | |
| 366 | clause.id, | |
| 367 | clause.id === aim.clause ? aim.setting : clause.settings[0], | |
| 368 | ]), | |
| 369 | ); | |
| 370 | ||
| 371 | const offered = await api( | |
| 372 | `/api/tables/${id}/offer`, | |
| 373 | { method: "POST", ...jsonBody({ accord }) }, | |
| 374 | alice, | |
| 375 | ); | |
| 376 | expect(offered.status).toBe(200); | |
| 377 | expect(((await offered.json()) as any).standing.accord).toEqual(accord); | |
| 378 | // A second act on the same turn is a conflict, by name. | |
| 379 | const again = await api( | |
| 380 | `/api/tables/${id}/offer`, | |
| 381 | { method: "POST", ...jsonBody({ accord }) }, | |
| 382 | alice, | |
| 383 | ); | |
| 384 | expect(again.status).toBe(409); | |
| 385 | expect(((await again.json()) as any).message).toBe("It is not your turn."); | |
| 386 | ||
| 387 | // An accord that is not this charter's is refused at the boundary: a | |
| 388 | // clause that is on no charter, and a setting this clause does not have. | |
| 389 | for (const bent of [ | |
| 390 | { ...accord, "not-a-clause": "at-all" }, | |
| 391 | { ...accord, [charter[0]!.id]: "no-such-setting" }, | |
| 392 | ]) { | |
| 393 | const nonsense = await api( | |
| 394 | `/api/tables/${id}/offer`, | |
| 395 | { method: "POST", ...jsonBody({ accord: bent }) }, | |
| 396 | bob, | |
| 397 | ); | |
| 398 | expect(nonsense.status).toBe(422); | |
| 399 | } | |
| 400 | // A citation that is not the caller's to swear is refused the same way. | |
| 401 | expect( | |
| 402 | ( | |
| 403 | await api( | |
| 404 | `/api/tables/${id}/accept`, | |
| 405 | { | |
| 406 | method: "POST", | |
| 407 | ...jsonBody({ cite: { clause: "nothing", setting: "at all" } }), | |
| 408 | }, | |
| 409 | bob, | |
| 410 | ) | |
| 411 | ).status, | |
| 412 | ).toBe(422); | |
| 413 | ||
| 414 | const accepted = await api(`/api/tables/${id}/accept`, { method: "POST" }, bob); | |
| 415 | expect(accepted.status).toBe(200); | |
| 416 | const after = (await accepted.json()) as any; | |
| 417 | expect(after.standing.acceptances).toEqual([table.seats[1].id]); | |
| 418 | // §6: the acceptance paid with a true aim of his own that this accord | |
| 419 | // satisfies — whichever of them the charter offered first. | |
| 420 | const cite = after.acts.at(-1).cite; | |
| 421 | expect(accord[cite.clause]).toBe(cite.setting); | |
| 422 | expect( | |
| 423 | mine.seats | |
| 424 | .find((seat: any) => seat.player_name === "bob") | |
| 425 | .charge.aims.some( | |
| 426 | (one: any) => | |
| 427 | one.weight > 0 && | |
| 428 | one.clause === cite.clause && | |
| 429 | one.setting === cite.setting, | |
| 430 | ), | |
| 431 | ).toBe(true); | |
| 432 | expect(after.turn).toBe(table.seats[2].id); | |
| 433 | ||
| 434 | expect( | |
| 435 | (await api(`/api/tables/${id}/pass`, { method: "POST" }, carol)).status, | |
| 436 | ).toBe(200); | |
| 437 | // Someone who holds no seat here is refused before the rules are read. | |
| 438 | expect( | |
| 439 | (await api(`/api/tables/${id}/pass`, { method: "POST" }, erin)).status, | |
| 440 | ).toBe(403); | |
| 441 | expect((await api(`/api/tables/${id}/pass`, { method: "POST" })).status).toBe( | |
| 442 | 401, | |
| 443 | ); | |
| 444 | expect( | |
| 445 | (await api("/api/tables/9999/pass", { method: "POST" }, alice)).status, | |
| 446 | ).toBe(404); | |
| 447 | // The whole log is public, and carries nobody's charge. | |
| 448 | const watched = await stateOf(id, {}); | |
| 449 | expect(watched.acts.map((act: any) => act.kind)).toEqual([ | |
| 450 | "offer", | |
| 451 | "accept", | |
| 452 | "pass", | |
| 453 | ]); | |
| 454 | expect(watched.seats.every((seat: any) => seat.charge === null)).toBe(true); | |
| 455 | }); | |
| 456 | ||
| 457 | test("an act the action rations is 429 with its name", async () => { | |
| 458 | const gale = as("u11", "gale"); | |
| 459 | const id = await openTable(gale, "The Fast Hand"); | |
| 460 | const others = [as("u12", "hull"), as("u13", "keel"), as("u14", "luff")]; | |
| 461 | for (const player of others) | |
| 462 | expect( | |
| 463 | (await api(`/api/tables/${id}/join`, { method: "POST" }, player)).status, | |
| 464 | ).toBe(200); | |
| 465 | expect( | |
| 466 | (await api(`/api/tables/${id}/start`, { method: "POST" }, gale)).status, | |
| 467 | ).toBe(200); | |
| 468 | ||
| 469 | // Two buckets ration this request: the server's, per person, and the | |
| 470 | // action's, per seat per table. They are the same size, so a flood over | |
| 471 | // the wire always empties the server's first and answers as the server | |
| 472 | // does; the action's own refusal is only seen by spending that seat's | |
| 473 | // bucket here, with the call the action itself makes. | |
| 474 | for (let i = 0; i < 20; i++) allow(`act:${id}:u11`); | |
| 475 | const refused = await api(`/api/tables/${id}/pass`, { method: "POST" }, gale); | |
| 476 | expect(refused.status).toBe(429); | |
| 477 | expect((await refused.json()) as { error: string }).toMatchObject({ | |
| 478 | error: "rate_limited", | |
| 479 | }); | |
| 480 | }); | |
| 481 | ||
| 324 | 482 | test("no door hands out a charge that is not the viewer's", async () => { |
| 325 | 483 | const id = await openTable(alice, "The Sealed Manifest"); |
| 326 | 484 | expect( |
| @@ -516,9 +674,18 @@ test("where the app is written a bad payload is thrown, loudly (local)", () => { | ||
| 516 | 674 | test("every emit the app makes today passes the rule", async () => { |
| 517 | 675 | const heard: LiveEvent[] = []; |
| 518 | 676 | const stop = subscribe((event) => heard.push(event)); |
| 519 | const id = await openTable(alice, "The Addressed Table"); | |
| 520 | await api(`/api/tables/${id}/join`, { method: "POST" }, bob); | |
| 521 | await api(`/api/tables/${id}/leave`, { method: "POST" }, bob); | |
| 677 | // A table of its own, played by people no other test spends the ration | |
| 678 | // of: this one takes a dozen turns of the bucket to reach an act. | |
| 679 | const host = as("u21", "mast"); | |
| 680 | const quitter = as("u22", "sprit"); | |
| 681 | const id = await openTable(host, "The Addressed Table"); | |
| 682 | await api(`/api/tables/${id}/join`, { method: "POST" }, quitter); | |
| 683 | await api(`/api/tables/${id}/leave`, { method: "POST" }, quitter); | |
| 684 | // And the turn's own: a table dealt, and an act taken on it. | |
| 685 | for (const player of [quitter, as("u23", "boom"), as("u24", "gaff")]) | |
| 686 | await api(`/api/tables/${id}/join`, { method: "POST" }, player); | |
| 687 | await api(`/api/tables/${id}/start`, { method: "POST" }, host); | |
| 688 | await api(`/api/tables/${id}/pass`, { method: "POST" }, host); | |
| 522 | 689 | stop(); |
| 523 | 690 | // The emits that exist today, and the ids each one carries. A new type, |
| 524 | 691 | // or a new key, fails here until someone has said it is an address — |
| @@ -527,12 +694,22 @@ test("every emit the app makes today passes the rule", async () => { | ||
| 527 | 694 | "table.opened": ["table"], |
| 528 | 695 | "seat.joined": ["table"], |
| 529 | 696 | "seat.left": ["table"], |
| 697 | "table.started": ["table"], | |
| 698 | "act.made": ["seat", "table"], | |
| 699 | "turn.changed": ["table"], | |
| 530 | 700 | }; |
| 531 | 701 | expect(heard.map((event) => event.type)).toEqual([ |
| 532 | 702 | "table.opened", |
| 533 | 703 | "seat.joined", |
| 534 | 704 | "seat.joined", |
| 535 | 705 | "seat.left", |
| 706 | "seat.joined", | |
| 707 | "seat.joined", | |
| 708 | "seat.joined", | |
| 709 | "table.started", | |
| 710 | "turn.changed", | |
| 711 | "act.made", | |
| 712 | "turn.changed", | |
| 536 | 713 | ]); |
| 537 | 714 | for (const event of heard) { |
| 538 | 715 | expect(notAnAddress(event.data)).toBeUndefined(); |
| @@ -563,6 +740,7 @@ test("the page region carries the live types it is refreshed by", async () => { | ||
| 563 | 740 | "table.opened", |
| 564 | 741 | "seat.joined", |
| 565 | 742 | "seat.left", |
| 743 | "table.started", | |
| 566 | 744 | ]); |
| 567 | 745 | }); |
| 568 | 746 |
new above
offline
Read every changed line, ran the suite at
7739589(91 pass, 0 fail), probed the preview as a guest, and mutation-tested the new rules. The design is right and the security is clean — my only findings are two behaviours this change adds that no test would miss. Both fixes are a few lines.1. The new rate limit is untested — fix this first.
src/actions.ts:521adds a per-seat, per-table bucket,src/result.tsaddsrate_limitedto the vocabulary every door speaks, andsrc/routes.ts:58maps it to 429. I deleted theallow(...)call intaking()and re-ranbun test: 91 pass, 0 fail. The one security control this change introduces — the one that makes MCP and the terminal ration when they don't pass through the server's own bucket — can be removed without a single red test. It is also cheap to cover, becausetaking()spends a token before it judges the act: callpassTurn(db, bob, { id })21 times on a table where it is not bob's turn and the first 20 come backconflict, the 21strate_limited; a matching API test asserting 429 anderror: "rate_limited"nails the status map too. Use a fresh table and player so the module-level bucket doesn't bleed across tests.2. Starting a table whose seat order has gaps is untested.
src/actions.ts:380-389renumbers in two passes precisely so a seat moving into a place another seat is leaving cannot tripUNIQUE (game_id, ordinal). Every test starts a table with contiguous ordinals, so that case never runs: I deleted the negative-ordinal pass and the suite stayed green. The failure mode it guards is an exception inside the host's one irreversible act — a 500 onPOST /start. I probed the real code with five joins, two leaves and one more join (ordinals0, 2, 4, 5): it renumbers to0..3correctly and the turn walks the four survivors, so this is a missing regression guard, not a bug. Assert the ordinals and that the turn after two acts is the third seat.Note, no action here.
taking()reads andwritten()writes outside one transaction. That is safe today — the actions are synchronous in one process andsrc/tty.tstakes no turns — but the terminal is a second process on the same WAL file, so when #12/#13 put a turn behind it, two processes could each read "it's my turn" and both insert. Nothing in the schema stops a duplicate act the wayUNIQUE (game_id, player_id)stops a duplicate seat. Worth wrapping the read and the write indb.transactionwhen that lands.What I checked and found right. Identity is
who.role/who.idfrom the signed headers at every new door;startTablegates on host-or-admin, andtaking()gates on signed in, playing, a seat of your own before any rule is read. The rate-limit key is the identity, the server's own is identity-or-socket. Bodies go throughreadJson/readBody, capped by bytes read.validAccordrejects a missing clause, an unknown clause, an unknown setting and a non-object, and stores the canonical form;cited()refuses anything that is not two strings, and a citation is matched againstmay.cite— which never leaves the action — so a refusal says nothing about another seat's charge. No SQL is built from user text,seedisrandomUUIDand appears in no view (the test asserts it), charges stay redacted, and the act log carries only what §5 and §6 make public. Deriving the turn from the ledger rather than a pointer is the right call and the §10 transcript test is a good proof of it. On the preview:GET /api/tables200, an anonPOST /start401, a cross-origin one 403, a missing table 404, and the security floor intact on every response. The screenshots are clean at 360px and the footer printsFORGE_APP_SSHverbatim. README matches what is actually shipped, including what is deferred; no new dependency; page and terminal stay in step because neither takes a turn yet — keep that true when #12 lands.