20 KiB
Plan 4: Battle Interface, Bundled Scenarios, and Release Hardening
Purpose
Plan 4 of the four-plan BattleForge build. Replaces the deleted match-stub.php placeholder with a real, server-rendered hot-seat battle interface; ships three bundled scenarios that are playable without the editors; fixes the objective-round-trip gap in ScenarioSerializer; and adds a real end-to-end smoke test against the dev server.
Plans 1–3c have already delivered the standalone PHP project, the rules engine, the curated content, the HTTP layer, the scenario editors, and the JavaScript surface. Plan 4 is the last plan of the MVP and turns the existing scaffold into a finished first release.
Success Criteria
Plan 4 succeeds when a new user, without developer assistance, can:
- Open the home page, see the three bundled scenarios listed with summaries, and click one to start a match.
- Play the resulting match to a winner using a hot-seat battle interface that enforces alternating team turns and disables actions on the inactive team's units.
- Use move, attack, and the three curated abilities (heal, area damage, buff) during play, observing terrain modifiers, action-remaining counters, and per-round objective control where applicable.
- See the result of each action (unit damage, objective tally, action log) and the final winner banner with a "Return to home" link.
- Reload the page mid-match and resume from the same state.
- Watch the smoke test exercise a full match on a real
php -Sinstance in CI.
The combat remains a local hot-seat experience for two players on one device. Online play, accounts, AI opponents, and replay visualization are explicitly out of scope per the design spec.
In Scope
Server-Authoritative Match API
Four new POST endpoints, one per player action, all routed under /matches/current/ (the current segment identifies "the match in localStorage"; the actual matchId lives in the JSON body):
POST /matches/current/move— body{match, unitId, x, y}; runsCombatEngine::move; returns{match, turnToken, winnerTeamId}.POST /matches/current/attack— body{match, attackerId, targetId}; runsCombatEngine::attack.POST /matches/current/ability— body{match, unitId, abilityId, x, y}; thexandyare the target position (fortile-targeting abilities, the center; forally/enemy/selftargeting, the affected unit's position).POST /matches/current/end-turn— body{match}; runsCombatEngine::endTurn.
Each handler:
- Verifies the CSRF token against the
__csrfcookie (existing pattern). - Verifies the
X-Turn-Tokenheader against the supplied match state (see Turn Token below). - Decodes the JSON body into a
MatchStateviaScenarioSerializer::matchFromArray(defense in depth; the engine would also reject malformed state, but a 400 here gives a clearer error). - Calls the matching
CombatEnginemethod. - Computes a fresh
turnTokenfor the new state and returns it alongside the serialized match and (if set)winnerTeamId.
Rejected actions are mapped to HTTP statuses by the rules-engine boundary:
| Failure | HTTP | Body | Browser effect |
|---|---|---|---|
| CSRF mismatch | 403 | {"error":"csrf"} |
"Session expired. Reload the page." banner |
| Turn token mismatch | 409 | {"error":"turn"} |
"Turn has changed" banner; client re-reads localStorage and re-renders |
Malformed MatchState |
400 | {"errors":[...]} |
First error message as a toast; match unchanged |
CombatException (illegal action) |
409 | {"error":"<reason>"} |
Reason as a toast; match unchanged |
| Internal error | 500 | {"error":"server"} |
"Something went wrong" banner; match unchanged |
localStorage writes only happen on a 200 response. The in-browser state is recoverable by reload.
Turn Token
A new src/Application/TurnToken.php value object with two static methods:
public static function issue(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string
public static function verify(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex, string $token): bool
The token is hash_hmac('sha256', $secret, $matchId . '|' . $activeTeamId . '|' . $round . '|' . $lastActionIndex) truncated to 32 hex chars. The handler reads the team, round, and action log count from the incoming match (not from the response after the engine runs) and verifies the supplied token matches; the response carries the token for the next action's expected state.
The token binding:
matchIdis a per-match random hex set whenstartMatchreturns and held in the match'slocalStorageenvelope alongside theMatchStateJSON.activeTeamIdandroundare read from the incoming match.lastActionIndexiscount($match->actionLog).
A stale token (from a previous turn or a different round) fails verification. A tampered token fails verification. A missing token fails verification. The token is a defense-in-depth check; the engine's assertCanAct is the real authority.
Bundled Scenarios
Three Scenario JSON fixtures committed under public/assets/scenarios/:
skirmish.json— 8×8 open battlefield, no terrain, no objectives. Two symmetric teams of 3 (Alpha: defender + striker + support; Bravo: striker + scout + support). Victory:eliminate_all. The "show me the game" scenario.hold-the-line.json— 10×8 battlefield with a forest corridor down the center (Manhattan-cost 2, +1 defense). Alpha: 3 defenders in the corridor. Bravo: 2 strikers + 1 support + 1 scout approaching from the south. One objective marker at the center of the forest. Victory:hold_objective,holdRoundsRequired: 3.last-stand.json— 12×12 battlefield with a mix of forest, rough, and water. Asymmetric: Alpha has 6 mixed units (defenders, supports, scouts); Bravo has 4 (3 strikers + 1 scout). Victory:eliminate_all. The larger end of the rules.
Two new GET endpoints serve them:
GET /scenarios/bundled— reads the directory, validates each filename against/^[a-z0-9-]+$/, returns[{id, name, summary}, ...]sorted by id. Thenameandsummaryare read from amanifest.jsonin the same directory (one source of truth, no in-PHP strings to keep in sync with the fixture files).GET /scenarios/bundled/{id}— reads the fixture, runs it throughScenarioSerializer::scenarioFromArrayandScenarioValidator::validate(a fixture that fails validation is a bug; the endpoint returns 500 so CI catches it), returns the JSON.
The home page renders a "Play bundled" list of the three fixtures with their summaries. Clicking a scenario POSTs it to PostStartMatch (existing endpoint) and the existing flow takes over from there.
Match View
A single new src/Views/match.php template, served by GetMatchView at GET /matches/current. The template:
- Renders the shared layout with a CSRF meta tag and a
<script type="module" src="/js/match.js">tag. - Renders a server-rendered shell:
- Header bar: active team name, current round, End Turn button.
<div id="bf-grid" class="bf-grid" data-bf-width="..." data-bf-height="...">(empty; JS fills it).<aside id="bf-panel" class="bf-panel">with three action buttons (Move, Attack, Ability) and a "selected unit" indicator.<div id="bf-log" class="bf-log">with the last 10 action log entries.<div id="bf-result" class="bf-result" hidden>with the winner banner and a "Return to home" link.
- The End Turn button is disabled when
winnerTeamIdis non-null. - The active team's units are highlighted via a CSS class set by JS; the inactive team's units are visually dimmed and the action buttons on them are
disabled.
public/js/match.js is the third ESM file. It:
- On
DOMContentLoaded, readsmatch:currentfromlocalStorageviabfStorage.get('match:current'). If absent, renders the "no match — pick a scenario" state. - Renders the grid + units + panel from the snapshot. For the active unit, the action panel shows its
archetype.abilitiesas radio buttons (since units may have 0–2 abilities). - Clicking a friendly unit selects it (changes the panel's selected-unit indicator). Clicking an enemy unit is a no-op unless in attack mode.
- Clicking an empty tile in move-mode dispatches
POST /matches/current/movewith the latest match JSON, the new unit's destination, and the latestturnToken. - Clicking an enemy unit in attack-mode dispatches
POST .../attackwith the attacker id and target id. - Clicking a tile in ability-mode (after selecting an ability radio) dispatches
POST .../abilitywith the unit id, ability id, and target position. - End Turn is a single
POST .../end-turnwith the latest match JSON. - Each request includes
X-CSRF-Token: <csrf meta>andX-Turn-Token: <latest>. - On 200, the JS overwrites
match:currentinlocalStorage, stores the newturnTokenin module-local memory, and re-renders. On 409 (winnerTeamIdis set), it switches to the result state and disables all action controls. On 409 (turnmismatch), it re-readslocalStorage(the source) and re-renders. On 400/403/500, it shows a toast with the error and leaveslocalStoragealone. - The action log panel updates from the latest snapshot.
The disabled-vs-enabled rule: the engine's assertCanAct is the authority. The JS sets disabled on inactive-team unit buttons and on action buttons when the active unit has zero actions remaining, mirroring the engine's contract. The browser never decides legality.
ScenarioSerializer Objective Round-Trip Fix
src/Application/ScenarioSerializer.php currently drops the objectives field on the way back from a serialized MatchState (matchFromArray passes objectives: []). Plan 4 fixes this:
matchToArrayadds'objectives' => $objectivesArraywhere$objectivesArrayis built by iterating$match->objectivesand calling['id' => $objective->id, 'position' => self::positionToArray($objective->position)].matchFromArraybuilds theObjectiveMarkermap from($data['objectives'] ?? [])and passes it to the newMatchState.
A new unit test in tests/Unit/Application/ScenarioSerializerTest.php exercises round-trip on a HoldObjective MatchState and asserts the objective's id and position survive. This is the foundation of the bundled Hold the Line scenario working end-to-end.
End-to-End Smoke Test
A new tests/Integration/PostMatchActionSmokeTest.php:
- Boots
php -S 127.0.0.1:<free port> -t public/in a background process. - Reads the port from the boot output.
- Uses
stream_context_create+file_get_contents(no new dependency) to walk the Skirmish scenario to a winner: GET/, GET/scenarios/bundled, GET/scenarios/bundled/skirmish, POST/scenarios/skirmish/start, then the four action verbs in sequence untilwinnerTeamIdis set. - Asserts the JSON shapes, the
turnTokenfield is present on every action response, and a stale-token request returns 409 with the match unchanged. - Smoke-loads
hold-the-lineandlast-stand(start match only — these scenarios are heavier and don't need to be fully played). - Tears down the server with
proc_terminateintearDown.
The test is gated by the BATTLEFORGE_E2E env var. The default composer check run does not set it; the per-commit gate stays fast.
A new .github/workflows/ci-e2e.yml runs on PRs to develop and nightly, sets BATTLEFORGE_E2E=1, and runs the smoke test with a 5-minute timeout. The existing ci.yml is unchanged.
Out of Scope (deferred)
- Headless browser test (Playwright/Puppeteer). Out for the MVP per the design spec.
- Replay visualization. The action log is captured and rendered in the panel; full replay is a later plan.
- AI opponent. The spec explicitly excludes it.
- Online multiplayer, accounts, cloud sync. The spec explicitly excludes them.
- Mid-turn undo. The design decision is "no" — actions stand.
- Cross-scenario match continuation. A match is bound to its starting scenario.
System Boundaries (unchanged from Plan 1)
- Content library (Domain, unchanged) — archetype catalog, ability catalog, terrain behaviors.
- Scenario editor (web, unchanged) — Plan 3a/3b shipped this.
- Rules engine (Domain, unchanged) —
CombatEngineis the single authority; Plan 4 calls it from the new match-action handlers but does not modify it. - Battle interface (web, new) —
src/Views/match.php+public/js/match.js+ the four action handlers. Renders match state and submits player intent; never independently decides whether an action is legal.
The Application layer gains TurnToken (new) and a small extension to ScenarioSerializer (the objectives round-trip). The Domain layer is unchanged.
Data and Action Flow
Cold-start flow (unchanged from Plan 3)
Bundled scenario bootstrap
- User visits
GET /. - Home page renders a "Play bundled" list with the three scenarios' names and summaries (read via
GET /scenarios/bundled). - User clicks "Skirmish". JS POSTs the scenario JSON (from
GET /scenarios/bundled/skirmish) toPOST /scenarios/skirmish/start. - Existing
PostStartMatchreturns the initialMatchStateJSON. - JS stores the match in
match:current(along with a freshly-generatedmatchIdand an initialturnTokenfrom the response), then navigates to/matches/current.
Per-action flow
- User clicks a friendly unit to select it.
- User chooses an action mode (Move / Attack / Ability).
- User clicks a destination tile (or enemy unit, or selects an ability radio and clicks a target).
- JS reads the current match from in-memory state, builds the request body, and
fetch()es the matching endpoint withX-CSRF-TokenandX-Turn-Tokenheaders. - Server runs the engine, returns the new match and a fresh
turnToken. - JS replaces the in-memory state, writes
match:currenttolocalStorage, and re-renders.
End-of-match flow
- The engine's
attack,useAbility, orendTurnreturns aMatchStatewithwinnerTeamIdset. - The handler returns
200withwinnerTeamIdin the body (not 409 — the match is over, not rejected). - The JS recognizes
winnerTeamId !== null, switches the view to the result state, and disables all action controls. - The "Return to home" link clears
match:currentfromlocalStorageand navigates to/.
Validation and Failure Handling
Inherited from the design spec:
- A rejected action never partially changes match state or consumes resources.
- Corrupt or incompatible saved data is rejected safely and cannot start a partially initialized match.
- The CSRF token's invalid-or-missing case returns a generic 403.
Plan 4-specific:
- The turn token's invalid-or-stale case returns 409 with
{"error":"turn"}. The browser re-readslocalStorageand re-renders. The local copy is always the source of truth. - A malformed
MatchState(somehow arriving at the server) returns 400 with the validator's error messages. The validator is the source of truth; the engine would also reject it, but a 400 here gives a clearer message. - A bundled scenario fixture that fails
ScenarioValidator::validatemakes theGET /scenarios/bundled/{id}endpoint return 500. CI catches it because the integration test fetches each fixture and asserts 200. - A bundled scenario file whose name doesn't match
/^[a-z0-9-]+$/is rejected byGET /scenarios/bundled(it's not in the list) and byGET /scenarios/bundled/{id}(404). Path-traversal attempts are blocked. - The smoke test's
php -Sboot is wrapped in a try/finally that always tears the server down, even on test failure.
Security and Quality Requirements
Inherited from the design spec:
- Every state-changing form or request uses and verifies a nonce before mutation.
- All rendered output is escaped for its output context.
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
- JavaScript passes ESLint using
airbnb/baseand Prettier checks. - Composer configuration passes
composer validate --strict.
Plan 4-specific:
- The turn token is a 32-character hex HMAC-SHA256. The secret is the same
BATTLEFORGE_SECRETenv var orvar/secret.keyfile already used for CSRF — no new secret material. - The
X-Turn-Tokenis required on all four action endpoints. A missing header returns 409. - The match action handlers validate
matchIdagainst/^[a-f0-9]{16,}$/before computing the token, so a tampered matchId can't reach the HMAC input. - The bundled scenario endpoints validate filenames against
/^[a-z0-9-]+$/and userealpath+is_filebefore reading, preventing path traversal. public/js/match.jsusestextContentand DOM construction; neverinnerHTMLwith user data. The match log entries are set withtextContent.- The Content-Security-Policy header in
layout.phpis extended to allowscript-src 'self'(already implied) and to remain restrictive onstyle-srcandimg-src(no change from Plan 3c).
Verification Strategy
Unit tests (tests/Unit/Application/)
TurnTokenTest—issueis deterministic for the same inputs;verifyaccepts a freshly-issued token, rejects mutated inputs, rejects tokens for a differentmatchId, rejects tokens for a differentactiveTeamId, rejects tokens for a differentround, and rejects tokens for a differentlastActionIndex.ScenarioSerializerTest(modify) — adds a "match round-trips its objectives" case: build aHoldObjectiveMatchState, serialize, deserialize, assert the objective's id and position survive. Also assertsmatchToArrayincludes theobjectiveskey with the right shape.
Integration tests (tests/Integration/)
GetBundledScenariosTest— 200 with three entries; 200 with the rightid/name/summaryfields; sorted by id; rejects a directory entry with a non-conforming filename (via the test creating abad..jsonfile and asserting it's filtered out, then cleaned up).GetBundledScenarioTest— 200 for each bundled id, with aScenariothat passesScenarioValidator::validate; 404 for an unknown id; 500 for a fixture that fails validation (the test injects a deliberately-broken fixture and asserts the 500).GetMatchViewTest— 200 with thebf-matchcontainer, the CSRF meta, thematch.jsscript tag, and the security headers (CSP,X-Content-Type-Options,Referrer-Policy).PostMatchActionTest— four sub-tests, one per verb. Each has a happy path (assertsmatch,turnToken, and (for end-turn after a winning attack)winnerTeamId) and a rejection path (asserts 409 + the engine's reason, asserts the response body'smatchequals the request'smatch).
End-to-end smoke test (tests/Integration/PostMatchActionSmokeTest.php)
- Boots
php -Son a free port. - Walks Skirmish to a winner (using the dev server's actual JSON responses and
X-Turn-Token). - Smoke-loads
hold-the-lineandlast-stand(start match only). - Asserts a stale turn token returns 409 with the match unchanged.
- Tears down
php -SintearDown.
Gated by BATTLEFORGE_E2E=1. The default composer check does not set it.
CI
ci.yml(existing) — per-commit gate. No change. PHPCS, PHPStan, PHPUnit, ESLint, Prettier all green.ci-e2e.yml(new) — runs on PRs todevelopand nightly. SetsBATTLEFORGE_E2E=1. Times out at 5 minutes.
Manual usability check
A new tester with no BattleForge context can: open the dev URL, see the home page, click "Skirmish", play the match to completion (alternating with another person on the same device), see the winner banner, return home, click "Hold the Line", and play that match to completion — without leaving the browser or seeing any error that wasn't explained inline.
Release Boundary
Plan 4 is releasable when:
- Every in-scope capability and success criterion is met.
- The verification suite is green: 196+ existing tests pass, all new tests pass, PHPCS/PHPStan/ESLint/Prettier are clean.
- The smoke test passes against a real
php -Sinstance in CI. - The bundled scenarios are playable end-to-end.
- The
ScenarioSerializerobjective round-trip bug is fixed and covered by a test. - No out-of-scope capability is required to build or finish a local match.
The MVP is complete. Feedback from this release will decide whether the next investment should deepen combat, expand creation tools, add an AI opponent, or introduce asynchronous online play.