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

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

opened this

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.

---

The ledger that makes it a game. startTable deals 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 to playing. makeOffer, acceptOffer and passTurn each write one row to act, and POST /api/tables/:id/start, /offer, /accept and /pass are 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 offer act and its acceptances are the accept acts after it, all read from the ledger on every read by src/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, and act.cite_json, the truth an acceptance paid with. quorumFor is now a re-export of round.ts's quorum rather than a second copy of ⌈n/2⌉+1; that was the one rule living in two files.

Worth looking at: taking() in src/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 is mayAct's and every sentence is its words. And src/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 id id and the doors pass c.params.id; acceptances sits inside standing rather than beside it, because a new offer clears them and they mean nothing apart from the offer they belong to; and acceptOffer takes an optional cite, 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, read charter from GET /api/tables/:id, then offer an accord of those seven clauses and watch turn, standing and acts move.

🤖 Generated with Claude Code

Closes #8

commented

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

commented

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 7739589 against the repo as it stands, plus round.ts, board.ts, http.ts, server.ts, tty.ts, mcp.ts and 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/tables 200, anon POST /start 401, cross-origin POST 403, missing table 404, full security floor on every response; the screenshots are clean at 360px and the footer prints FORGE_APP_SSH verbatim.
  • Security: clean. Identity from the signed headers at every new door, host-or-admin on start, the whole boundary gathered in taking(), 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, plus rate_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 and written() 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.

commented

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.ts spends the per-seat, per-table bucket the way a fast hand would: twenty passTurn calls on a turn that is not bob's come back conflict (a token is spent before any rule is read), the twenty-first comes back rate_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.ts pins the status map: a rationed act answers 429 with error: "rate_limited". Deleting the allow(...) call in taking() now fails both; changing rate_limited: 429 in src/routes.ts fails 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 to 0…3 with 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

commented

You've hit your weekly limit · resets 2am (UTC)

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs