docs: clarify matchId lifecycle, body shape, and bundled manifest in Plan 4 spec
This commit is contained in:
@@ -23,12 +23,14 @@ The combat remains a local hot-seat experience for two players on one device. On
|
|||||||
|
|
||||||
### Server-Authoritative Match API
|
### 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):
|
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 `{match, unitId, x, y}`; runs `CombatEngine::move`; returns `{match, turnToken, winnerTeamId}`.
|
- `POST /matches/current/move` — body `{matchId, 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/attack` — body `{matchId, 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/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 `{match}`; runs `CombatEngine::endTurn`.
|
- `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:
|
Each handler:
|
||||||
|
|
||||||
@@ -38,6 +40,8 @@ Each handler:
|
|||||||
4. Calls the matching `CombatEngine` method.
|
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`.
|
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:
|
Rejected actions are mapped to HTTP statuses by the rules-engine boundary:
|
||||||
|
|
||||||
| Failure | HTTP | Body | Browser effect |
|
| Failure | HTTP | Body | Browser effect |
|
||||||
@@ -59,7 +63,7 @@ public static function issue(string $secret, string $matchId, string $activeTeam
|
|||||||
public static function verify(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex, string $token): bool
|
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 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:
|
The token binding:
|
||||||
|
|
||||||
@@ -79,10 +83,10 @@ Three `Scenario` JSON fixtures committed under `public/assets/scenarios/`:
|
|||||||
|
|
||||||
Two new GET endpoints serve them:
|
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` — 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.
|
- `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 POSTs it to `PostStartMatch` (existing endpoint) and the existing flow takes over from there.
|
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
|
### Match View
|
||||||
|
|
||||||
@@ -163,9 +167,11 @@ The `Application` layer gains `TurnToken` (new) and a small extension to `Scenar
|
|||||||
|
|
||||||
1. User visits `GET /`.
|
1. User visits `GET /`.
|
||||||
2. Home page renders a "Play bundled" list with the three scenarios' names and summaries (read via `GET /scenarios/bundled`).
|
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`.
|
3. User clicks "Skirmish". JS fetches the full `Scenario` JSON via `GET /scenarios/bundled/skirmish`.
|
||||||
4. Existing `PostStartMatch` returns the initial `MatchState` JSON.
|
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` (along with a freshly-generated `matchId` and an initial `turnToken` from the response), then navigates to `/matches/current`.
|
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
|
### Per-action flow
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user