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

3489 lines
136 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```text
src/
Application/
ScenarioSerializer.php MODIFY — round-trip objectives in matchToArray/matchFromArray
ScenarioDraft.php unchanged
ImageUploadService.php unchanged
ImageValidator.php unchanged
ValidatedImage.php unchanged
TurnToken.php NEW — issue/verify HMAC over (matchId, activeTeamId, round, lastActionIndex)
Domain/ unchanged — engine stays as-is
Http/
Handlers/
GetAssets.php unchanged
GetBattlefieldEditor.php unchanged
GetBundledScenarios.php NEW — list of {id, name, summary} from /public/assets/scenarios/
GetBundledScenario.php NEW — full Scenario JSON for a bundled id (validates the fixture)
GetHomePage.php unchanged
GetMatchView.php NEW — server-rendered shell for the match view (loads /public/js/match.js)
GetTeamEditor.php unchanged
PostBattlefieldEditor.php unchanged
PostImageUpload.php unchanged
PostMatchMove.php NEW — move action
PostMatchAttack.php NEW — attack action
PostMatchAbility.php NEW — use-ability action
PostMatchEndTurn.php NEW — end-turn action
PostStartMatch.php MODIFY — return {match, matchId, turnToken}; mint the matchId + initial token
PostTeamEditor.php unchanged
MatchActionSupport.php NEW — small helper used by all four action handlers: CSRF + turn-token
verification + MatchState decode + JSON error responses
Router.php MODIFY — register the five new routes
CsrfToken.php unchanged
Request.php / Response.php unchanged
Escape.php unchanged
Views/
layout.php unchanged
home.php MODIFY — render a bundled scenarios list (server-rendered; the JS does
not need to populate it)
match.php NEW — the match view (loads match.js, exposes container + CSRF meta)
team-editor.php unchanged
battlefield-editor.php unchanged
public/
js/
match.js NEW — client match renderer + action dispatcher
grid-editor.js unchanged
storage.js MODIFY — add bundle-bootstrap helpers (startBundledMatch) and a
matchId-aware currentMatch envelope
assets/
scenarios/
manifest.json NEW — name + summary for each bundled id
skirmish.json NEW — 8x8 open, eliminate_all
hold-the-line.json NEW — 10x8, hold_objective, 3 rounds
last-stand.json NEW — 12x12, eliminate_all, asymmetric 6v4
styles.css MODIFY — add .bf-match / .bf-unit / .bf-active / .bf-toast rules
placeholders/ unchanged
archetypes.json unchanged
tests/
Unit/
Application/
TurnTokenTest.php NEW
ScenarioSerializerTest.php MODIFY — add an objectives-round-trip case
Integration/
GetBundledScenariosTest.php NEW
GetBundledScenarioTest.php NEW
GetMatchViewTest.php NEW
PostMatchMoveTest.php NEW
PostMatchAttackTest.php NEW
PostMatchAbilityTest.php NEW
PostMatchEndTurnTest.php NEW
PostMatchActionSmokeTest.php NEW — boots php -S, walks Skirmish to a winner; gated by BATTLEFORGE_E2E=1
PostStartMatchTest.php MODIFY — assert {match, matchId, turnToken} response shape
FullFlowTest.php unchanged
GetHomePageTest.php MODIFY — assert the bundled-scenarios list is rendered server-side
.github/workflows/
ci.yml unchanged
ci-e2e.yml NEW — PR-to-develop + nightly, BATTLEFORGE_E2E=1, 5-minute timeout
docs/
superpowers/
plans/
2026-07-25-battle-interface.md THIS FILE
specs/
2026-07-25-battle-interface-design.md already committed
```
## Delivery Sequence
Plan 4 is the fourth and final plan in the MVP. It builds on Plans 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:
```php
public function testMatchRoundTripsItsObjectives(): void
{
$battlefield = new Battlefield(10, 8);
$units = [
new UnitState('a1', 'alpha', new Position(0, 0), 10, 10, 3, 3, 2, 2, false),
new UnitState('a2', 'alpha', new Position(1, 0), 10, 10, 3, 3, 2, 2, false),
new UnitState('a3', 'alpha', new Position(2, 0), 10, 10, 3, 3, 2, 2, false),
new UnitState('b1', 'bravo', new Position(9, 7), 10, 10, 3, 3, 2, 2, false),
new UnitState('b2', 'bravo', new Position(8, 7), 10, 10, 3, 3, 2, 2, false),
new UnitState('b3', 'bravo', new Position(7, 7), 10, 10, 3, 3, 2, 2, false),
];
$objective = new ObjectiveMarker('objective-1', new Position(5, 4));
$original = new MatchState(
battlefield: $battlefield,
units: $units,
activeTeamId: 'alpha',
objectives: ['objective-1' => $objective],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 3,
);
$array = ScenarioSerializer::matchToArray($original);
self::assertArrayHasKey('objectives', $array);
self::assertSame(
['id' => 'objective-1', 'position' => ['x' => 5, 'y' => 4]],
$array['objectives']['objective-1'] ?? null,
);
$rebuilt = ScenarioSerializer::matchFromArray($array);
self::assertArrayHasKey('objective-1', $rebuilt->objectives);
self::assertSame('objective-1', $rebuilt->objectives['objective-1']->id);
self::assertSame(5, $rebuilt->objectives['objective-1']->position->x);
self::assertSame(4, $rebuilt->objectives['objective-1']->position->y);
self::assertSame(VictoryCondition::HoldObjective, $rebuilt->victoryCondition);
self::assertSame(3, $rebuilt->holdRoundsRequired);
}
```
Add the necessary `use BattleForge\Domain\ObjectiveMarker;` and `use BattleForge\Domain\VictoryCondition;` imports at the top of the file if not already present.
- [ ] **Step 2: Run the test to verify it fails**
Run: `vendor/bin/phpunit tests/Unit/Application/ScenarioSerializerTest.php --filter RoundTripsItsObjectives`
Expected: FAIL because `matchToArray` does not include `objectives` and `matchFromArray` hard-codes `objectives: []`.
- [ ] **Step 3: Update `matchToArray` to include objectives**
In `src/Application/ScenarioSerializer.php`, inside `matchToArray`, add the following block immediately after the `$terrainMap` block (before the `$units` accumulation):
```php
$objectives = [];
foreach ($match->objectives as $id => $objective) {
$objectives[(string) $id] = [
'id' => $objective->id,
'position' => self::positionToArray($objective->position),
];
}
```
Then add `'objectives' => $objectives,` to the returned array, after `'objectiveControl' => $match->objectiveControl,`.
- [ ] **Step 4: Update `matchFromArray` to restore objectives**
In `src/Application/ScenarioSerializer.php`, inside `matchFromArray`, add the following block immediately after the `$units` accumulation (before the `return new MatchState(...)` call):
```php
$objectives = [];
foreach (($data['objectives'] ?? []) as $id => $row) {
$objectives[(string) $id] = new ObjectiveMarker(
(string) $row['id'],
self::positionFromArray($row['position']),
);
}
```
Then change `objectives: [],` to `objectives: $objectives,` in the `return new MatchState(...)` call.
Add the `use BattleForge\Domain\ObjectiveMarker;` import at the top of the file if not already present.
- [ ] **Step 5: Run the test to verify it passes**
Run: `vendor/bin/phpunit tests/Unit/Application/ScenarioSerializerTest.php`
Expected: PASS, all tests in the file.
- [ ] **Step 6: Run the full quality suite and commit**
Run: `composer check`
Expected: all checks green (PHPCS, PHPStan, all 196+ tests).
```powershell
git add src/Application/ScenarioSerializer.php tests/Unit/Application/ScenarioSerializerTest.php
git commit -m "fix: round-trip match objectives through ScenarioSerializer"
```
### Task 2: Build the `TurnToken` value object
**Files:**
- Create: `src/Application/TurnToken.php`
- Test: `tests/Unit/Application/TurnTokenTest.php`
**Interfaces:**
- Consumes: a secret string (the same `BATTLEFORGE_SECRET` or `var/secret.key` used for CSRF), a `matchId` (validated as 16+ lowercase hex chars before hashing), an `activeTeamId`, a `round` (>= 1), and a `lastActionIndex` (>= 0).
- Produces:
- `public static function issue(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string` — returns 32 lowercase hex chars.
- `public static function verify(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex, string $token): bool``hash_equals` against the freshly-issued token.
- The class validates `matchId` against `/^[a-f0-9]{16,}$/` and rejects empty `activeTeamId` and negative `round`/`lastActionIndex` with `InvalidArgumentException`.
- [ ] **Step 1: Write the failing test**
Create `tests/Unit/Application/TurnTokenTest.php`:
```php
<?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
<?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.
```powershell
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:
```php
$body = json_decode($response->body, true);
self::assertSame('alpha', $body['match']['activeTeamId'] ?? null);
self::assertSame(1, $body['match']['round'] ?? null);
self::assertCount(6, $body['match']['units'] ?? []);
self::assertIsString($body['matchId'] ?? null);
self::assertMatchesRegularExpression('/^[a-f0-9]{16,}$/', $body['matchId']);
self::assertIsString($body['turnToken'] ?? null);
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']);
```
- [ ] **Step 2: Add a new test that asserts the turn token is valid for the initial state**
Append the following test method inside `final class PostStartMatchTest`, immediately before the closing brace of the class:
```php
public function testItReturnsATurnTokenValidForTheInitialMatchState(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostStartMatch();
$request = $this->buildRequest(
rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
$matchId = $body['matchId'];
$turnToken = $body['turnToken'];
$lastActionIndex = count($body['match']['actionLog'] ?? []);
self::assertTrue(\BattleForge\Application\TurnToken::verify(
self::SECRET,
$matchId,
$body['match']['activeTeamId'],
$body['match']['round'],
$lastActionIndex,
$turnToken,
));
}
```
- [ ] **Step 3: Run the test to verify it fails**
Run: `vendor/bin/phpunit tests/Integration/PostStartMatchTest.php`
Expected: FAIL because `PostStartMatch` currently returns only `{match: ...}`.
- [ ] **Step 4: Modify `PostStartMatch` to mint the `matchId` and `turnToken`**
Replace `src/Http/Handlers/PostStartMatch.php` with:
```php
<?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.
```powershell
git add src/Http/Handlers/PostStartMatch.php tests/Integration/PostStartMatchTest.php
git commit -m "feat: mint matchId and turnToken in PostStartMatch"
```
### Task 4: Bundled scenario fixtures and `manifest.json`
**Files:**
- Create: `public/assets/scenarios/manifest.json`
- Create: `public/assets/scenarios/skirmish.json`
- Create: `public/assets/scenarios/hold-the-line.json`
- Create: `public/assets/scenarios/last-stand.json`
**Interfaces:**
- Consumes: `ScenarioSerializer::scenarioFromArray` and `ScenarioValidator::validate` (consumers read each fixture through these in Task 5).
- Produces: three valid `Scenario` JSON files in the same shape as `ScenarioSerializer::scenarioToArray` produces, plus a `manifest.json` whose keys match the fixture filenames (minus the `.json` extension) and whose values are `{name, summary}` strings.
- [ ] **Step 1: Create `manifest.json`**
Create `public/assets/scenarios/manifest.json`:
```json
{
"skirmish": {
"name": "Skirmish",
"summary": "8x8 open ground. Three units per side. Eliminate the enemy team."
},
"hold-the-line": {
"name": "Hold the Line",
"summary": "10x8 with a forest corridor. Three defenders hold the center against four attackers. Hold the objective for three rounds."
},
"last-stand": {
"name": "Last Stand",
"summary": "12x12 with mixed terrain. Six defenders versus four strikers. Eliminate the enemy team."
}
}
```
- [ ] **Step 2: Create `skirmish.json`**
Create `public/assets/scenarios/skirmish.json`:
```json
{
"id": "skirmish",
"name": "Skirmish",
"battlefield": {
"width": 8,
"height": 8,
"terrain": {}
},
"units": [
{ "id": "alpha-1", "teamId": "alpha", "position": { "x": 0, "y": 1 }, "maxHealth": 12, "health": 12, "attack": 3, "defense": 4, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "alpha-2", "teamId": "alpha", "position": { "x": 0, "y": 3 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "alpha-3", "teamId": "alpha", "position": { "x": 0, "y": 6 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] },
{ "id": "bravo-1", "teamId": "bravo", "position": { "x": 7, "y": 1 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-2", "teamId": "bravo", "position": { "x": 7, "y": 3 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] },
{ "id": "bravo-3", "teamId": "bravo", "position": { "x": 7, "y": 6 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] }
],
"deploymentZones": {
"alpha": { "teamId": "alpha", "positions": [{ "x": 0, "y": 1 }, { "x": 0, "y": 3 }, { "x": 0, "y": 6 }] },
"bravo": { "teamId": "bravo", "positions": [{ "x": 7, "y": 1 }, { "x": 7, "y": 3 }, { "x": 7, "y": 6 }] }
},
"objectives": {},
"victoryCondition": "eliminate_all",
"holdRoundsRequired": 1
}
```
- [ ] **Step 3: Create `hold-the-line.json`**
Create `public/assets/scenarios/hold-the-line.json`:
```json
{
"id": "hold-the-line",
"name": "Hold the Line",
"battlefield": {
"width": 10,
"height": 8,
"terrain": {
"3:2": "forest", "3:3": "forest", "3:4": "forest", "3:5": "forest",
"4:2": "forest", "4:3": "forest", "4:4": "forest", "4:5": "forest",
"5:2": "forest", "5:3": "forest", "5:4": "forest", "5:5": "forest"
}
},
"units": [
{ "id": "alpha-1", "teamId": "alpha", "position": { "x": 4, "y": 3 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 1, "archetype": "defender", "abilities": [] },
{ "id": "alpha-2", "teamId": "alpha", "position": { "x": 3, "y": 4 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 1, "archetype": "defender", "abilities": [] },
{ "id": "alpha-3", "teamId": "alpha", "position": { "x": 5, "y": 4 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 1, "archetype": "defender", "abilities": [] },
{ "id": "bravo-1", "teamId": "bravo", "position": { "x": 9, "y": 1 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-2", "teamId": "bravo", "position": { "x": 9, "y": 3 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-3", "teamId": "bravo", "position": { "x": 9, "y": 5 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] },
{ "id": "bravo-4", "teamId": "bravo", "position": { "x": 9, "y": 7 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }
],
"deploymentZones": {
"alpha": { "teamId": "alpha", "positions": [{ "x": 4, "y": 3 }, { "x": 3, "y": 4 }, { "x": 5, "y": 4 }] },
"bravo": { "teamId": "bravo", "positions": [{ "x": 9, "y": 1 }, { "x": 9, "y": 3 }, { "x": 9, "y": 5 }, { "x": 9, "y": 7 }] }
},
"objectives": {
"objective-1": { "id": "objective-1", "position": { "x": 4, "y": 4 } }
},
"victoryCondition": "hold_objective",
"holdRoundsRequired": 3
}
```
- [ ] **Step 4: Create `last-stand.json`**
Create `public/assets/scenarios/last-stand.json`:
```json
{
"id": "last-stand",
"name": "Last Stand",
"battlefield": {
"width": 12,
"height": 12,
"terrain": {
"2:2": "forest", "2:3": "forest", "2:8": "forest", "2:9": "forest",
"3:2": "forest", "3:3": "forest", "3:8": "forest", "3:9": "forest",
"5:5": "rough", "5:6": "rough", "6:5": "rough", "6:6": "rough",
"8:2": "water", "8:3": "water", "8:8": "water", "8:9": "water",
"9:2": "water", "9:3": "water", "9:8": "water", "9:9": "water"
}
},
"units": [
{ "id": "alpha-1", "teamId": "alpha", "position": { "x": 0, "y": 0 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "alpha-2", "teamId": "alpha", "position": { "x": 0, "y": 3 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "alpha-3", "teamId": "alpha", "position": { "x": 0, "y": 6 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "alpha-4", "teamId": "alpha", "position": { "x": 0, "y": 9 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] },
{ "id": "alpha-5", "teamId": "alpha", "position": { "x": 1, "y": 5 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] },
{ "id": "alpha-6", "teamId": "alpha", "position": { "x": 2, "y": 11 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] },
{ "id": "bravo-1", "teamId": "bravo", "position": { "x": 11, "y": 2 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-2", "teamId": "bravo", "position": { "x": 11, "y": 5 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-3", "teamId": "bravo", "position": { "x": 11, "y": 8 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-4", "teamId": "bravo", "position": { "x": 10, "y": 11 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }
],
"deploymentZones": {
"alpha": { "teamId": "alpha", "positions": [{ "x": 0, "y": 0 }, { "x": 0, "y": 3 }, { "x": 0, "y": 6 }, { "x": 0, "y": 9 }, { "x": 1, "y": 5 }, { "x": 2, "y": 11 }] },
"bravo": { "teamId": "bravo", "positions": [{ "x": 11, "y": 2 }, { "x": 11, "y": 5 }, { "x": 11, "y": 8 }, { "x": 10, "y": 11 }] }
},
"objectives": {},
"victoryCondition": "eliminate_all",
"holdRoundsRequired": 1
}
```
- [ ] **Step 5: Verify each fixture is a valid scenario**
Run: `php -r 'require "vendor/autoload.php"; foreach (["skirmish", "hold-the-line", "last-stand"] as $id) { $data = json_decode(file_get_contents("public/assets/scenarios/$id.json"), true, flags: JSON_THROW_ON_ERROR); $scenario = BattleForge\\Application\\ScenarioSerializer::scenarioFromArray($data); $errors = BattleForge\\Domain\\ScenarioValidator::validate($scenario); if ($errors !== []) { echo "$id: FAIL\n"; print_r($errors); exit(1); } echo "$id: OK\n"; }'`
Expected: each id prints `OK`. If any prints `FAIL`, the validator messages identify which constraint to fix in the fixture (most likely an out-of-bounds position, an unreachable objective, or a stat range violation).
- [ ] **Step 6: Commit the bundled fixtures**
```powershell
git add public/assets/scenarios/manifest.json public/assets/scenarios/skirmish.json public/assets/scenarios/hold-the-line.json public/assets/scenarios/last-stand.json
git commit -m "feat: add three bundled scenario fixtures and manifest"
```
### Task 5: `GetBundledScenarios` handler
**Files:**
- Create: `src/Http/Handlers/GetBundledScenarios.php`
- Test: `tests/Integration/GetBundledScenariosTest.php`
**Interfaces:**
- Consumes: a constructor arg `string $scenariosDir` (an absolute path to `public/assets/scenarios/`).
- Produces: `Response::json(200, [['id' => string, 'name' => string, 'summary' => string], ...])` sorted by id. The handler:
- Reads `manifest.json` from the scenarios dir; if the file is missing, returns `[]`.
- For each `*.json` file in the dir whose name matches `/^[a-z0-9-]+\.json$/`, derives an id by stripping `.json`. If the manifest has an entry for that id, includes it in the response. Any fixture without a manifest entry is filtered out.
- Returns the result sorted by id (so the order is stable regardless of the filesystem order).
- [ ] **Step 1: Write the failing test**
Create `tests/Integration/GetBundledScenariosTest.php`:
```php
<?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, $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
<?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.
```powershell
git add src/Http/Handlers/GetBundledScenarios.php tests/Integration/GetBundledScenariosTest.php
git commit -m "feat: add GetBundledScenarios handler with manifest-driven list"
```
### Task 6: `GetBundledScenario` handler
**Files:**
- Create: `src/Http/Handlers/GetBundledScenario.php`
- Test: `tests/Integration/GetBundledScenarioTest.php`
**Interfaces:**
- Consumes: a constructor arg `string $scenariosDir`.
- Produces:
- `Response::json(200, <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
<?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
<?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.
```powershell
git add src/Http/Handlers/GetBundledScenario.php tests/Integration/GetBundledScenarioTest.php
git commit -m "feat: add GetBundledScenario handler with fixture validation"
```
### Task 7: Update the home page to render the bundled scenarios list
**Files:**
- Modify: `src/Http/Handlers/GetHomePage.php`
- Modify: `src/Views/home.php`
- Modify: `tests/Integration/GetHomePageTest.php`
**Interfaces:**
- Consumes: a new constructor arg `string $scenariosDir` for `GetHomePage`. The handler reads `manifest.json` from that dir and passes the entries to the view.
- Produces: the home page renders a "Play bundled" `<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.
```php
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
<?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
<?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.
```powershell
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
<?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
<?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
<?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.
```powershell
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
<?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
<?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.
```powershell
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:
```php
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
<?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
<?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
<?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
<?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
<?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(1, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
new UnitState('alpha-3', 'alpha', new Position(2, 0), 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, 7), 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.
```powershell
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:
```php
$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:
```php
$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:
```php
$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.
```powershell
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:
```javascript
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`:
```javascript
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):
```javascript
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`:
```javascript
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`:
```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.
```powershell
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
<?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.
*/
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**
```powershell
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`:
```yaml
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**
```powershell
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)**
```powershell
git add -A
git commit -m "fix: address review findings"
```
If no changes were needed, skip this step.
## Completion Check
Run:
```powershell
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.