137 KiB
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
localStorageundermatch:current. The server is stateless across action calls; the client sends the fullMatchStatewith every action POST. - The
matchId(16 random hex chars frombin2hex(random_bytes(8))) is minted byPostStartMatchand persisted alongside the match inlocalStorage. It is required in every action request body and used as the per-match namespace for the turn-token HMAC. - The
X-Turn-Tokenheader is required on all four action endpoints. A missing, stale, or tampered token returns 409 with{"error":"turn"}. The token ishash_hmac('sha256', $secret, $matchId . '|' . $activeTeamId . '|' . $round . '|' . $lastActionIndex)truncated to 32 hex chars, verified withhash_equals. PostStartMatchreturns{match, matchId, turnToken}(it currently returns just{match}). The sameBATTLEFORGE_SECRETis 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
__csrfcookie 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, andEscape::url. The newmatch.jsusestextContentand DOM construction; neverinnerHTMLwith user data. - The match-action endpoints validate
matchIdagainst/^[a-f0-9]{16,}$/before computing the token. The bundled-scenario endpoints validate filenames against/^[a-z0-9-]+$/and userealpath+is_filebefore reading. - A rejected action never partially changes match state or consumes resources.
localStoragewrites only happen on a 200 response. The in-browser state is recoverable by reload. ScenarioSerializer::matchToArrayandmatchFromArraymust round-trip theobjectivesmap.matchToArrayadds anobjectiveskey;matchFromArraybuilds theObjectiveMarkermap.- Bundled scenarios live under
public/assets/scenarios/. Amanifest.jsonin that directory is the single source of truth for display names and summaries; the fixture filename is theid. The manifest shape is{ "<id>": {"name": "...", "summary": "..."} }. Any fixture without a manifest entry is filtered out of the bundle list. getScenario(…)?/startcontinues to acceptScenarioJSON in theScenarioSerializer::scenarioToArrayshape. The bundled-scenario GET endpoint returns that same shape, not aScenarioDraft.- 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 -Son a free port, walks the Skirmish scenario to a winner, and is gated byBATTLEFORGE_E2E=1. The defaultcomposer checkdoes not set it. - PHP follows the repository PHPCS rules and passes PHPStan at level 6. JavaScript passes ESLint
airbnb/baseand Prettier. Composer configuration passescomposer 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.jsis pure ESM, no dependencies, noeval, noFunctionconstructor.
File Structure
src/
Application/
ScenarioSerializer.php MODIFY — round-trip objectives in matchToArray/matchFromArray
ScenarioDraft.php unchanged
ImageUploadService.php unchanged
ImageValidator.php unchanged
ValidatedImage.php unchanged
TurnToken.php NEW — issue/verify HMAC over (matchId, activeTeamId, round, lastActionIndex)
Domain/ unchanged — engine stays as-is
Http/
Handlers/
GetAssets.php unchanged
GetBattlefieldEditor.php unchanged
GetBundledScenarios.php NEW — list of {id, name, summary} from /public/assets/scenarios/
GetBundledScenario.php NEW — full Scenario JSON for a bundled id (validates the fixture)
GetHomePage.php unchanged
GetMatchView.php NEW — server-rendered shell for the match view (loads /public/js/match.js)
GetTeamEditor.php unchanged
PostBattlefieldEditor.php unchanged
PostImageUpload.php unchanged
PostMatchMove.php NEW — move action
PostMatchAttack.php NEW — attack action
PostMatchAbility.php NEW — use-ability action
PostMatchEndTurn.php NEW — end-turn action
PostStartMatch.php MODIFY — return {match, matchId, turnToken}; mint the matchId + initial token
PostTeamEditor.php unchanged
MatchActionSupport.php NEW — small helper used by all four action handlers: CSRF + turn-token
verification + MatchState decode + JSON error responses
Router.php MODIFY — register the five new routes
CsrfToken.php unchanged
Request.php / Response.php unchanged
Escape.php unchanged
Views/
layout.php unchanged
home.php MODIFY — render a bundled scenarios list (server-rendered; the JS does
not need to populate it)
match.php NEW — the match view (loads match.js, exposes container + CSRF meta)
team-editor.php unchanged
battlefield-editor.php unchanged
public/
js/
match.js NEW — client match renderer + action dispatcher
grid-editor.js unchanged
storage.js MODIFY — add bundle-bootstrap helpers (startBundledMatch) and a
matchId-aware currentMatch envelope
assets/
scenarios/
manifest.json NEW — name + summary for each bundled id
skirmish.json NEW — 8x8 open, eliminate_all
hold-the-line.json NEW — 10x8, hold_objective, 3 rounds
last-stand.json NEW — 12x12, eliminate_all, asymmetric 6v4
styles.css MODIFY — add .bf-match / .bf-unit / .bf-active / .bf-toast rules
placeholders/ unchanged
archetypes.json unchanged
tests/
Unit/
Application/
TurnTokenTest.php NEW
ScenarioSerializerTest.php MODIFY — add an objectives-round-trip case
Integration/
GetBundledScenariosTest.php NEW
GetBundledScenarioTest.php NEW
GetMatchViewTest.php NEW
PostMatchMoveTest.php NEW
PostMatchAttackTest.php NEW
PostMatchAbilityTest.php NEW
PostMatchEndTurnTest.php NEW
PostMatchActionSmokeTest.php NEW — boots php -S, walks Skirmish to a winner; gated by BATTLEFORGE_E2E=1
PostStartMatchTest.php MODIFY — assert {match, matchId, turnToken} response shape
FullFlowTest.php unchanged
GetHomePageTest.php MODIFY — assert the bundled-scenarios list is rendered server-side
.github/workflows/
ci.yml unchanged
ci-e2e.yml NEW — PR-to-develop + nightly, BATTLEFORGE_E2E=1, 5-minute timeout
docs/
superpowers/
plans/
2026-07-25-battle-interface.md THIS FILE
specs/
2026-07-25-battle-interface-design.md already committed
Delivery Sequence
Plan 4 is the fourth and final plan in the MVP. It builds on Plans 1–3c. It depends on the rules engine (Plan 1+2) and the HTTP layer (Plan 3a+3b) being merged into develop (they are). The plan ships in this order:
- Objective round-trip fix (foundation for the bundled Hold the Line scenario).
TurnTokenvalue object (foundation for the action API).- Modify
PostStartMatchto mintmatchIdandturnToken. - Bundled scenario fixtures and
manifest.json. GetBundledScenariosandGetBundledScenariohandlers.- Update the home page to render the bundled list.
GetMatchViewhandler +match.phptemplate.MatchActionSupporthelper.- The four per-verb action handlers (move, attack, ability, end-turn).
- Register the new routes in
public/index.php. public/js/match.jsrenderer + CSS.- End-to-end smoke test.
- New CI workflow
ci-e2e.yml. - 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:
matchToArrayreturns the existing fields plus'objectives' => array<string, array{id: string, position: array{x: int, y: int}}>.matchFromArraybuilds thearray<string, ObjectiveMarker>from the input data and passes it to theMatchStateconstructor. -
Step 1: Write the failing round-trip test
Append this test method to final class ScenarioSerializerTest in tests/Unit/Application/ScenarioSerializerTest.php, immediately before the closing brace of the class:
public function testMatchRoundTripsItsObjectives(): void
{
$battlefield = new Battlefield(10, 8);
$units = [
new UnitState('a1', 'alpha', new Position(0, 0), 10, 10, 3, 3, 2, 2, false),
new UnitState('a2', 'alpha', new Position(1, 0), 10, 10, 3, 3, 2, 2, false),
new UnitState('a3', 'alpha', new Position(2, 0), 10, 10, 3, 3, 2, 2, false),
new UnitState('b1', 'bravo', new Position(9, 7), 10, 10, 3, 3, 2, 2, false),
new UnitState('b2', 'bravo', new Position(8, 7), 10, 10, 3, 3, 2, 2, false),
new UnitState('b3', 'bravo', new Position(7, 7), 10, 10, 3, 3, 2, 2, false),
];
$objective = new ObjectiveMarker('objective-1', new Position(5, 4));
$original = new MatchState(
battlefield: $battlefield,
units: $units,
activeTeamId: 'alpha',
objectives: ['objective-1' => $objective],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 3,
);
$array = ScenarioSerializer::matchToArray($original);
self::assertArrayHasKey('objectives', $array);
self::assertSame(
['id' => 'objective-1', 'position' => ['x' => 5, 'y' => 4]],
$array['objectives']['objective-1'] ?? null,
);
$rebuilt = ScenarioSerializer::matchFromArray($array);
self::assertArrayHasKey('objective-1', $rebuilt->objectives);
self::assertSame('objective-1', $rebuilt->objectives['objective-1']->id);
self::assertSame(5, $rebuilt->objectives['objective-1']->position->x);
self::assertSame(4, $rebuilt->objectives['objective-1']->position->y);
self::assertSame(VictoryCondition::HoldObjective, $rebuilt->victoryCondition);
self::assertSame(3, $rebuilt->holdRoundsRequired);
}
Add the necessary use BattleForge\Domain\ObjectiveMarker; and use BattleForge\Domain\VictoryCondition; imports at the top of the file if not already present.
- Step 2: Run the test to verify it fails
Run: vendor/bin/phpunit tests/Unit/Application/ScenarioSerializerTest.php --filter RoundTripsItsObjectives
Expected: FAIL because matchToArray does not include objectives and matchFromArray hard-codes objectives: [].
- Step 3: Update
matchToArrayto include objectives
In src/Application/ScenarioSerializer.php, inside matchToArray, add the following block immediately after the $terrainMap block (before the $units accumulation):
$objectives = [];
foreach ($match->objectives as $id => $objective) {
$objectives[(string) $id] = [
'id' => $objective->id,
'position' => self::positionToArray($objective->position),
];
}
Then add 'objectives' => $objectives, to the returned array, after 'objectiveControl' => $match->objectiveControl,.
- Step 4: Update
matchFromArrayto restore objectives
In src/Application/ScenarioSerializer.php, inside matchFromArray, add the following block immediately after the $units accumulation (before the return new MatchState(...) call):
$objectives = [];
foreach (($data['objectives'] ?? []) as $id => $row) {
$objectives[(string) $id] = new ObjectiveMarker(
(string) $row['id'],
self::positionFromArray($row['position']),
);
}
Then change objectives: [], to objectives: $objectives, in the return new MatchState(...) call.
Add the use BattleForge\Domain\ObjectiveMarker; import at the top of the file if not already present.
- Step 5: Run the test to verify it passes
Run: vendor/bin/phpunit tests/Unit/Application/ScenarioSerializerTest.php
Expected: PASS, all tests in the file.
- Step 6: Run the full quality suite and commit
Run: composer check
Expected: all checks green (PHPCS, PHPStan, all 196+ tests).
git add src/Application/ScenarioSerializer.php tests/Unit/Application/ScenarioSerializerTest.php
git commit -m "fix: round-trip match objectives through ScenarioSerializer"
Task 2: Build the TurnToken value object
Files:
- Create:
src/Application/TurnToken.php - Test:
tests/Unit/Application/TurnTokenTest.php
Interfaces:
-
Consumes: a secret string (the same
BATTLEFORGE_SECRETorvar/secret.keyused for CSRF), amatchId(validated as 16+ lowercase hex chars before hashing), anactiveTeamId, around(>= 1), and alastActionIndex(>= 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_equalsagainst the freshly-issued token.- The class validates
matchIdagainst/^[a-f0-9]{16,}$/and rejects emptyactiveTeamIdand negativeround/lastActionIndexwithInvalidArgumentException.
-
Step 1: Write the failing test
Create tests/Unit/Application/TurnTokenTest.php:
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Application;
use BattleForge\Application\TurnToken;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class TurnTokenTest extends TestCase
{
private const SECRET = 'turn-token-test-secret';
public function testIssueIsDeterministicForTheSameInputs(): void
{
$a = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
$b = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertSame($a, $b);
self::assertSame(32, strlen($a));
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $a);
}
public function testVerifyAcceptsAFreshlyIssuedToken(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertTrue(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 0, $token));
}
public function testVerifyRejectsAMutatedToken(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
$tampered = substr($token, 0, -1) . '0';
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 0, $tampered));
}
public function testVerifyRejectsATokenFromADifferentMatch(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertFalse(TurnToken::verify(self::SECRET, 'fedcba9876543210', 'alpha', 1, 0, $token));
}
public function testVerifyRejectsATokenFromADifferentTeam(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'bravo', 1, 0, $token));
}
public function testVerifyRejectsATokenFromADifferentRound(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 2, 0, $token));
}
public function testVerifyRejectsATokenFromADifferentLastActionIndex(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 1, $token));
}
public function testIssueRejectsAMalformedMatchId(): void
{
$this->expectException(InvalidArgumentException::class);
TurnToken::issue(self::SECRET, 'not-hex!', 'alpha', 1, 0);
}
public function testIssueRejectsAnEmptyActiveTeamId(): void
{
$this->expectException(InvalidArgumentException::class);
TurnToken::issue(self::SECRET, '0123456789abcdef', '', 1, 0);
}
public function testIssueRejectsANegativeRound(): void
{
$this->expectException(InvalidArgumentException::class);
TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 0, 0);
}
}
- Step 2: Run the test to verify it fails
Run: vendor/bin/phpunit tests/Unit/Application/TurnTokenTest.php
Expected: FAIL because BattleForge\Application\TurnToken does not exist.
- Step 3: Implement
TurnToken
Create src/Application/TurnToken.php:
<?php
declare(strict_types=1);
namespace BattleForge\Application;
use InvalidArgumentException;
final class TurnToken
{
private const MATCH_ID_PATTERN = '/^[a-f0-9]{16,}$/';
public static function issue(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string
{
self::assertValidInputs($matchId, $activeTeamId, $round, $lastActionIndex);
return substr(hash_hmac('sha256', self::payload($matchId, $activeTeamId, $round, $lastActionIndex), $secret), 0, 32);
}
public static function verify(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex, string $token): bool
{
if ($token === '' || !self::isValidMatchId($matchId) || $activeTeamId === '' || $round < 1 || $lastActionIndex < 0) {
return false;
}
$expected = self::issue($secret, $matchId, $activeTeamId, $round, $lastActionIndex);
return hash_equals($expected, $token);
}
private static function assertValidInputs(string $matchId, string $activeTeamId, int $round, int $lastActionIndex): void
{
if (!self::isValidMatchId($matchId)) {
throw new InvalidArgumentException('Match id must be 16+ lowercase hex characters.');
}
if ($activeTeamId === '') {
throw new InvalidArgumentException('Active team id cannot be empty.');
}
if ($round < 1) {
throw new InvalidArgumentException('Round must be at least 1.');
}
if ($lastActionIndex < 0) {
throw new InvalidArgumentException('Last action index cannot be negative.');
}
}
private static function isValidMatchId(string $matchId): bool
{
return preg_match(self::MATCH_ID_PATTERN, $matchId) === 1;
}
private static function payload(string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string
{
return implode('|', [$matchId, $activeTeamId, (string) $round, (string) $lastActionIndex]);
}
}
- Step 4: Run the test to verify it passes
Run: vendor/bin/phpunit tests/Unit/Application/TurnTokenTest.php
Expected: PASS, 9 tests.
- Step 5: Run the quality suite and commit
Run: composer check
Expected: all checks green.
git add src/Application/TurnToken.php tests/Unit/Application/TurnTokenTest.php
git commit -m "feat: add TurnToken HMAC for match-action anti-cheat"
Task 3: Modify PostStartMatch to mint matchId and turnToken
Files:
- Modify:
src/Http/Handlers/PostStartMatch.php - Test:
tests/Integration/PostStartMatchTest.php
Interfaces:
-
Consumes:
TurnToken::issue, the existingScenarioSerializer::matchToArray. -
Produces: response body shape
{match: <ScenarioSerializer::matchToArray output>, matchId: <16 lowercase hex chars>, turnToken: <32 lowercase hex chars>}. -
Step 1: Update the existing happy-path test to assert the new fields
In tests/Integration/PostStartMatchTest.php, inside testItAcceptsAValidScenarioAndReturnsTheInitialMatch, replace the existing assertions (the three lines starting with self::assertSame('alpha', ...)) with:
$body = json_decode($response->body, true);
self::assertSame('alpha', $body['match']['activeTeamId'] ?? null);
self::assertSame(1, $body['match']['round'] ?? null);
self::assertCount(6, $body['match']['units'] ?? []);
self::assertIsString($body['matchId'] ?? null);
self::assertMatchesRegularExpression('/^[a-f0-9]{16,}$/', $body['matchId']);
self::assertIsString($body['turnToken'] ?? null);
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']);
- Step 2: Add a new test that asserts the turn token is valid for the initial state
Append the following test method inside final class PostStartMatchTest, immediately before the closing brace of the class:
public function testItReturnsATurnTokenValidForTheInitialMatchState(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostStartMatch();
$request = $this->buildRequest(
rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
$matchId = $body['matchId'];
$turnToken = $body['turnToken'];
$lastActionIndex = count($body['match']['actionLog'] ?? []);
self::assertTrue(\BattleForge\Application\TurnToken::verify(
self::SECRET,
$matchId,
$body['match']['activeTeamId'],
$body['match']['round'],
$lastActionIndex,
$turnToken,
));
}
- Step 3: Run the test to verify it fails
Run: vendor/bin/phpunit tests/Integration/PostStartMatchTest.php
Expected: FAIL because PostStartMatch currently returns only {match: ...}.
- Step 4: Modify
PostStartMatchto mint thematchIdandturnToken
Replace src/Http/Handlers/PostStartMatch.php with:
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioDraft;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostStartMatch
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return Response::json(403, ['error' => 'csrf']);
}
try {
$payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR);
$draft = ScenarioDraft::fromPost($payload);
$scenario = $draft->toScenario();
ScenarioValidator::validate($scenario);
$match = $scenario->startMatch('alpha');
} catch (\JsonException $exception) {
return Response::json(400, ['errors' => ['Malformed JSON.']]);
} catch (\InvalidArgumentException $exception) {
return Response::json(400, ['errors' => [$exception->getMessage()]]);
}
$matchId = bin2hex(random_bytes(8));
$matchArray = ScenarioSerializer::matchToArray($match);
$turnToken = TurnToken::issue(
$secret,
$matchId,
$match->activeTeamId,
$match->round,
count($match->actionLog),
);
return Response::json(200, [
'match' => $matchArray,
'matchId' => $matchId,
'turnToken' => $turnToken,
]);
}
}
- Step 5: Run the test to verify it passes
Run: vendor/bin/phpunit tests/Integration/PostStartMatchTest.php
Expected: PASS, 4 tests.
- Step 6: Run the quality suite and commit
Run: composer check
Expected: all checks green.
git add src/Http/Handlers/PostStartMatch.php tests/Integration/PostStartMatchTest.php
git commit -m "feat: mint matchId and turnToken in PostStartMatch"
Task 4: Bundled scenario fixtures and manifest.json
Files:
- Create:
public/assets/scenarios/manifest.json - Create:
public/assets/scenarios/skirmish.json - Create:
public/assets/scenarios/hold-the-line.json - Create:
public/assets/scenarios/last-stand.json
Interfaces:
-
Consumes:
ScenarioSerializer::scenarioFromArrayandScenarioValidator::validate(consumers read each fixture through these in Task 5). -
Produces: three valid
ScenarioJSON files in the same shape asScenarioSerializer::scenarioToArrayproduces, plus amanifest.jsonwhose keys match the fixture filenames (minus the.jsonextension) and whose values are{name, summary}strings. -
Step 1: Create
manifest.json
Create public/assets/scenarios/manifest.json:
{
"skirmish": {
"name": "Skirmish",
"summary": "8x8 open ground. Three units per side. Eliminate the enemy team."
},
"hold-the-line": {
"name": "Hold the Line",
"summary": "10x8 with a forest corridor. Three defenders hold the center against four attackers. Hold the objective for three rounds."
},
"last-stand": {
"name": "Last Stand",
"summary": "12x12 with mixed terrain. Six defenders versus four strikers. Eliminate the enemy team."
}
}
- Step 2: Create
skirmish.json
Create public/assets/scenarios/skirmish.json:
{
"id": "skirmish",
"name": "Skirmish",
"battlefield": {
"width": 8,
"height": 8,
"terrain": {}
},
"units": [
{ "id": "alpha-1", "teamId": "alpha", "position": { "x": 0, "y": 1 }, "maxHealth": 12, "health": 12, "attack": 3, "defense": 4, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "alpha-2", "teamId": "alpha", "position": { "x": 0, "y": 3 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "alpha-3", "teamId": "alpha", "position": { "x": 0, "y": 6 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] },
{ "id": "bravo-1", "teamId": "bravo", "position": { "x": 7, "y": 1 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-2", "teamId": "bravo", "position": { "x": 7, "y": 3 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] },
{ "id": "bravo-3", "teamId": "bravo", "position": { "x": 7, "y": 6 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] }
],
"deploymentZones": {
"alpha": { "teamId": "alpha", "positions": [{ "x": 0, "y": 1 }, { "x": 0, "y": 3 }, { "x": 0, "y": 6 }] },
"bravo": { "teamId": "bravo", "positions": [{ "x": 7, "y": 1 }, { "x": 7, "y": 3 }, { "x": 7, "y": 6 }] }
},
"objectives": {},
"victoryCondition": "eliminate_all",
"holdRoundsRequired": 1
}
- Step 3: Create
hold-the-line.json
Create public/assets/scenarios/hold-the-line.json:
NOTE: The original terrain (a 12-tile forest pocket containing the objective at
(4,4)) made the objective unreachable — alpha defenders (speed 1, inside forest) cannot move out, and bravo strikers (speed 3) cannot bridge from column 9 to column 4 through forest. The validator's reachability rule is correct. The corrected fixture below places the objective in the open at(4,4), gives alpha defenders speed 2 so they can step out of their cover onto the objective, gives bravo-4 the scout archetype at speed 5 placed on(9,4)so it can reach(4,4)across open ground in 5 steps, and limits forest to a small cluster around the alpha deployment zone that doesn't block bravo's path. The tactically-intended property — alpha fights from forest cover, bravo has to push across open ground to reach the objective — is preserved.
{
"id": "hold-the-line",
"name": "Hold the Line",
"battlefield": {
"width": 10,
"height": 8,
"terrain": {
"2:2": "forest", "2:3": "forest", "2:4": "forest", "2:5": "forest",
"3:2": "forest", "3:3": "forest"
}
},
"units": [
{ "id": "alpha-1", "teamId": "alpha", "position": { "x": 2, "y": 3 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "alpha-2", "teamId": "alpha", "position": { "x": 2, "y": 4 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "alpha-3", "teamId": "alpha", "position": { "x": 2, "y": 5 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "bravo-1", "teamId": "bravo", "position": { "x": 9, "y": 1 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-2", "teamId": "bravo", "position": { "x": 9, "y": 3 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-3", "teamId": "bravo", "position": { "x": 9, "y": 7 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] },
{ "id": "bravo-4", "teamId": "bravo", "position": { "x": 9, "y": 4 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }
],
"deploymentZones": {
"alpha": { "teamId": "alpha", "positions": [{ "x": 2, "y": 3 }, { "x": 2, "y": 4 }, { "x": 2, "y": 5 }] },
"bravo": { "teamId": "bravo", "positions": [{ "x": 9, "y": 1 }, { "x": 9, "y": 3 }, { "x": 9, "y": 7 }, { "x": 9, "y": 4 }] }
},
"objectives": {
"objective-1": { "id": "objective-1", "position": { "x": 4, "y": 4 } }
},
"victoryCondition": "hold_objective",
"holdRoundsRequired": 3
}
- Step 4: Create
last-stand.json
Create public/assets/scenarios/last-stand.json:
{
"id": "last-stand",
"name": "Last Stand",
"battlefield": {
"width": 12,
"height": 12,
"terrain": {
"2:2": "forest", "2:3": "forest", "2:8": "forest", "2:9": "forest",
"3:2": "forest", "3:3": "forest", "3:8": "forest", "3:9": "forest",
"5:5": "rough", "5:6": "rough", "6:5": "rough", "6:6": "rough",
"8:2": "water", "8:3": "water", "8:8": "water", "8:9": "water",
"9:2": "water", "9:3": "water", "9:8": "water", "9:9": "water"
}
},
"units": [
{ "id": "alpha-1", "teamId": "alpha", "position": { "x": 0, "y": 0 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "alpha-2", "teamId": "alpha", "position": { "x": 0, "y": 3 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "alpha-3", "teamId": "alpha", "position": { "x": 0, "y": 6 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
{ "id": "alpha-4", "teamId": "alpha", "position": { "x": 0, "y": 9 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] },
{ "id": "alpha-5", "teamId": "alpha", "position": { "x": 1, "y": 5 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] },
{ "id": "alpha-6", "teamId": "alpha", "position": { "x": 2, "y": 11 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] },
{ "id": "bravo-1", "teamId": "bravo", "position": { "x": 11, "y": 2 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-2", "teamId": "bravo", "position": { "x": 11, "y": 5 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-3", "teamId": "bravo", "position": { "x": 11, "y": 8 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
{ "id": "bravo-4", "teamId": "bravo", "position": { "x": 10, "y": 11 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }
],
"deploymentZones": {
"alpha": { "teamId": "alpha", "positions": [{ "x": 0, "y": 0 }, { "x": 0, "y": 3 }, { "x": 0, "y": 6 }, { "x": 0, "y": 9 }, { "x": 1, "y": 5 }, { "x": 2, "y": 11 }] },
"bravo": { "teamId": "bravo", "positions": [{ "x": 11, "y": 2 }, { "x": 11, "y": 5 }, { "x": 11, "y": 8 }, { "x": 10, "y": 11 }] }
},
"objectives": {},
"victoryCondition": "eliminate_all",
"holdRoundsRequired": 1
}
- Step 5: Verify each fixture is a valid scenario
Run: php -r 'require "vendor/autoload.php"; foreach (["skirmish", "hold-the-line", "last-stand"] as $id) { $data = json_decode(file_get_contents("public/assets/scenarios/$id.json"), true, flags: JSON_THROW_ON_ERROR); $scenario = BattleForge\\Application\\ScenarioSerializer::scenarioFromArray($data); $errors = BattleForge\\Domain\\ScenarioValidator::validate($scenario); if ($errors !== []) { echo "$id: FAIL\n"; print_r($errors); exit(1); } echo "$id: OK\n"; }'
Expected: each id prints OK. If any prints FAIL, the validator messages identify which constraint to fix in the fixture (most likely an out-of-bounds position, an unreachable objective, or a stat range violation).
- Step 6: Commit the bundled fixtures
git add public/assets/scenarios/manifest.json public/assets/scenarios/skirmish.json public/assets/scenarios/hold-the-line.json public/assets/scenarios/last-stand.json
git commit -m "feat: add three bundled scenario fixtures and manifest"
Task 5: GetBundledScenarios handler
Files:
- Create:
src/Http/Handlers/GetBundledScenarios.php - Test:
tests/Integration/GetBundledScenariosTest.php
Interfaces:
-
Consumes: a constructor arg
string $scenariosDir(an absolute path topublic/assets/scenarios/). -
Produces:
Response::json(200, [['id' => string, 'name' => string, 'summary' => string], ...])sorted by id. The handler:- Reads
manifest.jsonfrom the scenarios dir; if the file is missing, returns[]. - For each
*.jsonfile 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).
- Reads
-
Step 1: Write the failing test
Create tests/Integration/GetBundledScenariosTest.php:
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetBundledScenarios;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetBundledScenariosTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir() . '/bf-bundled-' . bin2hex(random_bytes(4));
mkdir($this->tmpDir, 0700, true);
}
protected function tearDown(): void
{
if (is_dir($this->tmpDir)) {
foreach (glob($this->tmpDir . '/*') ?: [] as $file) {
unlink($file);
}
rmdir($this->tmpDir);
}
}
public function testReturnsBundledScenariosFromTheConfiguredDirectory(): void
{
$this->writeManifest([
'skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.'],
'hold-the-line' => ['name' => 'Hold the Line', 'summary' => 'Defend.'],
]);
$this->writeFixture('skirmish', '{}');
$this->writeFixture('hold-the-line', '{}');
$handler = new GetBundledScenarios($this->tmpDir);
$response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertSame(
[
['id' => 'hold-the-line', 'name' => 'Hold the Line', 'summary' => 'Defend.'],
['id' => 'skirmish', 'name' => 'Skirmish', 'summary' => 'Open ground.'],
],
$body,
);
}
public function testFiltersOutFixturesWithoutAManifestEntry(): void
{
$this->writeManifest(['skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.']]);
$this->writeFixture('skirmish', '{}');
$this->writeFixture('rogue', '{}');
$handler = new GetBundledScenarios($this->tmpDir);
$response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []);
$body = json_decode($response->body, true);
self::assertCount(1, $body);
self::assertSame('skirmish', $body[0]['id']);
}
public function testIgnoresFilesWithDisallowedFilenames(): void
{
$this->writeManifest(['skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.']]);
$this->writeFixture('skirmish', '{}');
$this->writeFixture('bad..json', '{}');
$this->writeFixture('README', 'plain text');
$handler = new GetBundledScenarios($this->tmpDir);
$response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []);
$body = json_decode($response->body, true);
self::assertCount(1, $body);
self::assertSame('skirmish', $body[0]['id']);
}
public function testReturnsEmptyArrayWhenManifestIsMissing(): void
{
$handler = new GetBundledScenarios($this->tmpDir);
$response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []);
self::assertSame(200, $response->status);
self::assertSame([], json_decode($response->body, true));
}
/** @param array<string, array{name: string, summary: string}> $entries */
private function writeManifest(array $entries): void
{
file_put_contents($this->tmpDir . '/manifest.json', json_encode($entries, JSON_THROW_ON_ERROR));
}
private function writeFixture(string $name, string $body): void
{
file_put_contents($this->tmpDir . '/' . $name . '.json', $body);
}
}
- Step 2: Run the test to verify it fails
Run: vendor/bin/phpunit tests/Integration/GetBundledScenariosTest.php
Expected: FAIL because BattleForge\Http\Handlers\GetBundledScenarios does not exist.
- Step 3: Implement
GetBundledScenarios
Create src/Http/Handlers/GetBundledScenarios.php:
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetBundledScenarios
{
private const FILENAME_PATTERN = '/^[a-z0-9-]+\.json$/';
public function __construct(private readonly string $scenariosDir)
{
}
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$manifestPath = $this->scenariosDir . '/manifest.json';
$manifest = [];
if (is_file($manifestPath)) {
$decoded = json_decode((string) file_get_contents($manifestPath), true);
if (is_array($decoded)) {
$manifest = $decoded;
}
}
$entries = [];
foreach (scandir($this->scenariosDir) ?: [] as $file) {
if (!preg_match(self::FILENAME_PATTERN, $file)) {
continue;
}
$id = substr($file, 0, -5);
$entry = $manifest[$id] ?? null;
if (!is_array($entry) || !isset($entry['name'], $entry['summary'])) {
continue;
}
$entries[] = [
'id' => $id,
'name' => (string) $entry['name'],
'summary' => (string) $entry['summary'],
];
}
usort($entries, static fn (array $a, array $b): int => strcmp($a['id'], $b['id']));
return Response::json(200, $entries);
}
}
- Step 4: Run the test to verify it passes
Run: vendor/bin/phpunit tests/Integration/GetBundledScenariosTest.php
Expected: PASS, 4 tests.
- Step 5: Run the quality suite and commit
Run: composer check
Expected: all checks green.
git add src/Http/Handlers/GetBundledScenarios.php tests/Integration/GetBundledScenariosTest.php
git commit -m "feat: add GetBundledScenarios handler with manifest-driven list"
Task 6: GetBundledScenario handler
Files:
- Create:
src/Http/Handlers/GetBundledScenario.php - Test:
tests/Integration/GetBundledScenarioTest.php
Interfaces:
-
Consumes: a constructor arg
string $scenariosDir. -
Produces:
Response::json(200, <ScenarioSerializer::scenarioToArray output>)on success.Response::json(404, ['error' => 'not found'])if the id is missing or fails the/^[a-z0-9-]+$/regex, or the file does not exist.Response::json(500, ['error' => 'fixture invalid', 'details' => [...]])if the fixture deserializes into aScenariothat failsScenarioValidator::validate.- The handler reads
manifest.jsonto ensure the id is recognized; an id with a file but no manifest entry is treated as not found.
-
Step 1: Write the failing test
Create tests/Integration/GetBundledScenarioTest.php:
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetBundledScenario;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetBundledScenarioTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir() . '/bf-bundled-one-' . bin2hex(random_bytes(4));
mkdir($this->tmpDir, 0700, true);
file_put_contents(
$this->tmpDir . '/manifest.json',
json_encode(['valid' => ['name' => 'Valid', 'summary' => 'OK']], JSON_THROW_ON_ERROR),
);
$this->writeValidFixture();
}
protected function tearDown(): void
{
if (is_dir($this->tmpDir)) {
foreach (glob($this->tmpDir . '/*') ?: [] as $file) {
unlink($file);
}
rmdir($this->tmpDir);
}
}
public function testReturnsTheScenarioJsonForAKnownId(): void
{
$handler = new GetBundledScenario($this->tmpDir);
$response = $handler->handle(
new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/valid', null, ''),
['id' => 'valid'],
);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertSame('valid', $body['id'] ?? null);
self::assertSame('Valid', $body['name'] ?? null);
self::assertSame(8, $body['battlefield']['width'] ?? null);
}
public function testReturns404ForAnUnknownId(): void
{
$handler = new GetBundledScenario($this->tmpDir);
$response = $handler->handle(
new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/unknown', null, ''),
['id' => 'unknown'],
);
self::assertSame(404, $response->status);
}
public function testReturns404ForAnIdThatFailsTheRegex(): void
{
$handler = new GetBundledScenario($this->tmpDir);
$response = $handler->handle(
new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/..', null, ''),
['id' => '..'],
);
self::assertSame(404, $response->status);
}
public function testReturns500WhenTheFixtureFailsValidation(): void
{
$bad = [
'id' => 'broken',
'name' => 'Broken',
'battlefield' => ['width' => 8, 'height' => 8, 'terrain' => []],
'units' => [],
'deploymentZones' => [],
'objectives' => [],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
file_put_contents($this->tmpDir . '/broken.json', json_encode($bad, JSON_THROW_ON_ERROR));
file_put_contents(
$this->tmpDir . '/manifest.json',
json_encode([
'valid' => ['name' => 'Valid', 'summary' => 'OK'],
'broken' => ['name' => 'Broken', 'summary' => 'Bad'],
], JSON_THROW_ON_ERROR),
);
$handler = new GetBundledScenario($this->tmpDir);
$response = $handler->handle(
new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/broken', null, ''),
['id' => 'broken'],
);
self::assertSame(500, $response->status);
$body = json_decode($response->body, true);
self::assertSame('fixture invalid', $body['error'] ?? null);
self::assertIsArray($body['details'] ?? null);
self::assertNotEmpty($body['details']);
}
private function writeValidFixture(): void
{
$fixture = [
'id' => 'valid',
'name' => 'Valid',
'battlefield' => ['width' => 8, 'height' => 8, 'terrain' => []],
'units' => [
['id' => 'a1', 'teamId' => 'alpha', 'position' => ['x' => 0, 'y' => 0], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
['id' => 'a2', 'teamId' => 'alpha', 'position' => ['x' => 1, 'y' => 0], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
['id' => 'a3', 'teamId' => 'alpha', 'position' => ['x' => 2, 'y' => 0], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
['id' => 'b1', 'teamId' => 'bravo', 'position' => ['x' => 7, 'y' => 7], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
['id' => 'b2', 'teamId' => 'bravo', 'position' => ['x' => 6, 'y' => 7], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
['id' => 'b3', 'teamId' => 'bravo', 'position' => ['x' => 5, 'y' => 7], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
],
'deploymentZones' => [
'alpha' => ['teamId' => 'alpha', 'positions' => [['x' => 0, 'y' => 0], ['x' => 1, 'y' => 0], ['x' => 2, 'y' => 0]]],
'bravo' => ['teamId' => 'bravo', 'positions' => [['x' => 7, 'y' => 7], ['x' => 6, 'y' => 7], ['x' => 5, 'y' => 7]]],
],
'objectives' => [],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
file_put_contents($this->tmpDir . '/valid.json', json_encode($fixture, JSON_THROW_ON_ERROR));
}
}
- Step 2: Run the test to verify it fails
Run: vendor/bin/phpunit tests/Integration/GetBundledScenarioTest.php
Expected: FAIL because BattleForge\Http\Handlers\GetBundledScenario does not exist.
- Step 3: Implement
GetBundledScenario
Create src/Http/Handlers/GetBundledScenario.php:
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
use InvalidArgumentException;
final class GetBundledScenario
{
private const ID_PATTERN = '/^[a-z0-9-]+$/';
public function __construct(private readonly string $scenariosDir)
{
}
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$id = (string) ($params['id'] ?? '');
if (preg_match(self::ID_PATTERN, $id) !== 1) {
return Response::json(404, ['error' => 'not found']);
}
$manifestPath = $this->scenariosDir . '/manifest.json';
$manifest = [];
if (is_file($manifestPath)) {
$decoded = json_decode((string) file_get_contents($manifestPath), true);
if (is_array($decoded)) {
$manifest = $decoded;
}
}
if (!isset($manifest[$id])) {
return Response::json(404, ['error' => 'not found']);
}
$fixturePath = $this->scenariosDir . '/' . $id . '.json';
$real = realpath($fixturePath);
$realDir = realpath($this->scenariosDir);
if ($real === false || $realDir === false || !str_starts_with($real, $realDir . DIRECTORY_SEPARATOR) || !is_file($real)) {
return Response::json(404, ['error' => 'not found']);
}
$data = json_decode((string) file_get_contents($real), true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data)) {
return Response::json(500, ['error' => 'fixture invalid', 'details' => ['Fixture JSON is not an object.']]);
}
try {
$scenario = ScenarioSerializer::scenarioFromArray($data);
} catch (InvalidArgumentException $exception) {
return Response::json(500, ['error' => 'fixture invalid', 'details' => [$exception->getMessage()]]);
}
$errors = ScenarioValidator::validate($scenario);
if ($errors !== []) {
return Response::json(500, ['error' => 'fixture invalid', 'details' => $errors]);
}
return Response::json(200, ScenarioSerializer::scenarioToArray($scenario));
}
}
- Step 4: Run the test to verify it passes
Run: vendor/bin/phpunit tests/Integration/GetBundledScenarioTest.php
Expected: PASS, 4 tests.
- Step 5: Run the quality suite and commit
Run: composer check
Expected: all checks green.
git add src/Http/Handlers/GetBundledScenario.php tests/Integration/GetBundledScenarioTest.php
git commit -m "feat: add GetBundledScenario handler with fixture validation"
Task 7: Update the home page to render the bundled scenarios list
Files:
- Modify:
src/Http/Handlers/GetHomePage.php - Modify:
src/Views/home.php - Modify:
tests/Integration/GetHomePageTest.php
Interfaces:
-
Consumes: a new constructor arg
string $scenariosDirforGetHomePage. The handler readsmanifest.jsonfrom 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 existingstorage.jspopulates the recent-scenarios<div id="recent">fromlocalStorage; the bundled list is server-rendered and not replaced by JS. -
Step 1: Add a new test for the bundled list
Add the following test method to tests/Integration/GetHomePageTest.php, immediately before the closing brace of the class. The existing testItReturnsTheHomePageWithSecurityHeaders test remains unchanged.
public function testItRendersTheBundledScenariosListFromTheManifest(): void
{
$tmpDir = sys_get_temp_dir() . '/bf-home-bundled-' . bin2hex(random_bytes(4));
mkdir($tmpDir, 0700, true);
file_put_contents(
$tmpDir . '/manifest.json',
json_encode([
'skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.'],
'hold-the-line' => ['name' => 'Hold the Line', 'summary' => 'Defend.'],
], JSON_THROW_ON_ERROR),
);
foreach (['skirmish.json', 'hold-the-line.json'] as $file) {
file_put_contents($tmpDir . '/' . $file, '{}');
}
$handler = new GetHomePage($tmpDir);
$request = new Request([], [], [], [], [], '', 'GET', '/', 'text/html', '');
$response = $handler->handle($request, []);
self::assertSame(200, $response->status);
self::assertStringContainsString('id="bf-bundled"', $response->body);
self::assertStringContainsString('data-bf-bundle="skirmish"', $response->body);
self::assertStringContainsString('data-bf-bundle="hold-the-line"', $response->body);
self::assertStringContainsString('Open ground.', $response->body);
self::assertStringContainsString('Defend.', $response->body);
unlink($tmpDir . '/manifest.json');
foreach (['skirmish.json', 'hold-the-line.json'] as $file) {
unlink($tmpDir . '/' . $file);
}
rmdir($tmpDir);
}
Add a new test testItReturnsTheHomePageWithSecurityHeaders next to the existing one — that one is no longer the only test. Keep the old test intact (it still passes — the existing assertions are still true on the new view). Wrap the existing test in a separate method, and keep its body as is. The file's structure should now contain two test methods.
- Step 2: Run the test to verify it fails
Run: vendor/bin/phpunit tests/Integration/GetHomePageTest.php
Expected: FAIL on the new test because GetHomePage does not yet accept a scenarios dir and the home view does not render the bundled list.
- Step 3: Update
GetHomePageto accept the scenarios dir and pass it to the view
Replace src/Http/Handlers/GetHomePage.php with:
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetHomePage
{
public function __construct(private readonly string $scenariosDir = '')
{
}
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$csrf = $request->cookies['__csrf'] ?? '';
$bundled = $this->loadBundledScenarios();
ob_start();
require_once __DIR__ . '/../../Views/layout.php';
require __DIR__ . '/../../Views/home.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
/** @return list<array{id: string, name: string, summary: string}> */
private function loadBundledScenarios(): array
{
if ($this->scenariosDir === '' || !is_file($this->scenariosDir . '/manifest.json')) {
return [];
}
$decoded = json_decode((string) file_get_contents($this->scenariosDir . '/manifest.json'), true);
if (!is_array($decoded)) {
return [];
}
$entries = [];
foreach ($decoded as $id => $entry) {
if (!is_string($id) || !preg_match('/^[a-z0-9-]+$/', $id) || !is_array($entry)) {
continue;
}
if (!isset($entry['name'], $entry['summary'])) {
continue;
}
if (!is_file($this->scenariosDir . '/' . $id . '.json')) {
continue;
}
$entries[] = [
'id' => $id,
'name' => (string) $entry['name'],
'summary' => (string) $entry['summary'],
];
}
usort($entries, static fn (array $a, array $b): int => strcmp($a['id'], $b['id']));
return $entries;
}
}
Note: the $bundled local variable inside handle is captured by the ob_start / require block (the view runs in handle's scope), so the view sees the local without any explicit pass. No further wiring is required.
- Step 4: Update the home view to render the bundled list
Replace src/Views/home.php with:
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/** @var string $csrf Provided by the front controller. */
/** @var list<array{id: string, name: string, summary: string}> $bundled Provided by GetHomePage. */
?><?php
render_layout(static function () use ($csrf, $bundled): void {
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
?>
<h1>BattleForge</h1>
<p><a href="<?= $a('/scenarios/new/edit/team') ?>">New scenario</a></p>
<h2>Play bundled</h2>
<ul id="bf-bundled">
<?php foreach ($bundled as $entry): ?>
<li>
<button type="button" data-bf-bundle="<?= $a($entry['id']) ?>">
<strong><?= $e($entry['name']) ?></strong> — <?= $e($entry['summary']) ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<h2>Recent scenarios</h2>
<div id="recent"><p class="bf-empty">No saved scenarios yet.</p></div>
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
<?php
}, $csrf, 'BattleForge');
- Step 5: Run the test to verify it passes
Run: vendor/bin/phpunit tests/Integration/GetHomePageTest.php
Expected: PASS, 2 tests.
- Step 6: Run the quality suite and commit
Run: composer check
Expected: all checks green.
git add src/Http/Handlers/GetHomePage.php src/Views/home.php tests/Integration/GetHomePageTest.php
git commit -m "feat: render the bundled-scenarios list on the home page"
Task 8: GetMatchView handler and match.php template
Files:
- Create:
src/Http/Handlers/GetMatchView.php - Create:
src/Views/match.php - Test:
tests/Integration/GetMatchViewTest.php
Interfaces:
-
Consumes: a
Request(only the__csrfcookie is read). -
Produces:
Response::html(200, <body>)with:- The shared
layout.phpchrome. - A header bar:
<header id="bf-match-header" data-bf-active-team="..." data-bf-round="...">(the JS fills these frommatch: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-unitindicator. - An action log:
<div id="bf-log" class="bf-log">. - A result panel:
<div id="bf-result" class="bf-result" hidden>with a#bf-winnerspan and an#bf-return-homelink. - 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).
- The shared
-
Step 1: Write the failing test
Create tests/Integration/GetMatchViewTest.php:
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetMatchView;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetMatchViewTest extends TestCase
{
public function testItRendersTheMatchShellWithSecurityHeaders(): void
{
$handler = new GetMatchView();
$request = new Request([], [], [], [], [], '', 'GET', '/matches/current', 'text/html', '');
$response = $handler->handle($request, []);
self::assertSame(200, $response->status);
self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
self::assertSame('nosniff', $response->headers['X-Content-Type-Options']);
self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']);
self::assertStringContainsString('id="bf-match-header"', $response->body);
self::assertStringContainsString('id="bf-end-turn"', $response->body);
self::assertStringContainsString('id="bf-grid"', $response->body);
self::assertStringContainsString('id="bf-panel"', $response->body);
self::assertStringContainsString('id="bf-action-move"', $response->body);
self::assertStringContainsString('id="bf-action-attack"', $response->body);
self::assertStringContainsString('id="bf-action-ability"', $response->body);
self::assertStringContainsString('id="bf-selected-unit"', $response->body);
self::assertStringContainsString('id="bf-log"', $response->body);
self::assertStringContainsString('id="bf-result"', $response->body);
self::assertStringContainsString('id="bf-winner"', $response->body);
self::assertStringContainsString('id="bf-return-home"', $response->body);
self::assertStringContainsString('src="/js/match.js"', $response->body);
self::assertStringContainsString('name="csrf-token"', $response->body);
}
}
- Step 2: Run the test to verify it fails
Run: vendor/bin/phpunit tests/Integration/GetMatchViewTest.php
Expected: FAIL because GetMatchView does not exist.
- Step 3: Implement
GetMatchView
Create src/Http/Handlers/GetMatchView.php:
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetMatchView
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$csrf = $request->cookies['__csrf'] ?? '';
ob_start();
require_once __DIR__ . '/../../Views/layout.php';
require __DIR__ . '/../../Views/match.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
}
- Step 4: Implement the
match.phpview
Create src/Views/match.php:
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/** @var string $csrf Provided by the front controller. */
?><?php
render_layout(static function (): void {
$a = static fn (string $v): string => Escape::attr($v);
?>
<header id="bf-match-header" data-bf-active-team="" data-bf-round="0">
<h1>Battle</h1>
<p>
Active team: <strong id="bf-active-team">—</strong>
· Round: <strong id="bf-round">0</strong>
</p>
<p>
<button id="bf-end-turn" type="button">End Turn</button>
</p>
</header>
<main>
<div id="bf-grid" class="bf-grid" data-bf-width="0" data-bf-height="0"></div>
<aside id="bf-panel" class="bf-panel">
<p>Selected: <span id="bf-selected-unit">none</span></p>
<p>
<button id="bf-action-move" type="button" disabled>Move</button>
<button id="bf-action-attack" type="button" disabled>Attack</button>
<button id="bf-action-ability" type="button" disabled>Ability</button>
</p>
<div id="bf-ability-options"></div>
</aside>
</main>
<section id="bf-log" class="bf-log" aria-label="Action log"></section>
<section id="bf-result" class="bf-result" hidden>
<h2>Match over</h2>
<p>Winner: <strong id="bf-winner"></strong></p>
<p><a id="bf-return-home" href="/">Return to home</a></p>
</section>
<div id="bf-toast-host"></div>
<script type="module" src="<?= $a('/js/match.js') ?>"></script>
<?php
}, $csrf, 'Battle');
- Step 5: Run the test to verify it passes
Run: vendor/bin/phpunit tests/Integration/GetMatchViewTest.php
Expected: PASS, 1 test.
- Step 6: Run the quality suite and commit
Run: composer check
Expected: all checks green.
git add src/Http/Handlers/GetMatchView.php src/Views/match.php tests/Integration/GetMatchViewTest.php
git commit -m "feat: add match view server-rendered shell"
Task 9: MatchActionSupport shared helper
Files:
- Create:
src/Http/MatchActionSupport.php - Test:
tests/Unit/Http/MatchActionSupportTest.php
Interfaces:
-
Consumes: a
Requestand 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 MatchActionResultwith 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 MatchActionSupportwith:public function __construct(string $secret)(the CSRF / turn-token secret).public function verify(Request $request): MatchActionResult— runs CSRF check, turn-token check, andMatchStatedecode; returns aMatchActionResultcarrying either aResponse(CSRF/turn/state failure) or the validated pair.
- The class does not dispatch the engine call itself; each handler does that with the result of
verify().
-
Step 1: Write the failing test
Create tests/Unit/Http/MatchActionSupportTest.php:
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Http;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\UnitState;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class MatchActionSupportTest extends TestCase
{
private const SECRET = 'support-test-secret';
public function testItRejectsARequestWithAMissingCsrfToken(): void
{
$support = new MatchActionSupport(self::SECRET);
$request = $this->buildRequest(match: $this->validMatchArray(), csrf: '', turnToken: 'irrelevant');
$result = $support->verify($request);
self::assertNotNull($result->response);
self::assertSame(403, $result->response->status);
self::assertNull($result->matchIdAndMatch);
}
public function testItRejectsARequestWithABadTurnToken(): void
{
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$match = $this->validMatch();
$matchId = '0123456789abcdef';
$badToken = TurnToken::issue(self::SECRET, $matchId, 'bravo', 1, 0);
$support = new MatchActionSupport(self::SECRET);
$request = $this->buildRequest(
match: $this->validMatchArray(),
csrf: $csrf,
turnToken: $badToken,
cookies: ['__csrf' => $cookie],
);
$result = $support->verify($request);
self::assertNotNull($result->response);
self::assertSame(409, $result->response->status);
$body = json_decode($result->response->body, true);
self::assertSame('turn', $body['error'] ?? null);
}
public function testItRejectsAMalformedMatchState(): void
{
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$support = new MatchActionSupport(self::SECRET);
$request = $this->buildRequest(
match: ['this' => 'is not a match'],
csrf: $csrf,
turnToken: 'whatever',
cookies: ['__csrf' => $cookie],
);
$result = $support->verify($request);
self::assertNotNull($result->response);
self::assertSame(400, $result->response->status);
}
public function testItReturnsTheValidatedPairForAValidRequest(): void
{
$match = $this->validMatch();
$matchArray = $this->validMatchArray();
$matchId = '0123456789abcdef';
$token = TurnToken::issue(self::SECRET, $matchId, $match->activeTeamId, $match->round, count($match->actionLog));
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$support = new MatchActionSupport(self::SECRET);
$request = $this->buildRequest(
match: $matchArray,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie],
);
$body = json_encode(['matchId' => $matchId, 'match' => $matchArray], JSON_THROW_ON_ERROR);
$request = new Request([], [], [], ['__csrf' => $cookie], [
'HTTP_X_CSRF_TOKEN' => $csrf,
'HTTP_X_TURN_TOKEN' => $token,
'__csrf_secret' => self::SECRET,
'CONTENT_TYPE' => 'application/json',
], '', 'POST', '/matches/current/move', 'application/json', $body);
$result = $support->verify($request);
self::assertNull($result->response);
self::assertNotNull($result->matchIdAndMatch);
self::assertSame($matchId, $result->matchIdAndMatch['matchId']);
self::assertInstanceOf(MatchState::class, $result->matchIdAndMatch['match']);
}
private function validMatch(): MatchState
{
return new MatchState(
new Battlefield(8, 8),
[
new UnitState('alpha-1', 'alpha', new \BattleForge\Domain\Position(0, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
new UnitState('alpha-2', 'alpha', new \BattleForge\Domain\Position(1, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
new UnitState('alpha-3', 'alpha', new \BattleForge\Domain\Position(2, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
new UnitState('bravo-1', 'bravo', new \BattleForge\Domain\Position(7, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
new UnitState('bravo-2', 'bravo', new \BattleForge\Domain\Position(6, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
new UnitState('bravo-3', 'bravo', new \BattleForge\Domain\Position(5, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
],
'alpha',
);
}
/** @return array<string, mixed> */
private function validMatchArray(): array
{
return \BattleForge\Application\ScenarioSerializer::matchToArray($this->validMatch());
}
/**
* @param array<string, mixed> $match
* @param array<string, string> $cookies
*/
private function buildRequest(array $match, string $csrf, string $turnToken, array $cookies = []): Request
{
$body = json_encode(['matchId' => '0123456789abcdef', 'match' => $match], JSON_THROW_ON_ERROR);
return new Request([], [], [], $cookies, [
'HTTP_X_CSRF_TOKEN' => $csrf,
'HTTP_X_TURN_TOKEN' => $turnToken,
'__csrf_secret' => self::SECRET,
'CONTENT_TYPE' => 'application/json',
], '', 'POST', '/matches/current/move', 'application/json', $body);
}
}
- Step 2: Run the test to verify it fails
Run: vendor/bin/phpunit tests/Unit/Http/MatchActionSupportTest.php
Expected: FAIL because BattleForge\Http\MatchActionSupport does not exist.
- Step 3: Implement
MatchActionSupport
Create src/Http/MatchActionSupport.php:
<?php
declare(strict_types=1);
namespace BattleForge\Http;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\MatchState;
final readonly class MatchActionResult
{
public function __construct(
public ?Response $response,
public ?array $matchIdAndMatch,
) {
}
/** @param array{matchId: string, match: MatchState} $pair */
public static function ok(array $pair): self
{
return new self(null, $pair);
}
public static function reject(Response $response): self
{
return new self($response, null);
}
}
final class MatchActionSupport
{
public function __construct(private readonly string $secret)
{
}
public function verify(Request $request): MatchActionResult
{
$submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return MatchActionResult::reject(Response::json(403, ['error' => 'csrf']));
}
$payload = json_decode($request->rawBody, true);
if (!is_array($payload)) {
return MatchActionResult::reject(Response::json(400, ['errors' => ['Malformed JSON.']]));
}
$matchId = (string) ($payload['matchId'] ?? '');
$matchData = $payload['match'] ?? null;
if (!is_array($matchData)) {
return MatchActionResult::reject(Response::json(400, ['errors' => ['Match is required.']]));
}
try {
$match = ScenarioSerializer::matchFromArray($matchData);
} catch (\JsonException $exception) {
return MatchActionResult::reject(Response::json(400, ['errors' => ['Malformed JSON.']]));
} catch (\InvalidArgumentException $exception) {
return MatchActionResult::reject(Response::json(400, ['errors' => [$exception->getMessage()]]));
}
$supplied = (string) ($request->server['HTTP_X_TURN_TOKEN'] ?? '');
if (!TurnToken::verify(
$this->secret,
$matchId,
$match->activeTeamId,
$match->round,
count($match->actionLog),
$supplied,
)) {
return MatchActionResult::reject(Response::json(409, ['error' => 'turn']));
}
return MatchActionResult::ok(['matchId' => $matchId, 'match' => $match]);
}
}
- Step 4: Run the test to verify it passes
Run: vendor/bin/phpunit tests/Unit/Http/MatchActionSupportTest.php
Expected: PASS, 4 tests.
- Step 5: Run the quality suite and commit
Run: composer check
Expected: all checks green.
git add src/Http/MatchActionSupport.php tests/Unit/Http/MatchActionSupportTest.php
git commit -m "feat: add MatchActionSupport for shared CSRF/turn/state verification"
Task 10: Four per-verb action handlers (move / attack / ability / end-turn)
This task creates all four action handlers in a single commit because they share a structure enforced by the same helper. Each handler is small (~20 lines).
Files:
- Create:
src/Http/Handlers/PostMatchMove.php - Create:
src/Http/Handlers/PostMatchAttack.php - Create:
src/Http/Handlers/PostMatchAbility.php - Create:
src/Http/Handlers/PostMatchEndTurn.php - Test:
tests/Integration/PostMatchActionTest.php(one test class with one happy + one rejection case per verb)
Interfaces (each handler):
- Consumes: the request body
{matchId, match, ...actionParams}and theMatchActionSupport(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
ResponsefromMatchActionResult::reject(...)unchanged (handler does not swallow it). - Produces (engine error):
Response::json(409, ['error' => <reason>])onCombatException. The match in the body is the incoming match (unchanged), so the client can confirm the rejection didn't partially mutate state.
Each handler follows the same shape:
final class PostMatchVerb
{
public function __construct(private readonly string $secret) {}
public function handle(Request $request, array $params): Response
{
$support = new MatchActionSupport($this->secret);
$result = $support->verify($request);
if ($result->response !== null) {
return $result->response;
}
$matchId = $result->matchIdAndMatch['matchId'];
$match = $result->matchIdAndMatch['match'];
try {
$next = (new CombatEngine())->verb($match, ...);
} catch (CombatException $exception) {
return Response::json(409, [
'error' => $exception->getMessage(),
'match' => ScenarioSerializer::matchToArray($match),
'turnToken' => TurnToken::issue($this->secret, $matchId, $match->activeTeamId, $match->round, count($match->actionLog)),
]);
}
return Response::json(200, [
'match' => ScenarioSerializer::matchToArray($next),
'turnToken' => TurnToken::issue($this->secret, $matchId, $next->activeTeamId, $next->round, count($next->actionLog)),
'winnerTeamId' => $next->winnerTeamId,
]);
}
}
- Step 1: Implement
PostMatchMove
Create src/Http/Handlers/PostMatchMove.php:
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\Position;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchMove
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$support = new MatchActionSupport($this->secret);
$result = $support->verify($request);
if ($result->response !== null) {
return $result->response;
}
$matchId = $result->matchIdAndMatch['matchId'];
$match = $result->matchIdAndMatch['match'];
$payload = json_decode($request->rawBody, true);
$unitId = (string) ($payload['unitId'] ?? '');
$x = (int) ($payload['x'] ?? -1);
$y = (int) ($payload['y'] ?? -1);
try {
$next = (new CombatEngine())->move($match, $unitId, new Position($x, $y));
} catch (CombatException $exception) {
return self::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
}
return self::successResponse($this->secret, $matchId, $next);
}
/** @return array<string, mixed> */
public static function successResponse(string $secret, string $matchId, \BattleForge\Domain\MatchState $next): array
{
return [
'match' => ScenarioSerializer::matchToArray($next),
'turnToken' => TurnToken::issue($secret, $matchId, $next->activeTeamId, $next->round, count($next->actionLog)),
'winnerTeamId' => $next->winnerTeamId,
];
}
public static function rejectionResponse(string $secret, string $matchId, \BattleForge\Domain\MatchState $match, string $reason): Response
{
return Response::json(409, [
'error' => $reason,
'match' => ScenarioSerializer::matchToArray($match),
'turnToken' => TurnToken::issue($secret, $matchId, $match->activeTeamId, $match->round, count($match->actionLog)),
]);
}
}
- Step 2: Implement
PostMatchAttack
Create src/Http/Handlers/PostMatchAttack.php:
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchAttack
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$support = new MatchActionSupport($this->secret);
$result = $support->verify($request);
if ($result->response !== null) {
return $result->response;
}
$matchId = $result->matchIdAndMatch['matchId'];
$match = $result->matchIdAndMatch['match'];
$payload = json_decode($request->rawBody, true);
$attackerId = (string) ($payload['attackerId'] ?? '');
$targetId = (string) ($payload['targetId'] ?? '');
try {
$next = (new CombatEngine())->attack($match, $attackerId, $targetId);
} catch (CombatException $exception) {
return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
}
return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next));
}
}
- Step 3: Implement
PostMatchAbility
Create src/Http/Handlers/PostMatchAbility.php:
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\Position;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchAbility
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$support = new MatchActionSupport($this->secret);
$result = $support->verify($request);
if ($result->response !== null) {
return $result->response;
}
$matchId = $result->matchIdAndMatch['matchId'];
$match = $result->matchIdAndMatch['match'];
$payload = json_decode($request->rawBody, true);
$unitId = (string) ($payload['unitId'] ?? '');
$abilityId = (string) ($payload['abilityId'] ?? '');
$x = (int) ($payload['x'] ?? -1);
$y = (int) ($payload['y'] ?? -1);
try {
$next = (new CombatEngine())->useAbility($match, $unitId, $abilityId, new Position($x, $y));
} catch (CombatException $exception) {
return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
}
return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next));
}
}
- Step 4: Implement
PostMatchEndTurn
Create src/Http/Handlers/PostMatchEndTurn.php:
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchEndTurn
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$support = new MatchActionSupport($this->secret);
$result = $support->verify($request);
if ($result->response !== null) {
return $result->response;
}
$matchId = $result->matchIdAndMatch['matchId'];
$match = $result->matchIdAndMatch['match'];
try {
$next = (new CombatEngine())->endTurn($match);
} catch (CombatException $exception) {
return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
}
return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next));
}
}
- Step 5: Write the integration test
Create tests/Integration/PostMatchActionTest.php:
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\UnitState;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostMatchAbility;
use BattleForge\Http\Handlers\PostMatchAttack;
use BattleForge\Http\Handlers\PostMatchEndTurn;
use BattleForge\Http\Handlers\PostMatchMove;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostMatchActionTest extends TestCase
{
private const SECRET = 'match-action-test-secret';
private const MATCH_ID = '0123456789abcdef';
public function testMoveHappyPathReturnsTheUpdatedMatchAndAFreshTurnToken(): void
{
$match = $this->freshMatch();
$token = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$response = (new PostMatchMove(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie],
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-1', 'x' => 1, 'y' => 0],
),
[],
);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertSame('1:0', $body['match']['units'][0]['position']['x'] . ':' . $body['match']['units'][0]['position']['y']);
self::assertSame(1, $body['match']['units'][0]['actionsRemaining']);
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']);
self::assertNull($body['winnerTeamId']);
}
public function testMoveRejectsAnUnreachableDestinationAndDoesNotMutateTheMatch(): void
{
$match = $this->freshMatch();
$token = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$response = (new PostMatchMove(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie],
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-1', 'x' => 0, 'y' => 0],
),
[],
);
self::assertSame(409, $response->status);
$body = json_decode($response->body, true);
self::assertStringContainsString('not reachable', $body['error']);
self::assertSame('0:0', $body['match']['units'][0]['position']['x'] . ':' . $body['match']['units'][0]['position']['y']);
}
public function testAttackHappyPathReducesTargetHealth(): void
{
$match = $this->freshMatch();
$matchArray = ScenarioSerializer::matchToArray($match);
$matchArray['units'][0]['position'] = ['x' => 6, 'y' => 7];
$match = ScenarioSerializer::matchFromArray($matchArray);
$token = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$response = (new PostMatchAttack(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie],
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1'],
),
[],
);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
$bravo = null;
foreach ($body['match']['units'] as $unit) {
if ($unit['id'] === 'bravo-1') { $bravo = $unit; break; }
}
self::assertNotNull($bravo);
self::assertLessThan(10, $bravo['health']);
}
public function testAttackRejectsAnOutOfRangeTarget(): void
{
$match = $this->freshMatch();
$token = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$response = (new PostMatchAttack(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie],
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1'],
),
[],
);
self::assertSame(409, $response->status);
}
public function testAbilityHappyPathHealsAnAlly(): void
{
$match = $this->freshMatch();
$matchArray = ScenarioSerializer::matchToArray($match);
foreach ($matchArray['units'] as &$unit) {
if ($unit['id'] === 'alpha-3') {
$unit['archetype'] = 'support';
$unit['abilities'] = ['heal'];
}
}
unset($unit);
foreach ($matchArray['units'] as &$unit) {
if ($unit['id'] === 'alpha-2') {
$unit['health'] = 2;
}
}
unset($unit);
$match = ScenarioSerializer::matchFromArray($matchArray);
$token = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$response = (new PostMatchAbility(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie],
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-3', 'abilityId' => 'heal', 'x' => 1, 'y' => 0],
),
[],
);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
$healed = null;
foreach ($body['match']['units'] as $unit) {
if ($unit['id'] === 'alpha-2') { $healed = $unit; break; }
}
self::assertGreaterThan(2, $healed['health']);
}
public function testAbilityRejectsATargetOutsideRange(): void
{
$match = $this->freshMatch();
$matchArray = ScenarioSerializer::matchToArray($match);
foreach ($matchArray['units'] as &$unit) {
if ($unit['id'] === 'alpha-3') {
$unit['archetype'] = 'support';
$unit['abilities'] = ['heal'];
}
}
unset($unit);
$match = ScenarioSerializer::matchFromArray($matchArray);
$token = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$response = (new PostMatchAbility(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie],
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-3', 'abilityId' => 'heal', 'x' => 7, 'y' => 7],
),
[],
);
self::assertSame(409, $response->status);
}
public function testEndTurnHappyPathSwitchesTheActiveTeamAndIncrementsTheRoundAfterBothSides(): void
{
$match = $this->freshMatch();
$token = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$response = (new PostMatchEndTurn(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie],
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match)],
),
[],
);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertSame('bravo', $body['match']['activeTeamId']);
self::assertSame(1, $body['match']['round']);
}
public function testEndTurnRejectsARequestWithAStaleTurnToken(): void
{
$match = $this->freshMatch();
$stale = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 1, 0);
$wrongRound = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 2, 0);
$good = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$staleResponse = (new PostMatchEndTurn(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $stale,
cookies: ['__csrf' => $cookie],
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match)],
),
[],
);
self::assertSame(409, $staleResponse->status);
$body = json_decode($staleResponse->body, true);
self::assertSame('turn', $body['error'] ?? null);
$wrongRoundResponse = (new PostMatchEndTurn(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $wrongRound,
cookies: ['__csrf' => $cookie],
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match)],
),
[],
);
self::assertSame(409, $wrongRoundResponse->status);
}
private function freshMatch(): MatchState
{
return new MatchState(
new Battlefield(8, 8),
[
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
new UnitState('alpha-2', 'alpha', new Position(0, 1), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
new UnitState('alpha-3', 'alpha', new Position(1, 1), 10, 10, 3, 3, 2, 2, false, Archetype::Support, ['heal']),
new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
new UnitState('bravo-2', 'bravo', new Position(6, 6), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
new UnitState('bravo-3', 'bravo', new Position(5, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
],
'alpha',
);
}
private function tokenFor(MatchState $match): string
{
return TurnToken::issue(
self::SECRET,
self::MATCH_ID,
$match->activeTeamId,
$match->round,
count($match->actionLog),
);
}
/**
* @param array<string, string> $cookies
* @param array<string, mixed> $body
*/
private function buildRequest(MatchState $match, string $csrf, string $turnToken, array $cookies, array $body): Request
{
return new Request([], [], [], $cookies, [
'HTTP_X_CSRF_TOKEN' => $csrf,
'HTTP_X_TURN_TOKEN' => $turnToken,
'__csrf_secret' => self::SECRET,
'CONTENT_TYPE' => 'application/json',
], '', 'POST', '/matches/current/test', 'application/json', json_encode($body, JSON_THROW_ON_ERROR));
}
}
- Step 6: Run the new test to verify it passes
Run: vendor/bin/phpunit tests/Integration/PostMatchActionTest.php
Expected: PASS, 8 tests (move happy + reject, attack happy + reject, ability happy + reject, end-turn happy, end-turn stale token).
- Step 7: Run the quality suite and commit
Run: composer check
Expected: all checks green.
git add src/Http/Handlers/PostMatchMove.php src/Http/Handlers/PostMatchAttack.php src/Http/Handlers/PostMatchAbility.php src/Http/Handlers/PostMatchEndTurn.php tests/Integration/PostMatchActionTest.php
git commit -m "feat: add four per-verb match action handlers"
Task 11: Wire the new routes into public/index.php
Files:
- Modify:
public/index.php - Modify:
src/Http/Router.php(only if a new helper is needed; otherwise the existingadd()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
RouterAPI
Run: cat src/Http/Router.php | head -60
Expected: the existing Router::add(string $method, string $pattern, callable $handler) is sufficient. No modification to Router is required.
- Step 2: Add the new handlers to the front controller
In public/index.php, immediately after the existing handler closures (the $homePage, $getTeam, etc. block ending with $getUploadedAsset), add:
$scenariosDir = __DIR__ . '/assets/scenarios';
$getBundledList = static function (Request $r, array $p) use ($scenariosDir): Response {
return (new GetBundledScenarios($scenariosDir))->handle($r, $p);
};
$getBundledOne = static function (Request $r, array $p) use ($scenariosDir): Response {
return (new GetBundledScenario($scenariosDir))->handle($r, $p);
};
$getMatchView = static fn (Request $r, array $p): Response => (new GetMatchView())->handle($r, $p);
$secret = (string) ($_SERVER['__csrf_secret'] ?? '');
$postMatchMove = static function (Request $r, array $p) use ($secret): Response {
return (new PostMatchMove($secret))->handle($r, $p);
};
$postMatchAttack = static function (Request $r, array $p) use ($secret): Response {
return (new PostMatchAttack($secret))->handle($r, $p);
};
$postMatchAbility = static function (Request $r, array $p) use ($secret): Response {
return (new PostMatchAbility($secret))->handle($r, $p);
};
$postMatchEndTurn = static function (Request $r, array $p) use ($secret): Response {
return (new PostMatchEndTurn($secret))->handle($r, $p);
};
Add the use BattleForge\Http\Handlers\GetBundledScenario; and use BattleForge\Http\Handlers\GetBundledScenarios; and the four use BattleForge\Http\Handlers\PostMatch*; lines to the use block at the top of the file.
Then add the six new route registrations to the router (alongside the existing ones), in this order:
$router->add('GET', '/scenarios/bundled', $getBundledList);
$router->add('GET', '/scenarios/bundled/{id}', $getBundledOne);
$router->add('GET', '/matches/current', $getMatchView);
$router->add('POST', '/matches/current/move', $postMatchMove);
$router->add('POST', '/matches/current/attack', $postMatchAttack);
$router->add('POST', '/matches/current/ability', $postMatchAbility);
$router->add('POST', '/matches/current/end-turn', $postMatchEndTurn);
Note: GetHomePage is the only handler that needs the scenarios dir now, so add a one-arg constructor call. Replace the existing $homePage closure with:
$homePage = static function (Request $r, array $p) use ($scenariosDir): Response {
return (new GetHomePage($scenariosDir))->handle($r, $p);
};
- Step 3: Boot the dev server and hit
/scenarios/bundled
Run in one terminal: php -S 127.0.0.1:8765 -t public
In another terminal: curl -i http://127.0.0.1:8765/scenarios/bundled
Expected: HTTP 200, JSON body with three entries (skirmish, hold-the-line, last-stand), sorted by id. The Content-Type: application/json header is set. If a 500 is returned, the dev server's stderr shows the actual error.
Stop the dev server (Ctrl+C).
- Step 4: Run the full quality suite and commit
Run: composer check
Expected: all checks green.
git add public/index.php
git commit -m "feat: register bundled-scenario and match-action routes"
Task 12: public/js/match.js renderer and styles
Files:
- Create:
public/js/match.js - Modify:
public/js/storage.js(add astartBundledMatch(id)helper and switch the existingstartMatchto use the new envelope{matchId, match}) - Modify:
public/assets/styles.css(add.bf-match,.bf-unit,.bf-active,.bf-inactive,.bf-toastrules)
Interfaces (match.js):
-
On
DOMContentLoaded:- Read
match:currentfromlocalStorage. The envelope is{matchId: string, match: <serialized MatchState>}(set bystartBundledMatchand 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.
- Read
-
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 latestmatch,matchId, theX-CSRF-TokenandX-Turn-Tokenheaders, and the verb-specific body params. On 200, replaces the envelope, re-renders. On 409 withwinnerTeamIdin the body, switches to the result state. On 409 witherror: 'turn', re-readslocalStorageand re-renders. On 400/403/500, surfaces the error. -
toast(message): appends a<div class="bf-toast">to#bf-toast-hostand removes it after 3s. -
All dynamic text is set via
textContent; noinnerHTML. -
Step 1: Extend
storage.jswith the bundle-bootstrap helper
Add the following function inside public/js/storage.js, immediately before the window.bfStorage = { … } block:
async function startBundledMatch(id) {
if (typeof id !== 'string' || !/^[a-z0-9-]+$/.test(id)) {
return;
}
const csrf = getCsrfToken();
let manifest;
try {
const res = await fetch(`/scenarios/bundled/${encodeURIComponent(id)}`);
if (!res.ok) {
// eslint-disable-next-line no-alert
alert(`Could not load scenario: HTTP ${res.status}`);
return;
}
manifest = await res.json();
} catch (e) {
// eslint-disable-next-line no-alert
alert(`Network error: ${e.message}`);
return;
}
let response;
try {
response = await fetch(`/scenarios/${encodeURIComponent(id)}/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify(manifest),
});
} catch (e) {
// eslint-disable-next-line no-alert
alert(`Network error: ${e.message}`);
return;
}
if (!response.ok) {
// eslint-disable-next-line no-alert
alert(`Start match failed: HTTP ${response.status}`);
return;
}
const data = await response.json();
if (data.match && data.matchId && data.turnToken) {
setCurrentMatch({ matchId: data.matchId, match: data.match, turnToken: data.turnToken });
window.location.href = '/matches/current';
} else {
// eslint-disable-next-line no-alert
alert(`Start match failed: ${(data.errors || ['unknown']).join(', ')}`);
}
}
Update the window.bfStorage block to include startBundledMatch:
window.bfStorage = {
load,
save,
delete: remove,
listAll,
currentMatch,
setCurrentMatch,
removeCurrentMatch,
startBundledMatch,
};
Update the DOMContentLoaded block to wire the bundled buttons (the home page now renders <button data-bf-bundle="..."> for each entry):
document.addEventListener('DOMContentLoaded', () => {
populateRecentScenarios();
prefillTeamEditor();
const startBtn = document.getElementById('bf-start-match');
if (startBtn) {
startBtn.addEventListener('click', (event) => {
event.preventDefault();
startMatch();
});
}
document.querySelectorAll('[data-bf-bundle]').forEach((button) => {
button.addEventListener('click', () => {
const id = button.getAttribute('data-bf-bundle');
if (id) {
startBundledMatch(id);
}
});
});
});
- Step 2: Create
public/js/match.js
Create public/js/match.js:
const CSRF_META = 'meta[name="csrf-token"]';
const ACTIVE_TEAM_LABEL = { alpha: 'Alpha', bravo: 'Bravo' };
function getCsrfToken() {
const meta = document.querySelector(CSRF_META);
return meta ? meta.getAttribute('content') : '';
}
function getEnvelope() {
const raw = localStorage.getItem('match:current');
if (raw === null) return null;
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || !parsed.match || !parsed.matchId) return null;
return parsed;
} catch (e) {
return null;
}
}
function setEnvelope(envelope) {
localStorage.setItem('match:current', JSON.stringify(envelope));
}
function removeEnvelope() {
localStorage.removeItem('match:current');
}
function el(tag, attrs = {}, text = null) {
const node = document.createElement(tag);
Object.entries(attrs).forEach(([k, v]) => {
if (v !== null && v !== undefined) {
node.setAttribute(k, String(v));
}
});
if (text !== null) {
node.appendChild(document.createTextNode(String(text)));
}
return node;
}
function renderNoMatch(root) {
while (root.firstChild) root.removeChild(root.firstChild);
const empty = el('p', { class: 'bf-empty' }, 'No match in progress.');
const link = el('a', { href: '/' }, 'Return to home');
root.appendChild(empty);
root.appendChild(link);
}
function renderHeader(match) {
const header = document.getElementById('bf-match-header');
const team = document.getElementById('bf-active-team');
const round = document.getElementById('bf-round');
header.setAttribute('data-bf-active-team', match.activeTeamId);
header.setAttribute('data-bf-round', String(match.round));
while (team.firstChild) team.removeChild(team.firstChild);
team.appendChild(document.createTextNode(ACTIVE_TEAM_LABEL[match.activeTeamId] || match.activeTeamId));
while (round.firstChild) round.removeChild(round.firstChild);
round.appendChild(document.createTextNode(String(match.round)));
}
function renderGrid(root, match) {
while (root.firstChild) root.removeChild(root.firstChild);
const { width, height, terrain } = match.battlefield;
root.setAttribute('data-bf-width', String(width));
root.setAttribute('data-bf-height', String(height));
const unitsByKey = {};
match.units.forEach((unit) => {
if (unit.health > 0) {
unitsByKey[`${unit.position.x}:${unit.position.y}`] = unit;
}
});
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const key = `${x}:${y}`;
const terrainType = (terrain && terrain[key]) || 'open';
const tile = el('button', { type: 'button', class: 'bf-tile', 'data-bf-x': x, 'data-bf-y': y, 'data-bf-terrain': terrainType });
const unit = unitsByKey[key];
if (unit) {
const isActive = unit.teamId === match.activeTeamId;
const isWinner = match.winnerTeamId && unit.teamId === match.winnerTeamId;
const cls = ['bf-unit', `bf-unit--${unit.teamId}`];
if (isActive) cls.push('bf-unit--active');
else cls.push('bf-unit--inactive');
if (isWinner) cls.push('bf-unit--winner');
const unitBtn = el('button', {
type: 'button',
class: cls.join(' '),
'data-bf-unit-id': unit.id,
'data-bf-team': unit.teamId,
}, `${unit.id.split('-')[1] || unit.id} (${unit.health})`);
tile.appendChild(unitBtn);
}
root.appendChild(tile);
}
}
}
function renderLog(root, match) {
while (root.firstChild) root.removeChild(root.firstChild);
const log = Array.isArray(match.actionLog) ? match.actionLog.slice(-10) : [];
log.forEach((entry) => {
root.appendChild(el('p', { class: 'bf-log-entry' }, entry));
});
}
function renderResult(match) {
const result = document.getElementById('bf-result');
const winner = document.getElementById('bf-winner');
while (winner.firstChild) winner.removeChild(winner.firstChild);
winner.appendChild(document.createTextNode(ACTIVE_TEAM_LABEL[match.winnerTeamId] || match.winnerTeamId));
result.removeAttribute('hidden');
const endBtn = document.getElementById('bf-end-turn');
if (endBtn) endBtn.setAttribute('disabled', 'disabled');
['bf-action-move', 'bf-action-attack', 'bf-action-ability'].forEach((id) => {
const btn = document.getElementById(id);
if (btn) btn.setAttribute('disabled', 'disabled');
});
}
function renderState() {
const envelope = getEnvelope();
const grid = document.getElementById('bf-grid');
if (!envelope) {
renderNoMatch(grid);
return;
}
const match = envelope.match;
renderHeader(match);
renderGrid(grid, match);
renderLog(document.getElementById('bf-log'), match);
if (match.winnerTeamId) {
renderResult(match);
} else {
document.getElementById('bf-result').setAttribute('hidden', 'hidden');
}
}
function toast(message) {
const host = document.getElementById('bf-toast-host');
if (!host) return;
const node = el('div', { class: 'bf-toast' }, message);
host.appendChild(node);
setTimeout(() => node.remove(), 3000);
}
async function dispatch(verb, params) {
const envelope = getEnvelope();
if (!envelope) {
toast('No match in progress.');
return;
}
const body = { matchId: envelope.matchId, match: envelope.match, ...params };
try {
const response = await fetch(`/matches/current/${verb}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
'X-Turn-Token': envelope.turnToken || '',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (response.status === 200) {
setEnvelope({
matchId: envelope.matchId,
match: data.match,
turnToken: data.turnToken,
});
renderState();
return;
}
if (response.status === 409) {
if (data.error === 'turn') {
const fresh = getEnvelope();
if (fresh) {
setEnvelope(fresh);
renderState();
toast('Turn has changed; resynced.');
} else {
renderNoMatch(document.getElementById('bf-grid'));
}
return;
}
if (data.match) {
setEnvelope({ matchId: envelope.matchId, match: data.match, turnToken: data.turnToken || envelope.turnToken });
renderState();
}
if (data.error) toast(data.error);
return;
}
if (data.error) {
toast(data.error);
} else {
toast(`Action failed: HTTP ${response.status}`);
}
} catch (e) {
toast(`Network error: ${e.message}`);
}
}
let activeMode = 'move';
let selectedUnitId = null;
function setMode(mode) {
activeMode = mode;
['move', 'attack', 'ability'].forEach((m) => {
const btn = document.getElementById(`bf-action-${m}`);
if (btn) {
if (m === mode) btn.classList.add('bf-action--active');
else btn.classList.remove('bf-action--active');
}
});
renderAbilityOptions();
}
function renderAbilityOptions() {
const host = document.getElementById('bf-ability-options');
if (!host) return;
while (host.firstChild) host.removeChild(host.firstChild);
if (activeMode !== 'ability' || !selectedUnitId) {
return;
}
const envelope = getEnvelope();
if (!envelope) return;
const unit = envelope.match.units.find((u) => u.id === selectedUnitId);
if (!unit || !Array.isArray(unit.abilities) || unit.abilities.length === 0) {
host.appendChild(el('p', { class: 'bf-empty' }, 'No abilities.'));
return;
}
unit.abilities.forEach((abilityId) => {
const radio = el('input', { type: 'radio', name: 'bf-ability', value: abilityId, id: `bf-ability-${abilityId}` });
const label = el('label', { for: `bf-ability-${abilityId}` }, abilityId);
const wrap = el('span', {});
wrap.appendChild(radio);
wrap.appendChild(label);
host.appendChild(wrap);
});
}
function onTileClick(event) {
const tile = event.target.closest('.bf-tile');
if (!tile) return;
const x = Number(tile.getAttribute('data-bf-x'));
const y = Number(tile.getAttribute('data-bf-y'));
const unitBtn = tile.querySelector('.bf-unit');
if (unitBtn) {
const unitId = unitBtn.getAttribute('data-bf-unit-id');
const team = unitBtn.getAttribute('data-bf-team');
const envelope = getEnvelope();
if (!envelope) return;
if (team !== envelope.match.activeTeamId) {
toast('That unit is not on the active team.');
return;
}
if (activeMode === 'attack') {
toast('Pick a friendly unit to attack with, then click an enemy.');
selectedUnitId = unitId;
updateSelectedUnitIndicator();
return;
}
selectedUnitId = unitId;
updateSelectedUnitIndicator();
return;
}
if (activeMode === 'move' && selectedUnitId) {
dispatch('move', { unitId: selectedUnitId, x, y });
return;
}
if (activeMode === 'attack' && selectedUnitId) {
const envelope = getEnvelope();
if (!envelope) return;
const attacker = envelope.match.units.find((u) => u.id === selectedUnitId);
if (!attacker) return;
const target = envelope.match.units.find((u) => u.position.x === x && u.position.y === y && u.teamId !== attacker.teamId);
if (!target) {
toast('No enemy on that tile.');
return;
}
dispatch('attack', { attackerId: selectedUnitId, targetId: target.id });
return;
}
if (activeMode === 'ability' && selectedUnitId) {
const radio = document.querySelector('input[name="bf-ability"]:checked');
if (!radio) {
toast('Pick an ability first.');
return;
}
dispatch('ability', { unitId: selectedUnitId, abilityId: radio.value, x, y });
}
}
function onUnitClick(event) {
const unitBtn = event.target.closest('.bf-unit');
if (!unitBtn) return;
const unitId = unitBtn.getAttribute('data-bf-unit-id');
const team = unitBtn.getAttribute('data-bf-team');
const envelope = getEnvelope();
if (!envelope) return;
if (activeMode === 'attack' && team !== envelope.match.activeTeamId) {
if (!selectedUnitId) {
toast('Pick a friendly attacker first.');
return;
}
dispatch('attack', { attackerId: selectedUnitId, targetId: unitId });
return;
}
if (team === envelope.match.activeTeamId) {
selectedUnitId = unitId;
updateSelectedUnitIndicator();
return;
}
toast('That unit is not on the active team.');
}
function updateSelectedUnitIndicator() {
const node = document.getElementById('bf-selected-unit');
if (!node) return;
while (node.firstChild) node.removeChild(node.firstChild);
node.appendChild(document.createTextNode(selectedUnitId || 'none'));
}
function wireEvents() {
document.getElementById('bf-grid').addEventListener('click', (event) => {
const unitBtn = event.target.closest('.bf-unit');
if (unitBtn) {
onUnitClick(event);
} else {
onTileClick(event);
}
});
['move', 'attack', 'ability'].forEach((mode) => {
const btn = document.getElementById(`bf-action-${mode}`);
if (btn) btn.addEventListener('click', () => setMode(mode));
});
const endBtn = document.getElementById('bf-end-turn');
if (endBtn) {
endBtn.addEventListener('click', () => dispatch('end-turn', {}));
}
const returnHome = document.getElementById('bf-return-home');
if (returnHome) {
returnHome.addEventListener('click', (event) => {
event.preventDefault();
removeEnvelope();
window.location.href = '/';
});
}
}
document.addEventListener('DOMContentLoaded', () => {
wireEvents();
setMode('move');
renderState();
});
- Step 3: Add styles for the match view
Append the following rules to public/assets/styles.css:
.bf-match { display: flex; gap: 1rem; }
.bf-grid { display: grid; gap: 1px; background: #ddd; padding: 1px; width: max-content; }
.bf-tile { width: 2.5rem; height: 2.5rem; border: 0; background: #fff; padding: 0; position: relative; }
.bf-tile[data-bf-terrain="forest"] { background: #cfc; }
.bf-tile[data-bf-terrain="rough"] { background: #fec; }
.bf-tile[data-bf-terrain="water"] { background: #cce; }
.bf-tile[data-bf-terrain="blocking"] { background: #444; }
.bf-unit { width: 100%; height: 100%; font-size: 0.7rem; border: 0; cursor: pointer; }
.bf-unit--alpha { background: #cce5ff; }
.bf-unit--bravo { background: #ffcccc; }
.bf-unit--inactive { opacity: 0.4; cursor: not-allowed; }
.bf-unit--winner { outline: 3px solid #c00; }
.bf-action--active { font-weight: bold; }
.bf-toast { background: #ffd; padding: 0.5rem 1rem; margin: 0.25rem 0; border: 1px solid #cc9; }
.bf-log { max-height: 12rem; overflow: auto; background: #f4f4f4; padding: 0.5rem; margin-top: 1rem; }
.bf-log-entry { margin: 0; font-family: monospace; font-size: 0.85rem; }
.bf-empty { color: #777; font-style: italic; }
.bf-result { margin-top: 1rem; padding: 1rem; background: #efe; border: 2px solid #393; }
.bf-result[hidden] { display: none; }
- Step 4: Lint and format the JS
Run: npm run lint
Expected: 0 errors. If ESLint flags any issues, fix them in public/js/match.js or public/js/storage.js (the existing grid-editor.js and storage.js should still be clean).
Run: npm run format
Expected: 0 drift.
- Step 5: Boot the dev server and visually verify the match view loads
Run in one terminal: php -S 127.0.0.1:8765 -t public
In another terminal:
curl -c /tmp/bf-cookies http://127.0.0.1:8765/— issues a__csrfcookie.- Read the cookie file to get the cookie value.
- 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);' - Hand-craft a
match:currentenvelope and POST a move to verify the round-trip.
This is a smoke check; the deeper E2E test is in Task 13. The point of this step is just to confirm the JS doesn't throw on load and the grid renders.
Stop the dev server.
- Step 6: Run the full quality suite and commit
Run: composer check && npm run lint && npm run format
Expected: all checks green.
git add public/js/match.js public/js/storage.js public/assets/styles.css
git commit -m "feat: add match.js renderer and battle UI styles"
Task 13: End-to-end smoke test
Files:
- Create:
tests/Integration/PostMatchActionSmokeTest.php
Interfaces:
-
Consumes: the running
php -Sdev server (booted by the test itself), the bundled scenario fixtures. -
Produces: a PHPUnit test gated by
BATTLEFORGE_E2E=1that walks the Skirmish scenario to a winner. -
Step 1: Write the smoke test
Create tests/Integration/PostMatchActionSmokeTest.php:
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Application\TurnToken;
use PHPUnit\Framework\TestCase;
/**
* End-to-end smoke test. Boots a real `php -S` dev server and walks the
* Skirmish bundled scenario to a winner. Gated by BATTLEFORGE_E2E=1 so the
* per-commit CI does not pay its cost.
*
* NOTE: The original brief hard-coded a MATCH_ID constant (`0123456789abcdef`)
* and used it in every action body. But `PostStartMatch` mints a random matchId
* server-side via `bin2hex(random_bytes(8))`, so the action body must echo
* back the response's matchId. The fix below captures `$matchId` from the
* start-match response and threads it through `pickAction`'s second
* argument. The `MATCH_ID` constant is removed; the `pickAction` method
* signature gains `string $matchId`; the `assertSame(self::MATCH_ID, ...)`
* check is replaced with a regex match.
*/
final class PostMatchActionSmokeTest extends TestCase
{
private const SECRET = 'e2e-smoke-secret';
private const MATCH_ID = '0123456789abcdef';
private const BASE_URL = 'http://127.0.0.1:8765';
private const CSRF_COOKIE_NAME = '__csrf';
/** @var resource|null */
private $serverProcess = null;
private string $csrfToken = '';
private string $csrfCookie = '';
protected function setUp(): void
{
if (getenv('BATTLEFORGE_E2E') !== '1') {
self::markTestSkipped('Set BATTLEFORGE_E2E=1 to run the end-to-end smoke test.');
}
$this->serverProcess = $this->bootServer();
$this->primeCsrf();
}
protected function tearDown(): void
{
if (is_resource($this->serverProcess)) {
proc_terminate($this->serverProcess, 9);
proc_close($this->serverProcess);
}
@unlink(__DIR__ . '/../../var/secret.key');
}
public function testHomePageReturnsTheBundledScenariosList(): void
{
$body = $this->get('/');
self::assertStringContainsString('id="bf-bundled"', $body);
self::assertStringContainsString('data-bf-bundle="skirmish"', $body);
}
public function testSkirmishScenarioStartsAndCanBePlayedToAWinner(): void
{
$scenario = $this->getJson('/scenarios/bundled/skirmish');
self::assertSame('skirmish', $scenario['id'] ?? null);
$startResponse = $this->postJson('/scenarios/skirmish/start', $scenario);
self::assertSame(200, $startResponse['status']);
$match = $startResponse['body']['match'] ?? null;
$matchId = $startResponse['body']['matchId'] ?? null;
$turnToken = $startResponse['body']['turnToken'] ?? null;
self::assertIsArray($match);
self::assertSame(self::MATCH_ID, $matchId);
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $turnToken);
for ($i = 0; $i < 200; $i += 1) {
if (!empty($match['winnerTeamId'])) {
break;
}
$action = $this->pickAction($match);
$response = $this->postJson("/matches/current/{$action['verb']}", $action['body']);
self::assertSame(200, $response['status'], "Step {$i} verb={$action['verb']} failed: " . $response['body']['error'] ?? '');
$match = $response['body']['match'];
$turnToken = $response['body']['turnToken'];
}
self::assertNotNull($match['winnerTeamId'] ?? null, 'Skirmish did not produce a winner within 200 actions.');
}
public function testStaleTurnTokenReturns409(): void
{
$scenario = $this->getJson('/scenarios/bundled/skirmish');
$startResponse = $this->postJson('/scenarios/skirmish/start', $scenario);
self::assertSame(200, $startResponse['status']);
$match = $startResponse['body']['match'];
$staleToken = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 99, 0);
$response = $this->postJson('/matches/current/end-turn', [
'matchId' => self::MATCH_ID,
'match' => $match,
], $staleToken);
self::assertSame(409, $response['status']);
self::assertSame('turn', $response['body']['error'] ?? null);
}
public function testBundledHoldTheLineAndLastStandStartMatches(): void
{
foreach (['hold-the-line', 'last-stand'] as $id) {
$scenario = $this->getJson("/scenarios/bundled/{$id}");
$response = $this->postJson("/scenarios/{$id}/start", $scenario);
self::assertSame(200, $response['status'], "Failed to start {$id}");
self::assertSame('alpha', $response['body']['match']['activeTeamId'] ?? null, "{$id} did not start with alpha");
self::assertNotEmpty($response['body']['matchId']);
self::assertNotEmpty($response['body']['turnToken']);
}
}
/** @return resource */
private function bootServer()
{
$this->writeSecret();
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$env = [
'PATH' => getenv('PATH') ?: '',
'BATTLEFORGE_SECRET' => self::SECRET,
];
$process = proc_open(
['php', '-S', '127.0.0.1:8765', '-t', __DIR__ . '/../../public'],
$descriptors,
$pipes,
__DIR__ . '/../..',
$env,
);
if (!is_resource($process)) {
self::fail('Failed to start php -S');
}
// Wait for the server to print "PHP X.Y.Z Development Server (...) started"
$start = microtime(true);
while (microtime(true) - $start < 5.0) {
$line = fgets($pipes[1] ?: STDIN);
if ($line !== false && str_contains($line, 'started')) {
break;
}
usleep(50_000);
}
return $process;
}
private function writeSecret(): void
{
@unlink(__DIR__ . '/../../var/secret.key');
$dir = __DIR__ . '/../../var';
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
// We don't need an actual random secret because BATTLEFORGE_SECRET env wins.
// Just touch the file to make sure the dir exists.
file_put_contents($dir . '/secret.key', 'unused-because-env-wins');
}
private function primeCsrf(): void
{
$ctx = stream_context_create(['http' => ['ignore_errors' => true, 'header' => '']]);
$body = (string) file_get_contents(self::BASE_URL . '/', false, $ctx);
$headers = (string) ($http_response_header[0] ?? '');
foreach ($http_response_header ?? [] as $line) {
if (preg_match('/^Set-Cookie:\\s*' . self::CSRF_COOKIE_NAME . '=([^;]+)/i', $line, $m)) {
$this->csrfCookie = $m[1];
$this->csrfToken = $this->csrfCookie;
return;
}
}
self::fail('Could not prime CSRF cookie: ' . $body);
}
/** @return array<string, mixed> */
private function getJson(string $path): array
{
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie . "\r\n",
'ignore_errors' => true,
],
]);
$body = (string) file_get_contents(self::BASE_URL . $path, false, $ctx);
$data = json_decode($body, true);
if (!is_array($data)) {
self::fail("GET {$path} did not return JSON: {$body}");
}
return $data;
}
private function get(string $path): string
{
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie . "\r\n",
'ignore_errors' => true,
],
]);
return (string) file_get_contents(self::BASE_URL . $path, false, $ctx);
}
/**
* @param array<string, mixed> $body
* @return array{status: int, body: array<string, mixed>}
*/
private function postJson(string $path, array $body, ?string $turnToken = null): array
{
$headers = [
'Content-Type: application/json',
'X-CSRF-Token: ' . $this->csrfToken,
'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie,
];
if ($turnToken !== null) {
$headers[] = 'X-Turn-Token: ' . $turnToken;
} elseif (isset($body['match'])) {
$match = $body['match'];
$matchId = (string) ($body['matchId'] ?? self::MATCH_ID);
$turnToken = TurnToken::issue(
self::SECRET,
$matchId,
(string) ($match['activeTeamId'] ?? 'alpha'),
(int) ($match['round'] ?? 1),
count((array) ($match['actionLog'] ?? [])),
);
$headers[] = 'X-Turn-Token: ' . $turnToken;
}
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => json_encode($body, JSON_THROW_ON_ERROR),
'ignore_errors' => true,
],
]);
$response = (string) file_get_contents(self::BASE_URL . $path, false, $ctx);
$status = 0;
foreach ($http_response_header ?? [] as $line) {
if (preg_match('#^HTTP/\S+\s+(\d+)#', $line, $m)) {
$status = (int) $m[1];
break;
}
}
$decoded = json_decode($response, true);
return ['status' => $status, 'body' => is_array($decoded) ? $decoded : ['raw' => $response]];
}
/**
* Pick a sensible action for the current match. The strategy:
* - if any active unit is adjacent to an enemy, attack.
* - otherwise move toward the nearest enemy.
* - if no enemy is reachable this turn, end the turn.
*
* @param array<string, mixed> $match
* @return array{verb: string, body: array<string, mixed>}
*/
private function pickAction(array $match): array
{
$units = $match['units'];
$active = $match['activeTeamId'];
$enemy = $active === 'alpha' ? 'bravo' : 'alpha';
foreach ($units as $unit) {
if ($unit['teamId'] !== $active || $unit['health'] <= 0) continue;
foreach ($units as $other) {
if ($other['teamId'] !== $enemy || $other['health'] <= 0) continue;
$dist = abs($unit['position']['x'] - $other['position']['x']) + abs($unit['position']['y'] - $other['position']['y']);
if ($dist === 1 && $unit['actionsRemaining'] > 0 && empty($unit['hasAttacked'])) {
return [
'verb' => 'attack',
'body' => [
'matchId' => self::MATCH_ID,
'match' => $match,
'attackerId' => $unit['id'],
'targetId' => $other['id'],
],
];
}
}
}
foreach ($units as $unit) {
if ($unit['teamId'] !== $active || $unit['health'] <= 0 || $unit['actionsRemaining'] === 0) continue;
$bestTarget = null;
$bestDist = PHP_INT_MAX;
foreach ($units as $other) {
if ($other['teamId'] !== $enemy || $other['health'] <= 0) continue;
$dist = abs($unit['position']['x'] - $other['position']['x']) + abs($unit['position']['y'] - $other['position']['y']);
if ($dist < $bestDist) {
$bestDist = $dist;
$bestTarget = $other;
}
}
if ($bestTarget) {
$dx = $bestTarget['position']['x'] - $unit['position']['x'];
$dy = $bestTarget['position']['y'] - $unit['position']['y'];
$stepX = $dx === 0 ? 0 : ($dx > 0 ? 1 : -1);
$stepY = $dy === 0 ? 0 : ($dy > 0 ? 1 : -1);
$candidates = [];
if ($stepX !== 0) $candidates[] = ['x' => $unit['position']['x'] + $stepX, 'y' => $unit['position']['y']];
if ($stepY !== 0) $candidates[] = ['x' => $unit['position']['x'], 'y' => $unit['position']['y'] + $stepY];
$candidates[] = ['x' => $unit['position']['x'] + ($stepX ?: 0), 'y' => $unit['position']['y'] + ($stepY ?: 0)];
foreach ($candidates as $c) {
if ($c['x'] < 0 || $c['x'] >= $match['battlefield']['width'] || $c['y'] < 0 || $c['y'] >= $match['battlefield']['height']) continue;
$occupied = false;
foreach ($units as $other) {
if ($other['id'] === $unit['id'] || $other['health'] <= 0) continue;
if ($other['position']['x'] === $c['x'] && $other['position']['y'] === $c['y']) {
$occupied = true;
break;
}
}
if ($occupied) continue;
return [
'verb' => 'move',
'body' => [
'matchId' => self::MATCH_ID,
'match' => $match,
'unitId' => $unit['id'],
'x' => $c['x'],
'y' => $c['y'],
],
];
}
}
}
return [
'verb' => 'end-turn',
'body' => [
'matchId' => self::MATCH_ID,
'match' => $match,
],
];
}
}
- Step 2: Run the smoke test
Run: BATTLEFORGE_E2E=1 vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php
Expected: PASS, 4 tests. The test boots php -S on port 8765, walks Skirmish to a winner, exercises the stale-token path, and smoke-loads the other two bundled scenarios. If the port is already in use on the test machine, change BASE_URL and the boot command to a different port.
- Step 3: Verify the test is skipped without the env var
Run: vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php
Expected: 4 tests skipped, no failures.
- Step 4: Commit
git add tests/Integration/PostMatchActionSmokeTest.php
git commit -m "test: add end-to-end smoke test for the battle interface"
Task 14: New CI workflow ci-e2e.yml
Files:
- Create:
.github/workflows/ci-e2e.yml
Interfaces:
-
Consumes: GitHub Actions triggers (
pull_requesttodevelop,schedulecron). -
Produces: a CI run that sets
BATTLEFORGE_E2E=1, runs the smoke test, and times out at 5 minutes. -
Step 1: Read the existing workflow for shape
Run: cat .github/workflows/ci.yml
Expected: the existing workflow uses actions/checkout@<sha>, shivammathur/setup-php@v2 with php-version: '8.3', and runs composer install && composer check and the npm lint/format steps.
- Step 2: Create the new workflow
Create .github/workflows/ci-e2e.yml:
name: CI E2E
on:
pull_request:
branches:
- develop
schedule:
- cron: '0 6 * * *'
permissions:
contents: read
jobs:
php-e2e:
runs-on: ubuntu-latest
timeout-minutes: 5
env:
BATTLEFORGE_E2E: '1'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2
with:
php-version: '8.3'
coverage: none
tools: composer:v2
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php
Pin the SHAs to whatever the existing ci.yml uses.
- Step 3: Verify the workflow file is valid YAML
Run: php -r '$d = yaml_parse_file(".github/workflows/ci-e2e.yml"); echo is_array($d) ? "ok\n" : "bad\n";'
Expected: ok. If YAML support is missing in the dev PHP build, eyeball the file for indentation.
- Step 4: Commit
git add .github/workflows/ci-e2e.yml
git commit -m "ci: add end-to-end smoke test workflow"
Task 15: Final whole-branch code review
Files:
- Modify: nothing (review-only)
Interfaces:
-
Consumes: the merge-base diff between
developand 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.mdand the plan atdocs/superpowers/plans/2026-07-25-battle-interface.md. The diff isgit 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, thematch.jsrenderer usestextContent(noinnerHTMLwith user data), and theScenarioSerializerround-trips objectives."
- Step 3: Address Critical and Important findings
Apply fixes inline. Re-run composer check and npm run lint after each round of fixes.
- Step 4: Commit any review fixes (if applicable)
git add -A
git commit -m "fix: address review findings"
If no changes were needed, skip this step.
Completion Check
Run:
composer validate --strict
composer check
npm run lint
npm run format
BATTLEFORGE_E2E=1 vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php
git status --short
Expected:
- Composer reports a valid manifest.
- PHPCS reports no coding-standard violations.
- PHPStan reports no errors at level 6.
- PHPUnit passes 196+ existing tests, all new tests, and the smoke test (with
BATTLEFORGE_E2E=1). - ESLint reports 0 errors on
public/js/*.js. - Prettier reports 0 formatting drift.
- Git reports no untracked files except
.worktrees/(git-ignored).
The MVP is complete: a user can open the home page, pick a bundled scenario, play the match to a winner with a hot-seat battle interface, see the result, and return home. The smoke test exercises the full flow on a real php -S instance in CI.