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

The turn: offer, accept, pass — and whose move it is #50

Merged opened this

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

Merged

mason/parley:mason/8-the-turn-offer-accept-pass-and-whose-mov → main 5 commits fork 1 thread · 1 unresolved

  • Merged. mason/8-the-turn-offer-accept-pass-and-whose-mov was merged into main as 948abc0.

Review

Sign in to review this change.
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.
4646
4747 | file | what it is |
4848 | ---------------------------------- | ------------------------------------------------------------ |
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 |
5050 | `src/board.ts` | the deal, the score and the sworn rule: by RULES.md, no db |
5151 | `src/round.ts` | the turn, the standing offer, the binding and the settlement |
5252 | `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
9393 how you sit down. The seats fill in as people arrive without a reload
9494 (`data-live="seat.joined seat.left table.started"`), and the first paint is
9595 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.
9799
98100 Following a stale link is a page too, not four words on a white screen: any
99101 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,
113115 and what an accord pays, are [RULES.md](RULES.md) and `src/board.ts`; whose
114116 turn it is, what stands, when it binds and what the settlement comes to are
115117 `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.
119136
120137 ## Who you are
121138
@@ -155,11 +172,26 @@ curl -X POST https://<app>/api/tables -H 'content-type: application/json' \
155172 -d '{"title":"The Wreck of the Fathom","seats_max":5}'
156173 curl -X POST https://<app>/api/tables/1/join
157174 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
158182 ```
159183
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
160190 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.
163195
164196 As an MCP server, for Claude Code or Claude Desktop — `list_tables`,
165197 `table_state`, `open_table`, `join_table`, `leave_table`:
@@ -174,8 +206,8 @@ claude mcp add --transport http __TITLE__ https://<app>/mcp
174206 }
175207 ```
176208
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,
179211 one reload behind.
180212
181213 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";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import {
7 acceptOffer,
78 canJoin,
89 canLeave,
910 joinTable,
1011 leaveTable,
1112 listTables,
13 makeOffer,
1214 openTable,
15 passTurn,
1316 quorumFor,
17 startTable,
1418 tableState,
1519 TITLE_MAX,
1620 type TableView,
1721 } from "./actions.ts";
1822 import { openDb } from "./db.ts";
23 import { a1, a2, CHARTER, charges } from "./example.ts";
24 import { subscribe } from "./live.ts";
1925 import type { Who } from "./who.ts";
2026
2127 const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-")));
@@ -25,6 +31,7 @@ const bob: Who = { role: "user", id: "u2", name: "bob" };
2531 const carol: Who = { role: "user", id: "u3", name: "carol" };
2632 const dave: Who = { role: "user", id: "u4", name: "dave" };
2733 const erin: Who = { role: "user", id: "u5", name: "erin" };
34 const frank: Who = { role: "user", id: "u6", name: "frank" };
2835 const root: Who = { role: "admin", id: "u0", name: "root" };
2936
3037 /** 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", () =>
277284 expect(listTables(db, anon, { limit: "5" }).ok).toBe(true);
278285 });
279286
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
280703 test("the schema keeps the promises the actions rest on", () => {
281704 const table = open(alice, { seats_max: 4 });
282705 const seats = (db as Database)
src/actions.ts +391 −23
@@ -17,8 +17,28 @@
1717 */
1818
1919 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";
2030 import { emit } from "./live.ts";
2131 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";
2242 import type { Who } from "./who.ts";
2343
2444 export type { Failure, Result };
@@ -33,6 +53,7 @@ interface GameRow {
3353 readonly host_id: string;
3454 readonly host_name: string;
3555 readonly seed: string | null;
56 readonly charter_json: string | null;
3657 readonly seats_max: number;
3758 readonly quorum: number;
3859 readonly turn_seconds: number;
@@ -60,6 +81,7 @@ interface ActRow {
6081 readonly seat_id: number | null;
6182 readonly kind: string;
6283 readonly accord_json: string | null;
84 readonly cite_json: string | null;
6385 readonly at: string;
6486 }
6587
@@ -80,6 +102,8 @@ export interface Act {
80102 readonly seat_id: number | null;
81103 readonly kind: string;
82104 readonly accord: unknown | null;
105 /** What an acceptance swore (§6). Public forever, and never cleared. */
106 readonly cite: Cite | null;
83107 readonly at: string;
84108 }
85109
@@ -103,10 +127,31 @@ export interface Table {
103127 readonly your_seat: number | null;
104128 }
105129
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
106141 /** A table with everything a player reads to take a turn. */
107142 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. */
110155 readonly acts: readonly Act[];
111156 }
112157
@@ -132,9 +177,10 @@ const LIST_LIMIT = { min: 1, max: 100, default: 50 } as const;
132177 /**
133178 * How many acceptances bind an accord: ceil(n/2)+1 — 3 of 4, 4 of 5, 4 of 6
134179 * (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.
136182 */
137 export const quorumFor = (seats: number): number => Math.ceil(seats / 2) + 1;
183 export { quorum as quorumFor } from "./round.ts";
138184
139185 // ---------------------------------------------------------------- reading
140186
@@ -210,7 +256,8 @@ export function openTable(
210256 if (deadline_seconds < 2 * turn_seconds)
211257 return fail("invalid", "A deadline is at least two turn windows long.");
212258 // 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);
214261
215262 // Opening a table is sitting down at it: the host takes the first seat in
216263 // the same transaction, so a table is never open with nobody at it.
@@ -225,7 +272,7 @@ export function openTable(
225272 who.id,
226273 who.name,
227274 seats_max,
228 quorum,
275 seatsQuorum,
229276 turn_seconds,
230277 deadline_seconds,
231278 )!;
@@ -289,6 +336,249 @@ export function leaveTable(
289336 return ok(state);
290337 }
291338
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);
outdated

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:521 adds a per-seat, per-table bucket, src/result.ts adds rate_limited to the vocabulary every door speaks, and src/routes.ts:58 maps it to 429. I deleted the allow(...) call in taking() and re-ran bun 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, because taking() spends a token before it judges the act: call passTurn(db, bob, { id }) 21 times on a table where it is not bob's turn and the first 20 come back conflict, the 21st rate_limited; a matching API test asserting 429 and error: "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-389 renumbers in two passes precisely so a seat moving into a place another seat is leaving cannot trip UNIQUE (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 on POST /start. I probed the real code with five joins, two leaves and one more join (ordinals 0, 2, 4, 5): it renumbers to 0..3 correctly 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 and written() writes outside one transaction. That is safe today — the actions are synchronous in one process and src/tty.ts takes 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 way UNIQUE (game_id, player_id) stops a duplicate seat. Worth wrapping the read and the write in db.transaction when that lands.

What I checked and found right. Identity is who.role/who.id from the signed headers at every new door; startTable gates on host-or-admin, and taking() 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 through readJson/readBody, capped by bytes read. validAccord rejects 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 against may.cite — which never leaves the action — so a refusal says nothing about another seat's charge. No SQL is built from user text, seed is randomUUID and 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/tables 200, an anon POST /start 401, 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 prints FORGE_APP_SSH verbatim. 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.

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
292582 // -------------------------------------------------------------- the rules
293583 // The page draws its buttons from these, so "can I?" is answered once and
294584 // 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 {
344634 };
345635 }
346636
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 */
348645 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;
349657 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 })),
366679 };
367680 }
368681
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
369732 // ------------------------------------------------------------ the plumbing
370733
371734 const find = (db: Database, id: unknown): GameRow | undefined => {
@@ -380,6 +743,11 @@ const find = (db: Database, id: unknown): GameRow | undefined => {
380743 );
381744 };
382745
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
383751 const seatRows = (db: Database, gameId: number): SeatRow[] =>
384752 db
385753 .query<SeatRow, [number]>(
src/db.ts +11
@@ -80,6 +80,17 @@ export const migrations: string[] = [
8080
8181 // The demo the app was born from. Its rows were never anyone's game.
8282 `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`,
8394 ];
8495
8596 export function openDb(dir = dataDir): Database {
src/page.ts +1 −1
@@ -38,7 +38,7 @@ export function home(input: {
3838 body: `<section class="card">${opener}
3939 <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>
4040 </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>`,
4242 });
4343 }
4444
src/result.ts +6 −1
@@ -15,7 +15,12 @@ export type Failure =
1515 * to take it: a seat at a full table, a second seat at the same one, a
1616 * table that is already playing.
1717 */
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";
1924
2025 export type Result<T> =
2126 | { ok: true; value: T }
src/routes.ts +46 −2
@@ -6,13 +6,17 @@
66
77 import type { Database } from "bun:sqlite";
88 import {
9 acceptOffer,
10 type Failure,
911 joinTable,
1012 leaveTable,
1113 listTables,
14 makeOffer,
1215 openTable,
13 tableState,
14 type Failure,
16 passTurn,
1517 type Result,
18 startTable,
19 tableState,
1620 } from "./actions.ts";
1721 import {
1822 back,
@@ -54,6 +58,7 @@ const STATUS: Record<Failure, number> = {
5458 not_found: 404,
5559 invalid: 422,
5660 conflict: 409,
61 rate_limited: 429,
5762 };
5863
5964 /** A result as JSON: the value, or `{ error, message }` with the matching status. */
@@ -240,6 +245,45 @@ export const routes: readonly Route[] = [
240245 handle: (c) => answer(leaveTable(c.db, c.who, { id: c.params.id })),
241246 },
242247
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
243287 // Live events, and the MCP server (the tools are the actions too).
244288 { method: "GET", path: "/events", handle: (c) => events(c.request) },
245289 {
src/server.test.ts +182 −4
@@ -15,7 +15,7 @@ import {
1515 } from "./board.ts";
1616 import { DEADLINE_SECONDS, TURN_SECONDS } from "./actions.ts";
1717 import { a2, cleat } from "./example.ts";
18 import { text } from "./http.ts";
18 import { allow, text } from "./http.ts";
1919 import { ID_MAX, type LiveEvent, notAnAddress, subscribe } from "./live.ts";
2020 import { layout } from "./pages/layout.ts";
2121 import { quorum } from "./round.ts";
@@ -43,6 +43,9 @@ const as = (
4343 };
4444 const alice = as("u1", "alice");
4545 const bob = as("u2", "bob");
46 const carol = as("u3", "carol");
47 const dave = as("u4", "dave");
48 const erin = as("u5", "erin");
4649 const root = as("u0", "root", "admin");
4750 const jsonBody = (body: unknown): RequestInit => ({
4851 headers: { "content-type": "application/json" },
@@ -321,6 +324,161 @@ test("the JSON API is the same actions with the same identity", async () => {
321324 expect((await api("/api/tables?limit=0", {}, alice)).status).toBe(422);
322325 });
323326
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
324482 test("no door hands out a charge that is not the viewer's", async () => {
325483 const id = await openTable(alice, "The Sealed Manifest");
326484 expect(
@@ -516,9 +674,18 @@ test("where the app is written a bad payload is thrown, loudly (local)", () => {
516674 test("every emit the app makes today passes the rule", async () => {
517675 const heard: LiveEvent[] = [];
518676 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);
522689 stop();
523690 // The emits that exist today, and the ids each one carries. A new type,
524691 // 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 () => {
527694 "table.opened": ["table"],
528695 "seat.joined": ["table"],
529696 "seat.left": ["table"],
697 "table.started": ["table"],
698 "act.made": ["seat", "table"],
699 "turn.changed": ["table"],
530700 };
531701 expect(heard.map((event) => event.type)).toEqual([
532702 "table.opened",
533703 "seat.joined",
534704 "seat.joined",
535705 "seat.left",
706 "seat.joined",
707 "seat.joined",
708 "seat.joined",
709 "table.started",
710 "turn.changed",
711 "act.made",
712 "turn.changed",
536713 ]);
537714 for (const event of heard) {
538715 expect(notAnAddress(event.data)).toBeUndefined();
@@ -563,6 +740,7 @@ test("the page region carries the live types it is refreshed by", async () => {
563740 "table.opened",
564741 "seat.joined",
565742 "seat.left",
743 "table.started",
566744 ]);
567745 });
568746

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs