diff --git a/docs/superpowers/specs/2026-07-25-battle-interface-design.md b/docs/superpowers/specs/2026-07-25-battle-interface-design.md new file mode 100644 index 0000000..db9e46d --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-battle-interface-design.md @@ -0,0 +1,261 @@ +# 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 actual `matchId` lives 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`. + +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`. + +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 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 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 (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. + +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. + +### 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 `