docs: add Plan 4 design spec (battle interface, bundled scenarios, smoke test)

This commit is contained in:
Keith Solomon
2026-07-25 21:35:43 -05:00
parent ced4363d20
commit fbb6960be2
@@ -0,0 +1,261 @@
# 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 actual `matchId` lives in the JSON body):
- `POST /matches/current/move` — body `{match, unitId, x, y}`; runs `CombatEngine::move`; returns `{match, turnToken, winnerTeamId}`.
- `POST /matches/current/attack` — body `{match, attackerId, targetId}`; runs `CombatEngine::attack`.
- `POST /matches/current/ability` — body `{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 `{match}`; runs `CombatEngine::endTurn`.
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`.
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:
```php
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:
- `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 (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 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 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:
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 POSTs the scenario JSON (from `GET /scenarios/bundled/skirmish`) to `POST /scenarios/skirmish/start`.
4. Existing `PostStartMatch` returns the initial `MatchState` JSON.
5. JS stores the match in `match:current` (along with a freshly-generated `matchId` and an initial `turnToken` from the response), then navigates to `/matches/current`.
### 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/`)
- **`TurnTokenTest`** — `issue` 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.