Tentative-turn model: per-turn time increments, free undo, append-only log (engine+viewer 2.0.0) - #9
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
timePerGame);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)toSave(): returnsundefinedwhile 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.Undodeleted — the saved log is append-only nowmoveAI/dropPlayeralways land on committed statesViewer 2.0.0
Bug fixes along the way
replay()now replayshiddenLogtoo — mid-auction saves no longer lose bidsdropPlayerno longer clobbers multi-biddercurrentPlayers(dropping one bidder wedged the game); dropped players' stale high bids no longer crash accept/decline(seed, log.length)— same seed ⇒ same game, replays reproduce seizures; fixed seizure log text reading.coloroff the wrong objectallowBuilds, lockfile v9, viewer build bitrot (vue 2.7 JSX shim, missing types)Player-visible differences
Testing
meta.public: false, engine tarball + viewer bundle) for live testing; v2 untouched