3498 lines
137 KiB
Markdown
3498 lines
137 KiB
Markdown
# Plan 4: Battle Interface, Bundled Scenarios, and Release Hardening
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the deleted `match-stub.php` placeholder with a real, server-rendered hot-seat battle interface; ship three bundled scenarios that are playable without the editors; fix the `ScenarioSerializer` objective round-trip gap; and add an end-to-end smoke test that boots `php -S` and walks a match to a winner.
|
||
|
||
**Architecture:** The rules engine stays in `src/Domain` and is unchanged. A new `TurnToken` value object mints HMAC-signed per-match tokens. Four small per-verb action handlers (move, attack, ability, end-turn) under `src/Http/Handlers/` each call the matching `CombatEngine` method, returning a fresh `MatchState` plus a new turn token. The browser reads the match from `localStorage` and dispatches every action as a stateless full-snapshot POST. The home page lists three bundled-scenario JSON fixtures served by two new GET endpoints. `public/js/match.js` is the third ESM file and renders the live match.
|
||
|
||
**Tech Stack:** PHP 8.3, vanilla ESM JavaScript (no bundler), Node 20+ with ESLint `airbnb-base` + Prettier (already in place from Plan 3c), PHPUnit 11, PHPStan level 6, PHP_CodeSniffer with the repository ruleset, `php -S` for the dev server and the smoke test.
|
||
|
||
## Global Constraints
|
||
|
||
These apply to every task and are copied verbatim from the spec.
|
||
|
||
- The match lives in the browser's `localStorage` under `match:current`. The server is stateless across action calls; the client sends the full `MatchState` with every action POST.
|
||
- The `matchId` (16 random hex chars from `bin2hex(random_bytes(8))`) is minted by `PostStartMatch` and persisted alongside the match in `localStorage`. It is required in every action request body and used as the per-match namespace for the turn-token HMAC.
|
||
- The `X-Turn-Token` header is required on all four action endpoints. A missing, stale, or tampered token returns 409 with `{"error":"turn"}`. The token is `hash_hmac('sha256', $secret, $matchId . '|' . $activeTeamId . '|' . $round . '|' . $lastActionIndex)` truncated to 32 hex chars, verified with `hash_equals`.
|
||
- `PostStartMatch` returns `{match, matchId, turnToken}` (it currently returns just `{match}`). The same `BATTLEFORGE_SECRET` is used for the turn token as for CSRF.
|
||
- Every state-changing form or request uses and verifies a CSRF token before mutation. The CSRF token comes from the existing `__csrf` cookie and `<meta name="csrf-token">` pattern. The four match-action endpoints verify CSRF and turn-token together.
|
||
- All rendered output is escaped for its output context via `Escape::html`, `Escape::attr`, and `Escape::url`. The new `match.js` uses `textContent` and DOM construction; never `innerHTML` with user data.
|
||
- The match-action endpoints validate `matchId` against `/^[a-f0-9]{16,}$/` before computing the token. The bundled-scenario endpoints validate filenames against `/^[a-z0-9-]+$/` and use `realpath` + `is_file` before reading.
|
||
- A rejected action never partially changes match state or consumes resources. `localStorage` writes only happen on a 200 response. The in-browser state is recoverable by reload.
|
||
- `ScenarioSerializer::matchToArray` and `matchFromArray` must round-trip the `objectives` map. `matchToArray` adds an `objectives` key; `matchFromArray` builds the `ObjectiveMarker` map.
|
||
- Bundled scenarios live under `public/assets/scenarios/`. A `manifest.json` in that directory is the single source of truth for display names and summaries; the fixture filename is the `id`. The manifest shape is `{ "<id>": {"name": "...", "summary": "..."} }`. Any fixture without a manifest entry is filtered out of the bundle list.
|
||
- `getScenario(…)?/start` continues to accept `Scenario` JSON in the `ScenarioSerializer::scenarioToArray` shape. The bundled-scenario GET endpoint returns that same shape, not a `ScenarioDraft`.
|
||
- Bundled-scenario fixtures must pass `ScenarioValidator::validate` (the endpoint returns 500 on failure so CI catches it).
|
||
- The end-to-end smoke test boots `php -S` on a free port, walks the Skirmish scenario to a winner, and is gated by `BATTLEFORGE_E2E=1`. The default `composer check` does not set it.
|
||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6. JavaScript passes ESLint `airbnb/base` and Prettier. Composer configuration passes `composer validate --strict`.
|
||
- Lint, static analysis, unit and integration tests, the end-to-end smoke test, and Composer validation are required CI checks.
|
||
- No third-party JS libraries. The new `match.js` is pure ESM, no dependencies, no `eval`, no `Function` constructor.
|
||
|
||
## File Structure
|
||
|
||
```text
|
||
src/
|
||
Application/
|
||
ScenarioSerializer.php MODIFY — round-trip objectives in matchToArray/matchFromArray
|
||
ScenarioDraft.php unchanged
|
||
ImageUploadService.php unchanged
|
||
ImageValidator.php unchanged
|
||
ValidatedImage.php unchanged
|
||
TurnToken.php NEW — issue/verify HMAC over (matchId, activeTeamId, round, lastActionIndex)
|
||
Domain/ unchanged — engine stays as-is
|
||
Http/
|
||
Handlers/
|
||
GetAssets.php unchanged
|
||
GetBattlefieldEditor.php unchanged
|
||
GetBundledScenarios.php NEW — list of {id, name, summary} from /public/assets/scenarios/
|
||
GetBundledScenario.php NEW — full Scenario JSON for a bundled id (validates the fixture)
|
||
GetHomePage.php unchanged
|
||
GetMatchView.php NEW — server-rendered shell for the match view (loads /public/js/match.js)
|
||
GetTeamEditor.php unchanged
|
||
PostBattlefieldEditor.php unchanged
|
||
PostImageUpload.php unchanged
|
||
PostMatchMove.php NEW — move action
|
||
PostMatchAttack.php NEW — attack action
|
||
PostMatchAbility.php NEW — use-ability action
|
||
PostMatchEndTurn.php NEW — end-turn action
|
||
PostStartMatch.php MODIFY — return {match, matchId, turnToken}; mint the matchId + initial token
|
||
PostTeamEditor.php unchanged
|
||
MatchActionSupport.php NEW — small helper used by all four action handlers: CSRF + turn-token
|
||
verification + MatchState decode + JSON error responses
|
||
Router.php MODIFY — register the five new routes
|
||
CsrfToken.php unchanged
|
||
Request.php / Response.php unchanged
|
||
Escape.php unchanged
|
||
Views/
|
||
layout.php unchanged
|
||
home.php MODIFY — render a bundled scenarios list (server-rendered; the JS does
|
||
not need to populate it)
|
||
match.php NEW — the match view (loads match.js, exposes container + CSRF meta)
|
||
team-editor.php unchanged
|
||
battlefield-editor.php unchanged
|
||
public/
|
||
js/
|
||
match.js NEW — client match renderer + action dispatcher
|
||
grid-editor.js unchanged
|
||
storage.js MODIFY — add bundle-bootstrap helpers (startBundledMatch) and a
|
||
matchId-aware currentMatch envelope
|
||
assets/
|
||
scenarios/
|
||
manifest.json NEW — name + summary for each bundled id
|
||
skirmish.json NEW — 8x8 open, eliminate_all
|
||
hold-the-line.json NEW — 10x8, hold_objective, 3 rounds
|
||
last-stand.json NEW — 12x12, eliminate_all, asymmetric 6v4
|
||
styles.css MODIFY — add .bf-match / .bf-unit / .bf-active / .bf-toast rules
|
||
placeholders/ unchanged
|
||
archetypes.json unchanged
|
||
tests/
|
||
Unit/
|
||
Application/
|
||
TurnTokenTest.php NEW
|
||
ScenarioSerializerTest.php MODIFY — add an objectives-round-trip case
|
||
Integration/
|
||
GetBundledScenariosTest.php NEW
|
||
GetBundledScenarioTest.php NEW
|
||
GetMatchViewTest.php NEW
|
||
PostMatchMoveTest.php NEW
|
||
PostMatchAttackTest.php NEW
|
||
PostMatchAbilityTest.php NEW
|
||
PostMatchEndTurnTest.php NEW
|
||
PostMatchActionSmokeTest.php NEW — boots php -S, walks Skirmish to a winner; gated by BATTLEFORGE_E2E=1
|
||
PostStartMatchTest.php MODIFY — assert {match, matchId, turnToken} response shape
|
||
FullFlowTest.php unchanged
|
||
GetHomePageTest.php MODIFY — assert the bundled-scenarios list is rendered server-side
|
||
.github/workflows/
|
||
ci.yml unchanged
|
||
ci-e2e.yml NEW — PR-to-develop + nightly, BATTLEFORGE_E2E=1, 5-minute timeout
|
||
docs/
|
||
superpowers/
|
||
plans/
|
||
2026-07-25-battle-interface.md THIS FILE
|
||
specs/
|
||
2026-07-25-battle-interface-design.md already committed
|
||
```
|
||
|
||
## Delivery Sequence
|
||
|
||
Plan 4 is the fourth and final plan in the MVP. It builds on Plans 1–3c. It depends on the rules engine (Plan 1+2) and the HTTP layer (Plan 3a+3b) being merged into `develop` (they are). The plan ships in this order:
|
||
|
||
1. Objective round-trip fix (foundation for the bundled Hold the Line scenario).
|
||
2. `TurnToken` value object (foundation for the action API).
|
||
3. Modify `PostStartMatch` to mint `matchId` and `turnToken`.
|
||
4. Bundled scenario fixtures and `manifest.json`.
|
||
5. `GetBundledScenarios` and `GetBundledScenario` handlers.
|
||
6. Update the home page to render the bundled list.
|
||
7. `GetMatchView` handler + `match.php` template.
|
||
8. `MatchActionSupport` helper.
|
||
9. The four per-verb action handlers (move, attack, ability, end-turn).
|
||
10. Register the new routes in `public/index.php`.
|
||
11. `public/js/match.js` renderer + CSS.
|
||
12. End-to-end smoke test.
|
||
13. New CI workflow `ci-e2e.yml`.
|
||
14. Final whole-branch code review.
|
||
|
||
## Execution Preflight
|
||
|
||
Execute this plan in an isolated worktree on `feature/battle-interface`, branched from `develop`. The implementation branch will be submitted as a pull request into `develop` only after every completion check is green.
|
||
|
||
### Task 1: Fix `ScenarioSerializer` objective round-trip
|
||
|
||
**Files:**
|
||
- Modify: `src/Application/ScenarioSerializer.php`
|
||
- Test: `tests/Unit/Application/ScenarioSerializerTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ObjectiveMarker`, `Position`, `MatchState`.
|
||
- Produces: `matchToArray` returns the existing fields plus `'objectives' => array<string, array{id: string, position: array{x: int, y: int}}>`. `matchFromArray` builds the `array<string, ObjectiveMarker>` from the input data and passes it to the `MatchState` constructor.
|
||
|
||
- [ ] **Step 1: Write the failing round-trip test**
|
||
|
||
Append this test method to `final class ScenarioSerializerTest` in `tests/Unit/Application/ScenarioSerializerTest.php`, immediately before the closing brace of the class:
|
||
|
||
```php
|
||
public function testMatchRoundTripsItsObjectives(): void
|
||
{
|
||
$battlefield = new Battlefield(10, 8);
|
||
$units = [
|
||
new UnitState('a1', 'alpha', new Position(0, 0), 10, 10, 3, 3, 2, 2, false),
|
||
new UnitState('a2', 'alpha', new Position(1, 0), 10, 10, 3, 3, 2, 2, false),
|
||
new UnitState('a3', 'alpha', new Position(2, 0), 10, 10, 3, 3, 2, 2, false),
|
||
new UnitState('b1', 'bravo', new Position(9, 7), 10, 10, 3, 3, 2, 2, false),
|
||
new UnitState('b2', 'bravo', new Position(8, 7), 10, 10, 3, 3, 2, 2, false),
|
||
new UnitState('b3', 'bravo', new Position(7, 7), 10, 10, 3, 3, 2, 2, false),
|
||
];
|
||
$objective = new ObjectiveMarker('objective-1', new Position(5, 4));
|
||
$original = new MatchState(
|
||
battlefield: $battlefield,
|
||
units: $units,
|
||
activeTeamId: 'alpha',
|
||
objectives: ['objective-1' => $objective],
|
||
victoryCondition: VictoryCondition::HoldObjective,
|
||
holdRoundsRequired: 3,
|
||
);
|
||
|
||
$array = ScenarioSerializer::matchToArray($original);
|
||
self::assertArrayHasKey('objectives', $array);
|
||
self::assertSame(
|
||
['id' => 'objective-1', 'position' => ['x' => 5, 'y' => 4]],
|
||
$array['objectives']['objective-1'] ?? null,
|
||
);
|
||
|
||
$rebuilt = ScenarioSerializer::matchFromArray($array);
|
||
self::assertArrayHasKey('objective-1', $rebuilt->objectives);
|
||
self::assertSame('objective-1', $rebuilt->objectives['objective-1']->id);
|
||
self::assertSame(5, $rebuilt->objectives['objective-1']->position->x);
|
||
self::assertSame(4, $rebuilt->objectives['objective-1']->position->y);
|
||
self::assertSame(VictoryCondition::HoldObjective, $rebuilt->victoryCondition);
|
||
self::assertSame(3, $rebuilt->holdRoundsRequired);
|
||
}
|
||
```
|
||
|
||
Add the necessary `use BattleForge\Domain\ObjectiveMarker;` and `use BattleForge\Domain\VictoryCondition;` imports at the top of the file if not already present.
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `vendor/bin/phpunit tests/Unit/Application/ScenarioSerializerTest.php --filter RoundTripsItsObjectives`
|
||
Expected: FAIL because `matchToArray` does not include `objectives` and `matchFromArray` hard-codes `objectives: []`.
|
||
|
||
- [ ] **Step 3: Update `matchToArray` to include objectives**
|
||
|
||
In `src/Application/ScenarioSerializer.php`, inside `matchToArray`, add the following block immediately after the `$terrainMap` block (before the `$units` accumulation):
|
||
|
||
```php
|
||
$objectives = [];
|
||
foreach ($match->objectives as $id => $objective) {
|
||
$objectives[(string) $id] = [
|
||
'id' => $objective->id,
|
||
'position' => self::positionToArray($objective->position),
|
||
];
|
||
}
|
||
```
|
||
|
||
Then add `'objectives' => $objectives,` to the returned array, after `'objectiveControl' => $match->objectiveControl,`.
|
||
|
||
- [ ] **Step 4: Update `matchFromArray` to restore objectives**
|
||
|
||
In `src/Application/ScenarioSerializer.php`, inside `matchFromArray`, add the following block immediately after the `$units` accumulation (before the `return new MatchState(...)` call):
|
||
|
||
```php
|
||
$objectives = [];
|
||
foreach (($data['objectives'] ?? []) as $id => $row) {
|
||
$objectives[(string) $id] = new ObjectiveMarker(
|
||
(string) $row['id'],
|
||
self::positionFromArray($row['position']),
|
||
);
|
||
}
|
||
```
|
||
|
||
Then change `objectives: [],` to `objectives: $objectives,` in the `return new MatchState(...)` call.
|
||
|
||
Add the `use BattleForge\Domain\ObjectiveMarker;` import at the top of the file if not already present.
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
Run: `vendor/bin/phpunit tests/Unit/Application/ScenarioSerializerTest.php`
|
||
Expected: PASS, all tests in the file.
|
||
|
||
- [ ] **Step 6: Run the full quality suite and commit**
|
||
|
||
Run: `composer check`
|
||
Expected: all checks green (PHPCS, PHPStan, all 196+ tests).
|
||
|
||
```powershell
|
||
git add src/Application/ScenarioSerializer.php tests/Unit/Application/ScenarioSerializerTest.php
|
||
git commit -m "fix: round-trip match objectives through ScenarioSerializer"
|
||
```
|
||
|
||
### Task 2: Build the `TurnToken` value object
|
||
|
||
**Files:**
|
||
- Create: `src/Application/TurnToken.php`
|
||
- Test: `tests/Unit/Application/TurnTokenTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: a secret string (the same `BATTLEFORGE_SECRET` or `var/secret.key` used for CSRF), a `matchId` (validated as 16+ lowercase hex chars before hashing), an `activeTeamId`, a `round` (>= 1), and a `lastActionIndex` (>= 0).
|
||
- Produces:
|
||
- `public static function issue(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string` — returns 32 lowercase hex chars.
|
||
- `public static function verify(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex, string $token): bool` — `hash_equals` against the freshly-issued token.
|
||
- The class validates `matchId` against `/^[a-f0-9]{16,}$/` and rejects empty `activeTeamId` and negative `round`/`lastActionIndex` with `InvalidArgumentException`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/Unit/Application/TurnTokenTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Tests\Unit\Application;
|
||
|
||
use BattleForge\Application\TurnToken;
|
||
use InvalidArgumentException;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class TurnTokenTest extends TestCase
|
||
{
|
||
private const SECRET = 'turn-token-test-secret';
|
||
|
||
public function testIssueIsDeterministicForTheSameInputs(): void
|
||
{
|
||
$a = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
|
||
$b = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
|
||
|
||
self::assertSame($a, $b);
|
||
self::assertSame(32, strlen($a));
|
||
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $a);
|
||
}
|
||
|
||
public function testVerifyAcceptsAFreshlyIssuedToken(): void
|
||
{
|
||
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
|
||
|
||
self::assertTrue(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 0, $token));
|
||
}
|
||
|
||
public function testVerifyRejectsAMutatedToken(): void
|
||
{
|
||
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
|
||
$tampered = substr($token, 0, -1) . '0';
|
||
|
||
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 0, $tampered));
|
||
}
|
||
|
||
public function testVerifyRejectsATokenFromADifferentMatch(): void
|
||
{
|
||
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
|
||
|
||
self::assertFalse(TurnToken::verify(self::SECRET, 'fedcba9876543210', 'alpha', 1, 0, $token));
|
||
}
|
||
|
||
public function testVerifyRejectsATokenFromADifferentTeam(): void
|
||
{
|
||
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
|
||
|
||
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'bravo', 1, 0, $token));
|
||
}
|
||
|
||
public function testVerifyRejectsATokenFromADifferentRound(): void
|
||
{
|
||
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
|
||
|
||
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 2, 0, $token));
|
||
}
|
||
|
||
public function testVerifyRejectsATokenFromADifferentLastActionIndex(): void
|
||
{
|
||
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
|
||
|
||
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 1, $token));
|
||
}
|
||
|
||
public function testIssueRejectsAMalformedMatchId(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
TurnToken::issue(self::SECRET, 'not-hex!', 'alpha', 1, 0);
|
||
}
|
||
|
||
public function testIssueRejectsAnEmptyActiveTeamId(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
TurnToken::issue(self::SECRET, '0123456789abcdef', '', 1, 0);
|
||
}
|
||
|
||
public function testIssueRejectsANegativeRound(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 0, 0);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `vendor/bin/phpunit tests/Unit/Application/TurnTokenTest.php`
|
||
Expected: FAIL because `BattleForge\Application\TurnToken` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `TurnToken`**
|
||
|
||
Create `src/Application/TurnToken.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Application;
|
||
|
||
use InvalidArgumentException;
|
||
|
||
final class TurnToken
|
||
{
|
||
private const MATCH_ID_PATTERN = '/^[a-f0-9]{16,}$/';
|
||
|
||
public static function issue(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string
|
||
{
|
||
self::assertValidInputs($matchId, $activeTeamId, $round, $lastActionIndex);
|
||
|
||
return substr(hash_hmac('sha256', self::payload($matchId, $activeTeamId, $round, $lastActionIndex), $secret), 0, 32);
|
||
}
|
||
|
||
public static function verify(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex, string $token): bool
|
||
{
|
||
if ($token === '' || !self::isValidMatchId($matchId) || $activeTeamId === '' || $round < 1 || $lastActionIndex < 0) {
|
||
return false;
|
||
}
|
||
|
||
$expected = self::issue($secret, $matchId, $activeTeamId, $round, $lastActionIndex);
|
||
|
||
return hash_equals($expected, $token);
|
||
}
|
||
|
||
private static function assertValidInputs(string $matchId, string $activeTeamId, int $round, int $lastActionIndex): void
|
||
{
|
||
if (!self::isValidMatchId($matchId)) {
|
||
throw new InvalidArgumentException('Match id must be 16+ lowercase hex characters.');
|
||
}
|
||
if ($activeTeamId === '') {
|
||
throw new InvalidArgumentException('Active team id cannot be empty.');
|
||
}
|
||
if ($round < 1) {
|
||
throw new InvalidArgumentException('Round must be at least 1.');
|
||
}
|
||
if ($lastActionIndex < 0) {
|
||
throw new InvalidArgumentException('Last action index cannot be negative.');
|
||
}
|
||
}
|
||
|
||
private static function isValidMatchId(string $matchId): bool
|
||
{
|
||
return preg_match(self::MATCH_ID_PATTERN, $matchId) === 1;
|
||
}
|
||
|
||
private static function payload(string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string
|
||
{
|
||
return implode('|', [$matchId, $activeTeamId, (string) $round, (string) $lastActionIndex]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `vendor/bin/phpunit tests/Unit/Application/TurnTokenTest.php`
|
||
Expected: PASS, 9 tests.
|
||
|
||
- [ ] **Step 5: Run the quality suite and commit**
|
||
|
||
Run: `composer check`
|
||
Expected: all checks green.
|
||
|
||
```powershell
|
||
git add src/Application/TurnToken.php tests/Unit/Application/TurnTokenTest.php
|
||
git commit -m "feat: add TurnToken HMAC for match-action anti-cheat"
|
||
```
|
||
|
||
### Task 3: Modify `PostStartMatch` to mint `matchId` and `turnToken`
|
||
|
||
**Files:**
|
||
- Modify: `src/Http/Handlers/PostStartMatch.php`
|
||
- Test: `tests/Integration/PostStartMatchTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `TurnToken::issue`, the existing `ScenarioSerializer::matchToArray`.
|
||
- Produces: response body shape `{match: <ScenarioSerializer::matchToArray output>, matchId: <16 lowercase hex chars>, turnToken: <32 lowercase hex chars>}`.
|
||
|
||
- [ ] **Step 1: Update the existing happy-path test to assert the new fields**
|
||
|
||
In `tests/Integration/PostStartMatchTest.php`, inside `testItAcceptsAValidScenarioAndReturnsTheInitialMatch`, replace the existing assertions (the three lines starting with `self::assertSame('alpha', ...)`) with:
|
||
|
||
```php
|
||
$body = json_decode($response->body, true);
|
||
self::assertSame('alpha', $body['match']['activeTeamId'] ?? null);
|
||
self::assertSame(1, $body['match']['round'] ?? null);
|
||
self::assertCount(6, $body['match']['units'] ?? []);
|
||
self::assertIsString($body['matchId'] ?? null);
|
||
self::assertMatchesRegularExpression('/^[a-f0-9]{16,}$/', $body['matchId']);
|
||
self::assertIsString($body['turnToken'] ?? null);
|
||
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']);
|
||
```
|
||
|
||
- [ ] **Step 2: Add a new test that asserts the turn token is valid for the initial state**
|
||
|
||
Append the following test method inside `final class PostStartMatchTest`, immediately before the closing brace of the class:
|
||
|
||
```php
|
||
public function testItReturnsATurnTokenValidForTheInitialMatchState(): void
|
||
{
|
||
[$token, $cookie] = CsrfToken::issue(self::SECRET);
|
||
$handler = new PostStartMatch();
|
||
$request = $this->buildRequest(
|
||
rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR),
|
||
csrfHeader: $token,
|
||
cookies: ['__csrf' => $cookie],
|
||
);
|
||
$response = $handler->handle($request, ['id' => 'demo']);
|
||
|
||
self::assertSame(200, $response->status);
|
||
$body = json_decode($response->body, true);
|
||
$matchId = $body['matchId'];
|
||
$turnToken = $body['turnToken'];
|
||
$lastActionIndex = count($body['match']['actionLog'] ?? []);
|
||
|
||
self::assertTrue(\BattleForge\Application\TurnToken::verify(
|
||
self::SECRET,
|
||
$matchId,
|
||
$body['match']['activeTeamId'],
|
||
$body['match']['round'],
|
||
$lastActionIndex,
|
||
$turnToken,
|
||
));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run the test to verify it fails**
|
||
|
||
Run: `vendor/bin/phpunit tests/Integration/PostStartMatchTest.php`
|
||
Expected: FAIL because `PostStartMatch` currently returns only `{match: ...}`.
|
||
|
||
- [ ] **Step 4: Modify `PostStartMatch` to mint the `matchId` and `turnToken`**
|
||
|
||
Replace `src/Http/Handlers/PostStartMatch.php` with:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Http\Handlers;
|
||
|
||
use BattleForge\Application\ScenarioDraft;
|
||
use BattleForge\Application\ScenarioSerializer;
|
||
use BattleForge\Application\TurnToken;
|
||
use BattleForge\Domain\ScenarioValidator;
|
||
use BattleForge\Http\CsrfToken;
|
||
use BattleForge\Http\Request;
|
||
use BattleForge\Http\Response;
|
||
|
||
final class PostStartMatch
|
||
{
|
||
/** @param array<string, string> $params */
|
||
public function handle(Request $request, array $params): Response
|
||
{
|
||
$submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? '');
|
||
$expected = (string) ($request->cookies['__csrf'] ?? '');
|
||
$secret = (string) ($request->server['__csrf_secret'] ?? '');
|
||
|
||
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
|
||
return Response::json(403, ['error' => 'csrf']);
|
||
}
|
||
|
||
try {
|
||
$payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR);
|
||
$draft = ScenarioDraft::fromPost($payload);
|
||
$scenario = $draft->toScenario();
|
||
ScenarioValidator::validate($scenario);
|
||
$match = $scenario->startMatch('alpha');
|
||
} catch (\JsonException $exception) {
|
||
return Response::json(400, ['errors' => ['Malformed JSON.']]);
|
||
} catch (\InvalidArgumentException $exception) {
|
||
return Response::json(400, ['errors' => [$exception->getMessage()]]);
|
||
}
|
||
|
||
$matchId = bin2hex(random_bytes(8));
|
||
$matchArray = ScenarioSerializer::matchToArray($match);
|
||
$turnToken = TurnToken::issue(
|
||
$secret,
|
||
$matchId,
|
||
$match->activeTeamId,
|
||
$match->round,
|
||
count($match->actionLog),
|
||
);
|
||
|
||
return Response::json(200, [
|
||
'match' => $matchArray,
|
||
'matchId' => $matchId,
|
||
'turnToken' => $turnToken,
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
Run: `vendor/bin/phpunit tests/Integration/PostStartMatchTest.php`
|
||
Expected: PASS, 4 tests.
|
||
|
||
- [ ] **Step 6: Run the quality suite and commit**
|
||
|
||
Run: `composer check`
|
||
Expected: all checks green.
|
||
|
||
```powershell
|
||
git add src/Http/Handlers/PostStartMatch.php tests/Integration/PostStartMatchTest.php
|
||
git commit -m "feat: mint matchId and turnToken in PostStartMatch"
|
||
```
|
||
|
||
### Task 4: Bundled scenario fixtures and `manifest.json`
|
||
|
||
**Files:**
|
||
- Create: `public/assets/scenarios/manifest.json`
|
||
- Create: `public/assets/scenarios/skirmish.json`
|
||
- Create: `public/assets/scenarios/hold-the-line.json`
|
||
- Create: `public/assets/scenarios/last-stand.json`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ScenarioSerializer::scenarioFromArray` and `ScenarioValidator::validate` (consumers read each fixture through these in Task 5).
|
||
- Produces: three valid `Scenario` JSON files in the same shape as `ScenarioSerializer::scenarioToArray` produces, plus a `manifest.json` whose keys match the fixture filenames (minus the `.json` extension) and whose values are `{name, summary}` strings.
|
||
|
||
- [ ] **Step 1: Create `manifest.json`**
|
||
|
||
Create `public/assets/scenarios/manifest.json`:
|
||
|
||
```json
|
||
{
|
||
"skirmish": {
|
||
"name": "Skirmish",
|
||
"summary": "8x8 open ground. Three units per side. Eliminate the enemy team."
|
||
},
|
||
"hold-the-line": {
|
||
"name": "Hold the Line",
|
||
"summary": "10x8 with a forest corridor. Three defenders hold the center against four attackers. Hold the objective for three rounds."
|
||
},
|
||
"last-stand": {
|
||
"name": "Last Stand",
|
||
"summary": "12x12 with mixed terrain. Six defenders versus four strikers. Eliminate the enemy team."
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `skirmish.json`**
|
||
|
||
Create `public/assets/scenarios/skirmish.json`:
|
||
|
||
```json
|
||
{
|
||
"id": "skirmish",
|
||
"name": "Skirmish",
|
||
"battlefield": {
|
||
"width": 8,
|
||
"height": 8,
|
||
"terrain": {}
|
||
},
|
||
"units": [
|
||
{ "id": "alpha-1", "teamId": "alpha", "position": { "x": 0, "y": 1 }, "maxHealth": 12, "health": 12, "attack": 3, "defense": 4, "speed": 2, "archetype": "defender", "abilities": [] },
|
||
{ "id": "alpha-2", "teamId": "alpha", "position": { "x": 0, "y": 3 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
|
||
{ "id": "alpha-3", "teamId": "alpha", "position": { "x": 0, "y": 6 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] },
|
||
{ "id": "bravo-1", "teamId": "bravo", "position": { "x": 7, "y": 1 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
|
||
{ "id": "bravo-2", "teamId": "bravo", "position": { "x": 7, "y": 3 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] },
|
||
{ "id": "bravo-3", "teamId": "bravo", "position": { "x": 7, "y": 6 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] }
|
||
],
|
||
"deploymentZones": {
|
||
"alpha": { "teamId": "alpha", "positions": [{ "x": 0, "y": 1 }, { "x": 0, "y": 3 }, { "x": 0, "y": 6 }] },
|
||
"bravo": { "teamId": "bravo", "positions": [{ "x": 7, "y": 1 }, { "x": 7, "y": 3 }, { "x": 7, "y": 6 }] }
|
||
},
|
||
"objectives": {},
|
||
"victoryCondition": "eliminate_all",
|
||
"holdRoundsRequired": 1
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `hold-the-line.json`**
|
||
|
||
Create `public/assets/scenarios/hold-the-line.json`:
|
||
|
||
> **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.
|
||
|
||
```json
|
||
{
|
||
"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`:
|
||
|
||
```json
|
||
{
|
||
"id": "last-stand",
|
||
"name": "Last Stand",
|
||
"battlefield": {
|
||
"width": 12,
|
||
"height": 12,
|
||
"terrain": {
|
||
"2:2": "forest", "2:3": "forest", "2:8": "forest", "2:9": "forest",
|
||
"3:2": "forest", "3:3": "forest", "3:8": "forest", "3:9": "forest",
|
||
"5:5": "rough", "5:6": "rough", "6:5": "rough", "6:6": "rough",
|
||
"8:2": "water", "8:3": "water", "8:8": "water", "8:9": "water",
|
||
"9:2": "water", "9:3": "water", "9:8": "water", "9:9": "water"
|
||
}
|
||
},
|
||
"units": [
|
||
{ "id": "alpha-1", "teamId": "alpha", "position": { "x": 0, "y": 0 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
|
||
{ "id": "alpha-2", "teamId": "alpha", "position": { "x": 0, "y": 3 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
|
||
{ "id": "alpha-3", "teamId": "alpha", "position": { "x": 0, "y": 6 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] },
|
||
{ "id": "alpha-4", "teamId": "alpha", "position": { "x": 0, "y": 9 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] },
|
||
{ "id": "alpha-5", "teamId": "alpha", "position": { "x": 1, "y": 5 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] },
|
||
{ "id": "alpha-6", "teamId": "alpha", "position": { "x": 2, "y": 11 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] },
|
||
{ "id": "bravo-1", "teamId": "bravo", "position": { "x": 11, "y": 2 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
|
||
{ "id": "bravo-2", "teamId": "bravo", "position": { "x": 11, "y": 5 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
|
||
{ "id": "bravo-3", "teamId": "bravo", "position": { "x": 11, "y": 8 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] },
|
||
{ "id": "bravo-4", "teamId": "bravo", "position": { "x": 10, "y": 11 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }
|
||
],
|
||
"deploymentZones": {
|
||
"alpha": { "teamId": "alpha", "positions": [{ "x": 0, "y": 0 }, { "x": 0, "y": 3 }, { "x": 0, "y": 6 }, { "x": 0, "y": 9 }, { "x": 1, "y": 5 }, { "x": 2, "y": 11 }] },
|
||
"bravo": { "teamId": "bravo", "positions": [{ "x": 11, "y": 2 }, { "x": 11, "y": 5 }, { "x": 11, "y": 8 }, { "x": 10, "y": 11 }] }
|
||
},
|
||
"objectives": {},
|
||
"victoryCondition": "eliminate_all",
|
||
"holdRoundsRequired": 1
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Verify each fixture is a valid scenario**
|
||
|
||
Run: `php -r 'require "vendor/autoload.php"; foreach (["skirmish", "hold-the-line", "last-stand"] as $id) { $data = json_decode(file_get_contents("public/assets/scenarios/$id.json"), true, flags: JSON_THROW_ON_ERROR); $scenario = BattleForge\\Application\\ScenarioSerializer::scenarioFromArray($data); $errors = BattleForge\\Domain\\ScenarioValidator::validate($scenario); if ($errors !== []) { echo "$id: FAIL\n"; print_r($errors); exit(1); } echo "$id: OK\n"; }'`
|
||
Expected: each id prints `OK`. If any prints `FAIL`, the validator messages identify which constraint to fix in the fixture (most likely an out-of-bounds position, an unreachable objective, or a stat range violation).
|
||
|
||
- [ ] **Step 6: Commit the bundled fixtures**
|
||
|
||
```powershell
|
||
git add public/assets/scenarios/manifest.json public/assets/scenarios/skirmish.json public/assets/scenarios/hold-the-line.json public/assets/scenarios/last-stand.json
|
||
git commit -m "feat: add three bundled scenario fixtures and manifest"
|
||
```
|
||
|
||
### Task 5: `GetBundledScenarios` handler
|
||
|
||
**Files:**
|
||
- Create: `src/Http/Handlers/GetBundledScenarios.php`
|
||
- Test: `tests/Integration/GetBundledScenariosTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: a constructor arg `string $scenariosDir` (an absolute path to `public/assets/scenarios/`).
|
||
- Produces: `Response::json(200, [['id' => string, 'name' => string, 'summary' => string], ...])` sorted by id. The handler:
|
||
- Reads `manifest.json` from the scenarios dir; if the file is missing, returns `[]`.
|
||
- For each `*.json` file in the dir whose name matches `/^[a-z0-9-]+\.json$/`, derives an id by stripping `.json`. If the manifest has an entry for that id, includes it in the response. Any fixture without a manifest entry is filtered out.
|
||
- Returns the result sorted by id (so the order is stable regardless of the filesystem order).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/Integration/GetBundledScenariosTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Tests\Integration;
|
||
|
||
use BattleForge\Http\Handlers\GetBundledScenarios;
|
||
use BattleForge\Http\Request;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class GetBundledScenariosTest extends TestCase
|
||
{
|
||
private string $tmpDir;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
$this->tmpDir = sys_get_temp_dir() . '/bf-bundled-' . bin2hex(random_bytes(4));
|
||
mkdir($this->tmpDir, 0700, true);
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
if (is_dir($this->tmpDir)) {
|
||
foreach (glob($this->tmpDir . '/*') ?: [] as $file) {
|
||
unlink($file);
|
||
}
|
||
rmdir($this->tmpDir);
|
||
}
|
||
}
|
||
|
||
public function testReturnsBundledScenariosFromTheConfiguredDirectory(): void
|
||
{
|
||
$this->writeManifest([
|
||
'skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.'],
|
||
'hold-the-line' => ['name' => 'Hold the Line', 'summary' => 'Defend.'],
|
||
]);
|
||
$this->writeFixture('skirmish', '{}');
|
||
$this->writeFixture('hold-the-line', '{}');
|
||
|
||
$handler = new GetBundledScenarios($this->tmpDir);
|
||
$response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []);
|
||
|
||
self::assertSame(200, $response->status);
|
||
$body = json_decode($response->body, true);
|
||
self::assertSame(
|
||
[
|
||
['id' => 'hold-the-line', 'name' => 'Hold the Line', 'summary' => 'Defend.'],
|
||
['id' => 'skirmish', 'name' => 'Skirmish', 'summary' => 'Open ground.'],
|
||
],
|
||
$body,
|
||
);
|
||
}
|
||
|
||
public function testFiltersOutFixturesWithoutAManifestEntry(): void
|
||
{
|
||
$this->writeManifest(['skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.']]);
|
||
$this->writeFixture('skirmish', '{}');
|
||
$this->writeFixture('rogue', '{}');
|
||
|
||
$handler = new GetBundledScenarios($this->tmpDir);
|
||
$response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []);
|
||
|
||
$body = json_decode($response->body, true);
|
||
self::assertCount(1, $body);
|
||
self::assertSame('skirmish', $body[0]['id']);
|
||
}
|
||
|
||
public function testIgnoresFilesWithDisallowedFilenames(): void
|
||
{
|
||
$this->writeManifest(['skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.']]);
|
||
$this->writeFixture('skirmish', '{}');
|
||
$this->writeFixture('bad..json', '{}');
|
||
$this->writeFixture('README', 'plain text');
|
||
|
||
$handler = new GetBundledScenarios($this->tmpDir);
|
||
$response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []);
|
||
|
||
$body = json_decode($response->body, true);
|
||
self::assertCount(1, $body);
|
||
self::assertSame('skirmish', $body[0]['id']);
|
||
}
|
||
|
||
public function testReturnsEmptyArrayWhenManifestIsMissing(): void
|
||
{
|
||
$handler = new GetBundledScenarios($this->tmpDir);
|
||
$response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []);
|
||
|
||
self::assertSame(200, $response->status);
|
||
self::assertSame([], json_decode($response->body, true));
|
||
}
|
||
|
||
/** @param array<string, array{name: string, summary: string}> $entries */
|
||
private function writeManifest(array $entries): void
|
||
{
|
||
file_put_contents($this->tmpDir . '/manifest.json', json_encode($entries, JSON_THROW_ON_ERROR));
|
||
}
|
||
|
||
private function writeFixture(string $name, string $body): void
|
||
{
|
||
file_put_contents($this->tmpDir . '/' . $name . '.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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Http\Handlers;
|
||
|
||
use BattleForge\Http\Request;
|
||
use BattleForge\Http\Response;
|
||
|
||
final class GetBundledScenarios
|
||
{
|
||
private const FILENAME_PATTERN = '/^[a-z0-9-]+\.json$/';
|
||
|
||
public function __construct(private readonly string $scenariosDir)
|
||
{
|
||
}
|
||
|
||
/** @param array<string, string> $params */
|
||
public function handle(Request $request, array $params): Response
|
||
{
|
||
$manifestPath = $this->scenariosDir . '/manifest.json';
|
||
$manifest = [];
|
||
if (is_file($manifestPath)) {
|
||
$decoded = json_decode((string) file_get_contents($manifestPath), true);
|
||
if (is_array($decoded)) {
|
||
$manifest = $decoded;
|
||
}
|
||
}
|
||
|
||
$entries = [];
|
||
foreach (scandir($this->scenariosDir) ?: [] as $file) {
|
||
if (!preg_match(self::FILENAME_PATTERN, $file)) {
|
||
continue;
|
||
}
|
||
$id = substr($file, 0, -5);
|
||
$entry = $manifest[$id] ?? null;
|
||
if (!is_array($entry) || !isset($entry['name'], $entry['summary'])) {
|
||
continue;
|
||
}
|
||
$entries[] = [
|
||
'id' => $id,
|
||
'name' => (string) $entry['name'],
|
||
'summary' => (string) $entry['summary'],
|
||
];
|
||
}
|
||
|
||
usort($entries, static fn (array $a, array $b): int => strcmp($a['id'], $b['id']));
|
||
|
||
return Response::json(200, $entries);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `vendor/bin/phpunit tests/Integration/GetBundledScenariosTest.php`
|
||
Expected: PASS, 4 tests.
|
||
|
||
- [ ] **Step 5: Run the quality suite and commit**
|
||
|
||
Run: `composer check`
|
||
Expected: all checks green.
|
||
|
||
```powershell
|
||
git add src/Http/Handlers/GetBundledScenarios.php tests/Integration/GetBundledScenariosTest.php
|
||
git commit -m "feat: add GetBundledScenarios handler with manifest-driven list"
|
||
```
|
||
|
||
### Task 6: `GetBundledScenario` handler
|
||
|
||
**Files:**
|
||
- Create: `src/Http/Handlers/GetBundledScenario.php`
|
||
- Test: `tests/Integration/GetBundledScenarioTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: a constructor arg `string $scenariosDir`.
|
||
- Produces:
|
||
- `Response::json(200, <ScenarioSerializer::scenarioToArray output>)` on success.
|
||
- `Response::json(404, ['error' => 'not found'])` if the id is missing or fails the `/^[a-z0-9-]+$/` regex, or the file does not exist.
|
||
- `Response::json(500, ['error' => 'fixture invalid', 'details' => [...]])` if the fixture deserializes into a `Scenario` that fails `ScenarioValidator::validate`.
|
||
- The handler reads `manifest.json` to ensure the id is recognized; an id with a file but no manifest entry is treated as not found.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/Integration/GetBundledScenarioTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Tests\Integration;
|
||
|
||
use BattleForge\Http\Handlers\GetBundledScenario;
|
||
use BattleForge\Http\Request;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class GetBundledScenarioTest extends TestCase
|
||
{
|
||
private string $tmpDir;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
$this->tmpDir = sys_get_temp_dir() . '/bf-bundled-one-' . bin2hex(random_bytes(4));
|
||
mkdir($this->tmpDir, 0700, true);
|
||
file_put_contents(
|
||
$this->tmpDir . '/manifest.json',
|
||
json_encode(['valid' => ['name' => 'Valid', 'summary' => 'OK']], JSON_THROW_ON_ERROR),
|
||
);
|
||
$this->writeValidFixture();
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
if (is_dir($this->tmpDir)) {
|
||
foreach (glob($this->tmpDir . '/*') ?: [] as $file) {
|
||
unlink($file);
|
||
}
|
||
rmdir($this->tmpDir);
|
||
}
|
||
}
|
||
|
||
public function testReturnsTheScenarioJsonForAKnownId(): void
|
||
{
|
||
$handler = new GetBundledScenario($this->tmpDir);
|
||
$response = $handler->handle(
|
||
new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/valid', null, ''),
|
||
['id' => 'valid'],
|
||
);
|
||
|
||
self::assertSame(200, $response->status);
|
||
$body = json_decode($response->body, true);
|
||
self::assertSame('valid', $body['id'] ?? null);
|
||
self::assertSame('Valid', $body['name'] ?? null);
|
||
self::assertSame(8, $body['battlefield']['width'] ?? null);
|
||
}
|
||
|
||
public function testReturns404ForAnUnknownId(): void
|
||
{
|
||
$handler = new GetBundledScenario($this->tmpDir);
|
||
$response = $handler->handle(
|
||
new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/unknown', null, ''),
|
||
['id' => 'unknown'],
|
||
);
|
||
|
||
self::assertSame(404, $response->status);
|
||
}
|
||
|
||
public function testReturns404ForAnIdThatFailsTheRegex(): void
|
||
{
|
||
$handler = new GetBundledScenario($this->tmpDir);
|
||
$response = $handler->handle(
|
||
new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/..', null, ''),
|
||
['id' => '..'],
|
||
);
|
||
|
||
self::assertSame(404, $response->status);
|
||
}
|
||
|
||
public function testReturns500WhenTheFixtureFailsValidation(): void
|
||
{
|
||
$bad = [
|
||
'id' => 'broken',
|
||
'name' => 'Broken',
|
||
'battlefield' => ['width' => 8, 'height' => 8, 'terrain' => []],
|
||
'units' => [],
|
||
'deploymentZones' => [],
|
||
'objectives' => [],
|
||
'victoryCondition' => 'eliminate_all',
|
||
'holdRoundsRequired' => 1,
|
||
];
|
||
file_put_contents($this->tmpDir . '/broken.json', json_encode($bad, JSON_THROW_ON_ERROR));
|
||
file_put_contents(
|
||
$this->tmpDir . '/manifest.json',
|
||
json_encode([
|
||
'valid' => ['name' => 'Valid', 'summary' => 'OK'],
|
||
'broken' => ['name' => 'Broken', 'summary' => 'Bad'],
|
||
], JSON_THROW_ON_ERROR),
|
||
);
|
||
|
||
$handler = new GetBundledScenario($this->tmpDir);
|
||
$response = $handler->handle(
|
||
new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/broken', null, ''),
|
||
['id' => 'broken'],
|
||
);
|
||
|
||
self::assertSame(500, $response->status);
|
||
$body = json_decode($response->body, true);
|
||
self::assertSame('fixture invalid', $body['error'] ?? null);
|
||
self::assertIsArray($body['details'] ?? null);
|
||
self::assertNotEmpty($body['details']);
|
||
}
|
||
|
||
private function writeValidFixture(): void
|
||
{
|
||
$fixture = [
|
||
'id' => 'valid',
|
||
'name' => 'Valid',
|
||
'battlefield' => ['width' => 8, 'height' => 8, 'terrain' => []],
|
||
'units' => [
|
||
['id' => 'a1', 'teamId' => 'alpha', 'position' => ['x' => 0, 'y' => 0], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
|
||
['id' => 'a2', 'teamId' => 'alpha', 'position' => ['x' => 1, 'y' => 0], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
|
||
['id' => 'a3', 'teamId' => 'alpha', 'position' => ['x' => 2, 'y' => 0], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
|
||
['id' => 'b1', 'teamId' => 'bravo', 'position' => ['x' => 7, 'y' => 7], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
|
||
['id' => 'b2', 'teamId' => 'bravo', 'position' => ['x' => 6, 'y' => 7], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
|
||
['id' => 'b3', 'teamId' => 'bravo', 'position' => ['x' => 5, 'y' => 7], 'maxHealth' => 10, 'health' => 10, 'attack' => 3, 'defense' => 3, 'speed' => 2, 'archetype' => 'defender', 'abilities' => []],
|
||
],
|
||
'deploymentZones' => [
|
||
'alpha' => ['teamId' => 'alpha', 'positions' => [['x' => 0, 'y' => 0], ['x' => 1, 'y' => 0], ['x' => 2, 'y' => 0]]],
|
||
'bravo' => ['teamId' => 'bravo', 'positions' => [['x' => 7, 'y' => 7], ['x' => 6, 'y' => 7], ['x' => 5, 'y' => 7]]],
|
||
],
|
||
'objectives' => [],
|
||
'victoryCondition' => 'eliminate_all',
|
||
'holdRoundsRequired' => 1,
|
||
];
|
||
file_put_contents($this->tmpDir . '/valid.json', json_encode($fixture, JSON_THROW_ON_ERROR));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `vendor/bin/phpunit tests/Integration/GetBundledScenarioTest.php`
|
||
Expected: FAIL because `BattleForge\Http\Handlers\GetBundledScenario` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `GetBundledScenario`**
|
||
|
||
Create `src/Http/Handlers/GetBundledScenario.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Http\Handlers;
|
||
|
||
use BattleForge\Application\ScenarioSerializer;
|
||
use BattleForge\Domain\ScenarioValidator;
|
||
use BattleForge\Http\Request;
|
||
use BattleForge\Http\Response;
|
||
use InvalidArgumentException;
|
||
|
||
final class GetBundledScenario
|
||
{
|
||
private const ID_PATTERN = '/^[a-z0-9-]+$/';
|
||
|
||
public function __construct(private readonly string $scenariosDir)
|
||
{
|
||
}
|
||
|
||
/** @param array<string, string> $params */
|
||
public function handle(Request $request, array $params): Response
|
||
{
|
||
$id = (string) ($params['id'] ?? '');
|
||
|
||
if (preg_match(self::ID_PATTERN, $id) !== 1) {
|
||
return Response::json(404, ['error' => 'not found']);
|
||
}
|
||
|
||
$manifestPath = $this->scenariosDir . '/manifest.json';
|
||
$manifest = [];
|
||
if (is_file($manifestPath)) {
|
||
$decoded = json_decode((string) file_get_contents($manifestPath), true);
|
||
if (is_array($decoded)) {
|
||
$manifest = $decoded;
|
||
}
|
||
}
|
||
|
||
if (!isset($manifest[$id])) {
|
||
return Response::json(404, ['error' => 'not found']);
|
||
}
|
||
|
||
$fixturePath = $this->scenariosDir . '/' . $id . '.json';
|
||
$real = realpath($fixturePath);
|
||
$realDir = realpath($this->scenariosDir);
|
||
if ($real === false || $realDir === false || !str_starts_with($real, $realDir . DIRECTORY_SEPARATOR) || !is_file($real)) {
|
||
return Response::json(404, ['error' => 'not found']);
|
||
}
|
||
|
||
$data = json_decode((string) file_get_contents($real), true, flags: JSON_THROW_ON_ERROR);
|
||
if (!is_array($data)) {
|
||
return Response::json(500, ['error' => 'fixture invalid', 'details' => ['Fixture JSON is not an object.']]);
|
||
}
|
||
|
||
try {
|
||
$scenario = ScenarioSerializer::scenarioFromArray($data);
|
||
} catch (InvalidArgumentException $exception) {
|
||
return Response::json(500, ['error' => 'fixture invalid', 'details' => [$exception->getMessage()]]);
|
||
}
|
||
|
||
$errors = ScenarioValidator::validate($scenario);
|
||
if ($errors !== []) {
|
||
return Response::json(500, ['error' => 'fixture invalid', 'details' => $errors]);
|
||
}
|
||
|
||
return Response::json(200, ScenarioSerializer::scenarioToArray($scenario));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `vendor/bin/phpunit tests/Integration/GetBundledScenarioTest.php`
|
||
Expected: PASS, 4 tests.
|
||
|
||
- [ ] **Step 5: Run the quality suite and commit**
|
||
|
||
Run: `composer check`
|
||
Expected: all checks green.
|
||
|
||
```powershell
|
||
git add src/Http/Handlers/GetBundledScenario.php tests/Integration/GetBundledScenarioTest.php
|
||
git commit -m "feat: add GetBundledScenario handler with fixture validation"
|
||
```
|
||
|
||
### Task 7: Update the home page to render the bundled scenarios list
|
||
|
||
**Files:**
|
||
- Modify: `src/Http/Handlers/GetHomePage.php`
|
||
- Modify: `src/Views/home.php`
|
||
- Modify: `tests/Integration/GetHomePageTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: a new constructor arg `string $scenariosDir` for `GetHomePage`. The handler reads `manifest.json` from that dir and passes the entries to the view.
|
||
- Produces: the home page renders a "Play bundled" `<ul>` listing each entry with `<button data-bf-bundle="{id}">` (one button per scenario). The existing `storage.js` populates the recent-scenarios `<div id="recent">` from `localStorage`; the bundled list is server-rendered and not replaced by JS.
|
||
|
||
- [ ] **Step 1: Add a new test for the bundled list**
|
||
|
||
Add the following test method to `tests/Integration/GetHomePageTest.php`, immediately before the closing brace of the class. The existing `testItReturnsTheHomePageWithSecurityHeaders` test remains unchanged.
|
||
|
||
```php
|
||
public function testItRendersTheBundledScenariosListFromTheManifest(): void
|
||
{
|
||
$tmpDir = sys_get_temp_dir() . '/bf-home-bundled-' . bin2hex(random_bytes(4));
|
||
mkdir($tmpDir, 0700, true);
|
||
file_put_contents(
|
||
$tmpDir . '/manifest.json',
|
||
json_encode([
|
||
'skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.'],
|
||
'hold-the-line' => ['name' => 'Hold the Line', 'summary' => 'Defend.'],
|
||
], JSON_THROW_ON_ERROR),
|
||
);
|
||
foreach (['skirmish.json', 'hold-the-line.json'] as $file) {
|
||
file_put_contents($tmpDir . '/' . $file, '{}');
|
||
}
|
||
|
||
$handler = new GetHomePage($tmpDir);
|
||
$request = new Request([], [], [], [], [], '', 'GET', '/', 'text/html', '');
|
||
$response = $handler->handle($request, []);
|
||
|
||
self::assertSame(200, $response->status);
|
||
self::assertStringContainsString('id="bf-bundled"', $response->body);
|
||
self::assertStringContainsString('data-bf-bundle="skirmish"', $response->body);
|
||
self::assertStringContainsString('data-bf-bundle="hold-the-line"', $response->body);
|
||
self::assertStringContainsString('Open ground.', $response->body);
|
||
self::assertStringContainsString('Defend.', $response->body);
|
||
|
||
unlink($tmpDir . '/manifest.json');
|
||
foreach (['skirmish.json', 'hold-the-line.json'] as $file) {
|
||
unlink($tmpDir . '/' . $file);
|
||
}
|
||
rmdir($tmpDir);
|
||
}
|
||
```
|
||
|
||
Add a new test `testItReturnsTheHomePageWithSecurityHeaders` next to the existing one — that one is no longer the only test. Keep the old test intact (it still passes — the existing assertions are still true on the new view). Wrap the existing test in a separate method, and keep its body as is. The file's structure should now contain two test methods.
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `vendor/bin/phpunit tests/Integration/GetHomePageTest.php`
|
||
Expected: FAIL on the new test because `GetHomePage` does not yet accept a scenarios dir and the home view does not render the bundled list.
|
||
|
||
- [ ] **Step 3: Update `GetHomePage` to accept the scenarios dir and pass it to the view**
|
||
|
||
Replace `src/Http/Handlers/GetHomePage.php` with:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Http\Handlers;
|
||
|
||
use BattleForge\Http\Request;
|
||
use BattleForge\Http\Response;
|
||
|
||
final class GetHomePage
|
||
{
|
||
public function __construct(private readonly string $scenariosDir = '')
|
||
{
|
||
}
|
||
|
||
/** @param array<string, string> $params */
|
||
public function handle(Request $request, array $params): Response
|
||
{
|
||
$csrf = $request->cookies['__csrf'] ?? '';
|
||
$bundled = $this->loadBundledScenarios();
|
||
|
||
ob_start();
|
||
require_once __DIR__ . '/../../Views/layout.php';
|
||
require __DIR__ . '/../../Views/home.php';
|
||
$body = (string) ob_get_clean();
|
||
|
||
return Response::html(200, $body);
|
||
}
|
||
|
||
/** @return list<array{id: string, name: string, summary: string}> */
|
||
private function loadBundledScenarios(): array
|
||
{
|
||
if ($this->scenariosDir === '' || !is_file($this->scenariosDir . '/manifest.json')) {
|
||
return [];
|
||
}
|
||
$decoded = json_decode((string) file_get_contents($this->scenariosDir . '/manifest.json'), true);
|
||
if (!is_array($decoded)) {
|
||
return [];
|
||
}
|
||
$entries = [];
|
||
foreach ($decoded as $id => $entry) {
|
||
if (!is_string($id) || !preg_match('/^[a-z0-9-]+$/', $id) || !is_array($entry)) {
|
||
continue;
|
||
}
|
||
if (!isset($entry['name'], $entry['summary'])) {
|
||
continue;
|
||
}
|
||
if (!is_file($this->scenariosDir . '/' . $id . '.json')) {
|
||
continue;
|
||
}
|
||
$entries[] = [
|
||
'id' => $id,
|
||
'name' => (string) $entry['name'],
|
||
'summary' => (string) $entry['summary'],
|
||
];
|
||
}
|
||
usort($entries, static fn (array $a, array $b): int => strcmp($a['id'], $b['id']));
|
||
return $entries;
|
||
}
|
||
}
|
||
```
|
||
|
||
Note: the `$bundled` local variable inside `handle` is captured by the `ob_start` / `require` block (the view runs in `handle`'s scope), so the view sees the local without any explicit pass. No further wiring is required.
|
||
|
||
- [ ] **Step 4: Update the home view to render the bundled list**
|
||
|
||
Replace `src/Views/home.php` with:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use BattleForge\Http\Escape;
|
||
|
||
/** @var string $csrf Provided by the front controller. */
|
||
/** @var list<array{id: string, name: string, summary: string}> $bundled Provided by GetHomePage. */
|
||
?><?php
|
||
render_layout(static function () use ($csrf, $bundled): void {
|
||
$e = static fn (string $v): string => Escape::html($v);
|
||
$a = static fn (string $v): string => Escape::attr($v);
|
||
?>
|
||
<h1>BattleForge</h1>
|
||
<p><a href="<?= $a('/scenarios/new/edit/team') ?>">New scenario</a></p>
|
||
<h2>Play bundled</h2>
|
||
<ul id="bf-bundled">
|
||
<?php foreach ($bundled as $entry): ?>
|
||
<li>
|
||
<button type="button" data-bf-bundle="<?= $a($entry['id']) ?>">
|
||
<strong><?= $e($entry['name']) ?></strong> — <?= $e($entry['summary']) ?>
|
||
</button>
|
||
</li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<h2>Recent scenarios</h2>
|
||
<div id="recent"><p class="bf-empty">No saved scenarios yet.</p></div>
|
||
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
|
||
<?php
|
||
}, $csrf, 'BattleForge');
|
||
```
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
Run: `vendor/bin/phpunit tests/Integration/GetHomePageTest.php`
|
||
Expected: PASS, 2 tests.
|
||
|
||
- [ ] **Step 6: Run the quality suite and commit**
|
||
|
||
Run: `composer check`
|
||
Expected: all checks green.
|
||
|
||
```powershell
|
||
git add src/Http/Handlers/GetHomePage.php src/Views/home.php tests/Integration/GetHomePageTest.php
|
||
git commit -m "feat: render the bundled-scenarios list on the home page"
|
||
```
|
||
|
||
### Task 8: `GetMatchView` handler and `match.php` template
|
||
|
||
**Files:**
|
||
- Create: `src/Http/Handlers/GetMatchView.php`
|
||
- Create: `src/Views/match.php`
|
||
- Test: `tests/Integration/GetMatchViewTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: a `Request` (only the `__csrf` cookie is read).
|
||
- Produces: `Response::html(200, <body>)` with:
|
||
- The shared `layout.php` chrome.
|
||
- A header bar: `<header id="bf-match-header" data-bf-active-team="..." data-bf-round="...">` (the JS fills these from `match:current`).
|
||
- An End Turn button: `<button id="bf-end-turn" type="button">`.
|
||
- A grid container: `<div id="bf-grid" class="bf-grid" data-bf-width="0" data-bf-height="0">` (the JS fills in the dimensions and the tiles).
|
||
- An action panel: `<aside id="bf-panel">` with three action buttons (`#bf-action-move`, `#bf-action-attack`, `#bf-action-ability`) and a `#bf-selected-unit` indicator.
|
||
- An action log: `<div id="bf-log" class="bf-log">`.
|
||
- A result panel: `<div id="bf-result" class="bf-result" hidden>` with a `#bf-winner` span and an `#bf-return-home` link.
|
||
- A `<script type="module" src="/js/match.js">` tag.
|
||
- The CSRF meta tag (already provided by the layout).
|
||
- The security headers (already provided by the front controller, not the handler).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/Integration/GetMatchViewTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Tests\Integration;
|
||
|
||
use BattleForge\Http\Handlers\GetMatchView;
|
||
use BattleForge\Http\Request;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class GetMatchViewTest extends TestCase
|
||
{
|
||
public function testItRendersTheMatchShellWithSecurityHeaders(): void
|
||
{
|
||
$handler = new GetMatchView();
|
||
$request = new Request([], [], [], [], [], '', 'GET', '/matches/current', 'text/html', '');
|
||
$response = $handler->handle($request, []);
|
||
|
||
self::assertSame(200, $response->status);
|
||
self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
|
||
self::assertSame('nosniff', $response->headers['X-Content-Type-Options']);
|
||
self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']);
|
||
|
||
self::assertStringContainsString('id="bf-match-header"', $response->body);
|
||
self::assertStringContainsString('id="bf-end-turn"', $response->body);
|
||
self::assertStringContainsString('id="bf-grid"', $response->body);
|
||
self::assertStringContainsString('id="bf-panel"', $response->body);
|
||
self::assertStringContainsString('id="bf-action-move"', $response->body);
|
||
self::assertStringContainsString('id="bf-action-attack"', $response->body);
|
||
self::assertStringContainsString('id="bf-action-ability"', $response->body);
|
||
self::assertStringContainsString('id="bf-selected-unit"', $response->body);
|
||
self::assertStringContainsString('id="bf-log"', $response->body);
|
||
self::assertStringContainsString('id="bf-result"', $response->body);
|
||
self::assertStringContainsString('id="bf-winner"', $response->body);
|
||
self::assertStringContainsString('id="bf-return-home"', $response->body);
|
||
self::assertStringContainsString('src="/js/match.js"', $response->body);
|
||
self::assertStringContainsString('name="csrf-token"', $response->body);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `vendor/bin/phpunit tests/Integration/GetMatchViewTest.php`
|
||
Expected: FAIL because `GetMatchView` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `GetMatchView`**
|
||
|
||
Create `src/Http/Handlers/GetMatchView.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Http\Handlers;
|
||
|
||
use BattleForge\Http\Request;
|
||
use BattleForge\Http\Response;
|
||
|
||
final class GetMatchView
|
||
{
|
||
/** @param array<string, string> $params */
|
||
public function handle(Request $request, array $params): Response
|
||
{
|
||
$csrf = $request->cookies['__csrf'] ?? '';
|
||
|
||
ob_start();
|
||
require_once __DIR__ . '/../../Views/layout.php';
|
||
require __DIR__ . '/../../Views/match.php';
|
||
$body = (string) ob_get_clean();
|
||
|
||
return Response::html(200, $body);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement the `match.php` view**
|
||
|
||
Create `src/Views/match.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use BattleForge\Http\Escape;
|
||
|
||
/** @var string $csrf Provided by the front controller. */
|
||
?><?php
|
||
render_layout(static function (): void {
|
||
$a = static fn (string $v): string => Escape::attr($v);
|
||
?>
|
||
<header id="bf-match-header" data-bf-active-team="" data-bf-round="0">
|
||
<h1>Battle</h1>
|
||
<p>
|
||
Active team: <strong id="bf-active-team">—</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.
|
||
|
||
```powershell
|
||
git add src/Http/Handlers/GetMatchView.php src/Views/match.php tests/Integration/GetMatchViewTest.php
|
||
git commit -m "feat: add match view server-rendered shell"
|
||
```
|
||
|
||
### Task 9: `MatchActionSupport` shared helper
|
||
|
||
**Files:**
|
||
- Create: `src/Http/MatchActionSupport.php`
|
||
- Test: `tests/Unit/Http/MatchActionSupportTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: a `Request` and a callback for the per-verb engine call.
|
||
- Produces: a normalized result object so the four per-verb handlers stay small. Specifically:
|
||
- `final readonly class MatchActionResult` with public `?Response $response` (when the request was rejected at the boundary) and `?array $matchIdAndMatch` (the validated `(matchId, match)` pair to feed the engine). The four handlers call `$support->verify($request)` and then branch on the result.
|
||
- `final class MatchActionSupport` with:
|
||
- `public function __construct(string $secret)` (the CSRF / turn-token secret).
|
||
- `public function verify(Request $request): MatchActionResult` — runs CSRF check, turn-token check, and `MatchState` decode; returns a `MatchActionResult` carrying either a `Response` (CSRF/turn/state failure) or the validated pair.
|
||
- The class does *not* dispatch the engine call itself; each handler does that with the result of `verify()`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/Unit/Http/MatchActionSupportTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Tests\Unit\Http;
|
||
|
||
use BattleForge\Application\TurnToken;
|
||
use BattleForge\Domain\Archetype;
|
||
use BattleForge\Domain\Battlefield;
|
||
use BattleForge\Domain\MatchState;
|
||
use BattleForge\Domain\UnitState;
|
||
use BattleForge\Http\CsrfToken;
|
||
use BattleForge\Http\MatchActionSupport;
|
||
use BattleForge\Http\Request;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class MatchActionSupportTest extends TestCase
|
||
{
|
||
private const SECRET = 'support-test-secret';
|
||
|
||
public function testItRejectsARequestWithAMissingCsrfToken(): void
|
||
{
|
||
$support = new MatchActionSupport(self::SECRET);
|
||
$request = $this->buildRequest(match: $this->validMatchArray(), csrf: '', turnToken: 'irrelevant');
|
||
|
||
$result = $support->verify($request);
|
||
|
||
self::assertNotNull($result->response);
|
||
self::assertSame(403, $result->response->status);
|
||
self::assertNull($result->matchIdAndMatch);
|
||
}
|
||
|
||
public function testItRejectsARequestWithABadTurnToken(): void
|
||
{
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
$match = $this->validMatch();
|
||
$matchId = '0123456789abcdef';
|
||
$badToken = TurnToken::issue(self::SECRET, $matchId, 'bravo', 1, 0);
|
||
|
||
$support = new MatchActionSupport(self::SECRET);
|
||
$request = $this->buildRequest(
|
||
match: $this->validMatchArray(),
|
||
csrf: $csrf,
|
||
turnToken: $badToken,
|
||
cookies: ['__csrf' => $cookie],
|
||
);
|
||
|
||
$result = $support->verify($request);
|
||
|
||
self::assertNotNull($result->response);
|
||
self::assertSame(409, $result->response->status);
|
||
$body = json_decode($result->response->body, true);
|
||
self::assertSame('turn', $body['error'] ?? null);
|
||
}
|
||
|
||
public function testItRejectsAMalformedMatchState(): void
|
||
{
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
$support = new MatchActionSupport(self::SECRET);
|
||
$request = $this->buildRequest(
|
||
match: ['this' => 'is not a match'],
|
||
csrf: $csrf,
|
||
turnToken: 'whatever',
|
||
cookies: ['__csrf' => $cookie],
|
||
);
|
||
|
||
$result = $support->verify($request);
|
||
|
||
self::assertNotNull($result->response);
|
||
self::assertSame(400, $result->response->status);
|
||
}
|
||
|
||
public function testItReturnsTheValidatedPairForAValidRequest(): void
|
||
{
|
||
$match = $this->validMatch();
|
||
$matchArray = $this->validMatchArray();
|
||
$matchId = '0123456789abcdef';
|
||
$token = TurnToken::issue(self::SECRET, $matchId, $match->activeTeamId, $match->round, count($match->actionLog));
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
|
||
$support = new MatchActionSupport(self::SECRET);
|
||
$request = $this->buildRequest(
|
||
match: $matchArray,
|
||
csrf: $csrf,
|
||
turnToken: $token,
|
||
cookies: ['__csrf' => $cookie],
|
||
);
|
||
$body = json_encode(['matchId' => $matchId, 'match' => $matchArray], JSON_THROW_ON_ERROR);
|
||
$request = new Request([], [], [], ['__csrf' => $cookie], [
|
||
'HTTP_X_CSRF_TOKEN' => $csrf,
|
||
'HTTP_X_TURN_TOKEN' => $token,
|
||
'__csrf_secret' => self::SECRET,
|
||
'CONTENT_TYPE' => 'application/json',
|
||
], '', 'POST', '/matches/current/move', 'application/json', $body);
|
||
|
||
$result = $support->verify($request);
|
||
|
||
self::assertNull($result->response);
|
||
self::assertNotNull($result->matchIdAndMatch);
|
||
self::assertSame($matchId, $result->matchIdAndMatch['matchId']);
|
||
self::assertInstanceOf(MatchState::class, $result->matchIdAndMatch['match']);
|
||
}
|
||
|
||
private function validMatch(): MatchState
|
||
{
|
||
return new MatchState(
|
||
new Battlefield(8, 8),
|
||
[
|
||
new UnitState('alpha-1', 'alpha', new \BattleForge\Domain\Position(0, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
|
||
new UnitState('alpha-2', 'alpha', new \BattleForge\Domain\Position(1, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
|
||
new UnitState('alpha-3', 'alpha', new \BattleForge\Domain\Position(2, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
|
||
new UnitState('bravo-1', 'bravo', new \BattleForge\Domain\Position(7, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
|
||
new UnitState('bravo-2', 'bravo', new \BattleForge\Domain\Position(6, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
|
||
new UnitState('bravo-3', 'bravo', new \BattleForge\Domain\Position(5, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker),
|
||
],
|
||
'alpha',
|
||
);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function validMatchArray(): array
|
||
{
|
||
return \BattleForge\Application\ScenarioSerializer::matchToArray($this->validMatch());
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $match
|
||
* @param array<string, string> $cookies
|
||
*/
|
||
private function buildRequest(array $match, string $csrf, string $turnToken, array $cookies = []): Request
|
||
{
|
||
$body = json_encode(['matchId' => '0123456789abcdef', 'match' => $match], JSON_THROW_ON_ERROR);
|
||
return new Request([], [], [], $cookies, [
|
||
'HTTP_X_CSRF_TOKEN' => $csrf,
|
||
'HTTP_X_TURN_TOKEN' => $turnToken,
|
||
'__csrf_secret' => self::SECRET,
|
||
'CONTENT_TYPE' => 'application/json',
|
||
], '', 'POST', '/matches/current/move', 'application/json', $body);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `vendor/bin/phpunit tests/Unit/Http/MatchActionSupportTest.php`
|
||
Expected: FAIL because `BattleForge\Http\MatchActionSupport` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `MatchActionSupport`**
|
||
|
||
Create `src/Http/MatchActionSupport.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Http;
|
||
|
||
use BattleForge\Application\ScenarioSerializer;
|
||
use BattleForge\Application\TurnToken;
|
||
use BattleForge\Domain\MatchState;
|
||
|
||
final readonly class MatchActionResult
|
||
{
|
||
public function __construct(
|
||
public ?Response $response,
|
||
public ?array $matchIdAndMatch,
|
||
) {
|
||
}
|
||
|
||
/** @param array{matchId: string, match: MatchState} $pair */
|
||
public static function ok(array $pair): self
|
||
{
|
||
return new self(null, $pair);
|
||
}
|
||
|
||
public static function reject(Response $response): self
|
||
{
|
||
return new self($response, null);
|
||
}
|
||
}
|
||
|
||
final class MatchActionSupport
|
||
{
|
||
public function __construct(private readonly string $secret)
|
||
{
|
||
}
|
||
|
||
public function verify(Request $request): MatchActionResult
|
||
{
|
||
$submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? '');
|
||
$expected = (string) ($request->cookies['__csrf'] ?? '');
|
||
$secret = (string) ($request->server['__csrf_secret'] ?? '');
|
||
|
||
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
|
||
return MatchActionResult::reject(Response::json(403, ['error' => 'csrf']));
|
||
}
|
||
|
||
$payload = json_decode($request->rawBody, true);
|
||
if (!is_array($payload)) {
|
||
return MatchActionResult::reject(Response::json(400, ['errors' => ['Malformed JSON.']]));
|
||
}
|
||
|
||
$matchId = (string) ($payload['matchId'] ?? '');
|
||
$matchData = $payload['match'] ?? null;
|
||
if (!is_array($matchData)) {
|
||
return MatchActionResult::reject(Response::json(400, ['errors' => ['Match is required.']]));
|
||
}
|
||
|
||
try {
|
||
$match = ScenarioSerializer::matchFromArray($matchData);
|
||
} catch (\JsonException $exception) {
|
||
return MatchActionResult::reject(Response::json(400, ['errors' => ['Malformed JSON.']]));
|
||
} catch (\InvalidArgumentException $exception) {
|
||
return MatchActionResult::reject(Response::json(400, ['errors' => [$exception->getMessage()]]));
|
||
}
|
||
|
||
$supplied = (string) ($request->server['HTTP_X_TURN_TOKEN'] ?? '');
|
||
if (!TurnToken::verify(
|
||
$this->secret,
|
||
$matchId,
|
||
$match->activeTeamId,
|
||
$match->round,
|
||
count($match->actionLog),
|
||
$supplied,
|
||
)) {
|
||
return MatchActionResult::reject(Response::json(409, ['error' => 'turn']));
|
||
}
|
||
|
||
return MatchActionResult::ok(['matchId' => $matchId, 'match' => $match]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `vendor/bin/phpunit tests/Unit/Http/MatchActionSupportTest.php`
|
||
Expected: PASS, 4 tests.
|
||
|
||
- [ ] **Step 5: Run the quality suite and commit**
|
||
|
||
Run: `composer check`
|
||
Expected: all checks green.
|
||
|
||
```powershell
|
||
git add src/Http/MatchActionSupport.php tests/Unit/Http/MatchActionSupportTest.php
|
||
git commit -m "feat: add MatchActionSupport for shared CSRF/turn/state verification"
|
||
```
|
||
|
||
### Task 10: Four per-verb action handlers (move / attack / ability / end-turn)
|
||
|
||
This task creates all four action handlers in a single commit because they share a structure enforced by the same helper. Each handler is small (~20 lines).
|
||
|
||
**Files:**
|
||
- Create: `src/Http/Handlers/PostMatchMove.php`
|
||
- Create: `src/Http/Handlers/PostMatchAttack.php`
|
||
- Create: `src/Http/Handlers/PostMatchAbility.php`
|
||
- Create: `src/Http/Handlers/PostMatchEndTurn.php`
|
||
- Test: `tests/Integration/PostMatchActionTest.php` (one test class with one happy + one rejection case per verb)
|
||
|
||
**Interfaces (each handler):**
|
||
- Consumes: the request body `{matchId, match, ...actionParams}` and the `MatchActionSupport` (constructed with the secret from `$request->server['__csrf_secret']`).
|
||
- Produces: `Response::json(200, ['match' => <serialized>, 'turnToken' => <hex>, 'winnerTeamId' => <string|null>])` on success.
|
||
- Produces (rejection): the `Response` from `MatchActionResult::reject(...)` unchanged (handler does not swallow it).
|
||
- Produces (engine error): `Response::json(409, ['error' => <reason>])` on `CombatException`. The match in the body is the *incoming* match (unchanged), so the client can confirm the rejection didn't partially mutate state.
|
||
|
||
Each handler follows the same shape:
|
||
|
||
```php
|
||
final class PostMatchVerb
|
||
{
|
||
public function __construct(private readonly string $secret) {}
|
||
|
||
public function handle(Request $request, array $params): Response
|
||
{
|
||
$support = new MatchActionSupport($this->secret);
|
||
$result = $support->verify($request);
|
||
if ($result->response !== null) {
|
||
return $result->response;
|
||
}
|
||
$matchId = $result->matchIdAndMatch['matchId'];
|
||
$match = $result->matchIdAndMatch['match'];
|
||
try {
|
||
$next = (new CombatEngine())->verb($match, ...);
|
||
} catch (CombatException $exception) {
|
||
return Response::json(409, [
|
||
'error' => $exception->getMessage(),
|
||
'match' => ScenarioSerializer::matchToArray($match),
|
||
'turnToken' => TurnToken::issue($this->secret, $matchId, $match->activeTeamId, $match->round, count($match->actionLog)),
|
||
]);
|
||
}
|
||
return Response::json(200, [
|
||
'match' => ScenarioSerializer::matchToArray($next),
|
||
'turnToken' => TurnToken::issue($this->secret, $matchId, $next->activeTeamId, $next->round, count($next->actionLog)),
|
||
'winnerTeamId' => $next->winnerTeamId,
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1: Implement `PostMatchMove`**
|
||
|
||
Create `src/Http/Handlers/PostMatchMove.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Http\Handlers;
|
||
|
||
use BattleForge\Application\ScenarioSerializer;
|
||
use BattleForge\Application\TurnToken;
|
||
use BattleForge\Domain\CombatEngine;
|
||
use BattleForge\Domain\CombatException;
|
||
use BattleForge\Domain\Position;
|
||
use BattleForge\Http\MatchActionSupport;
|
||
use BattleForge\Http\Request;
|
||
use BattleForge\Http\Response;
|
||
|
||
final class PostMatchMove
|
||
{
|
||
public function __construct(private readonly string $secret)
|
||
{
|
||
}
|
||
|
||
/** @param array<string, string> $params */
|
||
public function handle(Request $request, array $params): Response
|
||
{
|
||
$support = new MatchActionSupport($this->secret);
|
||
$result = $support->verify($request);
|
||
if ($result->response !== null) {
|
||
return $result->response;
|
||
}
|
||
$matchId = $result->matchIdAndMatch['matchId'];
|
||
$match = $result->matchIdAndMatch['match'];
|
||
|
||
$payload = json_decode($request->rawBody, true);
|
||
$unitId = (string) ($payload['unitId'] ?? '');
|
||
$x = (int) ($payload['x'] ?? -1);
|
||
$y = (int) ($payload['y'] ?? -1);
|
||
|
||
try {
|
||
$next = (new CombatEngine())->move($match, $unitId, new Position($x, $y));
|
||
} catch (CombatException $exception) {
|
||
return self::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
|
||
}
|
||
|
||
return self::successResponse($this->secret, $matchId, $next);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public static function successResponse(string $secret, string $matchId, \BattleForge\Domain\MatchState $next): array
|
||
{
|
||
return [
|
||
'match' => ScenarioSerializer::matchToArray($next),
|
||
'turnToken' => TurnToken::issue($secret, $matchId, $next->activeTeamId, $next->round, count($next->actionLog)),
|
||
'winnerTeamId' => $next->winnerTeamId,
|
||
];
|
||
}
|
||
|
||
public static function rejectionResponse(string $secret, string $matchId, \BattleForge\Domain\MatchState $match, string $reason): Response
|
||
{
|
||
return Response::json(409, [
|
||
'error' => $reason,
|
||
'match' => ScenarioSerializer::matchToArray($match),
|
||
'turnToken' => TurnToken::issue($secret, $matchId, $match->activeTeamId, $match->round, count($match->actionLog)),
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Implement `PostMatchAttack`**
|
||
|
||
Create `src/Http/Handlers/PostMatchAttack.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Http\Handlers;
|
||
|
||
use BattleForge\Domain\CombatEngine;
|
||
use BattleForge\Domain\CombatException;
|
||
use BattleForge\Http\MatchActionSupport;
|
||
use BattleForge\Http\Request;
|
||
use BattleForge\Http\Response;
|
||
|
||
final class PostMatchAttack
|
||
{
|
||
public function __construct(private readonly string $secret)
|
||
{
|
||
}
|
||
|
||
/** @param array<string, string> $params */
|
||
public function handle(Request $request, array $params): Response
|
||
{
|
||
$support = new MatchActionSupport($this->secret);
|
||
$result = $support->verify($request);
|
||
if ($result->response !== null) {
|
||
return $result->response;
|
||
}
|
||
$matchId = $result->matchIdAndMatch['matchId'];
|
||
$match = $result->matchIdAndMatch['match'];
|
||
|
||
$payload = json_decode($request->rawBody, true);
|
||
$attackerId = (string) ($payload['attackerId'] ?? '');
|
||
$targetId = (string) ($payload['targetId'] ?? '');
|
||
|
||
try {
|
||
$next = (new CombatEngine())->attack($match, $attackerId, $targetId);
|
||
} catch (CombatException $exception) {
|
||
return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
|
||
}
|
||
|
||
return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `PostMatchAbility`**
|
||
|
||
Create `src/Http/Handlers/PostMatchAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Http\Handlers;
|
||
|
||
use BattleForge\Domain\CombatEngine;
|
||
use BattleForge\Domain\CombatException;
|
||
use BattleForge\Domain\Position;
|
||
use BattleForge\Http\MatchActionSupport;
|
||
use BattleForge\Http\Request;
|
||
use BattleForge\Http\Response;
|
||
|
||
final class PostMatchAbility
|
||
{
|
||
public function __construct(private readonly string $secret)
|
||
{
|
||
}
|
||
|
||
/** @param array<string, string> $params */
|
||
public function handle(Request $request, array $params): Response
|
||
{
|
||
$support = new MatchActionSupport($this->secret);
|
||
$result = $support->verify($request);
|
||
if ($result->response !== null) {
|
||
return $result->response;
|
||
}
|
||
$matchId = $result->matchIdAndMatch['matchId'];
|
||
$match = $result->matchIdAndMatch['match'];
|
||
|
||
$payload = json_decode($request->rawBody, true);
|
||
$unitId = (string) ($payload['unitId'] ?? '');
|
||
$abilityId = (string) ($payload['abilityId'] ?? '');
|
||
$x = (int) ($payload['x'] ?? -1);
|
||
$y = (int) ($payload['y'] ?? -1);
|
||
|
||
try {
|
||
$next = (new CombatEngine())->useAbility($match, $unitId, $abilityId, new Position($x, $y));
|
||
} catch (CombatException $exception) {
|
||
return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
|
||
}
|
||
|
||
return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `PostMatchEndTurn`**
|
||
|
||
Create `src/Http/Handlers/PostMatchEndTurn.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Http\Handlers;
|
||
|
||
use BattleForge\Domain\CombatEngine;
|
||
use BattleForge\Domain\CombatException;
|
||
use BattleForge\Http\MatchActionSupport;
|
||
use BattleForge\Http\Request;
|
||
use BattleForge\Http\Response;
|
||
|
||
final class PostMatchEndTurn
|
||
{
|
||
public function __construct(private readonly string $secret)
|
||
{
|
||
}
|
||
|
||
/** @param array<string, string> $params */
|
||
public function handle(Request $request, array $params): Response
|
||
{
|
||
$support = new MatchActionSupport($this->secret);
|
||
$result = $support->verify($request);
|
||
if ($result->response !== null) {
|
||
return $result->response;
|
||
}
|
||
$matchId = $result->matchIdAndMatch['matchId'];
|
||
$match = $result->matchIdAndMatch['match'];
|
||
|
||
try {
|
||
$next = (new CombatEngine())->endTurn($match);
|
||
} catch (CombatException $exception) {
|
||
return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage());
|
||
}
|
||
|
||
return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Write the integration test**
|
||
|
||
Create `tests/Integration/PostMatchActionTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Tests\Integration;
|
||
|
||
use BattleForge\Application\ScenarioSerializer;
|
||
use BattleForge\Application\TurnToken;
|
||
use BattleForge\Domain\Archetype;
|
||
use BattleForge\Domain\Battlefield;
|
||
use BattleForge\Domain\MatchState;
|
||
use BattleForge\Domain\Position;
|
||
use BattleForge\Domain\UnitState;
|
||
use BattleForge\Http\CsrfToken;
|
||
use BattleForge\Http\Handlers\PostMatchAbility;
|
||
use BattleForge\Http\Handlers\PostMatchAttack;
|
||
use BattleForge\Http\Handlers\PostMatchEndTurn;
|
||
use BattleForge\Http\Handlers\PostMatchMove;
|
||
use BattleForge\Http\Request;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class PostMatchActionTest extends TestCase
|
||
{
|
||
private const SECRET = 'match-action-test-secret';
|
||
private const MATCH_ID = '0123456789abcdef';
|
||
|
||
public function testMoveHappyPathReturnsTheUpdatedMatchAndAFreshTurnToken(): void
|
||
{
|
||
$match = $this->freshMatch();
|
||
$token = $this->tokenFor($match);
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
|
||
$response = (new PostMatchMove(self::SECRET))->handle(
|
||
$this->buildRequest(
|
||
match: $match,
|
||
csrf: $csrf,
|
||
turnToken: $token,
|
||
cookies: ['__csrf' => $cookie],
|
||
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-1', 'x' => 1, 'y' => 0],
|
||
),
|
||
[],
|
||
);
|
||
|
||
self::assertSame(200, $response->status);
|
||
$body = json_decode($response->body, true);
|
||
self::assertSame('1:0', $body['match']['units'][0]['position']['x'] . ':' . $body['match']['units'][0]['position']['y']);
|
||
self::assertSame(1, $body['match']['units'][0]['actionsRemaining']);
|
||
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']);
|
||
self::assertNull($body['winnerTeamId']);
|
||
}
|
||
|
||
public function testMoveRejectsAnUnreachableDestinationAndDoesNotMutateTheMatch(): void
|
||
{
|
||
$match = $this->freshMatch();
|
||
$token = $this->tokenFor($match);
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
|
||
$response = (new PostMatchMove(self::SECRET))->handle(
|
||
$this->buildRequest(
|
||
match: $match,
|
||
csrf: $csrf,
|
||
turnToken: $token,
|
||
cookies: ['__csrf' => $cookie],
|
||
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-1', 'x' => 0, 'y' => 0],
|
||
),
|
||
[],
|
||
);
|
||
|
||
self::assertSame(409, $response->status);
|
||
$body = json_decode($response->body, true);
|
||
self::assertStringContainsString('not reachable', $body['error']);
|
||
self::assertSame('0:0', $body['match']['units'][0]['position']['x'] . ':' . $body['match']['units'][0]['position']['y']);
|
||
}
|
||
|
||
public function testAttackHappyPathReducesTargetHealth(): void
|
||
{
|
||
$match = $this->freshMatch();
|
||
$matchArray = ScenarioSerializer::matchToArray($match);
|
||
$matchArray['units'][0]['position'] = ['x' => 6, 'y' => 7];
|
||
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||
$token = $this->tokenFor($match);
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
|
||
$response = (new PostMatchAttack(self::SECRET))->handle(
|
||
$this->buildRequest(
|
||
match: $match,
|
||
csrf: $csrf,
|
||
turnToken: $token,
|
||
cookies: ['__csrf' => $cookie],
|
||
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1'],
|
||
),
|
||
[],
|
||
);
|
||
|
||
self::assertSame(200, $response->status);
|
||
$body = json_decode($response->body, true);
|
||
$bravo = null;
|
||
foreach ($body['match']['units'] as $unit) {
|
||
if ($unit['id'] === 'bravo-1') { $bravo = $unit; break; }
|
||
}
|
||
self::assertNotNull($bravo);
|
||
self::assertLessThan(10, $bravo['health']);
|
||
}
|
||
|
||
public function testAttackRejectsAnOutOfRangeTarget(): void
|
||
{
|
||
$match = $this->freshMatch();
|
||
$token = $this->tokenFor($match);
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
|
||
$response = (new PostMatchAttack(self::SECRET))->handle(
|
||
$this->buildRequest(
|
||
match: $match,
|
||
csrf: $csrf,
|
||
turnToken: $token,
|
||
cookies: ['__csrf' => $cookie],
|
||
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1'],
|
||
),
|
||
[],
|
||
);
|
||
|
||
self::assertSame(409, $response->status);
|
||
}
|
||
|
||
public function testAbilityHappyPathHealsAnAlly(): void
|
||
{
|
||
$match = $this->freshMatch();
|
||
$matchArray = ScenarioSerializer::matchToArray($match);
|
||
foreach ($matchArray['units'] as &$unit) {
|
||
if ($unit['id'] === 'alpha-3') {
|
||
$unit['archetype'] = 'support';
|
||
$unit['abilities'] = ['heal'];
|
||
}
|
||
}
|
||
unset($unit);
|
||
foreach ($matchArray['units'] as &$unit) {
|
||
if ($unit['id'] === 'alpha-2') {
|
||
$unit['health'] = 2;
|
||
}
|
||
}
|
||
unset($unit);
|
||
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||
$token = $this->tokenFor($match);
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
|
||
$response = (new PostMatchAbility(self::SECRET))->handle(
|
||
$this->buildRequest(
|
||
match: $match,
|
||
csrf: $csrf,
|
||
turnToken: $token,
|
||
cookies: ['__csrf' => $cookie],
|
||
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-3', 'abilityId' => 'heal', 'x' => 1, 'y' => 0],
|
||
),
|
||
[],
|
||
);
|
||
|
||
self::assertSame(200, $response->status);
|
||
$body = json_decode($response->body, true);
|
||
$healed = null;
|
||
foreach ($body['match']['units'] as $unit) {
|
||
if ($unit['id'] === 'alpha-2') { $healed = $unit; break; }
|
||
}
|
||
self::assertGreaterThan(2, $healed['health']);
|
||
}
|
||
|
||
public function testAbilityRejectsATargetOutsideRange(): void
|
||
{
|
||
$match = $this->freshMatch();
|
||
$matchArray = ScenarioSerializer::matchToArray($match);
|
||
foreach ($matchArray['units'] as &$unit) {
|
||
if ($unit['id'] === 'alpha-3') {
|
||
$unit['archetype'] = 'support';
|
||
$unit['abilities'] = ['heal'];
|
||
}
|
||
}
|
||
unset($unit);
|
||
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||
$token = $this->tokenFor($match);
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
|
||
$response = (new PostMatchAbility(self::SECRET))->handle(
|
||
$this->buildRequest(
|
||
match: $match,
|
||
csrf: $csrf,
|
||
turnToken: $token,
|
||
cookies: ['__csrf' => $cookie],
|
||
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-3', 'abilityId' => 'heal', 'x' => 7, 'y' => 7],
|
||
),
|
||
[],
|
||
);
|
||
|
||
self::assertSame(409, $response->status);
|
||
}
|
||
|
||
public function testEndTurnHappyPathSwitchesTheActiveTeamAndIncrementsTheRoundAfterBothSides(): void
|
||
{
|
||
$match = $this->freshMatch();
|
||
$token = $this->tokenFor($match);
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
|
||
$response = (new PostMatchEndTurn(self::SECRET))->handle(
|
||
$this->buildRequest(
|
||
match: $match,
|
||
csrf: $csrf,
|
||
turnToken: $token,
|
||
cookies: ['__csrf' => $cookie],
|
||
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match)],
|
||
),
|
||
[],
|
||
);
|
||
|
||
self::assertSame(200, $response->status);
|
||
$body = json_decode($response->body, true);
|
||
self::assertSame('bravo', $body['match']['activeTeamId']);
|
||
self::assertSame(1, $body['match']['round']);
|
||
}
|
||
|
||
public function testEndTurnRejectsARequestWithAStaleTurnToken(): void
|
||
{
|
||
$match = $this->freshMatch();
|
||
$stale = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 1, 0);
|
||
$wrongRound = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 2, 0);
|
||
$good = $this->tokenFor($match);
|
||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||
|
||
$staleResponse = (new PostMatchEndTurn(self::SECRET))->handle(
|
||
$this->buildRequest(
|
||
match: $match,
|
||
csrf: $csrf,
|
||
turnToken: $stale,
|
||
cookies: ['__csrf' => $cookie],
|
||
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match)],
|
||
),
|
||
[],
|
||
);
|
||
self::assertSame(409, $staleResponse->status);
|
||
$body = json_decode($staleResponse->body, true);
|
||
self::assertSame('turn', $body['error'] ?? null);
|
||
|
||
$wrongRoundResponse = (new PostMatchEndTurn(self::SECRET))->handle(
|
||
$this->buildRequest(
|
||
match: $match,
|
||
csrf: $csrf,
|
||
turnToken: $wrongRound,
|
||
cookies: ['__csrf' => $cookie],
|
||
body: ['matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match)],
|
||
),
|
||
[],
|
||
);
|
||
self::assertSame(409, $wrongRoundResponse->status);
|
||
}
|
||
|
||
private function freshMatch(): MatchState
|
||
{
|
||
return new MatchState(
|
||
new Battlefield(8, 8),
|
||
[
|
||
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender),
|
||
new UnitState('alpha-2', 'alpha', new Position(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.
|
||
|
||
```powershell
|
||
git add src/Http/Handlers/PostMatchMove.php src/Http/Handlers/PostMatchAttack.php src/Http/Handlers/PostMatchAbility.php src/Http/Handlers/PostMatchEndTurn.php tests/Integration/PostMatchActionTest.php
|
||
git commit -m "feat: add four per-verb match action handlers"
|
||
```
|
||
|
||
### Task 11: Wire the new routes into `public/index.php`
|
||
|
||
**Files:**
|
||
- Modify: `public/index.php`
|
||
- Modify: `src/Http/Router.php` (only if a new helper is needed; otherwise the existing `add()` method suffices)
|
||
|
||
**Interfaces:**
|
||
- Consumes: the six new handlers (two for bundled scenarios, one for the match view, four for match actions), each constructed with the secret / scenarios dir from the front controller.
|
||
- Produces: six new route registrations.
|
||
|
||
- [ ] **Step 1: Examine the existing `Router` API**
|
||
|
||
Run: `cat src/Http/Router.php | head -60`
|
||
Expected: the existing `Router::add(string $method, string $pattern, callable $handler)` is sufficient. No modification to `Router` is required.
|
||
|
||
- [ ] **Step 2: Add the new handlers to the front controller**
|
||
|
||
In `public/index.php`, immediately after the existing handler closures (the `$homePage`, `$getTeam`, etc. block ending with `$getUploadedAsset`), add:
|
||
|
||
```php
|
||
$scenariosDir = __DIR__ . '/assets/scenarios';
|
||
$getBundledList = static function (Request $r, array $p) use ($scenariosDir): Response {
|
||
return (new GetBundledScenarios($scenariosDir))->handle($r, $p);
|
||
};
|
||
$getBundledOne = static function (Request $r, array $p) use ($scenariosDir): Response {
|
||
return (new GetBundledScenario($scenariosDir))->handle($r, $p);
|
||
};
|
||
$getMatchView = static fn (Request $r, array $p): Response => (new GetMatchView())->handle($r, $p);
|
||
$secret = (string) ($_SERVER['__csrf_secret'] ?? '');
|
||
$postMatchMove = static function (Request $r, array $p) use ($secret): Response {
|
||
return (new PostMatchMove($secret))->handle($r, $p);
|
||
};
|
||
$postMatchAttack = static function (Request $r, array $p) use ($secret): Response {
|
||
return (new PostMatchAttack($secret))->handle($r, $p);
|
||
};
|
||
$postMatchAbility = static function (Request $r, array $p) use ($secret): Response {
|
||
return (new PostMatchAbility($secret))->handle($r, $p);
|
||
};
|
||
$postMatchEndTurn = static function (Request $r, array $p) use ($secret): Response {
|
||
return (new PostMatchEndTurn($secret))->handle($r, $p);
|
||
};
|
||
```
|
||
|
||
Add the `use BattleForge\Http\Handlers\GetBundledScenario;` and `use BattleForge\Http\Handlers\GetBundledScenarios;` and the four `use BattleForge\Http\Handlers\PostMatch*;` lines to the `use` block at the top of the file.
|
||
|
||
Then add the six new route registrations to the router (alongside the existing ones), in this order:
|
||
|
||
```php
|
||
$router->add('GET', '/scenarios/bundled', $getBundledList);
|
||
$router->add('GET', '/scenarios/bundled/{id}', $getBundledOne);
|
||
$router->add('GET', '/matches/current', $getMatchView);
|
||
$router->add('POST', '/matches/current/move', $postMatchMove);
|
||
$router->add('POST', '/matches/current/attack', $postMatchAttack);
|
||
$router->add('POST', '/matches/current/ability', $postMatchAbility);
|
||
$router->add('POST', '/matches/current/end-turn', $postMatchEndTurn);
|
||
```
|
||
|
||
Note: `GetHomePage` is the only handler that needs the scenarios dir now, so add a one-arg constructor call. Replace the existing `$homePage` closure with:
|
||
|
||
```php
|
||
$homePage = static function (Request $r, array $p) use ($scenariosDir): Response {
|
||
return (new GetHomePage($scenariosDir))->handle($r, $p);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: Boot the dev server and hit `/scenarios/bundled`**
|
||
|
||
Run in one terminal: `php -S 127.0.0.1:8765 -t public`
|
||
|
||
In another terminal: `curl -i http://127.0.0.1:8765/scenarios/bundled`
|
||
|
||
Expected: HTTP 200, JSON body with three entries (skirmish, hold-the-line, last-stand), sorted by id. The `Content-Type: application/json` header is set. If a 500 is returned, the dev server's stderr shows the actual error.
|
||
|
||
Stop the dev server (Ctrl+C).
|
||
|
||
- [ ] **Step 4: Run the full quality suite and commit**
|
||
|
||
Run: `composer check`
|
||
Expected: all checks green.
|
||
|
||
```powershell
|
||
git add public/index.php
|
||
git commit -m "feat: register bundled-scenario and match-action routes"
|
||
```
|
||
|
||
### Task 12: `public/js/match.js` renderer and styles
|
||
|
||
**Files:**
|
||
- Create: `public/js/match.js`
|
||
- Modify: `public/js/storage.js` (add a `startBundledMatch(id)` helper and switch the existing `startMatch` to use the new envelope `{matchId, match}`)
|
||
- Modify: `public/assets/styles.css` (add `.bf-match`, `.bf-unit`, `.bf-active`, `.bf-inactive`, `.bf-toast` rules)
|
||
|
||
**Interfaces (match.js):**
|
||
- On `DOMContentLoaded`:
|
||
- Read `match:current` from `localStorage`. The envelope is `{matchId: string, match: <serialized MatchState>}` (set by `startBundledMatch` and by the action response).
|
||
- If absent, render a "no match" state with a link to `/`.
|
||
- Otherwise, render the grid (one `<button class="bf-tile" data-bf-x data-bf-y data-bf-terrain>` per tile), the units (one `<button class="bf-unit" data-bf-unit-id data-bf-team>` per living unit placed inside its tile), the action log (last 10 entries), the active-team banner, and the End Turn button.
|
||
- Action modes: `move` (default), `attack`, `ability`. Each mode disables the others' action buttons; clicking a tile or unit attempts the action.
|
||
- `dispatch({verb, body})` posts to `/matches/current/{verb}` with the latest `match`, `matchId`, the `X-CSRF-Token` and `X-Turn-Token` headers, and the verb-specific body params. On 200, replaces the envelope, re-renders. On 409 with `winnerTeamId` in the body, switches to the result state. On 409 with `error: 'turn'`, re-reads `localStorage` and re-renders. On 400/403/500, surfaces the error.
|
||
- `toast(message)`: appends a `<div class="bf-toast">` to `#bf-toast-host` and removes it after 3s.
|
||
- All dynamic text is set via `textContent`; no `innerHTML`.
|
||
|
||
- [ ] **Step 1: Extend `storage.js` with the bundle-bootstrap helper**
|
||
|
||
Add the following function inside `public/js/storage.js`, immediately before the `window.bfStorage = { … }` block:
|
||
|
||
```javascript
|
||
async function startBundledMatch(id) {
|
||
if (typeof id !== 'string' || !/^[a-z0-9-]+$/.test(id)) {
|
||
return;
|
||
}
|
||
const csrf = getCsrfToken();
|
||
let manifest;
|
||
try {
|
||
const res = await fetch(`/scenarios/bundled/${encodeURIComponent(id)}`);
|
||
if (!res.ok) {
|
||
// eslint-disable-next-line no-alert
|
||
alert(`Could not load scenario: HTTP ${res.status}`);
|
||
return;
|
||
}
|
||
manifest = await res.json();
|
||
} catch (e) {
|
||
// eslint-disable-next-line no-alert
|
||
alert(`Network error: ${e.message}`);
|
||
return;
|
||
}
|
||
let response;
|
||
try {
|
||
response = await fetch(`/scenarios/${encodeURIComponent(id)}/start`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': csrf,
|
||
},
|
||
body: JSON.stringify(manifest),
|
||
});
|
||
} catch (e) {
|
||
// eslint-disable-next-line no-alert
|
||
alert(`Network error: ${e.message}`);
|
||
return;
|
||
}
|
||
if (!response.ok) {
|
||
// eslint-disable-next-line no-alert
|
||
alert(`Start match failed: HTTP ${response.status}`);
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
if (data.match && data.matchId && data.turnToken) {
|
||
setCurrentMatch({ matchId: data.matchId, match: data.match, turnToken: data.turnToken });
|
||
window.location.href = '/matches/current';
|
||
} else {
|
||
// eslint-disable-next-line no-alert
|
||
alert(`Start match failed: ${(data.errors || ['unknown']).join(', ')}`);
|
||
}
|
||
}
|
||
```
|
||
|
||
Update the `window.bfStorage` block to include `startBundledMatch`:
|
||
|
||
```javascript
|
||
window.bfStorage = {
|
||
load,
|
||
save,
|
||
delete: remove,
|
||
listAll,
|
||
currentMatch,
|
||
setCurrentMatch,
|
||
removeCurrentMatch,
|
||
startBundledMatch,
|
||
};
|
||
```
|
||
|
||
Update the `DOMContentLoaded` block to wire the bundled buttons (the home page now renders `<button data-bf-bundle="...">` for each entry):
|
||
|
||
```javascript
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
populateRecentScenarios();
|
||
prefillTeamEditor();
|
||
const startBtn = document.getElementById('bf-start-match');
|
||
if (startBtn) {
|
||
startBtn.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
startMatch();
|
||
});
|
||
}
|
||
document.querySelectorAll('[data-bf-bundle]').forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
const id = button.getAttribute('data-bf-bundle');
|
||
if (id) {
|
||
startBundledMatch(id);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Create `public/js/match.js`**
|
||
|
||
Create `public/js/match.js`:
|
||
|
||
```javascript
|
||
const CSRF_META = 'meta[name="csrf-token"]';
|
||
const ACTIVE_TEAM_LABEL = { alpha: 'Alpha', bravo: 'Bravo' };
|
||
|
||
function getCsrfToken() {
|
||
const meta = document.querySelector(CSRF_META);
|
||
return meta ? meta.getAttribute('content') : '';
|
||
}
|
||
|
||
function getEnvelope() {
|
||
const raw = localStorage.getItem('match:current');
|
||
if (raw === null) return null;
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (!parsed || typeof parsed !== 'object' || !parsed.match || !parsed.matchId) return null;
|
||
return parsed;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function setEnvelope(envelope) {
|
||
localStorage.setItem('match:current', JSON.stringify(envelope));
|
||
}
|
||
|
||
function removeEnvelope() {
|
||
localStorage.removeItem('match:current');
|
||
}
|
||
|
||
function el(tag, attrs = {}, text = null) {
|
||
const node = document.createElement(tag);
|
||
Object.entries(attrs).forEach(([k, v]) => {
|
||
if (v !== null && v !== undefined) {
|
||
node.setAttribute(k, String(v));
|
||
}
|
||
});
|
||
if (text !== null) {
|
||
node.appendChild(document.createTextNode(String(text)));
|
||
}
|
||
return node;
|
||
}
|
||
|
||
function renderNoMatch(root) {
|
||
while (root.firstChild) root.removeChild(root.firstChild);
|
||
const empty = el('p', { class: 'bf-empty' }, 'No match in progress.');
|
||
const link = el('a', { href: '/' }, 'Return to home');
|
||
root.appendChild(empty);
|
||
root.appendChild(link);
|
||
}
|
||
|
||
function renderHeader(match) {
|
||
const header = document.getElementById('bf-match-header');
|
||
const team = document.getElementById('bf-active-team');
|
||
const round = document.getElementById('bf-round');
|
||
header.setAttribute('data-bf-active-team', match.activeTeamId);
|
||
header.setAttribute('data-bf-round', String(match.round));
|
||
while (team.firstChild) team.removeChild(team.firstChild);
|
||
team.appendChild(document.createTextNode(ACTIVE_TEAM_LABEL[match.activeTeamId] || match.activeTeamId));
|
||
while (round.firstChild) round.removeChild(round.firstChild);
|
||
round.appendChild(document.createTextNode(String(match.round)));
|
||
}
|
||
|
||
function renderGrid(root, match) {
|
||
while (root.firstChild) root.removeChild(root.firstChild);
|
||
const { width, height, terrain } = match.battlefield;
|
||
root.setAttribute('data-bf-width', String(width));
|
||
root.setAttribute('data-bf-height', String(height));
|
||
|
||
const unitsByKey = {};
|
||
match.units.forEach((unit) => {
|
||
if (unit.health > 0) {
|
||
unitsByKey[`${unit.position.x}:${unit.position.y}`] = unit;
|
||
}
|
||
});
|
||
|
||
for (let y = 0; y < height; y += 1) {
|
||
for (let x = 0; x < width; x += 1) {
|
||
const key = `${x}:${y}`;
|
||
const terrainType = (terrain && terrain[key]) || 'open';
|
||
const tile = el('button', { type: 'button', class: 'bf-tile', 'data-bf-x': x, 'data-bf-y': y, 'data-bf-terrain': terrainType });
|
||
const unit = unitsByKey[key];
|
||
if (unit) {
|
||
const isActive = unit.teamId === match.activeTeamId;
|
||
const isWinner = match.winnerTeamId && unit.teamId === match.winnerTeamId;
|
||
const cls = ['bf-unit', `bf-unit--${unit.teamId}`];
|
||
if (isActive) cls.push('bf-unit--active');
|
||
else cls.push('bf-unit--inactive');
|
||
if (isWinner) cls.push('bf-unit--winner');
|
||
const unitBtn = el('button', {
|
||
type: 'button',
|
||
class: cls.join(' '),
|
||
'data-bf-unit-id': unit.id,
|
||
'data-bf-team': unit.teamId,
|
||
}, `${unit.id.split('-')[1] || unit.id} (${unit.health})`);
|
||
tile.appendChild(unitBtn);
|
||
}
|
||
root.appendChild(tile);
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderLog(root, match) {
|
||
while (root.firstChild) root.removeChild(root.firstChild);
|
||
const log = Array.isArray(match.actionLog) ? match.actionLog.slice(-10) : [];
|
||
log.forEach((entry) => {
|
||
root.appendChild(el('p', { class: 'bf-log-entry' }, entry));
|
||
});
|
||
}
|
||
|
||
function renderResult(match) {
|
||
const result = document.getElementById('bf-result');
|
||
const winner = document.getElementById('bf-winner');
|
||
while (winner.firstChild) winner.removeChild(winner.firstChild);
|
||
winner.appendChild(document.createTextNode(ACTIVE_TEAM_LABEL[match.winnerTeamId] || match.winnerTeamId));
|
||
result.removeAttribute('hidden');
|
||
const endBtn = document.getElementById('bf-end-turn');
|
||
if (endBtn) endBtn.setAttribute('disabled', 'disabled');
|
||
['bf-action-move', 'bf-action-attack', 'bf-action-ability'].forEach((id) => {
|
||
const btn = document.getElementById(id);
|
||
if (btn) btn.setAttribute('disabled', 'disabled');
|
||
});
|
||
}
|
||
|
||
function renderState() {
|
||
const envelope = getEnvelope();
|
||
const grid = document.getElementById('bf-grid');
|
||
if (!envelope) {
|
||
renderNoMatch(grid);
|
||
return;
|
||
}
|
||
const match = envelope.match;
|
||
renderHeader(match);
|
||
renderGrid(grid, match);
|
||
renderLog(document.getElementById('bf-log'), match);
|
||
if (match.winnerTeamId) {
|
||
renderResult(match);
|
||
} else {
|
||
document.getElementById('bf-result').setAttribute('hidden', 'hidden');
|
||
}
|
||
}
|
||
|
||
function toast(message) {
|
||
const host = document.getElementById('bf-toast-host');
|
||
if (!host) return;
|
||
const node = el('div', { class: 'bf-toast' }, message);
|
||
host.appendChild(node);
|
||
setTimeout(() => node.remove(), 3000);
|
||
}
|
||
|
||
async function dispatch(verb, params) {
|
||
const envelope = getEnvelope();
|
||
if (!envelope) {
|
||
toast('No match in progress.');
|
||
return;
|
||
}
|
||
const body = { matchId: envelope.matchId, match: envelope.match, ...params };
|
||
try {
|
||
const response = await fetch(`/matches/current/${verb}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': getCsrfToken(),
|
||
'X-Turn-Token': envelope.turnToken || '',
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (response.status === 200) {
|
||
setEnvelope({
|
||
matchId: envelope.matchId,
|
||
match: data.match,
|
||
turnToken: data.turnToken,
|
||
});
|
||
renderState();
|
||
return;
|
||
}
|
||
if (response.status === 409) {
|
||
if (data.error === 'turn') {
|
||
const fresh = getEnvelope();
|
||
if (fresh) {
|
||
setEnvelope(fresh);
|
||
renderState();
|
||
toast('Turn has changed; resynced.');
|
||
} else {
|
||
renderNoMatch(document.getElementById('bf-grid'));
|
||
}
|
||
return;
|
||
}
|
||
if (data.match) {
|
||
setEnvelope({ matchId: envelope.matchId, match: data.match, turnToken: data.turnToken || envelope.turnToken });
|
||
renderState();
|
||
}
|
||
if (data.error) toast(data.error);
|
||
return;
|
||
}
|
||
if (data.error) {
|
||
toast(data.error);
|
||
} else {
|
||
toast(`Action failed: HTTP ${response.status}`);
|
||
}
|
||
} catch (e) {
|
||
toast(`Network error: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
let activeMode = 'move';
|
||
let selectedUnitId = null;
|
||
|
||
function setMode(mode) {
|
||
activeMode = mode;
|
||
['move', 'attack', 'ability'].forEach((m) => {
|
||
const btn = document.getElementById(`bf-action-${m}`);
|
||
if (btn) {
|
||
if (m === mode) btn.classList.add('bf-action--active');
|
||
else btn.classList.remove('bf-action--active');
|
||
}
|
||
});
|
||
renderAbilityOptions();
|
||
}
|
||
|
||
function renderAbilityOptions() {
|
||
const host = document.getElementById('bf-ability-options');
|
||
if (!host) return;
|
||
while (host.firstChild) host.removeChild(host.firstChild);
|
||
if (activeMode !== 'ability' || !selectedUnitId) {
|
||
return;
|
||
}
|
||
const envelope = getEnvelope();
|
||
if (!envelope) return;
|
||
const unit = envelope.match.units.find((u) => u.id === selectedUnitId);
|
||
if (!unit || !Array.isArray(unit.abilities) || unit.abilities.length === 0) {
|
||
host.appendChild(el('p', { class: 'bf-empty' }, 'No abilities.'));
|
||
return;
|
||
}
|
||
unit.abilities.forEach((abilityId) => {
|
||
const radio = el('input', { type: 'radio', name: 'bf-ability', value: abilityId, id: `bf-ability-${abilityId}` });
|
||
const label = el('label', { for: `bf-ability-${abilityId}` }, abilityId);
|
||
const wrap = el('span', {});
|
||
wrap.appendChild(radio);
|
||
wrap.appendChild(label);
|
||
host.appendChild(wrap);
|
||
});
|
||
}
|
||
|
||
function onTileClick(event) {
|
||
const tile = event.target.closest('.bf-tile');
|
||
if (!tile) return;
|
||
const x = Number(tile.getAttribute('data-bf-x'));
|
||
const y = Number(tile.getAttribute('data-bf-y'));
|
||
const unitBtn = tile.querySelector('.bf-unit');
|
||
if (unitBtn) {
|
||
const unitId = unitBtn.getAttribute('data-bf-unit-id');
|
||
const team = unitBtn.getAttribute('data-bf-team');
|
||
const envelope = getEnvelope();
|
||
if (!envelope) return;
|
||
if (team !== envelope.match.activeTeamId) {
|
||
toast('That unit is not on the active team.');
|
||
return;
|
||
}
|
||
if (activeMode === 'attack') {
|
||
toast('Pick a friendly unit to attack with, then click an enemy.');
|
||
selectedUnitId = unitId;
|
||
updateSelectedUnitIndicator();
|
||
return;
|
||
}
|
||
selectedUnitId = unitId;
|
||
updateSelectedUnitIndicator();
|
||
return;
|
||
}
|
||
if (activeMode === 'move' && selectedUnitId) {
|
||
dispatch('move', { unitId: selectedUnitId, x, y });
|
||
return;
|
||
}
|
||
if (activeMode === 'attack' && selectedUnitId) {
|
||
const envelope = getEnvelope();
|
||
if (!envelope) return;
|
||
const attacker = envelope.match.units.find((u) => u.id === selectedUnitId);
|
||
if (!attacker) return;
|
||
const target = envelope.match.units.find((u) => u.position.x === x && u.position.y === y && u.teamId !== attacker.teamId);
|
||
if (!target) {
|
||
toast('No enemy on that tile.');
|
||
return;
|
||
}
|
||
dispatch('attack', { attackerId: selectedUnitId, targetId: target.id });
|
||
return;
|
||
}
|
||
if (activeMode === 'ability' && selectedUnitId) {
|
||
const radio = document.querySelector('input[name="bf-ability"]:checked');
|
||
if (!radio) {
|
||
toast('Pick an ability first.');
|
||
return;
|
||
}
|
||
dispatch('ability', { unitId: selectedUnitId, abilityId: radio.value, x, y });
|
||
}
|
||
}
|
||
|
||
function onUnitClick(event) {
|
||
const unitBtn = event.target.closest('.bf-unit');
|
||
if (!unitBtn) return;
|
||
const unitId = unitBtn.getAttribute('data-bf-unit-id');
|
||
const team = unitBtn.getAttribute('data-bf-team');
|
||
const envelope = getEnvelope();
|
||
if (!envelope) return;
|
||
if (activeMode === 'attack' && team !== envelope.match.activeTeamId) {
|
||
if (!selectedUnitId) {
|
||
toast('Pick a friendly attacker first.');
|
||
return;
|
||
}
|
||
dispatch('attack', { attackerId: selectedUnitId, targetId: unitId });
|
||
return;
|
||
}
|
||
if (team === envelope.match.activeTeamId) {
|
||
selectedUnitId = unitId;
|
||
updateSelectedUnitIndicator();
|
||
return;
|
||
}
|
||
toast('That unit is not on the active team.');
|
||
}
|
||
|
||
function updateSelectedUnitIndicator() {
|
||
const node = document.getElementById('bf-selected-unit');
|
||
if (!node) return;
|
||
while (node.firstChild) node.removeChild(node.firstChild);
|
||
node.appendChild(document.createTextNode(selectedUnitId || 'none'));
|
||
}
|
||
|
||
function wireEvents() {
|
||
document.getElementById('bf-grid').addEventListener('click', (event) => {
|
||
const unitBtn = event.target.closest('.bf-unit');
|
||
if (unitBtn) {
|
||
onUnitClick(event);
|
||
} else {
|
||
onTileClick(event);
|
||
}
|
||
});
|
||
['move', 'attack', 'ability'].forEach((mode) => {
|
||
const btn = document.getElementById(`bf-action-${mode}`);
|
||
if (btn) btn.addEventListener('click', () => setMode(mode));
|
||
});
|
||
const endBtn = document.getElementById('bf-end-turn');
|
||
if (endBtn) {
|
||
endBtn.addEventListener('click', () => dispatch('end-turn', {}));
|
||
}
|
||
const returnHome = document.getElementById('bf-return-home');
|
||
if (returnHome) {
|
||
returnHome.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
removeEnvelope();
|
||
window.location.href = '/';
|
||
});
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
wireEvents();
|
||
setMode('move');
|
||
renderState();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Add styles for the match view**
|
||
|
||
Append the following rules to `public/assets/styles.css`:
|
||
|
||
```css
|
||
.bf-match { display: flex; gap: 1rem; }
|
||
.bf-grid { display: grid; gap: 1px; background: #ddd; padding: 1px; width: max-content; }
|
||
.bf-tile { width: 2.5rem; height: 2.5rem; border: 0; background: #fff; padding: 0; position: relative; }
|
||
.bf-tile[data-bf-terrain="forest"] { background: #cfc; }
|
||
.bf-tile[data-bf-terrain="rough"] { background: #fec; }
|
||
.bf-tile[data-bf-terrain="water"] { background: #cce; }
|
||
.bf-tile[data-bf-terrain="blocking"] { background: #444; }
|
||
.bf-unit { width: 100%; height: 100%; font-size: 0.7rem; border: 0; cursor: pointer; }
|
||
.bf-unit--alpha { background: #cce5ff; }
|
||
.bf-unit--bravo { background: #ffcccc; }
|
||
.bf-unit--inactive { opacity: 0.4; cursor: not-allowed; }
|
||
.bf-unit--winner { outline: 3px solid #c00; }
|
||
.bf-action--active { font-weight: bold; }
|
||
.bf-toast { background: #ffd; padding: 0.5rem 1rem; margin: 0.25rem 0; border: 1px solid #cc9; }
|
||
.bf-log { max-height: 12rem; overflow: auto; background: #f4f4f4; padding: 0.5rem; margin-top: 1rem; }
|
||
.bf-log-entry { margin: 0; font-family: monospace; font-size: 0.85rem; }
|
||
.bf-empty { color: #777; font-style: italic; }
|
||
.bf-result { margin-top: 1rem; padding: 1rem; background: #efe; border: 2px solid #393; }
|
||
.bf-result[hidden] { display: none; }
|
||
```
|
||
|
||
- [ ] **Step 4: Lint and format the JS**
|
||
|
||
Run: `npm run lint`
|
||
Expected: 0 errors. If ESLint flags any issues, fix them in `public/js/match.js` or `public/js/storage.js` (the existing `grid-editor.js` and `storage.js` should still be clean).
|
||
|
||
Run: `npm run format`
|
||
Expected: 0 drift.
|
||
|
||
- [ ] **Step 5: Boot the dev server and visually verify the match view loads**
|
||
|
||
Run in one terminal: `php -S 127.0.0.1:8765 -t public`
|
||
|
||
In another terminal:
|
||
1. `curl -c /tmp/bf-cookies http://127.0.0.1:8765/` — issues a `__csrf` cookie.
|
||
2. Read the cookie file to get the cookie value.
|
||
3. Mint a turn token in PHP (one-off CLI): `php -r 'require "vendor/autoload.php"; echo BattleForge\Application\TurnToken::issue("dev", "0123456789abcdef", "alpha", 1, 0);'`
|
||
4. Hand-craft a `match:current` envelope and POST a move to verify the round-trip.
|
||
|
||
This is a smoke check; the deeper E2E test is in Task 13. The point of this step is just to confirm the JS doesn't throw on load and the grid renders.
|
||
|
||
Stop the dev server.
|
||
|
||
- [ ] **Step 6: Run the full quality suite and commit**
|
||
|
||
Run: `composer check && npm run lint && npm run format`
|
||
Expected: all checks green.
|
||
|
||
```powershell
|
||
git add public/js/match.js public/js/storage.js public/assets/styles.css
|
||
git commit -m "feat: add match.js renderer and battle UI styles"
|
||
```
|
||
|
||
### Task 13: End-to-end smoke test
|
||
|
||
**Files:**
|
||
- Create: `tests/Integration/PostMatchActionSmokeTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: the running `php -S` dev server (booted by the test itself), the bundled scenario fixtures.
|
||
- Produces: a PHPUnit test gated by `BATTLEFORGE_E2E=1` that walks the Skirmish scenario to a winner.
|
||
|
||
- [ ] **Step 1: Write the smoke test**
|
||
|
||
Create `tests/Integration/PostMatchActionSmokeTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace BattleForge\Tests\Integration;
|
||
|
||
use BattleForge\Application\TurnToken;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/**
|
||
* End-to-end smoke test. Boots a real `php -S` dev server and walks the
|
||
* Skirmish bundled scenario to a winner. Gated by BATTLEFORGE_E2E=1 so the
|
||
* per-commit CI does not pay its cost.
|
||
*
|
||
* 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**
|
||
|
||
```powershell
|
||
git add tests/Integration/PostMatchActionSmokeTest.php
|
||
git commit -m "test: add end-to-end smoke test for the battle interface"
|
||
```
|
||
|
||
### Task 14: New CI workflow `ci-e2e.yml`
|
||
|
||
**Files:**
|
||
- Create: `.github/workflows/ci-e2e.yml`
|
||
|
||
**Interfaces:**
|
||
- Consumes: GitHub Actions triggers (`pull_request` to `develop`, `schedule` cron).
|
||
- Produces: a CI run that sets `BATTLEFORGE_E2E=1`, runs the smoke test, and times out at 5 minutes.
|
||
|
||
- [ ] **Step 1: Read the existing workflow for shape**
|
||
|
||
Run: `cat .github/workflows/ci.yml`
|
||
Expected: the existing workflow uses `actions/checkout@<sha>`, `shivammathur/setup-php@v2` with `php-version: '8.3'`, and runs `composer install && composer check` and the npm lint/format steps.
|
||
|
||
- [ ] **Step 2: Create the new workflow**
|
||
|
||
Create `.github/workflows/ci-e2e.yml`:
|
||
|
||
```yaml
|
||
name: CI E2E
|
||
|
||
on:
|
||
pull_request:
|
||
branches:
|
||
- develop
|
||
schedule:
|
||
- cron: '0 6 * * *'
|
||
|
||
permissions:
|
||
contents: read
|
||
|
||
jobs:
|
||
php-e2e:
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 5
|
||
env:
|
||
BATTLEFORGE_E2E: '1'
|
||
steps:
|
||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||
with:
|
||
persist-credentials: false
|
||
- uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2
|
||
with:
|
||
php-version: '8.3'
|
||
coverage: none
|
||
tools: composer:v2
|
||
- run: composer install --no-interaction --prefer-dist
|
||
- run: vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php
|
||
```
|
||
|
||
Pin the SHAs to whatever the existing `ci.yml` uses.
|
||
|
||
- [ ] **Step 3: Verify the workflow file is valid YAML**
|
||
|
||
Run: `php -r '$d = yaml_parse_file(".github/workflows/ci-e2e.yml"); echo is_array($d) ? "ok\n" : "bad\n";'`
|
||
Expected: `ok`. If YAML support is missing in the dev PHP build, eyeball the file for indentation.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```powershell
|
||
git add .github/workflows/ci-e2e.yml
|
||
git commit -m "ci: add end-to-end smoke test workflow"
|
||
```
|
||
|
||
### Task 15: Final whole-branch code review
|
||
|
||
**Files:**
|
||
- Modify: nothing (review-only)
|
||
|
||
**Interfaces:**
|
||
- Consumes: the merge-base diff between `develop` and the feature branch.
|
||
- Produces: a list of Critical / Important findings for the implementer to address before merging. Minor findings are deferred to a later plan.
|
||
|
||
- [ ] **Step 1: Generate the merge-base diff**
|
||
|
||
Run: `git fetch origin develop && git diff origin/develop...HEAD --stat`
|
||
Expected: the output lists every file changed in the feature branch, including the new tests and handlers.
|
||
|
||
- [ ] **Step 2: Dispatch a fresh reviewer subagent**
|
||
|
||
Use the AskUserQuestion or dispatch flow to send a single reviewer with this prompt:
|
||
|
||
> "You are reviewing a PHP + JavaScript feature branch (the Plan 4 implementation of BattleForge's battle interface, bundled scenarios, and smoke test) against the spec at `docs/superpowers/specs/2026-07-25-battle-interface-design.md` and the plan at `docs/superpowers/plans/2026-07-25-battle-interface.md`. The diff is `git diff origin/develop...HEAD`. Check for: (1) spec compliance (every in-scope capability from the design spec is implemented), (2) code quality (clean separation of concerns, proper error handling, type safety, ESLint/PHPStan/PHPCS clean), (3) production readiness (no obvious bugs, no leftover debug code), (4) security: the match-action endpoints validate CSRF + turn-token + matchId regex, the bundled-scenario endpoints validate filenames and validate each fixture, the `match.js` renderer uses `textContent` (no `innerHTML` with user data), and the `ScenarioSerializer` round-trips objectives."
|
||
|
||
- [ ] **Step 3: Address Critical and Important findings**
|
||
|
||
Apply fixes inline. Re-run `composer check` and `npm run lint` after each round of fixes.
|
||
|
||
- [ ] **Step 4: Commit any review fixes (if applicable)**
|
||
|
||
```powershell
|
||
git add -A
|
||
git commit -m "fix: address review findings"
|
||
```
|
||
|
||
If no changes were needed, skip this step.
|
||
|
||
## Completion Check
|
||
|
||
Run:
|
||
|
||
```powershell
|
||
composer validate --strict
|
||
composer check
|
||
npm run lint
|
||
npm run format
|
||
BATTLEFORGE_E2E=1 vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php
|
||
git status --short
|
||
```
|
||
|
||
Expected:
|
||
|
||
- Composer reports a valid manifest.
|
||
- PHPCS reports no coding-standard violations.
|
||
- PHPStan reports no errors at level 6.
|
||
- PHPUnit passes 196+ existing tests, all new tests, and the smoke test (with `BATTLEFORGE_E2E=1`).
|
||
- ESLint reports 0 errors on `public/js/*.js`.
|
||
- Prettier reports 0 formatting drift.
|
||
- Git reports no untracked files except `.worktrees/` (git-ignored).
|
||
|
||
The MVP is complete: a user can open the home page, pick a bundled scenario, play the match to a winner with a hot-seat battle interface, see the result, and return home. The smoke test exercises the full flow on a real `php -S` instance in CI.
|
||
|
||
|
||
|
||
|
||
|