# Plan 4: Battle Interface, Bundled Scenarios, and Release Hardening > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the deleted `match-stub.php` placeholder with a real, server-rendered hot-seat battle interface; ship three bundled scenarios that are playable without the editors; fix the `ScenarioSerializer` objective round-trip gap; and add an end-to-end smoke test that boots `php -S` and walks a match to a winner. **Architecture:** The rules engine stays in `src/Domain` and is unchanged. A new `TurnToken` value object mints HMAC-signed per-match tokens. Four small per-verb action handlers (move, attack, ability, end-turn) under `src/Http/Handlers/` each call the matching `CombatEngine` method, returning a fresh `MatchState` plus a new turn token. The browser reads the match from `localStorage` and dispatches every action as a stateless full-snapshot POST. The home page lists three bundled-scenario JSON fixtures served by two new GET endpoints. `public/js/match.js` is the third ESM file and renders the live match. **Tech Stack:** PHP 8.3, vanilla ESM JavaScript (no bundler), Node 20+ with ESLint `airbnb-base` + Prettier (already in place from Plan 3c), PHPUnit 11, PHPStan level 6, PHP_CodeSniffer with the repository ruleset, `php -S` for the dev server and the smoke test. ## Global Constraints These apply to every task and are copied verbatim from the spec. - The match lives in the browser's `localStorage` under `match:current`. The server is stateless across action calls; the client sends the full `MatchState` with every action POST. - The `matchId` (16 random hex chars from `bin2hex(random_bytes(8))`) is minted by `PostStartMatch` and persisted alongside the match in `localStorage`. It is required in every action request body and used as the per-match namespace for the turn-token HMAC. - The `X-Turn-Token` header is required on all four action endpoints. A missing, stale, or tampered token returns 409 with `{"error":"turn"}`. The token is `hash_hmac('sha256', $secret, $matchId . '|' . $activeTeamId . '|' . $round . '|' . $lastActionIndex)` truncated to 32 hex chars, verified with `hash_equals`. - `PostStartMatch` returns `{match, matchId, turnToken}` (it currently returns just `{match}`). The same `BATTLEFORGE_SECRET` is used for the turn token as for CSRF. - Every state-changing form or request uses and verifies a CSRF token before mutation. The CSRF token comes from the existing `__csrf` cookie and `` pattern. The four match-action endpoints verify CSRF and turn-token together. - All rendered output is escaped for its output context via `Escape::html`, `Escape::attr`, and `Escape::url`. The new `match.js` uses `textContent` and DOM construction; never `innerHTML` with user data. - The match-action endpoints validate `matchId` against `/^[a-f0-9]{16,}$/` before computing the token. The bundled-scenario endpoints validate filenames against `/^[a-z0-9-]+$/` and use `realpath` + `is_file` before reading. - A rejected action never partially changes match state or consumes resources. `localStorage` writes only happen on a 200 response. The in-browser state is recoverable by reload. - `ScenarioSerializer::matchToArray` and `matchFromArray` must round-trip the `objectives` map. `matchToArray` adds an `objectives` key; `matchFromArray` builds the `ObjectiveMarker` map. - Bundled scenarios live under `public/assets/scenarios/`. A `manifest.json` in that directory is the single source of truth for display names and summaries; the fixture filename is the `id`. The manifest shape is `{ "": {"name": "...", "summary": "..."} }`. Any fixture without a manifest entry is filtered out of the bundle list. - `getScenario(…)?/start` continues to accept `Scenario` JSON in the `ScenarioSerializer::scenarioToArray` shape. The bundled-scenario GET endpoint returns that same shape, not a `ScenarioDraft`. - Bundled-scenario fixtures must pass `ScenarioValidator::validate` (the endpoint returns 500 on failure so CI catches it). - The end-to-end smoke test boots `php -S` on a free port, walks the Skirmish scenario to a winner, and is gated by `BATTLEFORGE_E2E=1`. The default `composer check` does not set it. - PHP follows the repository PHPCS rules and passes PHPStan at level 6. JavaScript passes ESLint `airbnb/base` and Prettier. Composer configuration passes `composer validate --strict`. - Lint, static analysis, unit and integration tests, the end-to-end smoke test, and Composer validation are required CI checks. - No third-party JS libraries. The new `match.js` is pure ESM, no dependencies, no `eval`, no `Function` constructor. ## File Structure ```text src/ Application/ ScenarioSerializer.php MODIFY — round-trip objectives in matchToArray/matchFromArray ScenarioDraft.php unchanged ImageUploadService.php unchanged ImageValidator.php unchanged ValidatedImage.php unchanged TurnToken.php NEW — issue/verify HMAC over (matchId, activeTeamId, round, lastActionIndex) Domain/ unchanged — engine stays as-is Http/ Handlers/ GetAssets.php unchanged GetBattlefieldEditor.php unchanged GetBundledScenarios.php NEW — list of {id, name, summary} from /public/assets/scenarios/ GetBundledScenario.php NEW — full Scenario JSON for a bundled id (validates the fixture) GetHomePage.php unchanged GetMatchView.php NEW — server-rendered shell for the match view (loads /public/js/match.js) GetTeamEditor.php unchanged PostBattlefieldEditor.php unchanged PostImageUpload.php unchanged PostMatchMove.php NEW — move action PostMatchAttack.php NEW — attack action PostMatchAbility.php NEW — use-ability action PostMatchEndTurn.php NEW — end-turn action PostStartMatch.php MODIFY — return {match, matchId, turnToken}; mint the matchId + initial token PostTeamEditor.php unchanged MatchActionSupport.php NEW — small helper used by all four action handlers: CSRF + turn-token verification + MatchState decode + JSON error responses Router.php MODIFY — register the five new routes CsrfToken.php unchanged Request.php / Response.php unchanged Escape.php unchanged Views/ layout.php unchanged home.php MODIFY — render a bundled scenarios list (server-rendered; the JS does not need to populate it) match.php NEW — the match view (loads match.js, exposes container + CSRF meta) team-editor.php unchanged battlefield-editor.php unchanged public/ js/ match.js NEW — client match renderer + action dispatcher grid-editor.js unchanged storage.js MODIFY — add bundle-bootstrap helpers (startBundledMatch) and a matchId-aware currentMatch envelope assets/ scenarios/ manifest.json NEW — name + summary for each bundled id skirmish.json NEW — 8x8 open, eliminate_all hold-the-line.json NEW — 10x8, hold_objective, 3 rounds last-stand.json NEW — 12x12, eliminate_all, asymmetric 6v4 styles.css MODIFY — add .bf-match / .bf-unit / .bf-active / .bf-toast rules placeholders/ unchanged archetypes.json unchanged tests/ Unit/ Application/ TurnTokenTest.php NEW ScenarioSerializerTest.php MODIFY — add an objectives-round-trip case Integration/ GetBundledScenariosTest.php NEW GetBundledScenarioTest.php NEW GetMatchViewTest.php NEW PostMatchMoveTest.php NEW PostMatchAttackTest.php NEW PostMatchAbilityTest.php NEW PostMatchEndTurnTest.php NEW PostMatchActionSmokeTest.php NEW — boots php -S, walks Skirmish to a winner; gated by BATTLEFORGE_E2E=1 PostStartMatchTest.php MODIFY — assert {match, matchId, turnToken} response shape FullFlowTest.php unchanged GetHomePageTest.php MODIFY — assert the bundled-scenarios list is rendered server-side .github/workflows/ ci.yml unchanged ci-e2e.yml NEW — PR-to-develop + nightly, BATTLEFORGE_E2E=1, 5-minute timeout docs/ superpowers/ plans/ 2026-07-25-battle-interface.md THIS FILE specs/ 2026-07-25-battle-interface-design.md already committed ``` ## Delivery Sequence Plan 4 is the fourth and final plan in the MVP. It builds on Plans 1–3c. It depends on the rules engine (Plan 1+2) and the HTTP layer (Plan 3a+3b) being merged into `develop` (they are). The plan ships in this order: 1. Objective round-trip fix (foundation for the bundled Hold the Line scenario). 2. `TurnToken` value object (foundation for the action API). 3. Modify `PostStartMatch` to mint `matchId` and `turnToken`. 4. Bundled scenario fixtures and `manifest.json`. 5. `GetBundledScenarios` and `GetBundledScenario` handlers. 6. Update the home page to render the bundled list. 7. `GetMatchView` handler + `match.php` template. 8. `MatchActionSupport` helper. 9. The four per-verb action handlers (move, attack, ability, end-turn). 10. Register the new routes in `public/index.php`. 11. `public/js/match.js` renderer + CSS. 12. End-to-end smoke test. 13. New CI workflow `ci-e2e.yml`. 14. Final whole-branch code review. ## Execution Preflight Execute this plan in an isolated worktree on `feature/battle-interface`, branched from `develop`. The implementation branch will be submitted as a pull request into `develop` only after every completion check is green. ### Task 1: Fix `ScenarioSerializer` objective round-trip **Files:** - Modify: `src/Application/ScenarioSerializer.php` - Test: `tests/Unit/Application/ScenarioSerializerTest.php` **Interfaces:** - Consumes: `ObjectiveMarker`, `Position`, `MatchState`. - Produces: `matchToArray` returns the existing fields plus `'objectives' => array`. `matchFromArray` builds the `array` from the input data and passes it to the `MatchState` constructor. - [ ] **Step 1: Write the failing round-trip test** Append this test method to `final class ScenarioSerializerTest` in `tests/Unit/Application/ScenarioSerializerTest.php`, immediately before the closing brace of the class: ```php public function testMatchRoundTripsItsObjectives(): void { $battlefield = new Battlefield(10, 8); $units = [ new UnitState('a1', 'alpha', new Position(0, 0), 10, 10, 3, 3, 2, 2, false), new UnitState('a2', 'alpha', new Position(1, 0), 10, 10, 3, 3, 2, 2, false), new UnitState('a3', 'alpha', new Position(2, 0), 10, 10, 3, 3, 2, 2, false), new UnitState('b1', 'bravo', new Position(9, 7), 10, 10, 3, 3, 2, 2, false), new UnitState('b2', 'bravo', new Position(8, 7), 10, 10, 3, 3, 2, 2, false), new UnitState('b3', 'bravo', new Position(7, 7), 10, 10, 3, 3, 2, 2, false), ]; $objective = new ObjectiveMarker('objective-1', new Position(5, 4)); $original = new MatchState( battlefield: $battlefield, units: $units, activeTeamId: 'alpha', objectives: ['objective-1' => $objective], victoryCondition: VictoryCondition::HoldObjective, holdRoundsRequired: 3, ); $array = ScenarioSerializer::matchToArray($original); self::assertArrayHasKey('objectives', $array); self::assertSame( ['id' => 'objective-1', 'position' => ['x' => 5, 'y' => 4]], $array['objectives']['objective-1'] ?? null, ); $rebuilt = ScenarioSerializer::matchFromArray($array); self::assertArrayHasKey('objective-1', $rebuilt->objectives); self::assertSame('objective-1', $rebuilt->objectives['objective-1']->id); self::assertSame(5, $rebuilt->objectives['objective-1']->position->x); self::assertSame(4, $rebuilt->objectives['objective-1']->position->y); self::assertSame(VictoryCondition::HoldObjective, $rebuilt->victoryCondition); self::assertSame(3, $rebuilt->holdRoundsRequired); } ``` Add the necessary `use BattleForge\Domain\ObjectiveMarker;` and `use BattleForge\Domain\VictoryCondition;` imports at the top of the file if not already present. - [ ] **Step 2: Run the test to verify it fails** Run: `vendor/bin/phpunit tests/Unit/Application/ScenarioSerializerTest.php --filter RoundTripsItsObjectives` Expected: FAIL because `matchToArray` does not include `objectives` and `matchFromArray` hard-codes `objectives: []`. - [ ] **Step 3: Update `matchToArray` to include objectives** In `src/Application/ScenarioSerializer.php`, inside `matchToArray`, add the following block immediately after the `$terrainMap` block (before the `$units` accumulation): ```php $objectives = []; foreach ($match->objectives as $id => $objective) { $objectives[(string) $id] = [ 'id' => $objective->id, 'position' => self::positionToArray($objective->position), ]; } ``` Then add `'objectives' => $objectives,` to the returned array, after `'objectiveControl' => $match->objectiveControl,`. - [ ] **Step 4: Update `matchFromArray` to restore objectives** In `src/Application/ScenarioSerializer.php`, inside `matchFromArray`, add the following block immediately after the `$units` accumulation (before the `return new MatchState(...)` call): ```php $objectives = []; foreach (($data['objectives'] ?? []) as $id => $row) { $objectives[(string) $id] = new ObjectiveMarker( (string) $row['id'], self::positionFromArray($row['position']), ); } ``` Then change `objectives: [],` to `objectives: $objectives,` in the `return new MatchState(...)` call. Add the `use BattleForge\Domain\ObjectiveMarker;` import at the top of the file if not already present. - [ ] **Step 5: Run the test to verify it passes** Run: `vendor/bin/phpunit tests/Unit/Application/ScenarioSerializerTest.php` Expected: PASS, all tests in the file. - [ ] **Step 6: Run the full quality suite and commit** Run: `composer check` Expected: all checks green (PHPCS, PHPStan, all 196+ tests). ```powershell git add src/Application/ScenarioSerializer.php tests/Unit/Application/ScenarioSerializerTest.php git commit -m "fix: round-trip match objectives through ScenarioSerializer" ``` ### Task 2: Build the `TurnToken` value object **Files:** - Create: `src/Application/TurnToken.php` - Test: `tests/Unit/Application/TurnTokenTest.php` **Interfaces:** - Consumes: a secret string (the same `BATTLEFORGE_SECRET` or `var/secret.key` used for CSRF), a `matchId` (validated as 16+ lowercase hex chars before hashing), an `activeTeamId`, a `round` (>= 1), and a `lastActionIndex` (>= 0). - Produces: - `public static function issue(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string` — returns 32 lowercase hex chars. - `public static function verify(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex, string $token): bool` — `hash_equals` against the freshly-issued token. - The class validates `matchId` against `/^[a-f0-9]{16,}$/` and rejects empty `activeTeamId` and negative `round`/`lastActionIndex` with `InvalidArgumentException`. - [ ] **Step 1: Write the failing test** Create `tests/Unit/Application/TurnTokenTest.php`: ```php expectException(InvalidArgumentException::class); TurnToken::issue(self::SECRET, 'not-hex!', 'alpha', 1, 0); } public function testIssueRejectsAnEmptyActiveTeamId(): void { $this->expectException(InvalidArgumentException::class); TurnToken::issue(self::SECRET, '0123456789abcdef', '', 1, 0); } public function testIssueRejectsANegativeRound(): void { $this->expectException(InvalidArgumentException::class); TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 0, 0); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `vendor/bin/phpunit tests/Unit/Application/TurnTokenTest.php` Expected: FAIL because `BattleForge\Application\TurnToken` does not exist. - [ ] **Step 3: Implement `TurnToken`** Create `src/Application/TurnToken.php`: ```php , matchId: <16 lowercase hex chars>, turnToken: <32 lowercase hex chars>}`. - [ ] **Step 1: Update the existing happy-path test to assert the new fields** In `tests/Integration/PostStartMatchTest.php`, inside `testItAcceptsAValidScenarioAndReturnsTheInitialMatch`, replace the existing assertions (the three lines starting with `self::assertSame('alpha', ...)`) with: ```php $body = json_decode($response->body, true); self::assertSame('alpha', $body['match']['activeTeamId'] ?? null); self::assertSame(1, $body['match']['round'] ?? null); self::assertCount(6, $body['match']['units'] ?? []); self::assertIsString($body['matchId'] ?? null); self::assertMatchesRegularExpression('/^[a-f0-9]{16,}$/', $body['matchId']); self::assertIsString($body['turnToken'] ?? null); self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']); ``` - [ ] **Step 2: Add a new test that asserts the turn token is valid for the initial state** Append the following test method inside `final class PostStartMatchTest`, immediately before the closing brace of the class: ```php public function testItReturnsATurnTokenValidForTheInitialMatchState(): void { [$token, $cookie] = CsrfToken::issue(self::SECRET); $handler = new PostStartMatch(); $request = $this->buildRequest( rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR), csrfHeader: $token, cookies: ['__csrf' => $cookie], ); $response = $handler->handle($request, ['id' => 'demo']); self::assertSame(200, $response->status); $body = json_decode($response->body, true); $matchId = $body['matchId']; $turnToken = $body['turnToken']; $lastActionIndex = count($body['match']['actionLog'] ?? []); self::assertTrue(\BattleForge\Application\TurnToken::verify( self::SECRET, $matchId, $body['match']['activeTeamId'], $body['match']['round'], $lastActionIndex, $turnToken, )); } ``` - [ ] **Step 3: Run the test to verify it fails** Run: `vendor/bin/phpunit tests/Integration/PostStartMatchTest.php` Expected: FAIL because `PostStartMatch` currently returns only `{match: ...}`. - [ ] **Step 4: Modify `PostStartMatch` to mint the `matchId` and `turnToken`** Replace `src/Http/Handlers/PostStartMatch.php` with: ```php $params */ public function handle(Request $request, array $params): Response { $submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? ''); $expected = (string) ($request->cookies['__csrf'] ?? ''); $secret = (string) ($request->server['__csrf_secret'] ?? ''); if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) { return Response::json(403, ['error' => 'csrf']); } try { $payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR); $draft = ScenarioDraft::fromPost($payload); $scenario = $draft->toScenario(); ScenarioValidator::validate($scenario); $match = $scenario->startMatch('alpha'); } catch (\JsonException $exception) { return Response::json(400, ['errors' => ['Malformed JSON.']]); } catch (\InvalidArgumentException $exception) { return Response::json(400, ['errors' => [$exception->getMessage()]]); } $matchId = bin2hex(random_bytes(8)); $matchArray = ScenarioSerializer::matchToArray($match); $turnToken = TurnToken::issue( $secret, $matchId, $match->activeTeamId, $match->round, count($match->actionLog), ); return Response::json(200, [ 'match' => $matchArray, 'matchId' => $matchId, 'turnToken' => $turnToken, ]); } } ``` - [ ] **Step 5: Run the test to verify it passes** Run: `vendor/bin/phpunit tests/Integration/PostStartMatchTest.php` Expected: PASS, 4 tests. - [ ] **Step 6: Run the quality suite and commit** Run: `composer check` Expected: all checks green. ```powershell git add src/Http/Handlers/PostStartMatch.php tests/Integration/PostStartMatchTest.php git commit -m "feat: mint matchId and turnToken in PostStartMatch" ``` ### Task 4: Bundled scenario fixtures and `manifest.json` **Files:** - Create: `public/assets/scenarios/manifest.json` - Create: `public/assets/scenarios/skirmish.json` - Create: `public/assets/scenarios/hold-the-line.json` - Create: `public/assets/scenarios/last-stand.json` **Interfaces:** - Consumes: `ScenarioSerializer::scenarioFromArray` and `ScenarioValidator::validate` (consumers read each fixture through these in Task 5). - Produces: three valid `Scenario` JSON files in the same shape as `ScenarioSerializer::scenarioToArray` produces, plus a `manifest.json` whose keys match the fixture filenames (minus the `.json` extension) and whose values are `{name, summary}` strings. - [ ] **Step 1: Create `manifest.json`** Create `public/assets/scenarios/manifest.json`: ```json { "skirmish": { "name": "Skirmish", "summary": "8x8 open ground. Three units per side. Eliminate the enemy team." }, "hold-the-line": { "name": "Hold the Line", "summary": "10x8 with a forest corridor. Three defenders hold the center against four attackers. Hold the objective for three rounds." }, "last-stand": { "name": "Last Stand", "summary": "12x12 with mixed terrain. Six defenders versus four strikers. Eliminate the enemy team." } } ``` - [ ] **Step 2: Create `skirmish.json`** Create `public/assets/scenarios/skirmish.json`: ```json { "id": "skirmish", "name": "Skirmish", "battlefield": { "width": 8, "height": 8, "terrain": {} }, "units": [ { "id": "alpha-1", "teamId": "alpha", "position": { "x": 0, "y": 1 }, "maxHealth": 12, "health": 12, "attack": 3, "defense": 4, "speed": 2, "archetype": "defender", "abilities": [] }, { "id": "alpha-2", "teamId": "alpha", "position": { "x": 0, "y": 3 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, { "id": "alpha-3", "teamId": "alpha", "position": { "x": 0, "y": 6 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] }, { "id": "bravo-1", "teamId": "bravo", "position": { "x": 7, "y": 1 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, { "id": "bravo-2", "teamId": "bravo", "position": { "x": 7, "y": 3 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }, { "id": "bravo-3", "teamId": "bravo", "position": { "x": 7, "y": 6 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] } ], "deploymentZones": { "alpha": { "teamId": "alpha", "positions": [{ "x": 0, "y": 1 }, { "x": 0, "y": 3 }, { "x": 0, "y": 6 }] }, "bravo": { "teamId": "bravo", "positions": [{ "x": 7, "y": 1 }, { "x": 7, "y": 3 }, { "x": 7, "y": 6 }] } }, "objectives": {}, "victoryCondition": "eliminate_all", "holdRoundsRequired": 1 } ``` - [ ] **Step 3: Create `hold-the-line.json`** Create `public/assets/scenarios/hold-the-line.json`: ```json { "id": "hold-the-line", "name": "Hold the Line", "battlefield": { "width": 10, "height": 8, "terrain": { "3:2": "forest", "3:3": "forest", "3:4": "forest", "3:5": "forest", "4:2": "forest", "4:3": "forest", "4:4": "forest", "4:5": "forest", "5:2": "forest", "5:3": "forest", "5:4": "forest", "5:5": "forest" } }, "units": [ { "id": "alpha-1", "teamId": "alpha", "position": { "x": 4, "y": 3 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 1, "archetype": "defender", "abilities": [] }, { "id": "alpha-2", "teamId": "alpha", "position": { "x": 3, "y": 4 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 1, "archetype": "defender", "abilities": [] }, { "id": "alpha-3", "teamId": "alpha", "position": { "x": 5, "y": 4 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 1, "archetype": "defender", "abilities": [] }, { "id": "bravo-1", "teamId": "bravo", "position": { "x": 9, "y": 1 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, { "id": "bravo-2", "teamId": "bravo", "position": { "x": 9, "y": 3 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, { "id": "bravo-3", "teamId": "bravo", "position": { "x": 9, "y": 5 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] }, { "id": "bravo-4", "teamId": "bravo", "position": { "x": 9, "y": 7 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] } ], "deploymentZones": { "alpha": { "teamId": "alpha", "positions": [{ "x": 4, "y": 3 }, { "x": 3, "y": 4 }, { "x": 5, "y": 4 }] }, "bravo": { "teamId": "bravo", "positions": [{ "x": 9, "y": 1 }, { "x": 9, "y": 3 }, { "x": 9, "y": 5 }, { "x": 9, "y": 7 }] } }, "objectives": { "objective-1": { "id": "objective-1", "position": { "x": 4, "y": 4 } } }, "victoryCondition": "hold_objective", "holdRoundsRequired": 3 } ``` - [ ] **Step 4: Create `last-stand.json`** Create `public/assets/scenarios/last-stand.json`: ```json { "id": "last-stand", "name": "Last Stand", "battlefield": { "width": 12, "height": 12, "terrain": { "2:2": "forest", "2:3": "forest", "2:8": "forest", "2:9": "forest", "3:2": "forest", "3:3": "forest", "3:8": "forest", "3:9": "forest", "5:5": "rough", "5:6": "rough", "6:5": "rough", "6:6": "rough", "8:2": "water", "8:3": "water", "8:8": "water", "8:9": "water", "9:2": "water", "9:3": "water", "9:8": "water", "9:9": "water" } }, "units": [ { "id": "alpha-1", "teamId": "alpha", "position": { "x": 0, "y": 0 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] }, { "id": "alpha-2", "teamId": "alpha", "position": { "x": 0, "y": 3 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] }, { "id": "alpha-3", "teamId": "alpha", "position": { "x": 0, "y": 6 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] }, { "id": "alpha-4", "teamId": "alpha", "position": { "x": 0, "y": 9 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] }, { "id": "alpha-5", "teamId": "alpha", "position": { "x": 1, "y": 5 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }, { "id": "alpha-6", "teamId": "alpha", "position": { "x": 2, "y": 11 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }, { "id": "bravo-1", "teamId": "bravo", "position": { "x": 11, "y": 2 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, { "id": "bravo-2", "teamId": "bravo", "position": { "x": 11, "y": 5 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, { "id": "bravo-3", "teamId": "bravo", "position": { "x": 11, "y": 8 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, { "id": "bravo-4", "teamId": "bravo", "position": { "x": 10, "y": 11 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] } ], "deploymentZones": { "alpha": { "teamId": "alpha", "positions": [{ "x": 0, "y": 0 }, { "x": 0, "y": 3 }, { "x": 0, "y": 6 }, { "x": 0, "y": 9 }, { "x": 1, "y": 5 }, { "x": 2, "y": 11 }] }, "bravo": { "teamId": "bravo", "positions": [{ "x": 11, "y": 2 }, { "x": 11, "y": 5 }, { "x": 11, "y": 8 }, { "x": 10, "y": 11 }] } }, "objectives": {}, "victoryCondition": "eliminate_all", "holdRoundsRequired": 1 } ``` - [ ] **Step 5: Verify each fixture is a valid scenario** Run: `php -r 'require "vendor/autoload.php"; foreach (["skirmish", "hold-the-line", "last-stand"] as $id) { $data = json_decode(file_get_contents("public/assets/scenarios/$id.json"), true, flags: JSON_THROW_ON_ERROR); $scenario = BattleForge\\Application\\ScenarioSerializer::scenarioFromArray($data); $errors = BattleForge\\Domain\\ScenarioValidator::validate($scenario); if ($errors !== []) { echo "$id: FAIL\n"; print_r($errors); exit(1); } echo "$id: OK\n"; }'` Expected: each id prints `OK`. If any prints `FAIL`, the validator messages identify which constraint to fix in the fixture (most likely an out-of-bounds position, an unreachable objective, or a stat range violation). - [ ] **Step 6: Commit the bundled fixtures** ```powershell git add public/assets/scenarios/manifest.json public/assets/scenarios/skirmish.json public/assets/scenarios/hold-the-line.json public/assets/scenarios/last-stand.json git commit -m "feat: add three bundled scenario fixtures and manifest" ``` ### Task 5: `GetBundledScenarios` handler **Files:** - Create: `src/Http/Handlers/GetBundledScenarios.php` - Test: `tests/Integration/GetBundledScenariosTest.php` **Interfaces:** - Consumes: a constructor arg `string $scenariosDir` (an absolute path to `public/assets/scenarios/`). - Produces: `Response::json(200, [['id' => string, 'name' => string, 'summary' => string], ...])` sorted by id. The handler: - Reads `manifest.json` from the scenarios dir; if the file is missing, returns `[]`. - For each `*.json` file in the dir whose name matches `/^[a-z0-9-]+\.json$/`, derives an id by stripping `.json`. If the manifest has an entry for that id, includes it in the response. Any fixture without a manifest entry is filtered out. - Returns the result sorted by id (so the order is stable regardless of the filesystem order). - [ ] **Step 1: Write the failing test** Create `tests/Integration/GetBundledScenariosTest.php`: ```php tmpDir = sys_get_temp_dir() . '/bf-bundled-' . bin2hex(random_bytes(4)); mkdir($this->tmpDir, 0700, true); } protected function tearDown(): void { if (is_dir($this->tmpDir)) { foreach (glob($this->tmpDir . '/*') ?: [] as $file) { unlink($file); } rmdir($this->tmpDir); } } public function testReturnsBundledScenariosFromTheConfiguredDirectory(): void { $this->writeManifest([ 'skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.'], 'hold-the-line' => ['name' => 'Hold the Line', 'summary' => 'Defend.'], ]); $this->writeFixture('skirmish', '{}'); $this->writeFixture('hold-the-line', '{}'); $handler = new GetBundledScenarios($this->tmpDir); $response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []); self::assertSame(200, $response->status); $body = json_decode($response->body, true); self::assertSame( [ ['id' => 'hold-the-line', 'name' => 'Hold the Line', 'summary' => 'Defend.'], ['id' => 'skirmish', 'name' => 'Skirmish', 'summary' => 'Open ground.'], ], $body, ); } public function testFiltersOutFixturesWithoutAManifestEntry(): void { $this->writeManifest(['skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.']]); $this->writeFixture('skirmish', '{}'); $this->writeFixture('rogue', '{}'); $handler = new GetBundledScenarios($this->tmpDir); $response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []); $body = json_decode($response->body, true); self::assertCount(1, $body); self::assertSame('skirmish', $body[0]['id']); } public function testIgnoresFilesWithDisallowedFilenames(): void { $this->writeManifest(['skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.']]); $this->writeFixture('skirmish', '{}'); $this->writeFixture('bad..json', '{}'); $this->writeFixture('README', 'plain text'); $handler = new GetBundledScenarios($this->tmpDir); $response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []); $body = json_decode($response->body, true); self::assertCount(1, $body); self::assertSame('skirmish', $body[0]['id']); } public function testReturnsEmptyArrayWhenManifestIsMissing(): void { $handler = new GetBundledScenarios($this->tmpDir); $response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []); self::assertSame(200, $response->status); self::assertSame([], json_decode($response->body, true)); } /** @param array $entries */ private function writeManifest(array $entries): void { file_put_contents($this->tmpDir . '/manifest.json', json_encode($entries, JSON_THROW_ON_ERROR)); } private function writeFixture(string $name, string $body): void { file_put_contents($this->tmpDir . '/' . $name, $body); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `vendor/bin/phpunit tests/Integration/GetBundledScenariosTest.php` Expected: FAIL because `BattleForge\Http\Handlers\GetBundledScenarios` does not exist. - [ ] **Step 3: Implement `GetBundledScenarios`** Create `src/Http/Handlers/GetBundledScenarios.php`: ```php $params */ public function handle(Request $request, array $params): Response { $manifestPath = $this->scenariosDir . '/manifest.json'; $manifest = []; if (is_file($manifestPath)) { $decoded = json_decode((string) file_get_contents($manifestPath), true); if (is_array($decoded)) { $manifest = $decoded; } } $entries = []; foreach (scandir($this->scenariosDir) ?: [] as $file) { if (!preg_match(self::FILENAME_PATTERN, $file)) { continue; } $id = substr($file, 0, -5); $entry = $manifest[$id] ?? null; if (!is_array($entry) || !isset($entry['name'], $entry['summary'])) { continue; } $entries[] = [ 'id' => $id, 'name' => (string) $entry['name'], 'summary' => (string) $entry['summary'], ]; } usort($entries, static fn (array $a, array $b): int => strcmp($a['id'], $b['id'])); return Response::json(200, $entries); } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `vendor/bin/phpunit tests/Integration/GetBundledScenariosTest.php` Expected: PASS, 4 tests. - [ ] **Step 5: Run the quality suite and commit** Run: `composer check` Expected: all checks green. ```powershell git add src/Http/Handlers/GetBundledScenarios.php tests/Integration/GetBundledScenariosTest.php git commit -m "feat: add GetBundledScenarios handler with manifest-driven list" ``` ### Task 6: `GetBundledScenario` handler **Files:** - Create: `src/Http/Handlers/GetBundledScenario.php` - Test: `tests/Integration/GetBundledScenarioTest.php` **Interfaces:** - Consumes: a constructor arg `string $scenariosDir`. - Produces: - `Response::json(200, )` on success. - `Response::json(404, ['error' => 'not found'])` if the id is missing or fails the `/^[a-z0-9-]+$/` regex, or the file does not exist. - `Response::json(500, ['error' => 'fixture invalid', 'details' => [...]])` if the fixture deserializes into a `Scenario` that fails `ScenarioValidator::validate`. - The handler reads `manifest.json` to ensure the id is recognized; an id with a file but no manifest entry is treated as not found. - [ ] **Step 1: Write the failing test** Create `tests/Integration/GetBundledScenarioTest.php`: ```php tmpDir = sys_get_temp_dir() . '/bf-bundled-one-' . bin2hex(random_bytes(4)); mkdir($this->tmpDir, 0700, true); file_put_contents( $this->tmpDir . '/manifest.json', json_encode(['valid' => ['name' => 'Valid', 'summary' => 'OK']], JSON_THROW_ON_ERROR), ); $this->writeValidFixture(); } protected function tearDown(): void { if (is_dir($this->tmpDir)) { foreach (glob($this->tmpDir . '/*') ?: [] as $file) { unlink($file); } rmdir($this->tmpDir); } } public function testReturnsTheScenarioJsonForAKnownId(): void { $handler = new GetBundledScenario($this->tmpDir); $response = $handler->handle( new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/valid', null, ''), ['id' => 'valid'], ); self::assertSame(200, $response->status); $body = json_decode($response->body, true); self::assertSame('valid', $body['id'] ?? null); self::assertSame('Valid', $body['name'] ?? null); self::assertSame(8, $body['battlefield']['width'] ?? null); } public function testReturns404ForAnUnknownId(): void { $handler = new GetBundledScenario($this->tmpDir); $response = $handler->handle( new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/unknown', null, ''), ['id' => 'unknown'], ); self::assertSame(404, $response->status); } public function testReturns404ForAnIdThatFailsTheRegex(): void { $handler = new GetBundledScenario($this->tmpDir); $response = $handler->handle( new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/..', null, ''), ['id' => '..'], ); self::assertSame(404, $response->status); } public function testReturns500WhenTheFixtureFailsValidation(): void { $bad = [ 'id' => 'broken', 'name' => 'Broken', 'battlefield' => ['width' => 8, 'height' => 8, 'terrain' => []], 'units' => [], 'deploymentZones' => [], 'objectives' => [], 'victoryCondition' => 'eliminate_all', 'holdRoundsRequired' => 1, ]; file_put_contents($this->tmpDir . '/broken.json', json_encode($bad, JSON_THROW_ON_ERROR)); file_put_contents( $this->tmpDir . '/manifest.json', json_encode([ 'valid' => ['name' => 'Valid', 'summary' => 'OK'], 'broken' => ['name' => 'Broken', 'summary' => 'Bad'], ], JSON_THROW_ON_ERROR), ); $handler = new GetBundledScenario($this->tmpDir); $response = $handler->handle( new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/broken', null, ''), ['id' => 'broken'], ); self::assertSame(500, $response->status); $body = json_decode($response->body, true); self::assertSame('fixture invalid', $body['error'] ?? null); self::assertIsArray($body['details'] ?? null); self::assertNotEmpty($body['details']); } private function writeValidFixture(): void { $fixture = [ 'id' => 'valid', 'name' => 'Valid', 'battlefield' => ['width' => 8, 'height' => 8, 'terrain' => []], 'units' => [ ['id' => 'a1', 'teamId' => 'alpha', 'position' => ['x' => 0, 'y' => 0], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []], ['id' => 'a2', 'teamId' => 'alpha', 'position' => ['x' => 1, 'y' => 0], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []], ['id' => 'a3', 'teamId' => 'alpha', 'position' => ['x' => 2, 'y' => 0], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []], ['id' => 'b1', 'teamId' => 'bravo', 'position' => ['x' => 7, 'y' => 7], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []], ['id' => 'b2', 'teamId' => 'bravo', 'position' => ['x' => 6, 'y' => 7], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []], ['id' => 'b3', 'teamId' => 'bravo', 'position' => ['x' => 5, 'y' => 7], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []], ], 'deploymentZones' => [ 'alpha' => ['teamId' => 'alpha', 'positions' => [['x' => 0, 'y' => 0], ['x' => 1, 'y' => 0], ['x' => 2, 'y' => 0]]], 'bravo' => ['teamId' => 'bravo', 'positions' => [['x' => 7, 'y' => 7], ['x' => 6, 'y' => 7], ['x' => 5, 'y' => 7]]], ], 'objectives' => [], 'victoryCondition' => 'eliminate_all', 'holdRoundsRequired' => 1, ]; file_put_contents($this->tmpDir . '/valid.json', json_encode($fixture, JSON_THROW_ON_ERROR)); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `vendor/bin/phpunit tests/Integration/GetBundledScenarioTest.php` Expected: FAIL because `BattleForge\Http\Handlers\GetBundledScenario` does not exist. - [ ] **Step 3: Implement `GetBundledScenario`** Create `src/Http/Handlers/GetBundledScenario.php`: ```php $params */ public function handle(Request $request, array $params): Response { $id = (string) ($params['id'] ?? ''); if (preg_match(self::ID_PATTERN, $id) !== 1) { return Response::json(404, ['error' => 'not found']); } $manifestPath = $this->scenariosDir . '/manifest.json'; $manifest = []; if (is_file($manifestPath)) { $decoded = json_decode((string) file_get_contents($manifestPath), true); if (is_array($decoded)) { $manifest = $decoded; } } if (!isset($manifest[$id])) { return Response::json(404, ['error' => 'not found']); } $fixturePath = $this->scenariosDir . '/' . $id . '.json'; $real = realpath($fixturePath); $realDir = realpath($this->scenariosDir); if ($real === false || $realDir === false || !str_starts_with($real, $realDir . DIRECTORY_SEPARATOR) || !is_file($real)) { return Response::json(404, ['error' => 'not found']); } $data = json_decode((string) file_get_contents($real), true, flags: JSON_THROW_ON_ERROR); if (!is_array($data)) { return Response::json(500, ['error' => 'fixture invalid', 'details' => ['Fixture JSON is not an object.']]); } try { $scenario = ScenarioSerializer::scenarioFromArray($data); } catch (InvalidArgumentException $exception) { return Response::json(500, ['error' => 'fixture invalid', 'details' => [$exception->getMessage()]]); } $errors = ScenarioValidator::validate($scenario); if ($errors !== []) { return Response::json(500, ['error' => 'fixture invalid', 'details' => $errors]); } return Response::json(200, ScenarioSerializer::scenarioToArray($scenario)); } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `vendor/bin/phpunit tests/Integration/GetBundledScenarioTest.php` Expected: PASS, 4 tests. - [ ] **Step 5: Run the quality suite and commit** Run: `composer check` Expected: all checks green. ```powershell git add src/Http/Handlers/GetBundledScenario.php tests/Integration/GetBundledScenarioTest.php git commit -m "feat: add GetBundledScenario handler with fixture validation" ``` ### Task 7: Update the home page to render the bundled scenarios list **Files:** - Modify: `src/Http/Handlers/GetHomePage.php` - Modify: `src/Views/home.php` - Modify: `tests/Integration/GetHomePageTest.php` **Interfaces:** - Consumes: a new constructor arg `string $scenariosDir` for `GetHomePage`. The handler reads `manifest.json` from that dir and passes the entries to the view. - Produces: the home page renders a "Play bundled" `
    ` listing each entry with `

Recent scenarios

No saved scenarios yet.

)` with: - The shared `layout.php` chrome. - A header bar: `
` (the JS fills these from `match:current`). - An End Turn button: `