Files
BattleForge/docs/superpowers/specs/2026-07-25-battle-interface-design.md

22 KiB
Raw Permalink Blame History

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 13c 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:

  1. Open the home page, see the three bundled scenarios listed with summaries, and click one to start a match.
  2. 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.
  3. 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.
  4. See the result of each action (unit damage, objective tally, action log) and the final winner banner with a "Return to home" link.
  5. Reload the page mid-match and resume from the same state.
  6. Watch the smoke test exercise a full match on a real php -S instance 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 matchId (a 16-char hex minted at startMatch) and the MatchState both live in the JSON body:

  • POST /matches/current/move — body {matchId, match, unitId, x, y}; runs CombatEngine::move; returns {match, turnToken, winnerTeamId}.
  • POST /matches/current/attack — body {matchId, match, attackerId, targetId}; runs CombatEngine::attack.
  • POST /matches/current/ability — body {matchId, match, unitId, abilityId, x, y}; the x and y are the target position (for tile-targeting abilities, the center; for ally/enemy/self targeting, the affected unit's position).
  • POST /matches/current/end-turn — body {matchId, match}; runs CombatEngine::endTurn.

The matchId is required for turn-token verification on every action and is the only piece of state not derivable from the match JSON itself. The client persists it from the PostStartMatch response alongside the match in match:current.

Each handler:

  1. Verifies the CSRF token against the __csrf cookie (existing pattern).
  2. Verifies the X-Turn-Token header against the supplied match state (see Turn Token below).
  3. Decodes the JSON body into a MatchState via ScenarioSerializer::matchFromArray (defense in depth; the engine would also reject malformed state, but a 400 here gives a clearer error).
  4. Calls the matching CombatEngine method.
  5. Computes a fresh turnToken for the new state and returns it alongside the serialized match and (if set) winnerTeamId.

PostStartMatch is modified in Plan 4 to mint a matchId (16 random hex chars from bin2hex(random_bytes(8))) and a turnToken for the initial state, returning {match, matchId, turnToken} instead of just {match}. The same BATTLEFORGE_SECRET is used.

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 activeTeamId, round, and lastActionIndex (i.e. count($match->actionLog)) from the incoming match, computes the expected token, and hash_equals it against the supplied header. The response carries a fresh token computed against the new state — i.e. the response's turnToken is TurnToken::issue($secret, $matchId, $newState->activeTeamId, $newState->round, count($newState->actionLog)). The first turn token of a match is the one minted in the PostStartMatch response.

The token binding:

  • matchId is a per-match random hex set when startMatch returns and held in the match's localStorage envelope alongside the MatchState JSON.
  • activeTeamId and round are read from the incoming match.
  • lastActionIndex is count($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. The name and summary are read from a manifest.json in the same directory. The manifest is the single source of truth for display strings; the fixture filename is the id. The manifest shape is { "<id>": {"name": "...", "summary": "..."} }. Any fixture without a manifest entry is filtered out of the response.
  • GET /scenarios/bundled/{id} — reads the fixture, runs it through ScenarioSerializer::scenarioFromArray and ScenarioValidator::validate (a fixture that fails validation is a bug; the endpoint returns 500 so CI catches it), returns the JSON. The returned JSON is the Scenario shape (same as ScenarioSerializer::scenarioToArray), not a ScenarioDraft.

The home page renders a "Play bundled" list of the three fixtures with their summaries. Clicking a scenario fetches the full Scenario JSON via GET /scenarios/bundled/{id} and POSTs it to the existing PostStartMatch endpoint (no new route). PostStartMatch is modified in Plan 4 to return {match, matchId, turnToken} (it currently returns just {match}) so the JS can persist the new matchId and seed the next action's turn-token check.

Match View

A single new src/Views/match.php template, served by GetMatchView at GET /matches/current. The template:

  1. Renders the shared layout with a CSRF meta tag and a <script type="module" src="/js/match.js"> tag.
  2. 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.
  3. The End Turn button is disabled when winnerTeamId is non-null.
  4. 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:

  1. On DOMContentLoaded, reads match:current from localStorage via bfStorage.get('match:current'). If absent, renders the "no match — pick a scenario" state.
  2. Renders the grid + units + panel from the snapshot. For the active unit, the action panel shows its archetype.abilities as radio buttons (since units may have 02 abilities).
  3. 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.
  4. Clicking an empty tile in move-mode dispatches POST /matches/current/move with the latest match JSON, the new unit's destination, and the latest turnToken.
  5. Clicking an enemy unit in attack-mode dispatches POST .../attack with the attacker id and target id.
  6. Clicking a tile in ability-mode (after selecting an ability radio) dispatches POST .../ability with the unit id, ability id, and target position.
  7. End Turn is a single POST .../end-turn with the latest match JSON.
  8. Each request includes X-CSRF-Token: <csrf meta> and X-Turn-Token: <latest>.
  9. On 200, the JS overwrites match:current in localStorage, stores the new turnToken in module-local memory, and re-renders. On 409 (winnerTeamId is set), it switches to the result state and disables all action controls. On 409 (turn mismatch), it re-reads localStorage (the source) and re-renders. On 400/403/500, it shows a toast with the error and leaves localStorage alone.
  10. 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:

  1. matchToArray adds 'objectives' => $objectivesArray where $objectivesArray is built by iterating $match->objectives and calling ['id' => $objective->id, 'position' => self::positionToArray($objective->position)].
  2. matchFromArray builds the ObjectiveMarker map from ($data['objectives'] ?? []) and passes it to the new MatchState.

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:

  1. Boots php -S 127.0.0.1:<free port> -t public/ in a background process.
  2. Reads the port from the boot output.
  3. 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 until winnerTeamId is set.
  4. Asserts the JSON shapes, the turnToken field is present on every action response, and a stale-token request returns 409 with the match unchanged.
  5. Smoke-loads hold-the-line and last-stand (start match only — these scenarios are heavier and don't need to be fully played).
  6. Tears down the server with proc_terminate in tearDown.

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)

  1. Content library (Domain, unchanged) — archetype catalog, ability catalog, terrain behaviors.
  2. Scenario editor (web, unchanged) — Plan 3a/3b shipped this.
  3. Rules engine (Domain, unchanged) — CombatEngine is the single authority; Plan 4 calls it from the new match-action handlers but does not modify it.
  4. 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

  1. User visits GET /.
  2. Home page renders a "Play bundled" list with the three scenarios' names and summaries (read via GET /scenarios/bundled).
  3. User clicks "Skirmish". JS fetches the full Scenario JSON via GET /scenarios/bundled/skirmish.
  4. JS POSTs that JSON to POST /scenarios/skirmish/start (the existing PostStartMatch handler, modified to return {match, matchId, turnToken}).
  5. JS stores the match in match:current as {matchId, match} and stores the turnToken in module-local memory, then navigates to /matches/current.

If a user lands on /matches/current directly with no match:current in localStorage (recovery flow, e.g. opened in a new tab), the JS renders the "no match — pick a scenario" state with a link back to /. They do not start a match from this view.

Per-action flow

  1. User clicks a friendly unit to select it.
  2. User chooses an action mode (Move / Attack / Ability).
  3. User clicks a destination tile (or enemy unit, or selects an ability radio and clicks a target).
  4. JS reads the current match from in-memory state, builds the request body, and fetch()es the matching endpoint with X-CSRF-Token and X-Turn-Token headers.
  5. Server runs the engine, returns the new match and a fresh turnToken.
  6. JS replaces the in-memory state, writes match:current to localStorage, and re-renders.

End-of-match flow

  1. The engine's attack, useAbility, or endTurn returns a MatchState with winnerTeamId set.
  2. The handler returns 200 with winnerTeamId in the body (not 409 — the match is over, not rejected).
  3. The JS recognizes winnerTeamId !== null, switches the view to the result state, and disables all action controls.
  4. The "Return to home" link clears match:current from localStorage and 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-reads localStorage and 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::validate makes the GET /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 by GET /scenarios/bundled (it's not in the list) and by GET /scenarios/bundled/{id} (404). Path-traversal attempts are blocked.
  • The smoke test's php -S boot 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/base and 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_SECRET env var or var/secret.key file already used for CSRF — no new secret material.
  • The X-Turn-Token is required on all four action endpoints. A missing header returns 409.
  • The match action handlers validate matchId against /^[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 use realpath + is_file before reading, preventing path traversal.
  • public/js/match.js uses textContent and DOM construction; never innerHTML with user data. The match log entries are set with textContent.
  • The Content-Security-Policy header in layout.php is extended to allow script-src 'self' (already implied) and to remain restrictive on style-src and img-src (no change from Plan 3c).

Verification Strategy

Unit tests (tests/Unit/Application/)

  • TurnTokenTestissue is deterministic for the same inputs; verify accepts a freshly-issued token, rejects mutated inputs, rejects tokens for a different matchId, rejects tokens for a different activeTeamId, rejects tokens for a different round, and rejects tokens for a different lastActionIndex.
  • ScenarioSerializerTest (modify) — adds a "match round-trips its objectives" case: build a HoldObjective MatchState, serialize, deserialize, assert the objective's id and position survive. Also asserts matchToArray includes the objectives key with the right shape.

Integration tests (tests/Integration/)

  • GetBundledScenariosTest — 200 with three entries; 200 with the right id/name/summary fields; sorted by id; rejects a directory entry with a non-conforming filename (via the test creating a bad..json file and asserting it's filtered out, then cleaned up).
  • GetBundledScenarioTest — 200 for each bundled id, with a Scenario that passes ScenarioValidator::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 the bf-match container, the CSRF meta, the match.js script tag, and the security headers (CSP, X-Content-Type-Options, Referrer-Policy).
  • PostMatchActionTest — four sub-tests, one per verb. Each has a happy path (asserts match, turnToken, and (for end-turn after a winning attack) winnerTeamId) and a rejection path (asserts 409 + the engine's reason, asserts the response body's match equals the request's match).

End-to-end smoke test (tests/Integration/PostMatchActionSmokeTest.php)

  • Boots php -S on a free port.
  • Walks Skirmish to a winner (using the dev server's actual JSON responses and X-Turn-Token).
  • Smoke-loads hold-the-line and last-stand (start match only).
  • Asserts a stale turn token returns 409 with the match unchanged.
  • Tears down php -S in tearDown.

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 to develop and nightly. Sets BATTLEFORGE_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 -S instance in CI.
  • The bundled scenarios are playable end-to-end.
  • The ScenarioSerializer objective 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.