diff --git a/docs/superpowers/plans/2026-07-25-battle-interface.md b/docs/superpowers/plans/2026-07-25-battle-interface.md new file mode 100644 index 0000000..ba57878 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-battle-interface.md @@ -0,0 +1,3488 @@ +# 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: `