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.
Pushed; `bun test` is green (77 tests), and the migration was checked against a database written by the previous schema as well as a fresh one.
shipped production is serving it deploy 6ecafc0
Merged and pushed; 91 tests pass and both sides' doors answer.
---
Merges main into this branch. The one conflict was src/page.ts: main (#34) moved layout out into src/pages/layout.ts and made home a body poured into it, while this branch had added table.started to the event types the table list follows. The resolution keeps both — main's layout({ who, notice, body }) call, with the live types this branch's acts emit — so the notice is now the layout's job and the lobby still refreshes when a table starts.
Nothing else needed hand-resolving. src/routes.ts auto-merged with both sides' rows intact (main's GET/POST /style alongside this branch's /api/tables/:id/{start,offer,accept,pass}), and the README took both sides' sections and file-table rows. src/pages/table.ts arrived from main as the markup vocabulary that /style exercises and has no route yet, so there was nothing to wire up from this side — I left the table page itself to #12 rather than widening this pull.
To check it: / renders in the new layout and its table list carries data-live="table.opened seat.joined seat.left table.started", /style answers public, max-age=60, the turn routes still refuse an anonymous caller with 401, and the floor (CSP, nosniff, Referrer-Policy, private, no-store with Vary) is unchanged on the lobby. bun test: 91 pass, 0 fail.
🤖 Generated with Claude Code
I submitted one review on pull #50: changes requested, anchored at src/actions.ts:521.
What I did and found:
- Read the whole change at
7739589against the repo as it stands, plusround.ts,board.ts,http.ts,server.ts,tty.ts,mcp.tsand the schema, to judge it in context. - Ran the suite from a scratch extract of the head: 91 pass, 0 fail.
- Probed the preview as a guest:
GET /api/tables200, anonPOST /start401, cross-origin POST 403, missing table 404, full security floor on every response; the screenshots are clean at 360px and the footer printsFORGE_APP_SSHverbatim. - Security: clean. Identity from the signed headers at every new door, host-or-admin on
start, the whole boundary gathered intaking(), rate-limit key on the identity, bodies capped by bytes read,validAccord/cited()validating at the boundary, no SQL from user text, seed and charges never in a view, refusals that leak nothing about another seat's charge. - Two verified test gaps, each proven by mutation: deleting the new
allow(...)rate limit leaves the suite green (so the one security control this change adds, plusrate_limited/429, is untested), and deleting the negative-ordinal renumbering pass also leaves it green (no test starts a table whose seat order has gaps — the case that would 500 on the host's irreversible act). I probed the gap case directly and confirmed the implementation is correct, so it is a missing regression guard, not a bug. - Flagged as a note, not work for this pull:
taking()reads andwritten()writes outside one transaction — safe today, but a hazard once #12/#13 put a turn behind the terminal, which is a second process on the same WAL file.
My clone is unchanged and nothing was pushed.
Both findings are covered, with the mutations verified red. bun test: 94 pass, 0 fail.
The turn is three acts on one ledger — offer, accept, pass — and whose move it is is derived from the act count rather than stored: taking() in src/actions.ts gathers the whole boundary (signed in, a table being played, a seat of your own, a ration, a charge) before src/round.ts says what §5 and §6 allow, and written() inserts one row and emits act.made and turn.changed. Four API doors carry it, and the status follows the named failure.
This push answers the review with tests only, plus one comment.
- The ration is now covered.
src/actions.test.tsspends the per-seat, per-table bucket the way a fast hand would: twentypassTurncalls on a turn that is not bob's come backconflict(a token is spent before any rule is read), the twenty-first comes backrate_limited, and the test goes on to show the bucket is that seat's at that table and nobody else's — alice still takes her turn, and bob still acts at another table.src/server.test.tspins the status map: a rationed act answers 429 witherror: "rate_limited". Deleting theallow(...)call intaking()now fails both; changingrate_limited: 429insrc/routes.tsfails the API one. - Starting a table with gaps in its seat order is now covered. The new test seats five, lets two go, seats one more — ordinals
0, 2, 4, 5— starts it, and asserts the order closes to0…3with the four who stayed in the order they sat down in, that the turn starts at the first seat and that after two passes it is the third seat's.
One correction to the review's second finding: no test can kill the negative-ordinal pass, and the new test does not. seatRows hands the seats over in ascending order, so each renumbering target is already vacated by the seat that had it, and the UNIQUE (game_id, ordinal) collision is unreachable for any gap arrangement — I re-ran that mutation with the gap test in place and it stays green. The pass is not dead weight, though: it is what keeps the renumbering independent of the arrival order, which is one ORDER BY away from changing, inside the host's one irreversible act. I kept it and rewrote its comment to say exactly that, so the next reader does not mistake an unkillable line for an untested one.
The transaction note is left as a note, as flagged: taking() reads and written() writes outside one db.transaction, which is safe while one process takes every turn, and belongs with #12/#13 when the terminal starts taking them.
To try it in the preview: GET /api/tables/:id for the turn and the standing offer, POST /api/tables/:id/pass twice from the same seat in a loop to see the 429 by name, and /style at 360px for the parts a table is made of.
🤖 Generated with Claude Code
You've hit your weekly limit · resets 2am (UTC)
Pushed;
bun testis green (77 tests), and the migration was checked against a database written by the previous schema as well as a fresh one.---
The ledger that makes it a game.
startTabledeals the table — the charter and every seat's charge from a seed made in the action and never shown — closes the gaps in the seat order for good, sets the quorum for the seats that actually took rather than the seats that were laid, starts the clock and moves the table toplaying.makeOffer,acceptOfferandpassTurneach write one row toact, andPOST /api/tables/:id/start,/offer,/acceptand/passare the doors onto them.The turn is derived, not pointed at (the risk the issue asked to decide): whose move it is is the act count modulo the seat count, the standing offer is the latest
offeract and its acceptances are theacceptacts after it, all read from the ledger on every read bysrc/round.ts. So a new offer clears every acceptance by existing, an acceptance made before an offer counts for nothing, and there is no column that can fall out of step with the record. Two columns were appended —game.charter_json, public the moment it is dealt while the seed beside it stays secret, andact.cite_json, the truth an acceptance paid with.quorumForis now a re-export ofround.ts'squorumrather than a second copy of ⌈n/2⌉+1; that was the one rule living in two files.Worth looking at:
taking()insrc/actions.ts, which is the whole boundary of this run — signed in, a table, a table being played, a seat of your own, a rate no door can skip (rate_limited, 429, in the action so MCP and the terminal ration too), and a charge to play with — after which every verdict ismayAct's and every sentence is its words. Andsrc/actions.test.ts, which plays RULES.md §10's nine acts through the real actions and asserts the turn after each one, ending on the acceptance that reaches quorum.Three deliberate departures from the checklist, each small: the actions take
{ id }, not{ gameId }, because every other action in the module names a table's ididand the doors passc.params.id;acceptancessits insidestandingrather than beside it, because a new offer clears them and they mean nothing apart from the offer they belong to; andacceptOffertakes an optionalcite, defaulting to the first aim §6 allows, because §6 makes that choice a move and the ledger would otherwise record an unverified freshness. What I did not do: nothing settles or scores when quorum is reached (#10) — a bound table simply stops taking acts and waits; the clock writes no automatic passes yet (#11), though a table past its deadline already reads as ended; said claims are #9; and there is no table page or MCP tool for a turn yet (#12, #13), so the preview is playable over the API and not with a thumb.To try it on the preview: open a table, have four accounts sit,
POST …/start, readcharterfromGET /api/tables/:id, then offer an accord of those seven clauses and watchturn,standingandactsmove.🤖 Generated with Claude Code
Closes #8