Skip to content

Tentative-turn model: per-turn time increments, free undo, append-only log (engine+viewer 2.0.0) - #9

Merged
coyotte508 merged 20 commits into
masterfrom
moon/tentative-turns
Aug 17, 2026
Merged

Tentative-turn model: per-turn time increments, free undo, append-only log (engine+viewer 2.0.0)#9
coyotte508 merged 20 commits into
masterfrom
moon/tentative-turns

Conversation

@coyotte508

Copy link
Copy Markdown
Member

Ports Container to the tentative-turn model (as used by gaia-project): the viewer accumulates the current turn locally and resends the whole buffer on each action; the engine replays it from the last committed state and only marks the state saveable (toSave()) once the turn completes.

Why

On boardgamers.space, persistence and the per-move time increment are granted per saved move. Container's undo was a real engine move that popped the saved log, so:

  • undo itself was rewarded with a time increment, and move→undo→move cycles could farm time (bounded by timePerGame);
  • the saved log could shrink, breaking platform assumptions (incremental log slices, last-move info).

What changes

Engine 2.0.0

  • move() accepts the whole current-turn buffer (Move[]), replays it from the committed state; throws if a buffer continues past a turn boundary (exactly one turn per increment)
  • New toSave(): returns undefined while the turn is tentative (mirrors the old undo-availability rule: last visible log entry is a move by a current player; Pass/Accept/Decline/Bid commit unconditionally)
  • MoveName.Undo deleted — the saved log is append-only now
  • moveAI/dropPlayer always land on committed states

Viewer 2.0.0

  • Turn buffer + committed state; every action re-emits the full buffer; undo pops the buffer (no server call when it empties); guard against stale tentative echoes
  • Sidebar log behavior unchanged: one line per atomic action, live, removed on undo

Bug fixes along the way

  • replay() now replays hiddenLog too — mid-auction saves no longer lose bids
  • dropPlayer no longer clobbers multi-bidder currentPlayers (dropping one bidder wedged the game); dropped players' stale high bids no longer crash accept/decline
  • Upkeep seizures now use a seeded RNG derived from (seed, log.length) — same seed ⇒ same game, replays reproduce seizures; fixed seizure log text reading .color off the wrong object
  • Build fixes: pnpm ≥10.26 allowBuilds, lockfile v9, viewer build bitrot (vue 2.7 JSX shim, missing types)

Player-visible differences

  1. Undo is instant/local, scoped to the current turn (same scope as before)
  2. Time increments accrue once per completed turn; undo grants nothing
  3. Opponents see a turn's moves when it commits, not mid-turn
  4. Closing the tab mid-turn discards unfinished tentative moves
  5. Edge case: the last non-dropped player can no longer undo their own Pass (the old rule would have made that state unpersistable)

Testing

  • Engine: 18 mocha specs (tentative-not-saved, commit points through scripted 2p/3p auctions incl. bid ties, undo-by-truncation equivalence, multi-turn buffer rejection, malformed-buffer atomicity, log monotonicity, replay-with-hidden-bids equality, dropPlayer/moveAI committed, seeded-seizure determinism), eslint, tsc — green
  • Viewer: unit test + production build green; exercised live in a browser (loan → tentative, undo → revert, Pass → commit + AI resume)
  • Independent agent review (APPROVE, no blockers); all review nits addressed
  • Published as hidden v3 on boardgamers.space (meta.public: false, engine tarball + viewer bundle) for live testing; v2 untouched

…ize-imports plugin)

- pnpm 11 rewrites the v5 lockfile on install and refuses to run unreviewed
  dependency build scripts; declare them explicitly (all disabled — everything
  installs and builds fine without them) so installs and the pre-commit hook pass
- upgrade prettier-plugin-organize-imports 1.x -> 3.x: the old version crashes
  against typescript 4.9 (host.fileExists is not a function), breaking lint-staged
- ignore the local .pnpm-store
- wrapper move() now accepts the whole current turn as a Move[] buffer
  (bare move objects still accepted) and replays it from the committed state
- new toSave(): returns undefined while the turn is tentative so the platform
  neither persists mid-turn states nor grants time increments for them
- engine tracks commitment in G.newTurn: tentative iff the last visible log
  entry is a move by a player still in currentPlayers; Pass/Accept/Decline/Bid
  always commit (bids and bid-phase loans live in the hidden log and were
  never undoable)
- delete MoveName.Undo entirely (move type, engine case, available-moves
  offerings): the saved log is now append-only
- wrapper moveAI() plays atomic moves until the turn commits (bot driver and
  dropPlayer auto-play require committed states); dropPlayer marks committed
- logSlice() now includes the stripped state so the acting player's viewer
  receives tentative states via the move response
- bump to 2.0.0 (new major: move payload shape changed, Undo removed)
The viewer no longer compiled (bitrot surfaced by vue 2.7 + typescript 4.9 +
pnpm 11 strict hoisting):

- delete shims-tsx.d.ts: vue 2.7 ships its own JSX typings, ours duplicated
  the IntrinsicElements index signature
- type UIData.dragged structurally (Vue & { pieceType, pieceId }) instead of
  using a .vue module import as a type, which TS 4.9 rejects
- add csstype (vue's types import it; no longer hoisted) and @types/node +
  'node' in tsconfig types (self-contained.ts uses process.env)
- Game.vue keeps turnMoves (the current turn's atomic moves) alongside the
  last committed state; every action emits the FULL buffer as the move
  payload, and the local preview replays the buffer with fake engine moves
- undo pops the buffer: re-emits the shortened turn if non-empty, or just
  resets the preview to the committed state with no server call (nothing was
  ever persisted for the turn)
- the buffer is cleared whenever a committed state (newTurn !== false)
  arrives from the platform, or when a local move completes the turn
- undo button availability = non-empty buffer (the Undo engine move is gone)
- launch.ts feeds the state embedded in move responses (logSlice().state) to
  the game — that's the only channel through which tentative states arrive —
  and falls back to fetchState otherwise
- self-contained dev mode mimics the platform: replays the buffer from the
  committed state and only persists committed results
- bump to 2.0.0 (pairs with container-engine 2.x)
Engine (wrapper.spec.ts), simulating the platform (persist only what toSave
returns):

- mid-turn buffers are tentative (toSave undefined, nothing persisted) and
  the full turn commits when it completes
- bare move objects are accepted as one-element buffers; empty buffers are
  never saved
- undo-by-truncation: replaying a shortened buffer from the committed base
  equals never having made the popped move (including undoing to empty with
  no server call)
- scripted 2-player auction: sail-to-island commits immediately, a bid is a
  single-move committed turn, the auctioneer's mid-turn loan stays tentative,
  accept/decline commit and cannot be undone once control passes on
- the saved log length never shrinks across committed states
- moveAI always returns saveable states across a random 25-turn game;
  dropPlayer results are committed

Viewer: replace the dead example unit test (missing HelloWorld component)
with a real Calculator test so vue-cli-service test:unit passes.
The whole-turn buffer is replayed from the committed state; committing is
what grants the mover their per-turn time increment. In the degenerate
all-others-dropped case a buffer like [..., Pass, ...more] is a sequence of
individually legal moves spanning several turns — it would commit them all
for a single increment. Throw as soon as a committed move is followed by
more buffer elements.

Also log an error when moveAI's 500-iteration force-commit safety net
fires, so a would-be livelock is visible in server logs instead of being
silently masked.

Tests: malformed buffer rejection (nothing half-applied leaks) and
multi-turn buffer rejection.
Scripted 3-player game into a simultaneous-bidders auction; asserts every
bid is a single-move committed turn, the tie opens an additional-bid round
among the tied bidders, bids stay in the hidden log until the tie resolves,
and the accept settles the right amounts.
A state saved mid-bid-phase keeps its newest moves (bids, bid-phase loans)
in G.hiddenLog — they are only flushed to G.log when the accept/decline
phase starts. replay() only walked G.log, so an admin full-replay of such
a save silently lost the bids and diverged.

Whenever hiddenLog is non-empty it holds exactly the moves made after the
last visible log entry, in order (the visible log freezes during the bid
phase), so replaying [...log, ...hiddenLog] reconstructs the state
faithfully. logSlice's truncated-log replay now explicitly drops the
hidden log, since it reconstructs a historical state predating it.
dropPlayer unconditionally called nextPlayer, which resets currentPlayers
to a single player. During an auction (currentPlayers = all pending
bidders) dropping one bidder handed the turn to the auctioneer — who has
no available moves yet — wedging the game and discarding the other
bidders' pending turns. It also advanced the turn even when the dropped
player was not up at all, and skipped the next player's upkeep
(actions/available moves) in the move phase.

Now the drop auto-plays only the dropped player's own pending decision
through the regular engine path — Bid bash in the bid phase (including the
additional-bid round), Accept the highest bid as auctioneer, Pass in the
move phase — and touches nothing if the player is not up. The engine's
existing isDropped filters take care of skipping them afterwards.

Also make the highest-bid computation ignore dropped players' bids: a
dropped bidder's stale high bid used to poison Math.max, leaving
highestBidders empty and crashing the accept/decline phase.
Upkeep seizures picked containers with a bare Math.random, breaking the
platform guarantee that the same seed yields the same game and making
wrapper.replay diverge from the original whenever a seizure occurred
(seizures are log *events*, not moves, so nothing recorded the choice).

Seed a per-upkeep seedrandom stream from the persisted game seed plus the
visible log length — which uniquely identifies the upkeep within the game,
since each upkeep immediately follows its own Pass/Accept/Decline log
entry — so both live play and full replays reproduce the same seizures.

Typing removeRandom along the way exposed that warehouse/factory-store
seizure messages read .color on a ContainerOnStore (always undefined);
they now correctly read .piece.color.
Its only consumer was the wrapper's Undo handling, deleted when the
tentative-turn model replaced destructive undo; nothing in the engine or
the viewer sources references it anymore (full replays go through
wrapper.replay). Removing a public API is fine for the 2.0.0 major.
A move response's tentative state replaced the local preview
unconditionally. If the user had undone down to an empty buffer (which
re-emits nothing) or already made another move, a late echo transiently
showed a phantom or regressed move (and a wrong sidebar log) until the
next response landed.

Only accept a tentative state whose visible log extends the committed log
by exactly the buffered moves, in order — validated against the engine:
every tentative echo satisfies this (all tentative moves are visible-log
moves; bids and bid-phase loans go to the hidden log but commit
immediately and never take this path). Stale echoes are dropped; the echo
for the current buffer, if one is in flight, follows anyway.
Production deploys upload only dist/container-viewer.umd.min.js and
dist/container-viewer.css to S3; the lib build emitted the toolbar icons to
dist/img/*.svg and the sounds to dist/media/* and referenced them by relative
URL, so they all 404'd on the platform (v2 worked only because jsdelivr served
the whole npm package). Swap the svg rule's file-loader for url-loader and
raise the images/media inline limits so every asset becomes a data URI inside
the two uploaded files.
The preferences object handed to Game as a prop was a plain object owned by
launch(), and Vue 2 does not deep-observe prop values coming from a
non-reactive parent. Toggling sound or the drag/drop help indicators mutated
an unobserved object, so the UI only caught up on the next unrelated
re-render (in practice: after a refresh). Make the object a Vue.observable
and merge platform preference pushes into it instead of replacing it, so both
the in-game toggles and platform updates paint immediately.
After a drag-drop the viewer paints a local preview of the move synchronously,
then the server's tentative echo of the same turn buffer arrived a few hundred
ms later and went through replaceState again. Rebuilding every piece while the
drop animation was still in flight made the piece show at its destination
instantly while also tweening there from the drop point. Matching echoes now
short-circuit like stale ones already did — they only trigger the move sound,
which used to be the echo replace's job (local previews are silent).

Also delay the self-contained harness's tentative echo by 300ms so it lands
mid-animation like on the real platform instead of masking such races.
The modal kept its own light backgrounds (#fefefe content, #ccc/#fff log
rows) but let the text color inherit from the page, so the platform's dark
mode rendered it white-on-gray. Pin an explicit dark text color on
.modal-content — the viewer styles everything else with fixed colors too —
so the log (and the other modals) are readable in both themes.
The per-directory v5.3 lockfiles were stale against the 2.0.0 package.jsons and
unreadable by the workflows' pnpm 5 anyway; the root v9 workspace lockfile is
authoritative. Reformat sources for prettier-plugin-organize-imports v3 and
ignore build artifacts.
The shim's bare require expects the vue-3 parse shape but declares no
dependency, so it resolved through pnpm's hidden hoist — which could pick
vue 2.7's legacy-shaped 2.7.16 (CI) or 3.5.41 (local) depending on layout.
packageExtensions makes it deterministic.
@coyotte508
coyotte508 merged commit 93983c3 into master Aug 17, 2026
4 checks passed
@coyotte508
coyotte508 deleted the moon/tentative-turns branch August 17, 2026 10:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant