Skip to content

Configurable game setup: persistence, DSL, interpreter, and Play flow (#113–#120) - #125

Open
jordanwallwork wants to merge 12 commits into
masterfrom
feature/game-setup
Open

Configurable game setup: persistence, DSL, interpreter, and Play flow (#113–#120)#125
jordanwallwork wants to merge 12 commits into
masterfrom
feature/game-setup

Conversation

@jordanwallwork

Copy link
Copy Markdown
Owner

Implements the first tranche of the Configurable game setup wayfinder map (#98): server persistence, the setup DSL + validator, the deterministic interpreter, seat-ring layout, and the end-to-end Play flow that runs a setup onto the tabletop.

Each commit maps to one issue and was implemented + reviewed test-first.

Commits / issues

Issue Commit Summary
#113 GameSetup persistence c9cbe6c GameSetup entity (opaque jsonb document), migration, DTOs, project-scoped CRUD API at /projects/{projectId}/setups, service + 14 tests
#114 DSL types + validation c9cbe6c $lib/gamerunner DSL AST types + validateGameSetup (structure / referential / type-checking), SetupRunError union, 48 tests
#115 Blueprint model faec87f Blueprint = {id, scope, displayName, zones[]} + Zone geometry/visibility; pure placeZone/instantiateBlueprint (table/seat/edge fan-out); validator extended
#116 Visibility model 149947b Pure computeViewState(state, viewer, visibilityMap) → MaskedState (faceVisibility + presence masking, omniscient); viewController.svelte.ts seat switcher
#117 Core interpreter 2f16e0e runSetup(doc, {playerCount, options, seed, templates}) → {ok, state, visibility, trace} | {ok:false, error}; all 8 verbs, mulberry32 seeded RNG, fail-fast errors, replay trace
#118 Seat ring geometry 387f505 Swappable SeatLayoutStrategy (radial shipped); auto-fit radius from blueprint boxes + widen-only override; wired into the interpreter
#119 Setup list picker c22ba1b gameSetupsApi.list + SetupPickerDialog (validity badges, greyed invalid) + tabletop toolbar "Play" button
#120 Play dialog + execution bd05c1a Count stepper + typed option inputs, remembered choices, validateGameSetup gate, runSetup → populate table as one undo entry, wipe-confirm, error UX

Design notes

Verification

Not verified

The UI (#119/#120 dialogs and a real setup run) has not been clicked through in a running app — worth eyeballing the Play button/dialog styling against the dark tabletop chrome, and a full picker → configure → run → undo cycle.

🤖 Generated with Claude Code

jordanwallwork and others added 12 commits July 15, 2026 23:18
Implements #113 and #114 (parent map #98).

#113 — GameSetup persistence:
- GameSetup entity (Id, ProjectId, Name, Document jsonb, IsValid) stored
  as an opaque raw-JSON document, following the SpreadsheetDataSource
  string-to-jsonb pattern so it works across Npgsql and the in-memory
  test provider.
- AddGameSetup migration (cascade FK to Project, index on ProjectId).
- GameSetupSummaryDto / GameSetupDto with JsonElement passthrough,
  Create/Update request DTOs.
- GameSetupService with project-scoped CRUD + authorization, and five
  endpoints at /projects/{projectId}/setups. Service registered and
  endpoints mapped.
- 14 service unit tests.

#114 — setup DSL types + static validation ($lib/gamerunner):
- types.ts: GameSetup document, SetupOption, ZoneBlueprint, ZoneReference,
  Condition/ValueExpr, the eight verbs/Action union, SetupNode kinds,
  SetupValidationError and the closed SetupRunError union, plus a version
  field and CURRENT_SETUP_VERSION ceiling. Runtime interpreter is a
  separate later ticket (#108) and is intentionally absent.
- validate.ts: validateGameSetup(doc, projectContext) covering structure,
  referential integrity (component/blueprint/option ids, seat-context) and
  condition type-checking. Flat {docPath, message} errors, no flow
  analysis (per #110).
- 48 validator tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
Rework the setup DSL blueprint from a single zone into a named bundle of
zones, and add pure blueprint instantiation for the game runner.

- Blueprint is now {id, scope, displayName, zones}: a bundle of zones that
  share one scope (table|seat|edge), replacing #114's single-zone
  ZoneBlueprint. Each Zone carries {id, role, name, geometry, faceVisibility,
  presence}; geometry reuses the tabletop ZoneType + Rect vocabulary.
- ZoneReference gains a `role` so the setup program addresses a specific zone
  within a bundle (the (seat, role) addressing of decision #104).
- placeZone(blueprint, index) and instantiateBlueprint(blueprint, playerCount)
  produce concrete PlacedZones: table once, seat once per seat, edge once per
  edge (N edges for N seats). Seat zones carry their owner seatIndex (#103);
  world placement (the ring layout) is left to a downstream, swappable strategy
  (#102), keeping instantiation a pure function.
- validateGameSetup extends to the nested model: unique blueprint ids and
  displayNames, unique zone ids/roles within a blueprint, geometry checks, and
  referential integrity of (blueprint, role) zone references.
- Tests cover instantiation at 1/2/4/8 players and validator missing/duplicate
  blueprint and zone-role checks.

No EF migration: per decision #106 the whole document (blueprints included)
lives in the opaque GameSetup.Document jsonb column, so the blueprints field is
a JSON-only change with no schema impact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
Implement computeViewState, a pure function that projects the shared
omniscient TabletopState into the masked copy a single viewer is allowed
to see, per the #103 ownership/visibility model:

- Two orthogonal per-zone axes reused from the gamerunner vocabulary:
  faceVisibility (all|owner|others|none, "others" = Hanabi) redacts card
  identity where faces are hidden while keeping presence/back; presence
  (hidden-from-non-owners) removes the whole zone, its piles and cards
  from non-owners' views.
- Zone owner = its seatIndex; table/edge zones are unowned. Omniscient
  sees everything. The function never mutates its input.

Visibility-metadata seam: the live tabletop Zone type deliberately does
not carry ownership/visibility fields (nothing wires blueprint #115
PlacedZones into a live state yet), so computeViewState takes a separate
VisibilityMap keyed by zone id. This keeps it pure and testable and maps
one-to-one onto PlacedZone. Zones absent from the map are fully public
and unowned, so existing freeform solo play is unaffected.

A thin viewController.svelte.ts rune wrapper holds only the chosen viewer
(seat index or omniscient) and derives the masked view, so switching
seats changes the view without touching state. Tests cover 2/4/8 player
configs, all four faceVisibility values, purity and the freeform default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
Implement the pure, deterministic setup interpreter that walks a
statically-valid GameSetup AST and builds a tabletop TabletopState from a
blank table, emitting a linear SetupStep trace for animated replay (#107).

- runSetup(doc, {playerCount, options, seed, templates}) returns a result
  union: { ok:true, state, visibility, trace } or { ok:false, error }. A
  failed run applies nothing; runtime failures surface as the closed-union
  SetupRunError (insufficient-cards, zone-not-on-table, card-field-miss,
  internal) via fail-fast.
- All eight verbs: placeSeats/placeZone reuse #115 instantiateBlueprint/
  placeZone; shuffle/roll reuse the existing tabletop operations
  (shufflePile, rollPile); place/deal/move/flip build state directly.
- Seeded mulberry32 PRNG + counter ids threaded through an explicit
  RunContext, so same seed + options + playerCount is byte-identical.
- Ownership/visibility rides alongside as a #116 VisibilityMap (the
  tabletop Zone carries no ownership fields), feeding computeViewState.
- Condition algebra (all/any/not over typed comparisons of playerCount/
  option/count/cardField) wired into WhenNode and forEachSeat fan-out.
- Export placedZoneId from instantiate as the single source of truth for
  placed-zone ids so reference resolution can't drift from placement.
- Parametric tests at 1/2/4/8 players covering final state, ownership and
  determinism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
#115 stamps blueprint zones per seat/edge but copies blueprint-local
geometry verbatim, deferring world positioning to a swappable strategy.
This adds that strategy: a pure, deterministic seat-ring geometry module.

- seatRing.ts: `SeatLayoutStrategy` seam (#102) with a `radial` first
  implementation. Seats are evenly spaced on a circle (seat 0 at the
  bottom, nearest the viewer), edges on a tighter concentric ring between
  adjacent seats. Auto-fit radius is derived from the seat blueprint's
  bounding boxes so adjacent panels never overlap (bounding-circle /
  chord math); an optional `ringRadius` override (#111) only ever widens
  it. Components keep true physical scale (#109) — `positionRingZones`
  translates each seat/edge group so its bounding-box centre lands on the
  ring point, never scaling; table zones pass through unmoved.
- interpreter.ts: `runPlaceSeats` / `runPlaceZone` now route stamped
  zones through `positionRingZones` using a ring computed once and cached
  on the run context; `PlaceSeatsAction.ringRadius` feeds the override.
- Metadata (seatIndex, edgeIndex, role, visibility) and input order are
  preserved through positioning.

Tests cover 1/2/4/8-player angles, even spacing, auto-fit non-overlap,
override widening, edge placement, and metadata/order preservation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
Introduce the first screen of the setup Play flow: a dialog listing all
GameSetups for the current project, loaded from the setups API. Each row
shows the setup name and a Valid/Invalid badge; invalid setups are greyed
out and non-selectable. Selecting a valid setup reports it via an onSelect
callback and closes the dialog — execution (count/options + interpreter) is
deferred to #120, which plugs into that seam.

Adds the frontend GameSetupSummary type and gameSetupsApi.list client
(there was none yet), wired through the type/api barrels, plus a Play
button in the tabletop toolbar that opens the picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
Extend #119's setup picker into the full Play flow: pick a setup, configure
a bounded player count and typed options, validate, then run the interpreter
and load the result into the live tabletop.

- gameSetupsApi.get(projectId, setupId) fetches the full setup document
- PlaySetupDialog orchestrates load -> validation gate (#114) -> run (#117),
  decomposed into PlayerCountStepper, SetupOptionInputs and SetupErrorPanel
- Pure lib/play helpers (choices/storage/run): default+clamp choices,
  localStorage per-setup memory, projectContext, wipe-needed and seed logic
- store.loadSetupRun applies the whole run as one undo entry and stores the
  visibility map alongside for #116/#124
- Confirm wipe only when the table is non-empty; failed runs apply nothing;
  "Open in editor" left as a seam for #123

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
)

Wire #116's pure visibility masking into the live tabletop render and add
the toolbar control that #116 deferred, so a solo designer can view the
table omniscient (default) or through a single seat's eyes.

- Instantiate the #116 viewController at the tabletop root, fed by store
  state, store.visibility (#120) and a seat count derived from the run's
  visibility map. Expose renderState on the tabletop context: the live
  state when omniscient (identity — freeform play byte-for-byte unchanged,
  no cloning), or the masked projection for the selected seat.
- Funnel renderState through the render tree (TabletopSurface, ZoneRenderer,
  PileFace) so hidden zones/piles/cards drop out and face-hidden cards show
  their back (CardFace/FlipCard honour faceHidden). Interaction keeps
  operating on the live store.state; switching never touches saved state or
  undo history.
- Add SeatSwitcher toolbar control, shown only when the run has seat zones:
  a dropdown listing Omniscient plus seats 1..N. Defaults to omniscient.
- Add seatSwitcher.ts pure helpers (seatCountFromVisibility, hasSeatZones)
  with unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
Play a computed setup run back as a skippable, step-by-step build-up
instead of dropping the final table on instantly (decision #107).

- Add a per-verb animation registry (all 8 verbs) that decides each
  step's hold time; shuffle reuses the existing riffle timing, the rest
  hold for a tunable ~500ms beat. Extensible seam for bespoke animations.
- Add planReplay: a pure progressive reveal from the run's trace whose
  last frame equals the final state, so a full replay and a mid-flight
  skip land on the identical committed table.
- Drive playback from the store: frames render through a new replayState
  while the live table stays untouched, so the single closing commit
  keeps the whole run as ONE undo entry.
- Lock input during replay (interaction + keyboard guards, a screen-space
  overlay hosting the Skip button, disabled toolbar controls).
- Wire seed handling into the Play dialog: Play mints a new seed;
  "Replay same deal" re-runs the last seed deterministically, persisted
  per setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
Create the setup-editor page as a new project sub-route
(.../setups/[setupId]/edit) with a view-switch shell — a "Setup script"
tab (fully implemented here) and a "Blueprints" tab left as an obvious
stub for #122 to fill. Both views share one loaded GameSetup document and
one Save button, per decision #111.

The Setup-script view renders the DSL AST as editable prose: one sentence
per node with dropdown/number/text slots that edit the AST in place
(lossless round-trip, #104). when/forEachSeat render as collapsible
nested step lists; conditions and value expressions are editable
recursively. Steps can be added (any verb or block), deleted, reordered
(up/down and drag within a list), and inserted between. Live validation
runs validateGameSetup on every edit; failing slots get a wavy-red
underline and each node shows its docPath+message beneath it.

AST manipulation lives in pure, unit-tested helpers (gamerunner/edit.ts:
create/insert/append/delete/move/changeVerb/setSlotValue, computeIsValid,
zoneOptions) — the seam a future Blockly escape hatch (#101/#98) would
target instead of the DOM. Adds gameSetupsApi.update (PUT) which persists
the document plus the client-computed isValid (#110). Wires the Play
dialog's "Open in editor" seam to navigate to the new route.

Simplifications noted: seat/edge selectors are auto-assigned by context
(each / current) rather than index-pickable; deal's count-as-expression
is shown as a reset-to-number chip; drag reorder is within-list only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
)

Replace the #123 "Blueprints" tab stub with a real blueprint editor that
shares the setup-editor page's single document and Save (decision #111). The
view lists blueprints grouped by scope (seat/table/edge) with add/duplicate/
delete, edits each zone's name/role/geometry and #103 visibility preset, and
previews the blueprint on a single-seat canvas with the #118 auto-fit ring.

Blueprint/zone mutations live as pure, unit-tested helpers in a new
`blueprintEdit.ts` (sibling to #123's `edit.ts`): add/delete/duplicate
blueprint (fresh ids + unique displayName), add/delete zone, apply visibility
preset, and dangling-reference detection over the setup program. All mutate
the shared `$state` doc in place so the existing validity badge + Save just
work — no second save path.

Deleting a blueprint that setup steps still reference surfaces a warning
dialog listing the dangling doc paths; delete stays allowed (#110/#111
delete-anytime) and live validation flags the leftover references.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
The setup picker (#119) only browsed existing setups and the editor
(#123) edits by id, so there was no way to actually create a setup — the
picker's empty state was a dead end. Add the missing seam:

- gameSetupsApi.create() → POST /projects/{id}/setups
- emptyGameSetup() factory: a fresh, valid, empty document
- "New setup" button in the picker creates a blank setup and opens it in
  the editor; per-row "Edit" opens any setup (so invalid ones are no
  longer unreachable dead ends)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsqGnGTkY8y87pBDBEJmf8
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

💡 Need a hand with PR review? Try Gitar by Sonar!

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