Files
BattleForge/docs/superpowers/plans/2026-07-25-battle-interface.md
T

137 KiB
Raw Blame History

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 <meta name="csrf-token"> 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 { "<id>": {"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

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 13c. 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<string, array{id: string, position: array{x: int, y: int}}>. matchFromArray builds the array<string, ObjectiveMarker> 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:

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):

$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):

$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).

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): boolhash_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

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Application;

use BattleForge\Application\TurnToken;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

final class TurnTokenTest extends TestCase
{
    private const SECRET = 'turn-token-test-secret';

    public function testIssueIsDeterministicForTheSameInputs(): void
    {
        $a = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
        $b = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);

        self::assertSame($a, $b);
        self::assertSame(32, strlen($a));
        self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $a);
    }

    public function testVerifyAcceptsAFreshlyIssuedToken(): void
    {
        $token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);

        self::assertTrue(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 0, $token));
    }

    public function testVerifyRejectsAMutatedToken(): void
    {
        $token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
        $tampered = substr($token, 0, -1) . '0';

        self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 0, $tampered));
    }

    public function testVerifyRejectsATokenFromADifferentMatch(): void
    {
        $token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);

        self::assertFalse(TurnToken::verify(self::SECRET, 'fedcba9876543210', 'alpha', 1, 0, $token));
    }

    public function testVerifyRejectsATokenFromADifferentTeam(): void
    {
        $token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);

        self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'bravo', 1, 0, $token));
    }

    public function testVerifyRejectsATokenFromADifferentRound(): void
    {
        $token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);

        self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 2, 0, $token));
    }

    public function testVerifyRejectsATokenFromADifferentLastActionIndex(): void
    {
        $token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);

        self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 1, $token));
    }

    public function testIssueRejectsAMalformedMatchId(): void
    {
        $this->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

declare(strict_types=1);

namespace BattleForge\Application;

use InvalidArgumentException;

final class TurnToken
{
    private const MATCH_ID_PATTERN = '/^[a-f0-9]{16,}$/';

    public static function issue(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string
    {
        self::assertValidInputs($matchId, $activeTeamId, $round, $lastActionIndex);

        return substr(hash_hmac('sha256', self::payload($matchId, $activeTeamId, $round, $lastActionIndex), $secret), 0, 32);
    }

    public static function verify(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex, string $token): bool
    {
        if ($token === '' || !self::isValidMatchId($matchId) || $activeTeamId === '' || $round < 1 || $lastActionIndex < 0) {
            return false;
        }

        $expected = self::issue($secret, $matchId, $activeTeamId, $round, $lastActionIndex);

        return hash_equals($expected, $token);
    }

    private static function assertValidInputs(string $matchId, string $activeTeamId, int $round, int $lastActionIndex): void
    {
        if (!self::isValidMatchId($matchId)) {
            throw new InvalidArgumentException('Match id must be 16+ lowercase hex characters.');
        }
        if ($activeTeamId === '') {
            throw new InvalidArgumentException('Active team id cannot be empty.');
        }
        if ($round < 1) {
            throw new InvalidArgumentException('Round must be at least 1.');
        }
        if ($lastActionIndex < 0) {
            throw new InvalidArgumentException('Last action index cannot be negative.');
        }
    }

    private static function isValidMatchId(string $matchId): bool
    {
        return preg_match(self::MATCH_ID_PATTERN, $matchId) === 1;
    }

    private static function payload(string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string
    {
        return implode('|', [$matchId, $activeTeamId, (string) $round, (string) $lastActionIndex]);
    }
}
  • Step 4: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Unit/Application/TurnTokenTest.php Expected: PASS, 9 tests.

  • Step 5: Run the quality suite and commit

Run: composer check Expected: all checks green.

git add src/Application/TurnToken.php tests/Unit/Application/TurnTokenTest.php
git commit -m "feat: add TurnToken HMAC for match-action anti-cheat"

Task 3: Modify PostStartMatch to mint matchId and turnToken

Files:

  • Modify: src/Http/Handlers/PostStartMatch.php
  • Test: tests/Integration/PostStartMatchTest.php

Interfaces:

  • Consumes: TurnToken::issue, the existing ScenarioSerializer::matchToArray.

  • Produces: response body shape {match: <ScenarioSerializer::matchToArray output>, 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:

        $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:

    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

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Application\ScenarioDraft;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Request;
use BattleForge\Http\Response;

final class PostStartMatch
{
    /** @param array<string, string> $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.

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:

{
  "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:

{
  "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:

NOTE: The original terrain (a 12-tile forest pocket containing the objective at (4,4)) made the objective unreachable — alpha defenders (speed 1, inside forest) cannot move out, and bravo strikers (speed 3) cannot bridge from column 9 to column 4 through forest. The validator's reachability rule is correct. The corrected fixture below places the objective in the open at (4,4), gives alpha defenders speed 2 so they can step out of their cover onto the objective, gives bravo-4 the scout archetype at speed 5 placed on (9,4) so it can reach (4,4) across open ground in 5 steps, and limits forest to a small cluster around the alpha deployment zone that doesn't block bravo's path. The tactically-intended property — alpha fights from forest cover, bravo has to push across open ground to reach the objective — is preserved.

{
  "id": "hold-the-line",
  "name": "Hold the Line",
  "battlefield": {
    "width": 10,
    "height": 8,
    "terrain": {
      "2:2": "forest", "2:3": "forest", "2:4": "forest", "2:5": "forest",
      "3:2": "forest", "3:3": "forest"
    }
  },
  "units": [
    { "id": "alpha-1", "teamId": "alpha", "position": { "x": 2, "y": 3 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
    { "id": "alpha-2", "teamId": "alpha", "position": { "x": 2, "y": 4 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
    { "id": "alpha-3", "teamId": "alpha", "position": { "x": 2, "y": 5 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "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": 7 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] },
    { "id": "bravo-4", "teamId": "bravo", "position": { "x": 9, "y": 4 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }
  ],
  "deploymentZones": {
    "alpha": { "teamId": "alpha", "positions": [{ "x": 2, "y": 3 }, { "x": 2, "y": 4 }, { "x": 2, "y": 5 }] },
    "bravo": { "teamId": "bravo", "positions": [{ "x": 9, "y": 1 }, { "x": 9, "y": 3 }, { "x": 9, "y": 7 }, { "x": 9, "y": 4 }] }
  },
  "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:

{
  "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
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

declare(strict_types=1);

namespace BattleForge\Tests\Integration;

use BattleForge\Http\Handlers\GetBundledScenarios;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;

final class GetBundledScenariosTest extends TestCase
{
    private string $tmpDir;

    protected function setUp(): void
    {
        $this->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<string, array{name: string, summary: string}> $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 . '.json', $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

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Http\Request;
use BattleForge\Http\Response;

final class GetBundledScenarios
{
    private const FILENAME_PATTERN = '/^[a-z0-9-]+\.json$/';

    public function __construct(private readonly string $scenariosDir)
    {
    }

    /** @param array<string, string> $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.

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, <ScenarioSerializer::scenarioToArray output>) 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

declare(strict_types=1);

namespace BattleForge\Tests\Integration;

use BattleForge\Http\Handlers\GetBundledScenario;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;

final class GetBundledScenarioTest extends TestCase
{
    private string $tmpDir;

    protected function setUp(): void
    {
        $this->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

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Application\ScenarioSerializer;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
use InvalidArgumentException;

final class GetBundledScenario
{
    private const ID_PATTERN = '/^[a-z0-9-]+$/';

    public function __construct(private readonly string $scenariosDir)
    {
    }

    /** @param array<string, string> $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.

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" <ul> listing each entry with <button data-bf-bundle="{id}"> (one button per scenario). The existing storage.js populates the recent-scenarios <div id="recent"> from localStorage; the bundled list is server-rendered and not replaced by JS.

  • Step 1: Add a new test for the bundled list

Add the following test method to tests/Integration/GetHomePageTest.php, immediately before the closing brace of the class. The existing testItReturnsTheHomePageWithSecurityHeaders test remains unchanged.

    public function testItRendersTheBundledScenariosListFromTheManifest(): void
    {
        $tmpDir = sys_get_temp_dir() . '/bf-home-bundled-' . bin2hex(random_bytes(4));
        mkdir($tmpDir, 0700, true);
        file_put_contents(
            $tmpDir . '/manifest.json',
            json_encode([
                'skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.'],
                'hold-the-line' => ['name' => 'Hold the Line', 'summary' => 'Defend.'],
            ], JSON_THROW_ON_ERROR),
        );
        foreach (['skirmish.json', 'hold-the-line.json'] as $file) {
            file_put_contents($tmpDir . '/' . $file, '{}');
        }

        $handler = new GetHomePage($tmpDir);
        $request = new Request([], [], [], [], [], '', 'GET', '/', 'text/html', '');
        $response = $handler->handle($request, []);

        self::assertSame(200, $response->status);
        self::assertStringContainsString('id="bf-bundled"', $response->body);
        self::assertStringContainsString('data-bf-bundle="skirmish"', $response->body);
        self::assertStringContainsString('data-bf-bundle="hold-the-line"', $response->body);
        self::assertStringContainsString('Open ground.', $response->body);
        self::assertStringContainsString('Defend.', $response->body);

        unlink($tmpDir . '/manifest.json');
        foreach (['skirmish.json', 'hold-the-line.json'] as $file) {
            unlink($tmpDir . '/' . $file);
        }
        rmdir($tmpDir);
    }

Add a new test testItReturnsTheHomePageWithSecurityHeaders next to the existing one — that one is no longer the only test. Keep the old test intact (it still passes — the existing assertions are still true on the new view). Wrap the existing test in a separate method, and keep its body as is. The file's structure should now contain two test methods.

  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Integration/GetHomePageTest.php Expected: FAIL on the new test because GetHomePage does not yet accept a scenarios dir and the home view does not render the bundled list.

  • Step 3: Update GetHomePage to accept the scenarios dir and pass it to the view

Replace src/Http/Handlers/GetHomePage.php with:

<?php

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Http\Request;
use BattleForge\Http\Response;

final class GetHomePage
{
    public function __construct(private readonly string $scenariosDir = '')
    {
    }

    /** @param array<string, string> $params */
    public function handle(Request $request, array $params): Response
    {
        $csrf = $request->cookies['__csrf'] ?? '';
        $bundled = $this->loadBundledScenarios();

        ob_start();
        require_once __DIR__ . '/../../Views/layout.php';
        require __DIR__ . '/../../Views/home.php';
        $body = (string) ob_get_clean();

        return Response::html(200, $body);
    }

    /** @return list<array{id: string, name: string, summary: string}> */
    private function loadBundledScenarios(): array
    {
        if ($this->scenariosDir === '' || !is_file($this->scenariosDir . '/manifest.json')) {
            return [];
        }
        $decoded = json_decode((string) file_get_contents($this->scenariosDir . '/manifest.json'), true);
        if (!is_array($decoded)) {
            return [];
        }
        $entries = [];
        foreach ($decoded as $id => $entry) {
            if (!is_string($id) || !preg_match('/^[a-z0-9-]+$/', $id) || !is_array($entry)) {
                continue;
            }
            if (!isset($entry['name'], $entry['summary'])) {
                continue;
            }
            if (!is_file($this->scenariosDir . '/' . $id . '.json')) {
                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 $entries;
    }
}

Note: the $bundled local variable inside handle is captured by the ob_start / require block (the view runs in handle's scope), so the view sees the local without any explicit pass. No further wiring is required.

  • Step 4: Update the home view to render the bundled list

Replace src/Views/home.php with:

<?php

declare(strict_types=1);

use BattleForge\Http\Escape;

/** @var string $csrf Provided by the front controller. */
/** @var list<array{id: string, name: string, summary: string}> $bundled Provided by GetHomePage. */
?><?php
render_layout(static function () use ($csrf, $bundled): void {
    $e = static fn (string $v): string => Escape::html($v);
    $a = static fn (string $v): string => Escape::attr($v);
    ?>
    <h1>BattleForge</h1>
    <p><a href="<?= $a('/scenarios/new/edit/team') ?>">New scenario</a></p>
    <h2>Play bundled</h2>
    <ul id="bf-bundled">
    <?php foreach ($bundled as $entry): ?>
        <li>
            <button type="button" data-bf-bundle="<?= $a($entry['id']) ?>">
                <strong><?= $e($entry['name']) ?></strong> &mdash; <?= $e($entry['summary']) ?>
            </button>
        </li>
    <?php endforeach; ?>
    </ul>
    <h2>Recent scenarios</h2>
    <div id="recent"><p class="bf-empty">No saved scenarios yet.</p></div>
    <script type="module" src="<?= $a('/js/storage.js') ?>"></script>
    <?php
}, $csrf, 'BattleForge');
  • Step 5: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Integration/GetHomePageTest.php Expected: PASS, 2 tests.

  • Step 6: Run the quality suite and commit

Run: composer check Expected: all checks green.

git add src/Http/Handlers/GetHomePage.php src/Views/home.php tests/Integration/GetHomePageTest.php
git commit -m "feat: render the bundled-scenarios list on the home page"

Task 8: GetMatchView handler and match.php template

Files:

  • Create: src/Http/Handlers/GetMatchView.php
  • Create: src/Views/match.php
  • Test: tests/Integration/GetMatchViewTest.php

Interfaces:

  • Consumes: a Request (only the __csrf cookie is read).

  • Produces: Response::html(200, <body>) with:

    • The shared layout.php chrome.
    • A header bar: <header id="bf-match-header" data-bf-active-team="..." data-bf-round="..."> (the JS fills these from match:current).
    • An End Turn button: <button id="bf-end-turn" type="button">.
    • A grid container: <div id="bf-grid" class="bf-grid" data-bf-width="0" data-bf-height="0"> (the JS fills in the dimensions and the tiles).
    • An action panel: <aside id="bf-panel"> with three action buttons (#bf-action-move, #bf-action-attack, #bf-action-ability) and a #bf-selected-unit indicator.
    • An action log: <div id="bf-log" class="bf-log">.
    • A result panel: <div id="bf-result" class="bf-result" hidden> with a #bf-winner span and an #bf-return-home link.
    • A <script type="module" src="/js/match.js"> tag.
    • The CSRF meta tag (already provided by the layout).
    • The security headers (already provided by the front controller, not the handler).
  • Step 1: Write the failing test

Create tests/Integration/GetMatchViewTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Integration;

use BattleForge\Http\Handlers\GetMatchView;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;

final class GetMatchViewTest extends TestCase
{
    public function testItRendersTheMatchShellWithSecurityHeaders(): void
    {
        $handler = new GetMatchView();
        $request = new Request([], [], [], [], [], '', 'GET', '/matches/current', 'text/html', '');
        $response = $handler->handle($request, []);

        self::assertSame(200, $response->status);
        self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
        self::assertSame('nosniff', $response->headers['X-Content-Type-Options']);
        self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']);

        self::assertStringContainsString('id="bf-match-header"', $response->body);
        self::assertStringContainsString('id="bf-end-turn"', $response->body);
        self::assertStringContainsString('id="bf-grid"', $response->body);
        self::assertStringContainsString('id="bf-panel"', $response->body);
        self::assertStringContainsString('id="bf-action-move"', $response->body);
        self::assertStringContainsString('id="bf-action-attack"', $response->body);
        self::assertStringContainsString('id="bf-action-ability"', $response->body);
        self::assertStringContainsString('id="bf-selected-unit"', $response->body);
        self::assertStringContainsString('id="bf-log"', $response->body);
        self::assertStringContainsString('id="bf-result"', $response->body);
        self::assertStringContainsString('id="bf-winner"', $response->body);
        self::assertStringContainsString('id="bf-return-home"', $response->body);
        self::assertStringContainsString('src="/js/match.js"', $response->body);
        self::assertStringContainsString('name="csrf-token"', $response->body);
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Integration/GetMatchViewTest.php Expected: FAIL because GetMatchView does not exist.

  • Step 3: Implement GetMatchView

Create src/Http/Handlers/GetMatchView.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Http\Request;
use BattleForge\Http\Response;

final class GetMatchView
{
    /** @param array<string, string> $params */
    public function handle(Request $request, array $params): Response
    {
        $csrf = $request->cookies['__csrf'] ?? '';

        ob_start();
        require_once __DIR__ . '/../../Views/layout.php';
        require __DIR__ . '/../../Views/match.php';
        $body = (string) ob_get_clean();

        return Response::html(200, $body);
    }
}
  • Step 4: Implement the match.php view

Create src/Views/match.php:

<?php

declare(strict_types=1);

use BattleForge\Http\Escape;

/** @var string $csrf Provided by the front controller. */
?><?php
render_layout(static function (): void {
    $a = static fn (string $v): string => Escape::attr($v);
    ?>
    <header id="bf-match-header" data-bf-active-team="" data-bf-round="0">
        <h1>Battle</h1>
        <p>
            Active team: <strong id="bf-active-team">&mdash;</strong>
            &middot; Round: <strong id="bf-round">0</strong>
        </p>
        <p>
            <button id="bf-end-turn" type="button">End Turn</button>
        </p>
    </header>
    <main>
        <div id="bf-grid" class="bf-grid" data-bf-width="0" data-bf-height="0"></div>
        <aside id="bf-panel" class="bf-panel">
            <p>Selected: <span id="bf-selected-unit">none</span></p>
            <p>
                <button id="bf-action-move" type="button" disabled>Move</button>
                <button id="bf-action-attack" type="button" disabled>Attack</button>
                <button id="bf-action-ability" type="button" disabled>Ability</button>
            </p>
            <div id="bf-ability-options"></div>
        </aside>
    </main>
    <section id="bf-log" class="bf-log" aria-label="Action log"></section>
    <section id="bf-result" class="bf-result" hidden>
        <h2>Match over</h2>
        <p>Winner: <strong id="bf-winner"></strong></p>
        <p><a id="bf-return-home" href="/">Return to home</a></p>
    </section>
    <div id="bf-toast-host"></div>
    <script type="module" src="<?= $a('/js/match.js') ?>"></script>
    <?php
}, $csrf, 'Battle');
  • Step 5: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Integration/GetMatchViewTest.php Expected: PASS, 1 test.

  • Step 6: Run the quality suite and commit

Run: composer check Expected: all checks green.

git add src/Http/Handlers/GetMatchView.php src/Views/match.php tests/Integration/GetMatchViewTest.php
git commit -m "feat: add match view server-rendered shell"

Task 9: MatchActionSupport shared helper

Files:

  • Create: src/Http/MatchActionSupport.php
  • Test: tests/Unit/Http/MatchActionSupportTest.php

Interfaces:

  • Consumes: a Request and a callback for the per-verb engine call.

  • Produces: a normalized result object so the four per-verb handlers stay small. Specifically:

    • final readonly class MatchActionResult with public ?Response $response (when the request was rejected at the boundary) and ?array $matchIdAndMatch (the validated (matchId, match) pair to feed the engine). The four handlers call $support->verify($request) and then branch on the result.
    • final class MatchActionSupport with:
      • public function __construct(string $secret) (the CSRF / turn-token secret).
      • public function verify(Request $request): MatchActionResult — runs CSRF check, turn-token check, and MatchState decode; returns a MatchActionResult carrying either a Response (CSRF/turn/state failure) or the validated pair.
    • The class does not dispatch the engine call itself; each handler does that with the result of verify().
  • Step 1: Write the failing test

Create tests/Unit/Http/MatchActionSupportTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Http;

use BattleForge\Application\TurnToken;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\UnitState;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;

final class MatchActionSupportTest extends TestCase
{
    private const SECRET = 'support-test-secret';

    public function testItRejectsARequestWithAMissingCsrfToken(): void
    {
        $support = new MatchActionSupport(self::SECRET);
        $request = $this->buildRequest(match: $this->validMatchArray(), csrf: '', turnToken: 'irrelevant');

        $result = $support->verify($request);

        self::assertNotNull($result->response);
        self::assertSame(403, $result->response->status);
        self::assertNull($result->matchIdAndMatch);
    }

    public function testItRejectsARequestWithABadTurnToken(): void
    {
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);
        $match = $this->validMatch();
        $matchId = '0123456789abcdef';
        $badToken = TurnToken::issue(self::SECRET, $matchId, 'bravo', 1, 0);

        $support = new MatchActionSupport(self::SECRET);
        $request = $this->buildRequest(
            match: $this->validMatchArray(),
            csrf: $csrf,
            turnToken: $badToken,
            cookies: ['__csrf' => $cookie],
        );

        $result = $support->verify($request);

        self::assertNotNull($result->response);
        self::assertSame(409, $result->response->status);
        $body = json_decode($result->response->body, true);
        self::assertSame('turn', $body['error'] ?? null);
    }

    public function testItRejectsAMalformedMatchState(): void
    {
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);
        $support = new MatchActionSupport(self::SECRET);
        $request = $this->buildRequest(
            match: ['this' => 'is not a match'],
            csrf: $csrf,
            turnToken: 'whatever',
            cookies: ['__csrf' => $cookie],
        );

        $result = $support->verify($request);

        self::assertNotNull($result->response);
        self::assertSame(400, $result->response->status);
    }

    public function testItReturnsTheValidatedPairForAValidRequest(): void
    {
        $match = $this->validMatch();
        $matchArray = $this->validMatchArray();
        $matchId = '0123456789abcdef';
        $token = TurnToken::issue(self::SECRET, $matchId, $match->activeTeamId, $match->round, count($match->actionLog));
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);

        $support = new MatchActionSupport(self::SECRET);
        $request = $this->buildRequest(
            match: $matchArray,
            csrf: $csrf,
            turnToken: $token,
            cookies: ['__csrf' => $cookie],
        );
        $body = json_encode(['matchId' => $matchId, 'match' => $matchArray], JSON_THROW_ON_ERROR);
        $request = new Request([], [], [], ['__csrf' => $cookie], [
            'HTTP_X_CSRF_TOKEN' => $csrf,
            'HTTP_X_TURN_TOKEN' => $token,
            '__csrf_secret' => self::SECRET,
            'CONTENT_TYPE' => 'application/json',
        ], '', 'POST', '/matches/current/move', 'application/json', $body);

        $result = $support->verify($request);

        self::assertNull($result->response);
        self::assertNotNull($result->matchIdAndMatch);
        self::assertSame($matchId, $result->matchIdAndMatch['matchId']);
        self::assertInstanceOf(MatchState::class, $result->matchIdAndMatch['match']);
    }

    private function validMatch(): MatchState
    {
        return new MatchState(
            new Battlefield(8, 8),
            [
                new UnitState('alpha-1', 'alpha', new \BattleForge\Domain\Position(0, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
                new UnitState('alpha-2', 'alpha', new \BattleForge\Domain\Position(1, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
                new UnitState('alpha-3', 'alpha', new \BattleForge\Domain\Position(2, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
                new UnitState('bravo-1', 'bravo', new \BattleForge\Domain\Position(7, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
                new UnitState('bravo-2', 'bravo', new \BattleForge\Domain\Position(6, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
                new UnitState('bravo-3', 'bravo', new \BattleForge\Domain\Position(5, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
            ],
            'alpha',
        );
    }

    /** @return array<string, mixed> */
    private function validMatchArray(): array
    {
        return \BattleForge\Application\ScenarioSerializer::matchToArray($this->validMatch());
    }

    /**
     * @param array<string, mixed> $match
     * @param array<string, string> $cookies
     */
    private function buildRequest(array $match, string $csrf, string $turnToken, array $cookies = []): Request
    {
        $body = json_encode(['matchId' => '0123456789abcdef', 'match' => $match], JSON_THROW_ON_ERROR);
        return new Request([], [], [], $cookies, [
            'HTTP_X_CSRF_TOKEN' => $csrf,
            'HTTP_X_TURN_TOKEN' => $turnToken,
            '__csrf_secret' => self::SECRET,
            'CONTENT_TYPE' => 'application/json',
        ], '', 'POST', '/matches/current/move', 'application/json', $body);
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Http/MatchActionSupportTest.php Expected: FAIL because BattleForge\Http\MatchActionSupport does not exist.

  • Step 3: Implement MatchActionSupport

Create src/Http/MatchActionSupport.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http;

use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\MatchState;

final readonly class MatchActionResult
{
    public function __construct(
        public ?Response $response,
        public ?array $matchIdAndMatch,
    ) {
    }

    /** @param array{matchId: string, match: MatchState} $pair */
    public static function ok(array $pair): self
    {
        return new self(null, $pair);
    }

    public static function reject(Response $response): self
    {
        return new self($response, null);
    }
}

final class MatchActionSupport
{
    public function __construct(private readonly string $secret)
    {
    }

    public function verify(Request $request): MatchActionResult
    {
        $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 MatchActionResult::reject(Response::json(403, ['error' => 'csrf']));
        }

        $payload = json_decode($request->rawBody, true);
        if (!is_array($payload)) {
            return MatchActionResult::reject(Response::json(400, ['errors' => ['Malformed JSON.']]));
        }

        $matchId = (string) ($payload['matchId'] ?? '');
        $matchData = $payload['match'] ?? null;
        if (!is_array($matchData)) {
            return MatchActionResult::reject(Response::json(400, ['errors' => ['Match is required.']]));
        }

        try {
            $match = ScenarioSerializer::matchFromArray($matchData);
        } catch (\JsonException $exception) {
            return MatchActionResult::reject(Response::json(400, ['errors' => ['Malformed JSON.']]));
        } catch (\InvalidArgumentException $exception) {
            return MatchActionResult::reject(Response::json(400, ['errors' => [$exception->getMessage()]]));
        }

        $supplied = (string) ($request->server['HTTP_X_TURN_TOKEN'] ?? '');
        if (!TurnToken::verify(
            $this->secret,
            $matchId,
            $match->activeTeamId,
            $match->round,
            count($match->actionLog),
            $supplied,
        )) {
            return MatchActionResult::reject(Response::json(409, ['error' => 'turn']));
        }

        return MatchActionResult::ok(['matchId' => $matchId, 'match' => $match]);
    }
}
  • Step 4: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Unit/Http/MatchActionSupportTest.php Expected: PASS, 4 tests.

  • Step 5: Run the quality suite and commit

Run: composer check Expected: all checks green.

git add src/Http/MatchActionSupport.php tests/Unit/Http/MatchActionSupportTest.php
git commit -m "feat: add MatchActionSupport for shared CSRF/turn/state verification"

Task 10: Four per-verb action handlers (move / attack / ability / end-turn)

This task creates all four action handlers in a single commit because they share a structure enforced by the same helper. Each handler is small (~20 lines).

Files:

  • Create: src/Http/Handlers/PostMatchMove.php
  • Create: src/Http/Handlers/PostMatchAttack.php
  • Create: src/Http/Handlers/PostMatchAbility.php
  • Create: src/Http/Handlers/PostMatchEndTurn.php
  • Test: tests/Integration/PostMatchActionTest.php (one test class with one happy + one rejection case per verb)

Interfaces (each handler):

  • Consumes: the request body {matchId, match, ...actionParams} and the MatchActionSupport (constructed with the secret from $request->server['__csrf_secret']).
  • Produces: Response::json(200, ['match' => <serialized>, 'turnToken' => <hex>, 'winnerTeamId' => <string|null>]) on success.
  • Produces (rejection): the Response from MatchActionResult::reject(...) unchanged (handler does not swallow it).
  • Produces (engine error): Response::json(409, ['error' => <reason>]) on CombatException. The match in the body is the incoming match (unchanged), so the client can confirm the rejection didn't partially mutate state.

Each handler follows the same shape:

final class PostMatchVerb
{
    public function __construct(private readonly string $secret) {}

    public function handle(Request $request, array $params): Response
    {
        $support = new MatchActionSupport($this->secret);
        $result = $support->verify($request);
        if ($result->response !== null) {
            return $result->response;
        }
        $matchId = $result->matchIdAndMatch['matchId'];
        $match = $result->matchIdAndMatch['match'];
        try {
            $next = (new CombatEngine())->verb($match, ...);
        } catch (CombatException $exception) {
            return Response::json(409, [
                'error' => $exception->getMessage(),
                'match' => ScenarioSerializer::matchToArray($match),
                'turnToken' => TurnToken::issue($this->secret, $matchId, $match->activeTeamId, $match->round, count($match->actionLog)),
            ]);
        }
        return Response::json(200, [
            'match' => ScenarioSerializer::matchToArray($next),
            'turnToken' => TurnToken::issue($this->secret, $matchId, $next->activeTeamId, $next->round, count($next->actionLog)),
            'winnerTeamId' => $next->winnerTeamId,
        ]);
    }
}
  • Step 1: Implement PostMatchMove

Create src/Http/Handlers/PostMatchMove.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\Position;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;

final class PostMatchMove
{
    public function __construct(private readonly string $secret)
    {
    }

    /** @param array<string, string> $params */
    public function handle(Request $request, array $params): Response
    {
        $support = new MatchActionSupport($this->secret);
        $result = $support->verify($request);
        if ($result->response !== null) {
            return $result->response;
        }
        $matchId = $result->matchIdAndMatch['matchId'];
        $match = $result->matchIdAndMatch['match'];

        $payload = json_decode($request->rawBody, true);
        $unitId = (string) ($payload['unitId'] ?? '');
        $x = (int) ($payload['x'] ?? -1);
        $y = (int) ($payload['y'] ?? -1);

        try {
            $next = (new CombatEngine())->move($match, $unitId, new Position($x, $y));
        } catch (CombatException $exception) {
            return self::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
        }

        return self::successResponse($this->secret, $matchId, $next);
    }

    /** @return array<string, mixed> */
    public static function successResponse(string $secret, string $matchId, \BattleForge\Domain\MatchState $next): array
    {
        return [
            'match' => ScenarioSerializer::matchToArray($next),
            'turnToken' => TurnToken::issue($secret, $matchId, $next->activeTeamId, $next->round, count($next->actionLog)),
            'winnerTeamId' => $next->winnerTeamId,
        ];
    }

    public static function rejectionResponse(string $secret, string $matchId, \BattleForge\Domain\MatchState $match, string $reason): Response
    {
        return Response::json(409, [
            'error' => $reason,
            'match' => ScenarioSerializer::matchToArray($match),
            'turnToken' => TurnToken::issue($secret, $matchId, $match->activeTeamId, $match->round, count($match->actionLog)),
        ]);
    }
}
  • Step 2: Implement PostMatchAttack

Create src/Http/Handlers/PostMatchAttack.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;

final class PostMatchAttack
{
    public function __construct(private readonly string $secret)
    {
    }

    /** @param array<string, string> $params */
    public function handle(Request $request, array $params): Response
    {
        $support = new MatchActionSupport($this->secret);
        $result = $support->verify($request);
        if ($result->response !== null) {
            return $result->response;
        }
        $matchId = $result->matchIdAndMatch['matchId'];
        $match = $result->matchIdAndMatch['match'];

        $payload = json_decode($request->rawBody, true);
        $attackerId = (string) ($payload['attackerId'] ?? '');
        $targetId = (string) ($payload['targetId'] ?? '');

        try {
            $next = (new CombatEngine())->attack($match, $attackerId, $targetId);
        } catch (CombatException $exception) {
            return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
        }

        return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next));
    }
}
  • Step 3: Implement PostMatchAbility

Create src/Http/Handlers/PostMatchAbility.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\Position;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;

final class PostMatchAbility
{
    public function __construct(private readonly string $secret)
    {
    }

    /** @param array<string, string> $params */
    public function handle(Request $request, array $params): Response
    {
        $support = new MatchActionSupport($this->secret);
        $result = $support->verify($request);
        if ($result->response !== null) {
            return $result->response;
        }
        $matchId = $result->matchIdAndMatch['matchId'];
        $match = $result->matchIdAndMatch['match'];

        $payload = json_decode($request->rawBody, true);
        $unitId = (string) ($payload['unitId'] ?? '');
        $abilityId = (string) ($payload['abilityId'] ?? '');
        $x = (int) ($payload['x'] ?? -1);
        $y = (int) ($payload['y'] ?? -1);

        try {
            $next = (new CombatEngine())->useAbility($match, $unitId, $abilityId, new Position($x, $y));
        } catch (CombatException $exception) {
            return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
        }

        return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next));
    }
}
  • Step 4: Implement PostMatchEndTurn

Create src/Http/Handlers/PostMatchEndTurn.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;

final class PostMatchEndTurn
{
    public function __construct(private readonly string $secret)
    {
    }

    /** @param array<string, string> $params */
    public function handle(Request $request, array $params): Response
    {
        $support = new MatchActionSupport($this->secret);
        $result = $support->verify($request);
        if ($result->response !== null) {
            return $result->response;
        }
        $matchId = $result->matchIdAndMatch['matchId'];
        $match = $result->matchIdAndMatch['match'];

        try {
            $next = (new CombatEngine())->endTurn($match);
        } catch (CombatException $exception) {
            return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
        }

        return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next));
    }
}
  • Step 5: Write the integration test

Create tests/Integration/PostMatchActionTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Integration;

use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\UnitState;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostMatchAbility;
use BattleForge\Http\Handlers\PostMatchAttack;
use BattleForge\Http\Handlers\PostMatchEndTurn;
use BattleForge\Http\Handlers\PostMatchMove;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;

final class PostMatchActionTest extends TestCase
{
    private const SECRET = 'match-action-test-secret';
    private const MATCH_ID = '0123456789abcdef';

    public function testMoveHappyPathReturnsTheUpdatedMatchAndAFreshTurnToken(): void
    {
        $match = $this->freshMatch();
        $token = $this->tokenFor($match);
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);

        $response = (new PostMatchMove(self::SECRET))->handle(
            $this->buildRequest(
                match: $match,
                csrf: $csrf,
                turnToken: $token,
                cookies: ['__csrf' => $cookie],
                body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-1', 'x' => 1, 'y' => 0],
            ),
            [],
        );

        self::assertSame(200, $response->status);
        $body = json_decode($response->body, true);
        self::assertSame('1:0', $body['match']['units'][0]['position']['x'] . ':' . $body['match']['units'][0]['position']['y']);
        self::assertSame(1, $body['match']['units'][0]['actionsRemaining']);
        self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']);
        self::assertNull($body['winnerTeamId']);
    }

    public function testMoveRejectsAnUnreachableDestinationAndDoesNotMutateTheMatch(): void
    {
        $match = $this->freshMatch();
        $token = $this->tokenFor($match);
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);

        $response = (new PostMatchMove(self::SECRET))->handle(
            $this->buildRequest(
                match: $match,
                csrf: $csrf,
                turnToken: $token,
                cookies: ['__csrf' => $cookie],
                body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-1', 'x' => 0, 'y' => 0],
            ),
            [],
        );

        self::assertSame(409, $response->status);
        $body = json_decode($response->body, true);
        self::assertStringContainsString('not reachable', $body['error']);
        self::assertSame('0:0', $body['match']['units'][0]['position']['x'] . ':' . $body['match']['units'][0]['position']['y']);
    }

    public function testAttackHappyPathReducesTargetHealth(): void
    {
        $match = $this->freshMatch();
        $matchArray = ScenarioSerializer::matchToArray($match);
        $matchArray['units'][0]['position'] = ['x' => 6, 'y' => 7];
        $match = ScenarioSerializer::matchFromArray($matchArray);
        $token = $this->tokenFor($match);
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);

        $response = (new PostMatchAttack(self::SECRET))->handle(
            $this->buildRequest(
                match: $match,
                csrf: $csrf,
                turnToken: $token,
                cookies: ['__csrf' => $cookie],
                body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1'],
            ),
            [],
        );

        self::assertSame(200, $response->status);
        $body = json_decode($response->body, true);
        $bravo = null;
        foreach ($body['match']['units'] as $unit) {
            if ($unit['id'] === 'bravo-1') { $bravo = $unit; break; }
        }
        self::assertNotNull($bravo);
        self::assertLessThan(10, $bravo['health']);
    }

    public function testAttackRejectsAnOutOfRangeTarget(): void
    {
        $match = $this->freshMatch();
        $token = $this->tokenFor($match);
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);

        $response = (new PostMatchAttack(self::SECRET))->handle(
            $this->buildRequest(
                match: $match,
                csrf: $csrf,
                turnToken: $token,
                cookies: ['__csrf' => $cookie],
                body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1'],
            ),
            [],
        );

        self::assertSame(409, $response->status);
    }

    public function testAbilityHappyPathHealsAnAlly(): void
    {
        $match = $this->freshMatch();
        $matchArray = ScenarioSerializer::matchToArray($match);
        foreach ($matchArray['units'] as &$unit) {
            if ($unit['id'] === 'alpha-3') {
                $unit['archetype'] = 'support';
                $unit['abilities'] = ['heal'];
            }
        }
        unset($unit);
        foreach ($matchArray['units'] as &$unit) {
            if ($unit['id'] === 'alpha-2') {
                $unit['health'] = 2;
            }
        }
        unset($unit);
        $match = ScenarioSerializer::matchFromArray($matchArray);
        $token = $this->tokenFor($match);
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);

        $response = (new PostMatchAbility(self::SECRET))->handle(
            $this->buildRequest(
                match: $match,
                csrf: $csrf,
                turnToken: $token,
                cookies: ['__csrf' => $cookie],
                body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-3', 'abilityId' => 'heal', 'x' => 1, 'y' => 0],
            ),
            [],
        );

        self::assertSame(200, $response->status);
        $body = json_decode($response->body, true);
        $healed = null;
        foreach ($body['match']['units'] as $unit) {
            if ($unit['id'] === 'alpha-2') { $healed = $unit; break; }
        }
        self::assertGreaterThan(2, $healed['health']);
    }

    public function testAbilityRejectsATargetOutsideRange(): void
    {
        $match = $this->freshMatch();
        $matchArray = ScenarioSerializer::matchToArray($match);
        foreach ($matchArray['units'] as &$unit) {
            if ($unit['id'] === 'alpha-3') {
                $unit['archetype'] = 'support';
                $unit['abilities'] = ['heal'];
            }
        }
        unset($unit);
        $match = ScenarioSerializer::matchFromArray($matchArray);
        $token = $this->tokenFor($match);
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);

        $response = (new PostMatchAbility(self::SECRET))->handle(
            $this->buildRequest(
                match: $match,
                csrf: $csrf,
                turnToken: $token,
                cookies: ['__csrf' => $cookie],
                body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-3', 'abilityId' => 'heal', 'x' => 7, 'y' => 7],
            ),
            [],
        );

        self::assertSame(409, $response->status);
    }

    public function testEndTurnHappyPathSwitchesTheActiveTeamAndIncrementsTheRoundAfterBothSides(): void
    {
        $match = $this->freshMatch();
        $token = $this->tokenFor($match);
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);

        $response = (new PostMatchEndTurn(self::SECRET))->handle(
            $this->buildRequest(
                match: $match,
                csrf: $csrf,
                turnToken: $token,
                cookies: ['__csrf' => $cookie],
                body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match)],
            ),
            [],
        );

        self::assertSame(200, $response->status);
        $body = json_decode($response->body, true);
        self::assertSame('bravo', $body['match']['activeTeamId']);
        self::assertSame(1, $body['match']['round']);
    }

    public function testEndTurnRejectsARequestWithAStaleTurnToken(): void
    {
        $match = $this->freshMatch();
        $stale = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 1, 0);
        $wrongRound = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 2, 0);
        $good = $this->tokenFor($match);
        [$csrf, $cookie] = CsrfToken::issue(self::SECRET);

        $staleResponse = (new PostMatchEndTurn(self::SECRET))->handle(
            $this->buildRequest(
                match: $match,
                csrf: $csrf,
                turnToken: $stale,
                cookies: ['__csrf' => $cookie],
                body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match)],
            ),
            [],
        );
        self::assertSame(409, $staleResponse->status);
        $body = json_decode($staleResponse->body, true);
        self::assertSame('turn', $body['error'] ?? null);

        $wrongRoundResponse = (new PostMatchEndTurn(self::SECRET))->handle(
            $this->buildRequest(
                match: $match,
                csrf: $csrf,
                turnToken: $wrongRound,
                cookies: ['__csrf' => $cookie],
                body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match)],
            ),
            [],
        );
        self::assertSame(409, $wrongRoundResponse->status);
    }

    private function freshMatch(): MatchState
    {
        return new MatchState(
            new Battlefield(8, 8),
            [
                new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
                new UnitState('alpha-2', 'alpha', new Position(0, 1), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
                new UnitState('alpha-3', 'alpha', new Position(1, 1), 10, 10, 3, 3, 2, 2, false, Archetype::Support, ['heal']),
                new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
                new UnitState('bravo-2', 'bravo', new Position(6, 6), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
                new UnitState('bravo-3', 'bravo', new Position(5, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
            ],
            'alpha',
        );
    }

    private function tokenFor(MatchState $match): string
    {
        return TurnToken::issue(
            self::SECRET,
            self::MATCH_ID,
            $match->activeTeamId,
            $match->round,
            count($match->actionLog),
        );
    }

    /**
     * @param array<string, string> $cookies
     * @param array<string, mixed>  $body
     */
    private function buildRequest(MatchState $match, string $csrf, string $turnToken, array $cookies, array $body): Request
    {
        return new Request([], [], [], $cookies, [
            'HTTP_X_CSRF_TOKEN' => $csrf,
            'HTTP_X_TURN_TOKEN' => $turnToken,
            '__csrf_secret' => self::SECRET,
            'CONTENT_TYPE' => 'application/json',
        ], '', 'POST', '/matches/current/test', 'application/json', json_encode($body, JSON_THROW_ON_ERROR));
    }
}
  • Step 6: Run the new test to verify it passes

Run: vendor/bin/phpunit tests/Integration/PostMatchActionTest.php Expected: PASS, 8 tests (move happy + reject, attack happy + reject, ability happy + reject, end-turn happy, end-turn stale token).

  • Step 7: Run the quality suite and commit

Run: composer check Expected: all checks green.

git add src/Http/Handlers/PostMatchMove.php src/Http/Handlers/PostMatchAttack.php src/Http/Handlers/PostMatchAbility.php src/Http/Handlers/PostMatchEndTurn.php tests/Integration/PostMatchActionTest.php
git commit -m "feat: add four per-verb match action handlers"

Task 11: Wire the new routes into public/index.php

Files:

  • Modify: public/index.php
  • Modify: src/Http/Router.php (only if a new helper is needed; otherwise the existing add() method suffices)

Interfaces:

  • Consumes: the six new handlers (two for bundled scenarios, one for the match view, four for match actions), each constructed with the secret / scenarios dir from the front controller.

  • Produces: six new route registrations.

  • Step 1: Examine the existing Router API

Run: cat src/Http/Router.php | head -60 Expected: the existing Router::add(string $method, string $pattern, callable $handler) is sufficient. No modification to Router is required.

  • Step 2: Add the new handlers to the front controller

In public/index.php, immediately after the existing handler closures (the $homePage, $getTeam, etc. block ending with $getUploadedAsset), add:

$scenariosDir = __DIR__ . '/assets/scenarios';
$getBundledList = static function (Request $r, array $p) use ($scenariosDir): Response {
    return (new GetBundledScenarios($scenariosDir))->handle($r, $p);
};
$getBundledOne = static function (Request $r, array $p) use ($scenariosDir): Response {
    return (new GetBundledScenario($scenariosDir))->handle($r, $p);
};
$getMatchView = static fn (Request $r, array $p): Response => (new GetMatchView())->handle($r, $p);
$secret = (string) ($_SERVER['__csrf_secret'] ?? '');
$postMatchMove = static function (Request $r, array $p) use ($secret): Response {
    return (new PostMatchMove($secret))->handle($r, $p);
};
$postMatchAttack = static function (Request $r, array $p) use ($secret): Response {
    return (new PostMatchAttack($secret))->handle($r, $p);
};
$postMatchAbility = static function (Request $r, array $p) use ($secret): Response {
    return (new PostMatchAbility($secret))->handle($r, $p);
};
$postMatchEndTurn = static function (Request $r, array $p) use ($secret): Response {
    return (new PostMatchEndTurn($secret))->handle($r, $p);
};

Add the use BattleForge\Http\Handlers\GetBundledScenario; and use BattleForge\Http\Handlers\GetBundledScenarios; and the four use BattleForge\Http\Handlers\PostMatch*; lines to the use block at the top of the file.

Then add the six new route registrations to the router (alongside the existing ones), in this order:

$router->add('GET', '/scenarios/bundled', $getBundledList);
$router->add('GET', '/scenarios/bundled/{id}', $getBundledOne);
$router->add('GET', '/matches/current', $getMatchView);
$router->add('POST', '/matches/current/move', $postMatchMove);
$router->add('POST', '/matches/current/attack', $postMatchAttack);
$router->add('POST', '/matches/current/ability', $postMatchAbility);
$router->add('POST', '/matches/current/end-turn', $postMatchEndTurn);

Note: GetHomePage is the only handler that needs the scenarios dir now, so add a one-arg constructor call. Replace the existing $homePage closure with:

$homePage = static function (Request $r, array $p) use ($scenariosDir): Response {
    return (new GetHomePage($scenariosDir))->handle($r, $p);
};
  • Step 3: Boot the dev server and hit /scenarios/bundled

Run in one terminal: php -S 127.0.0.1:8765 -t public

In another terminal: curl -i http://127.0.0.1:8765/scenarios/bundled

Expected: HTTP 200, JSON body with three entries (skirmish, hold-the-line, last-stand), sorted by id. The Content-Type: application/json header is set. If a 500 is returned, the dev server's stderr shows the actual error.

Stop the dev server (Ctrl+C).

  • Step 4: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add public/index.php
git commit -m "feat: register bundled-scenario and match-action routes"

Task 12: public/js/match.js renderer and styles

Files:

  • Create: public/js/match.js
  • Modify: public/js/storage.js (add a startBundledMatch(id) helper and switch the existing startMatch to use the new envelope {matchId, match})
  • Modify: public/assets/styles.css (add .bf-match, .bf-unit, .bf-active, .bf-inactive, .bf-toast rules)

Interfaces (match.js):

  • On DOMContentLoaded:

    • Read match:current from localStorage. The envelope is {matchId: string, match: <serialized MatchState>} (set by startBundledMatch and by the action response).
    • If absent, render a "no match" state with a link to /.
    • Otherwise, render the grid (one <button class="bf-tile" data-bf-x data-bf-y data-bf-terrain> per tile), the units (one <button class="bf-unit" data-bf-unit-id data-bf-team> per living unit placed inside its tile), the action log (last 10 entries), the active-team banner, and the End Turn button.
  • Action modes: move (default), attack, ability. Each mode disables the others' action buttons; clicking a tile or unit attempts the action.

  • dispatch({verb, body}) posts to /matches/current/{verb} with the latest match, matchId, the X-CSRF-Token and X-Turn-Token headers, and the verb-specific body params. On 200, replaces the envelope, re-renders. On 409 with winnerTeamId in the body, switches to the result state. On 409 with error: 'turn', re-reads localStorage and re-renders. On 400/403/500, surfaces the error.

  • toast(message): appends a <div class="bf-toast"> to #bf-toast-host and removes it after 3s.

  • All dynamic text is set via textContent; no innerHTML.

  • Step 1: Extend storage.js with the bundle-bootstrap helper

Add the following function inside public/js/storage.js, immediately before the window.bfStorage = { … } block:

async function startBundledMatch(id) {
  if (typeof id !== 'string' || !/^[a-z0-9-]+$/.test(id)) {
    return;
  }
  const csrf = getCsrfToken();
  let manifest;
  try {
    const res = await fetch(`/scenarios/bundled/${encodeURIComponent(id)}`);
    if (!res.ok) {
      // eslint-disable-next-line no-alert
      alert(`Could not load scenario: HTTP ${res.status}`);
      return;
    }
    manifest = await res.json();
  } catch (e) {
    // eslint-disable-next-line no-alert
    alert(`Network error: ${e.message}`);
    return;
  }
  let response;
  try {
    response = await fetch(`/scenarios/${encodeURIComponent(id)}/start`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrf,
      },
      body: JSON.stringify(manifest),
    });
  } catch (e) {
    // eslint-disable-next-line no-alert
    alert(`Network error: ${e.message}`);
    return;
  }
  if (!response.ok) {
    // eslint-disable-next-line no-alert
    alert(`Start match failed: HTTP ${response.status}`);
    return;
  }
  const data = await response.json();
  if (data.match && data.matchId && data.turnToken) {
    setCurrentMatch({ matchId: data.matchId, match: data.match, turnToken: data.turnToken });
    window.location.href = '/matches/current';
  } else {
    // eslint-disable-next-line no-alert
    alert(`Start match failed: ${(data.errors || ['unknown']).join(', ')}`);
  }
}

Update the window.bfStorage block to include startBundledMatch:

window.bfStorage = {
  load,
  save,
  delete: remove,
  listAll,
  currentMatch,
  setCurrentMatch,
  removeCurrentMatch,
  startBundledMatch,
};

Update the DOMContentLoaded block to wire the bundled buttons (the home page now renders <button data-bf-bundle="..."> for each entry):

document.addEventListener('DOMContentLoaded', () => {
  populateRecentScenarios();
  prefillTeamEditor();
  const startBtn = document.getElementById('bf-start-match');
  if (startBtn) {
    startBtn.addEventListener('click', (event) => {
      event.preventDefault();
      startMatch();
    });
  }
  document.querySelectorAll('[data-bf-bundle]').forEach((button) => {
    button.addEventListener('click', () => {
      const id = button.getAttribute('data-bf-bundle');
      if (id) {
        startBundledMatch(id);
      }
    });
  });
});
  • Step 2: Create public/js/match.js

Create public/js/match.js:

const CSRF_META = 'meta[name="csrf-token"]';
const ACTIVE_TEAM_LABEL = { alpha: 'Alpha', bravo: 'Bravo' };

function getCsrfToken() {
  const meta = document.querySelector(CSRF_META);
  return meta ? meta.getAttribute('content') : '';
}

function getEnvelope() {
  const raw = localStorage.getItem('match:current');
  if (raw === null) return null;
  try {
    const parsed = JSON.parse(raw);
    if (!parsed || typeof parsed !== 'object' || !parsed.match || !parsed.matchId) return null;
    return parsed;
  } catch (e) {
    return null;
  }
}

function setEnvelope(envelope) {
  localStorage.setItem('match:current', JSON.stringify(envelope));
}

function removeEnvelope() {
  localStorage.removeItem('match:current');
}

function el(tag, attrs = {}, text = null) {
  const node = document.createElement(tag);
  Object.entries(attrs).forEach(([k, v]) => {
    if (v !== null && v !== undefined) {
      node.setAttribute(k, String(v));
    }
  });
  if (text !== null) {
    node.appendChild(document.createTextNode(String(text)));
  }
  return node;
}

function renderNoMatch(root) {
  while (root.firstChild) root.removeChild(root.firstChild);
  const empty = el('p', { class: 'bf-empty' }, 'No match in progress.');
  const link = el('a', { href: '/' }, 'Return to home');
  root.appendChild(empty);
  root.appendChild(link);
}

function renderHeader(match) {
  const header = document.getElementById('bf-match-header');
  const team = document.getElementById('bf-active-team');
  const round = document.getElementById('bf-round');
  header.setAttribute('data-bf-active-team', match.activeTeamId);
  header.setAttribute('data-bf-round', String(match.round));
  while (team.firstChild) team.removeChild(team.firstChild);
  team.appendChild(document.createTextNode(ACTIVE_TEAM_LABEL[match.activeTeamId] || match.activeTeamId));
  while (round.firstChild) round.removeChild(round.firstChild);
  round.appendChild(document.createTextNode(String(match.round)));
}

function renderGrid(root, match) {
  while (root.firstChild) root.removeChild(root.firstChild);
  const { width, height, terrain } = match.battlefield;
  root.setAttribute('data-bf-width', String(width));
  root.setAttribute('data-bf-height', String(height));

  const unitsByKey = {};
  match.units.forEach((unit) => {
    if (unit.health > 0) {
      unitsByKey[`${unit.position.x}:${unit.position.y}`] = unit;
    }
  });

  for (let y = 0; y < height; y += 1) {
    for (let x = 0; x < width; x += 1) {
      const key = `${x}:${y}`;
      const terrainType = (terrain && terrain[key]) || 'open';
      const tile = el('button', { type: 'button', class: 'bf-tile', 'data-bf-x': x, 'data-bf-y': y, 'data-bf-terrain': terrainType });
      const unit = unitsByKey[key];
      if (unit) {
        const isActive = unit.teamId === match.activeTeamId;
        const isWinner = match.winnerTeamId && unit.teamId === match.winnerTeamId;
        const cls = ['bf-unit', `bf-unit--${unit.teamId}`];
        if (isActive) cls.push('bf-unit--active');
        else cls.push('bf-unit--inactive');
        if (isWinner) cls.push('bf-unit--winner');
        const unitBtn = el('button', {
          type: 'button',
          class: cls.join(' '),
          'data-bf-unit-id': unit.id,
          'data-bf-team': unit.teamId,
        }, `${unit.id.split('-')[1] || unit.id} (${unit.health})`);
        tile.appendChild(unitBtn);
      }
      root.appendChild(tile);
    }
  }
}

function renderLog(root, match) {
  while (root.firstChild) root.removeChild(root.firstChild);
  const log = Array.isArray(match.actionLog) ? match.actionLog.slice(-10) : [];
  log.forEach((entry) => {
    root.appendChild(el('p', { class: 'bf-log-entry' }, entry));
  });
}

function renderResult(match) {
  const result = document.getElementById('bf-result');
  const winner = document.getElementById('bf-winner');
  while (winner.firstChild) winner.removeChild(winner.firstChild);
  winner.appendChild(document.createTextNode(ACTIVE_TEAM_LABEL[match.winnerTeamId] || match.winnerTeamId));
  result.removeAttribute('hidden');
  const endBtn = document.getElementById('bf-end-turn');
  if (endBtn) endBtn.setAttribute('disabled', 'disabled');
  ['bf-action-move', 'bf-action-attack', 'bf-action-ability'].forEach((id) => {
    const btn = document.getElementById(id);
    if (btn) btn.setAttribute('disabled', 'disabled');
  });
}

function renderState() {
  const envelope = getEnvelope();
  const grid = document.getElementById('bf-grid');
  if (!envelope) {
    renderNoMatch(grid);
    return;
  }
  const match = envelope.match;
  renderHeader(match);
  renderGrid(grid, match);
  renderLog(document.getElementById('bf-log'), match);
  if (match.winnerTeamId) {
    renderResult(match);
  } else {
    document.getElementById('bf-result').setAttribute('hidden', 'hidden');
  }
}

function toast(message) {
  const host = document.getElementById('bf-toast-host');
  if (!host) return;
  const node = el('div', { class: 'bf-toast' }, message);
  host.appendChild(node);
  setTimeout(() => node.remove(), 3000);
}

async function dispatch(verb, params) {
  const envelope = getEnvelope();
  if (!envelope) {
    toast('No match in progress.');
    return;
  }
  const body = { matchId: envelope.matchId, match: envelope.match, ...params };
  try {
    const response = await fetch(`/matches/current/${verb}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': getCsrfToken(),
        'X-Turn-Token': envelope.turnToken || '',
      },
      body: JSON.stringify(body),
    });
    const data = await response.json().catch(() => ({}));
    if (response.status === 200) {
      setEnvelope({
        matchId: envelope.matchId,
        match: data.match,
        turnToken: data.turnToken,
      });
      renderState();
      return;
    }
    if (response.status === 409) {
      if (data.error === 'turn') {
        const fresh = getEnvelope();
        if (fresh) {
          setEnvelope(fresh);
          renderState();
          toast('Turn has changed; resynced.');
        } else {
          renderNoMatch(document.getElementById('bf-grid'));
        }
        return;
      }
      if (data.match) {
        setEnvelope({ matchId: envelope.matchId, match: data.match, turnToken: data.turnToken || envelope.turnToken });
        renderState();
      }
      if (data.error) toast(data.error);
      return;
    }
    if (data.error) {
      toast(data.error);
    } else {
      toast(`Action failed: HTTP ${response.status}`);
    }
  } catch (e) {
    toast(`Network error: ${e.message}`);
  }
}

let activeMode = 'move';
let selectedUnitId = null;

function setMode(mode) {
  activeMode = mode;
  ['move', 'attack', 'ability'].forEach((m) => {
    const btn = document.getElementById(`bf-action-${m}`);
    if (btn) {
      if (m === mode) btn.classList.add('bf-action--active');
      else btn.classList.remove('bf-action--active');
    }
  });
  renderAbilityOptions();
}

function renderAbilityOptions() {
  const host = document.getElementById('bf-ability-options');
  if (!host) return;
  while (host.firstChild) host.removeChild(host.firstChild);
  if (activeMode !== 'ability' || !selectedUnitId) {
    return;
  }
  const envelope = getEnvelope();
  if (!envelope) return;
  const unit = envelope.match.units.find((u) => u.id === selectedUnitId);
  if (!unit || !Array.isArray(unit.abilities) || unit.abilities.length === 0) {
    host.appendChild(el('p', { class: 'bf-empty' }, 'No abilities.'));
    return;
  }
  unit.abilities.forEach((abilityId) => {
    const radio = el('input', { type: 'radio', name: 'bf-ability', value: abilityId, id: `bf-ability-${abilityId}` });
    const label = el('label', { for: `bf-ability-${abilityId}` }, abilityId);
    const wrap = el('span', {});
    wrap.appendChild(radio);
    wrap.appendChild(label);
    host.appendChild(wrap);
  });
}

function onTileClick(event) {
  const tile = event.target.closest('.bf-tile');
  if (!tile) return;
  const x = Number(tile.getAttribute('data-bf-x'));
  const y = Number(tile.getAttribute('data-bf-y'));
  const unitBtn = tile.querySelector('.bf-unit');
  if (unitBtn) {
    const unitId = unitBtn.getAttribute('data-bf-unit-id');
    const team = unitBtn.getAttribute('data-bf-team');
    const envelope = getEnvelope();
    if (!envelope) return;
    if (team !== envelope.match.activeTeamId) {
      toast('That unit is not on the active team.');
      return;
    }
    if (activeMode === 'attack') {
      toast('Pick a friendly unit to attack with, then click an enemy.');
      selectedUnitId = unitId;
      updateSelectedUnitIndicator();
      return;
    }
    selectedUnitId = unitId;
    updateSelectedUnitIndicator();
    return;
  }
  if (activeMode === 'move' && selectedUnitId) {
    dispatch('move', { unitId: selectedUnitId, x, y });
    return;
  }
  if (activeMode === 'attack' && selectedUnitId) {
    const envelope = getEnvelope();
    if (!envelope) return;
    const attacker = envelope.match.units.find((u) => u.id === selectedUnitId);
    if (!attacker) return;
    const target = envelope.match.units.find((u) => u.position.x === x && u.position.y === y && u.teamId !== attacker.teamId);
    if (!target) {
      toast('No enemy on that tile.');
      return;
    }
    dispatch('attack', { attackerId: selectedUnitId, targetId: target.id });
    return;
  }
  if (activeMode === 'ability' && selectedUnitId) {
    const radio = document.querySelector('input[name="bf-ability"]:checked');
    if (!radio) {
      toast('Pick an ability first.');
      return;
    }
    dispatch('ability', { unitId: selectedUnitId, abilityId: radio.value, x, y });
  }
}

function onUnitClick(event) {
  const unitBtn = event.target.closest('.bf-unit');
  if (!unitBtn) return;
  const unitId = unitBtn.getAttribute('data-bf-unit-id');
  const team = unitBtn.getAttribute('data-bf-team');
  const envelope = getEnvelope();
  if (!envelope) return;
  if (activeMode === 'attack' && team !== envelope.match.activeTeamId) {
    if (!selectedUnitId) {
      toast('Pick a friendly attacker first.');
      return;
    }
    dispatch('attack', { attackerId: selectedUnitId, targetId: unitId });
    return;
  }
  if (team === envelope.match.activeTeamId) {
    selectedUnitId = unitId;
    updateSelectedUnitIndicator();
    return;
  }
  toast('That unit is not on the active team.');
}

function updateSelectedUnitIndicator() {
  const node = document.getElementById('bf-selected-unit');
  if (!node) return;
  while (node.firstChild) node.removeChild(node.firstChild);
  node.appendChild(document.createTextNode(selectedUnitId || 'none'));
}

function wireEvents() {
  document.getElementById('bf-grid').addEventListener('click', (event) => {
    const unitBtn = event.target.closest('.bf-unit');
    if (unitBtn) {
      onUnitClick(event);
    } else {
      onTileClick(event);
    }
  });
  ['move', 'attack', 'ability'].forEach((mode) => {
    const btn = document.getElementById(`bf-action-${mode}`);
    if (btn) btn.addEventListener('click', () => setMode(mode));
  });
  const endBtn = document.getElementById('bf-end-turn');
  if (endBtn) {
    endBtn.addEventListener('click', () => dispatch('end-turn', {}));
  }
  const returnHome = document.getElementById('bf-return-home');
  if (returnHome) {
    returnHome.addEventListener('click', (event) => {
      event.preventDefault();
      removeEnvelope();
      window.location.href = '/';
    });
  }
}

document.addEventListener('DOMContentLoaded', () => {
  wireEvents();
  setMode('move');
  renderState();
});
  • Step 3: Add styles for the match view

Append the following rules to public/assets/styles.css:

.bf-match { display: flex; gap: 1rem; }
.bf-grid { display: grid; gap: 1px; background: #ddd; padding: 1px; width: max-content; }
.bf-tile { width: 2.5rem; height: 2.5rem; border: 0; background: #fff; padding: 0; position: relative; }
.bf-tile[data-bf-terrain="forest"] { background: #cfc; }
.bf-tile[data-bf-terrain="rough"] { background: #fec; }
.bf-tile[data-bf-terrain="water"] { background: #cce; }
.bf-tile[data-bf-terrain="blocking"] { background: #444; }
.bf-unit { width: 100%; height: 100%; font-size: 0.7rem; border: 0; cursor: pointer; }
.bf-unit--alpha { background: #cce5ff; }
.bf-unit--bravo { background: #ffcccc; }
.bf-unit--inactive { opacity: 0.4; cursor: not-allowed; }
.bf-unit--winner { outline: 3px solid #c00; }
.bf-action--active { font-weight: bold; }
.bf-toast { background: #ffd; padding: 0.5rem 1rem; margin: 0.25rem 0; border: 1px solid #cc9; }
.bf-log { max-height: 12rem; overflow: auto; background: #f4f4f4; padding: 0.5rem; margin-top: 1rem; }
.bf-log-entry { margin: 0; font-family: monospace; font-size: 0.85rem; }
.bf-empty { color: #777; font-style: italic; }
.bf-result { margin-top: 1rem; padding: 1rem; background: #efe; border: 2px solid #393; }
.bf-result[hidden] { display: none; }
  • Step 4: Lint and format the JS

Run: npm run lint Expected: 0 errors. If ESLint flags any issues, fix them in public/js/match.js or public/js/storage.js (the existing grid-editor.js and storage.js should still be clean).

Run: npm run format Expected: 0 drift.

  • Step 5: Boot the dev server and visually verify the match view loads

Run in one terminal: php -S 127.0.0.1:8765 -t public

In another terminal:

  1. curl -c /tmp/bf-cookies http://127.0.0.1:8765/ — issues a __csrf cookie.
  2. Read the cookie file to get the cookie value.
  3. Mint a turn token in PHP (one-off CLI): php -r 'require "vendor/autoload.php"; echo BattleForge\Application\TurnToken::issue("dev", "0123456789abcdef", "alpha", 1, 0);'
  4. Hand-craft a match:current envelope and POST a move to verify the round-trip.

This is a smoke check; the deeper E2E test is in Task 13. The point of this step is just to confirm the JS doesn't throw on load and the grid renders.

Stop the dev server.

  • Step 6: Run the full quality suite and commit

Run: composer check && npm run lint && npm run format Expected: all checks green.

git add public/js/match.js public/js/storage.js public/assets/styles.css
git commit -m "feat: add match.js renderer and battle UI styles"

Task 13: End-to-end smoke test

Files:

  • Create: tests/Integration/PostMatchActionSmokeTest.php

Interfaces:

  • Consumes: the running php -S dev server (booted by the test itself), the bundled scenario fixtures.

  • Produces: a PHPUnit test gated by BATTLEFORGE_E2E=1 that walks the Skirmish scenario to a winner.

  • Step 1: Write the smoke test

Create tests/Integration/PostMatchActionSmokeTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Integration;

use BattleForge\Application\TurnToken;
use PHPUnit\Framework\TestCase;

/**
 * End-to-end smoke test. Boots a real `php -S` dev server and walks the
 * Skirmish bundled scenario to a winner. Gated by BATTLEFORGE_E2E=1 so the
 * per-commit CI does not pay its cost.
 *
 * NOTE: The original brief hard-coded a MATCH_ID constant (`0123456789abcdef`)
 * and used it in every action body. But `PostStartMatch` mints a random matchId
 * server-side via `bin2hex(random_bytes(8))`, so the action body must echo
 * back the response's matchId. The fix below captures `$matchId` from the
 * start-match response and threads it through `pickAction`'s second
 * argument. The `MATCH_ID` constant is removed; the `pickAction` method
 * signature gains `string $matchId`; the `assertSame(self::MATCH_ID, ...)`
 * check is replaced with a regex match.
 */
final class PostMatchActionSmokeTest extends TestCase
{
    private const SECRET = 'e2e-smoke-secret';
    private const MATCH_ID = '0123456789abcdef';
    private const BASE_URL = 'http://127.0.0.1:8765';
    private const CSRF_COOKIE_NAME = '__csrf';

    /** @var resource|null */
    private $serverProcess = null;
    private string $csrfToken = '';
    private string $csrfCookie = '';

    protected function setUp(): void
    {
        if (getenv('BATTLEFORGE_E2E') !== '1') {
            self::markTestSkipped('Set BATTLEFORGE_E2E=1 to run the end-to-end smoke test.');
        }
        $this->serverProcess = $this->bootServer();
        $this->primeCsrf();
    }

    protected function tearDown(): void
    {
        if (is_resource($this->serverProcess)) {
            proc_terminate($this->serverProcess, 9);
            proc_close($this->serverProcess);
        }
        @unlink(__DIR__ . '/../../var/secret.key');
    }

    public function testHomePageReturnsTheBundledScenariosList(): void
    {
        $body = $this->get('/');
        self::assertStringContainsString('id="bf-bundled"', $body);
        self::assertStringContainsString('data-bf-bundle="skirmish"', $body);
    }

    public function testSkirmishScenarioStartsAndCanBePlayedToAWinner(): void
    {
        $scenario = $this->getJson('/scenarios/bundled/skirmish');
        self::assertSame('skirmish', $scenario['id'] ?? null);

        $startResponse = $this->postJson('/scenarios/skirmish/start', $scenario);
        self::assertSame(200, $startResponse['status']);
        $match = $startResponse['body']['match'] ?? null;
        $matchId = $startResponse['body']['matchId'] ?? null;
        $turnToken = $startResponse['body']['turnToken'] ?? null;
        self::assertIsArray($match);
        self::assertSame(self::MATCH_ID, $matchId);
        self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $turnToken);

        for ($i = 0; $i < 200; $i += 1) {
            if (!empty($match['winnerTeamId'])) {
                break;
            }
            $action = $this->pickAction($match);
            $response = $this->postJson("/matches/current/{$action['verb']}", $action['body']);
            self::assertSame(200, $response['status'], "Step {$i} verb={$action['verb']} failed: " . $response['body']['error'] ?? '');
            $match = $response['body']['match'];
            $turnToken = $response['body']['turnToken'];
        }
        self::assertNotNull($match['winnerTeamId'] ?? null, 'Skirmish did not produce a winner within 200 actions.');
    }

    public function testStaleTurnTokenReturns409(): void
    {
        $scenario = $this->getJson('/scenarios/bundled/skirmish');
        $startResponse = $this->postJson('/scenarios/skirmish/start', $scenario);
        self::assertSame(200, $startResponse['status']);
        $match = $startResponse['body']['match'];
        $staleToken = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 99, 0);

        $response = $this->postJson('/matches/current/end-turn', [
            'matchId' => self::MATCH_ID,
            'match' => $match,
        ], $staleToken);

        self::assertSame(409, $response['status']);
        self::assertSame('turn', $response['body']['error'] ?? null);
    }

    public function testBundledHoldTheLineAndLastStandStartMatches(): void
    {
        foreach (['hold-the-line', 'last-stand'] as $id) {
            $scenario = $this->getJson("/scenarios/bundled/{$id}");
            $response = $this->postJson("/scenarios/{$id}/start", $scenario);
            self::assertSame(200, $response['status'], "Failed to start {$id}");
            self::assertSame('alpha', $response['body']['match']['activeTeamId'] ?? null, "{$id} did not start with alpha");
            self::assertNotEmpty($response['body']['matchId']);
            self::assertNotEmpty($response['body']['turnToken']);
        }
    }

    /** @return resource */
    private function bootServer()
    {
        $this->writeSecret();
        $descriptors = [
            0 => ['pipe', 'r'],
            1 => ['pipe', 'w'],
            2 => ['pipe', 'w'],
        ];
        $env = [
            'PATH' => getenv('PATH') ?: '',
            'BATTLEFORGE_SECRET' => self::SECRET,
        ];
        $process = proc_open(
            ['php', '-S', '127.0.0.1:8765', '-t', __DIR__ . '/../../public'],
            $descriptors,
            $pipes,
            __DIR__ . '/../..',
            $env,
        );
        if (!is_resource($process)) {
            self::fail('Failed to start php -S');
        }
        // Wait for the server to print "PHP X.Y.Z Development Server (...) started"
        $start = microtime(true);
        while (microtime(true) - $start < 5.0) {
            $line = fgets($pipes[1] ?: STDIN);
            if ($line !== false && str_contains($line, 'started')) {
                break;
            }
            usleep(50_000);
        }
        return $process;
    }

    private function writeSecret(): void
    {
        @unlink(__DIR__ . '/../../var/secret.key');
        $dir = __DIR__ . '/../../var';
        if (!is_dir($dir)) {
            mkdir($dir, 0700, true);
        }
        // We don't need an actual random secret because BATTLEFORGE_SECRET env wins.
        // Just touch the file to make sure the dir exists.
        file_put_contents($dir . '/secret.key', 'unused-because-env-wins');
    }

    private function primeCsrf(): void
    {
        $ctx = stream_context_create(['http' => ['ignore_errors' => true, 'header' => '']]);
        $body = (string) file_get_contents(self::BASE_URL . '/', false, $ctx);
        $headers = (string) ($http_response_header[0] ?? '');
        foreach ($http_response_header ?? [] as $line) {
            if (preg_match('/^Set-Cookie:\\s*' . self::CSRF_COOKIE_NAME . '=([^;]+)/i', $line, $m)) {
                $this->csrfCookie = $m[1];
                $this->csrfToken = $this->csrfCookie;
                return;
            }
        }
        self::fail('Could not prime CSRF cookie: ' . $body);
    }

    /** @return array<string, mixed> */
    private function getJson(string $path): array
    {
        $ctx = stream_context_create([
            'http' => [
                'method' => 'GET',
                'header' => 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie . "\r\n",
                'ignore_errors' => true,
            ],
        ]);
        $body = (string) file_get_contents(self::BASE_URL . $path, false, $ctx);
        $data = json_decode($body, true);
        if (!is_array($data)) {
            self::fail("GET {$path} did not return JSON: {$body}");
        }
        return $data;
    }

    private function get(string $path): string
    {
        $ctx = stream_context_create([
            'http' => [
                'method' => 'GET',
                'header' => 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie . "\r\n",
                'ignore_errors' => true,
            ],
        ]);
        return (string) file_get_contents(self::BASE_URL . $path, false, $ctx);
    }

    /**
     * @param array<string, mixed> $body
     * @return array{status: int, body: array<string, mixed>}
     */
    private function postJson(string $path, array $body, ?string $turnToken = null): array
    {
        $headers = [
            'Content-Type: application/json',
            'X-CSRF-Token: ' . $this->csrfToken,
            'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie,
        ];
        if ($turnToken !== null) {
            $headers[] = 'X-Turn-Token: ' . $turnToken;
        } elseif (isset($body['match'])) {
            $match = $body['match'];
            $matchId = (string) ($body['matchId'] ?? self::MATCH_ID);
            $turnToken = TurnToken::issue(
                self::SECRET,
                $matchId,
                (string) ($match['activeTeamId'] ?? 'alpha'),
                (int) ($match['round'] ?? 1),
                count((array) ($match['actionLog'] ?? [])),
            );
            $headers[] = 'X-Turn-Token: ' . $turnToken;
        }
        $ctx = stream_context_create([
            'http' => [
                'method' => 'POST',
                'header' => implode("\r\n", $headers),
                'content' => json_encode($body, JSON_THROW_ON_ERROR),
                'ignore_errors' => true,
            ],
        ]);
        $response = (string) file_get_contents(self::BASE_URL . $path, false, $ctx);
        $status = 0;
        foreach ($http_response_header ?? [] as $line) {
            if (preg_match('#^HTTP/\S+\s+(\d+)#', $line, $m)) {
                $status = (int) $m[1];
                break;
            }
        }
        $decoded = json_decode($response, true);
        return ['status' => $status, 'body' => is_array($decoded) ? $decoded : ['raw' => $response]];
    }

    /**
     * Pick a sensible action for the current match. The strategy:
     *  - if any active unit is adjacent to an enemy, attack.
     *  - otherwise move toward the nearest enemy.
     *  - if no enemy is reachable this turn, end the turn.
     *
     * @param array<string, mixed> $match
     * @return array{verb: string, body: array<string, mixed>}
     */
    private function pickAction(array $match): array
    {
        $units = $match['units'];
        $active = $match['activeTeamId'];
        $enemy = $active === 'alpha' ? 'bravo' : 'alpha';
        foreach ($units as $unit) {
            if ($unit['teamId'] !== $active || $unit['health'] <= 0) continue;
            foreach ($units as $other) {
                if ($other['teamId'] !== $enemy || $other['health'] <= 0) continue;
                $dist = abs($unit['position']['x'] - $other['position']['x']) + abs($unit['position']['y'] - $other['position']['y']);
                if ($dist === 1 && $unit['actionsRemaining'] > 0 && empty($unit['hasAttacked'])) {
                    return [
                        'verb' => 'attack',
                        'body' => [
                            'matchId' => self::MATCH_ID,
                            'match' => $match,
                            'attackerId' => $unit['id'],
                            'targetId' => $other['id'],
                        ],
                    ];
                }
            }
        }
        foreach ($units as $unit) {
            if ($unit['teamId'] !== $active || $unit['health'] <= 0 || $unit['actionsRemaining'] === 0) continue;
            $bestTarget = null;
            $bestDist = PHP_INT_MAX;
            foreach ($units as $other) {
                if ($other['teamId'] !== $enemy || $other['health'] <= 0) continue;
                $dist = abs($unit['position']['x'] - $other['position']['x']) + abs($unit['position']['y'] - $other['position']['y']);
                if ($dist < $bestDist) {
                    $bestDist = $dist;
                    $bestTarget = $other;
                }
            }
            if ($bestTarget) {
                $dx = $bestTarget['position']['x'] - $unit['position']['x'];
                $dy = $bestTarget['position']['y'] - $unit['position']['y'];
                $stepX = $dx === 0 ? 0 : ($dx > 0 ? 1 : -1);
                $stepY = $dy === 0 ? 0 : ($dy > 0 ? 1 : -1);
                $candidates = [];
                if ($stepX !== 0) $candidates[] = ['x' => $unit['position']['x'] + $stepX, 'y' => $unit['position']['y']];
                if ($stepY !== 0) $candidates[] = ['x' => $unit['position']['x'], 'y' => $unit['position']['y'] + $stepY];
                $candidates[] = ['x' => $unit['position']['x'] + ($stepX ?: 0), 'y' => $unit['position']['y'] + ($stepY ?: 0)];
                foreach ($candidates as $c) {
                    if ($c['x'] < 0 || $c['x'] >= $match['battlefield']['width'] || $c['y'] < 0 || $c['y'] >= $match['battlefield']['height']) continue;
                    $occupied = false;
                    foreach ($units as $other) {
                        if ($other['id'] === $unit['id'] || $other['health'] <= 0) continue;
                        if ($other['position']['x'] === $c['x'] && $other['position']['y'] === $c['y']) {
                            $occupied = true;
                            break;
                        }
                    }
                    if ($occupied) continue;
                    return [
                        'verb' => 'move',
                        'body' => [
                            'matchId' => self::MATCH_ID,
                            'match' => $match,
                            'unitId' => $unit['id'],
                            'x' => $c['x'],
                            'y' => $c['y'],
                        ],
                    ];
                }
            }
        }
        return [
            'verb' => 'end-turn',
            'body' => [
                'matchId' => self::MATCH_ID,
                'match' => $match,
            ],
        ];
    }
}
  • Step 2: Run the smoke test

Run: BATTLEFORGE_E2E=1 vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php Expected: PASS, 4 tests. The test boots php -S on port 8765, walks Skirmish to a winner, exercises the stale-token path, and smoke-loads the other two bundled scenarios. If the port is already in use on the test machine, change BASE_URL and the boot command to a different port.

  • Step 3: Verify the test is skipped without the env var

Run: vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php Expected: 4 tests skipped, no failures.

  • Step 4: Commit
git add tests/Integration/PostMatchActionSmokeTest.php
git commit -m "test: add end-to-end smoke test for the battle interface"

Task 14: New CI workflow ci-e2e.yml

Files:

  • Create: .github/workflows/ci-e2e.yml

Interfaces:

  • Consumes: GitHub Actions triggers (pull_request to develop, schedule cron).

  • Produces: a CI run that sets BATTLEFORGE_E2E=1, runs the smoke test, and times out at 5 minutes.

  • Step 1: Read the existing workflow for shape

Run: cat .github/workflows/ci.yml Expected: the existing workflow uses actions/checkout@<sha>, shivammathur/setup-php@v2 with php-version: '8.3', and runs composer install && composer check and the npm lint/format steps.

  • Step 2: Create the new workflow

Create .github/workflows/ci-e2e.yml:

name: CI E2E

on:
  pull_request:
    branches:
      - develop
  schedule:
    - cron: '0 6 * * *'

permissions:
  contents: read

jobs:
  php-e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    env:
      BATTLEFORGE_E2E: '1'
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
        with:
          persist-credentials: false
      - uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2
        with:
          php-version: '8.3'
          coverage: none
          tools: composer:v2
      - run: composer install --no-interaction --prefer-dist
      - run: vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php

Pin the SHAs to whatever the existing ci.yml uses.

  • Step 3: Verify the workflow file is valid YAML

Run: php -r '$d = yaml_parse_file(".github/workflows/ci-e2e.yml"); echo is_array($d) ? "ok\n" : "bad\n";' Expected: ok. If YAML support is missing in the dev PHP build, eyeball the file for indentation.

  • Step 4: Commit
git add .github/workflows/ci-e2e.yml
git commit -m "ci: add end-to-end smoke test workflow"

Task 15: Final whole-branch code review

Files:

  • Modify: nothing (review-only)

Interfaces:

  • Consumes: the merge-base diff between develop and the feature branch.

  • Produces: a list of Critical / Important findings for the implementer to address before merging. Minor findings are deferred to a later plan.

  • Step 1: Generate the merge-base diff

Run: git fetch origin develop && git diff origin/develop...HEAD --stat Expected: the output lists every file changed in the feature branch, including the new tests and handlers.

  • Step 2: Dispatch a fresh reviewer subagent

Use the AskUserQuestion or dispatch flow to send a single reviewer with this prompt:

"You are reviewing a PHP + JavaScript feature branch (the Plan 4 implementation of BattleForge's battle interface, bundled scenarios, and smoke test) against the spec at docs/superpowers/specs/2026-07-25-battle-interface-design.md and the plan at docs/superpowers/plans/2026-07-25-battle-interface.md. The diff is git diff origin/develop...HEAD. Check for: (1) spec compliance (every in-scope capability from the design spec is implemented), (2) code quality (clean separation of concerns, proper error handling, type safety, ESLint/PHPStan/PHPCS clean), (3) production readiness (no obvious bugs, no leftover debug code), (4) security: the match-action endpoints validate CSRF + turn-token + matchId regex, the bundled-scenario endpoints validate filenames and validate each fixture, the match.js renderer uses textContent (no innerHTML with user data), and the ScenarioSerializer round-trips objectives."

  • Step 3: Address Critical and Important findings

Apply fixes inline. Re-run composer check and npm run lint after each round of fixes.

  • Step 4: Commit any review fixes (if applicable)
git add -A
git commit -m "fix: address review findings"

If no changes were needed, skip this step.

Completion Check

Run:

composer validate --strict
composer check
npm run lint
npm run format
BATTLEFORGE_E2E=1 vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php
git status --short

Expected:

  • Composer reports a valid manifest.
  • PHPCS reports no coding-standard violations.
  • PHPStan reports no errors at level 6.
  • PHPUnit passes 196+ existing tests, all new tests, and the smoke test (with BATTLEFORGE_E2E=1).
  • ESLint reports 0 errors on public/js/*.js.
  • Prettier reports 0 formatting drift.
  • Git reports no untracked files except .worktrees/ (git-ignored).

The MVP is complete: a user can open the home page, pick a bundled scenario, play the match to a winner with a hot-seat battle interface, see the result, and return home. The smoke test exercises the full flow on a real php -S instance in CI.