diff --git a/docs/superpowers/specs/2026-07-25-battle-interface-design.md b/docs/superpowers/specs/2026-07-25-battle-interface-design.md index db9e46d..53836f3 100644 --- a/docs/superpowers/specs/2026-07-25-battle-interface-design.md +++ b/docs/superpowers/specs/2026-07-25-battle-interface-design.md @@ -23,12 +23,14 @@ The combat remains a local hot-seat experience for two players on one device. On ### Server-Authoritative Match API -Four new POST endpoints, one per player action, all routed under `/matches/current/` (the `current` segment identifies "the match in `localStorage`"; the actual `matchId` lives in the JSON body): +Four new POST endpoints, one per player action, all routed under `/matches/current/`. The `current` segment identifies "the match in `localStorage`"; the `matchId` (a 16-char hex minted at `startMatch`) and the `MatchState` both live in the JSON body: -- `POST /matches/current/move` — body `{match, unitId, x, y}`; runs `CombatEngine::move`; returns `{match, turnToken, winnerTeamId}`. -- `POST /matches/current/attack` — body `{match, attackerId, targetId}`; runs `CombatEngine::attack`. -- `POST /matches/current/ability` — body `{match, unitId, abilityId, x, y}`; the `x` and `y` are the target position (for `tile`-targeting abilities, the center; for `ally`/`enemy`/`self` targeting, the affected unit's position). -- `POST /matches/current/end-turn` — body `{match}`; runs `CombatEngine::endTurn`. +- `POST /matches/current/move` — body `{matchId, match, unitId, x, y}`; runs `CombatEngine::move`; returns `{match, turnToken, winnerTeamId}`. +- `POST /matches/current/attack` — body `{matchId, match, attackerId, targetId}`; runs `CombatEngine::attack`. +- `POST /matches/current/ability` — body `{matchId, match, unitId, abilityId, x, y}`; the `x` and `y` are the target position (for `tile`-targeting abilities, the center; for `ally`/`enemy`/`self` targeting, the affected unit's position). +- `POST /matches/current/end-turn` — body `{matchId, match}`; runs `CombatEngine::endTurn`. + +The `matchId` is required for turn-token verification on every action and is the only piece of state not derivable from the match JSON itself. The client persists it from the `PostStartMatch` response alongside the match in `match:current`. Each handler: @@ -38,6 +40,8 @@ Each handler: 4. Calls the matching `CombatEngine` method. 5. Computes a fresh `turnToken` for the new state and returns it alongside the serialized match and (if set) `winnerTeamId`. +`PostStartMatch` is modified in Plan 4 to mint a `matchId` (16 random hex chars from `bin2hex(random_bytes(8))`) and a `turnToken` for the initial state, returning `{match, matchId, turnToken}` instead of just `{match}`. The same `BATTLEFORGE_SECRET` is used. + Rejected actions are mapped to HTTP statuses by the rules-engine boundary: | Failure | HTTP | Body | Browser effect | @@ -59,7 +63,7 @@ public static function issue(string $secret, string $matchId, string $activeTeam public static function verify(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex, string $token): bool ``` -The token is `hash_hmac('sha256', $secret, $matchId . '|' . $activeTeamId . '|' . $round . '|' . $lastActionIndex)` truncated to 32 hex chars. The handler reads the team, round, and action log count from the *incoming* match (not from the response after the engine runs) and verifies the supplied token matches; the response carries the token for the *next* action's expected state. +The token is `hash_hmac('sha256', $secret, $matchId . '|' . $activeTeamId . '|' . $round . '|' . $lastActionIndex)` truncated to 32 hex chars. The handler reads `activeTeamId`, `round`, and `lastActionIndex` (i.e. `count($match->actionLog)`) from the *incoming* match, computes the expected token, and `hash_equals` it against the supplied header. The response carries a fresh token computed against the *new* state — i.e. the response's `turnToken` is `TurnToken::issue($secret, $matchId, $newState->activeTeamId, $newState->round, count($newState->actionLog))`. The first turn token of a match is the one minted in the `PostStartMatch` response. The token binding: @@ -79,10 +83,10 @@ Three `Scenario` JSON fixtures committed under `public/assets/scenarios/`: Two new GET endpoints serve them: -- `GET /scenarios/bundled` — reads the directory, validates each filename against `/^[a-z0-9-]+$/`, returns `[{id, name, summary}, ...]` sorted by id. The `name` and `summary` are read from a `manifest.json` in the same directory (one source of truth, no in-PHP strings to keep in sync with the fixture files). -- `GET /scenarios/bundled/{id}` — reads the fixture, runs it through `ScenarioSerializer::scenarioFromArray` and `ScenarioValidator::validate` (a fixture that fails validation is a bug; the endpoint returns 500 so CI catches it), returns the JSON. +- `GET /scenarios/bundled` — reads the directory, validates each filename against `/^[a-z0-9-]+$/`, returns `[{id, name, summary}, ...]` sorted by id. The `name` and `summary` are read from a `manifest.json` in the same directory. The manifest is the single source of truth for display strings; the fixture filename is the `id`. The manifest shape is `{ "": {"name": "...", "summary": "..."} }`. Any fixture without a manifest entry is filtered out of the response. +- `GET /scenarios/bundled/{id}` — reads the fixture, runs it through `ScenarioSerializer::scenarioFromArray` and `ScenarioValidator::validate` (a fixture that fails validation is a bug; the endpoint returns 500 so CI catches it), returns the JSON. The returned JSON is the `Scenario` shape (same as `ScenarioSerializer::scenarioToArray`), not a `ScenarioDraft`. -The home page renders a "Play bundled" list of the three fixtures with their summaries. Clicking a scenario POSTs it to `PostStartMatch` (existing endpoint) and the existing flow takes over from there. +The home page renders a "Play bundled" list of the three fixtures with their summaries. Clicking a scenario fetches the full `Scenario` JSON via `GET /scenarios/bundled/{id}` and POSTs it to the existing `PostStartMatch` endpoint (no new route). `PostStartMatch` is modified in Plan 4 to return `{match, matchId, turnToken}` (it currently returns just `{match}`) so the JS can persist the new `matchId` and seed the next action's turn-token check. ### Match View @@ -163,9 +167,11 @@ The `Application` layer gains `TurnToken` (new) and a small extension to `Scenar 1. User visits `GET /`. 2. Home page renders a "Play bundled" list with the three scenarios' names and summaries (read via `GET /scenarios/bundled`). -3. User clicks "Skirmish". JS POSTs the scenario JSON (from `GET /scenarios/bundled/skirmish`) to `POST /scenarios/skirmish/start`. -4. Existing `PostStartMatch` returns the initial `MatchState` JSON. -5. JS stores the match in `match:current` (along with a freshly-generated `matchId` and an initial `turnToken` from the response), then navigates to `/matches/current`. +3. User clicks "Skirmish". JS fetches the full `Scenario` JSON via `GET /scenarios/bundled/skirmish`. +4. JS POSTs that JSON to `POST /scenarios/skirmish/start` (the existing `PostStartMatch` handler, modified to return `{match, matchId, turnToken}`). +5. JS stores the match in `match:current` as `{matchId, match}` and stores the `turnToken` in module-local memory, then navigates to `/matches/current`. + +If a user lands on `/matches/current` directly with no `match:current` in `localStorage` (recovery flow, e.g. opened in a new tab), the JS renders the "no match — pick a scenario" state with a link back to `/`. They do not start a match from this view. ### Per-action flow