# Plan 4: Battle Interface, Bundled Scenarios, and Release Hardening ## Purpose Plan 4 of the four-plan BattleForge build. Replaces the deleted `match-stub.php` placeholder with a real, server-rendered hot-seat battle interface; ships three bundled scenarios that are playable without the editors; fixes the objective-round-trip gap in `ScenarioSerializer`; and adds a real end-to-end smoke test against the dev server. Plans 1–3c have already delivered the standalone PHP project, the rules engine, the curated content, the HTTP layer, the scenario editors, and the JavaScript surface. Plan 4 is the last plan of the MVP and turns the existing scaffold into a finished first release. ## Success Criteria Plan 4 succeeds when a new user, without developer assistance, can: 1. Open the home page, see the three bundled scenarios listed with summaries, and click one to start a match. 2. Play the resulting match to a winner using a hot-seat battle interface that enforces alternating team turns and disables actions on the inactive team's units. 3. Use move, attack, and the three curated abilities (heal, area damage, buff) during play, observing terrain modifiers, action-remaining counters, and per-round objective control where applicable. 4. See the result of each action (unit damage, objective tally, action log) and the final winner banner with a "Return to home" link. 5. Reload the page mid-match and resume from the same state. 6. Watch the smoke test exercise a full match on a real `php -S` instance in CI. The combat remains a local hot-seat experience for two players on one device. Online play, accounts, AI opponents, and replay visualization are explicitly out of scope per the design spec. ## In Scope ### 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 `matchId` (a 16-char hex minted at `startMatch`) and the `MatchState` both live in the JSON body: - `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: 1. Verifies the CSRF token against the `__csrf` cookie (existing pattern). 2. Verifies the `X-Turn-Token` header against the supplied match state (see Turn Token below). 3. Decodes the JSON body into a `MatchState` via `ScenarioSerializer::matchFromArray` (defense in depth; the engine would also reject malformed state, but a 400 here gives a clearer error). 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 | |---|---|---|---| | CSRF mismatch | 403 | `{"error":"csrf"}` | "Session expired. Reload the page." banner | | Turn token mismatch | 409 | `{"error":"turn"}` | "Turn has changed" banner; client re-reads `localStorage` and re-renders | | Malformed `MatchState` | 400 | `{"errors":[...]}` | First error message as a toast; match unchanged | | `CombatException` (illegal action) | 409 | `{"error":""}` | Reason as a toast; match unchanged | | Internal error | 500 | `{"error":"server"}` | "Something went wrong" banner; match unchanged | `localStorage` writes only happen on a 200 response. The in-browser state is recoverable by reload. ### Turn Token A new `src/Application/TurnToken.php` value object with two static methods: ```php public static function issue(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string 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 `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: - `matchId` is a per-match random hex set when `startMatch` returns and held in the match's `localStorage` envelope alongside the `MatchState` JSON. - `activeTeamId` and `round` are read from the incoming match. - `lastActionIndex` is `count($match->actionLog)`. A stale token (from a previous turn or a different round) fails verification. A tampered token fails verification. A missing token fails verification. The token is a defense-in-depth check; the engine's `assertCanAct` is the real authority. ### Bundled Scenarios Three `Scenario` JSON fixtures committed under `public/assets/scenarios/`: - **`skirmish.json`** — 8×8 open battlefield, no terrain, no objectives. Two symmetric teams of 3 (Alpha: defender + striker + support; Bravo: striker + scout + support). Victory: `eliminate_all`. The "show me the game" scenario. - **`hold-the-line.json`** — 10×8 battlefield with a forest corridor down the center (Manhattan-cost 2, +1 defense). Alpha: 3 defenders in the corridor. Bravo: 2 strikers + 1 support + 1 scout approaching from the south. One objective marker at the center of the forest. Victory: `hold_objective`, `holdRoundsRequired: 3`. - **`last-stand.json`** — 12×12 battlefield with a mix of forest, rough, and water. Asymmetric: Alpha has 6 mixed units (defenders, supports, scouts); Bravo has 4 (3 strikers + 1 scout). Victory: `eliminate_all`. The larger end of the rules. 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. 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 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 A single new `src/Views/match.php` template, served by `GetMatchView` at `GET /matches/current`. The template: 1. Renders the shared layout with a CSRF meta tag and a `