Files
BattleForge/docs/superpowers/plans/2026-07-06-persistence-editors-ui.md

1819 lines
73 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan 3b: Editor Handlers, Views, and Front Controller Implementation Plan
> **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:** Build the six editor HTTP handlers, six view templates, the real front controller, and the full-flow integration test that complete the Plan 3 web layer.
**Architecture:** A front controller (`public/index.php`) dispatches every request to a `Http\Router` that routes by HTTP method + path to one of eight `Http\Handlers\*` classes. Handlers parse the request through a `Http\Request` value object, do their work through `Application\*` services (which know the `Domain`), and return a `Http\Response` rendered by `Views\*` PHP templates. CSRF and per-session upload tokens are issued by the front controller and validated by the handlers.
**Tech Stack:** PHP 8.3, PHPUnit 11, PHPStan level 6, PHP_CodeSniffer PSR-12, plain `php -S` for local dev.
## Global Constraints
These apply to every task and are copied verbatim from the design spec (which is a delta on the Plan 3 spec).
- Every state-changing form or request uses and verifies a CSRF token before mutation. Forms use a hidden `_csrf` field; fetch uses an `X-CSRF-Token` header.
- All rendered output is escaped for its output context via the `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Templates do not interpolate user input without one of these helpers.
- All uploaded images are content-validated by reading the first 12 bytes and checking the magic number against an allowlist (PNG, JPEG, WebP, GIF), then `getimagesize()` confirms dimensions are ≤ 512×512, then size is checked at ≤ 2 MB. The original filename and declared MIME are discarded after validation.
- `var/uploads/` is denied by `.htaccess` (Apache) and `index.php` (built-in server).
- The dev server is the built-in `php -S 0.0.0.0:8000 -t public` (documented in README).
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
- 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.
- The 4 `@phpstan-ignore function.alreadyNarrowedType` annotations from Plan 1+2+3a reference "Plan 4" in their comments; the actual JSON sources land in Plan 3. The Plan 3b front controller does not introduce a new annotation; the front controller sits below the threshold and the handlers and templates are pure routing and rendering.
- Plan 3b-specific: the front controller's `userToken` validation in `GetAssets` uses a strict format regex (`/^[a-f0-9]{32,}$/`) to harden the path-traversal surface. The `BATTLEFORGE_SECRET` env var takes precedence over the per-install file `var/secret.key` (a 32-byte binary file created on first run with `random_bytes(32)` and `chmod 0600`; git-ignored).
- Domain code has no dependency on HTTP, sessions, storage, or browser code (unchanged from Plans 1, 2, 3a).
## File Structure
```text
src/Http/Handlers/
├── GetTeamEditor.php (NEW) — renders the team editor form
├── PostTeamEditor.php (NEW) — processes the team editor form POST
├── GetBattlefieldEditor.php (NEW) — renders the battlefield editor page
├── PostBattlefieldEditor.php (NEW) — processes the JSON body
├── PostImageUpload.php (NEW) — multipart upload, returns {url} JSON
└── PostStartMatch.php (NEW) — JSON body, returns initial MatchState
src/Views/
├── layout.php (NEW) — shared chrome
├── home.php (NEW) — the home page (currently inline in GetHomePage)
├── team-editor.php (NEW) — the team editor form
├── battlefield-editor.php (NEW) — the battlefield editor with the empty grid
├── match-stub.php (NEW) — the Plan 4 placeholder
└── upload-result.php (NEW) — iframe-style response for the upload form
public/
├── index.php (MODIFY) — replace the Task 1 stub with the real front controller
└── assets/
└── (placeholders land in Plan 3c)
tests/Integration/
├── GetTeamEditorTest.php
├── PostTeamEditorTest.php
├── GetBattlefieldEditorTest.php
├── PostBattlefieldEditorTest.php
├── PostImageUploadTest.php
├── PostStartMatchTest.php
└── FullFlowTest.php
```
## Delivery Sequence
Plan 3b is the fourth of four plans, picking up where Plan 3a left off. It is the last plan in the original "Plan 3" deliverable.
1. View templates — establish the rendering foundation (Tasks 1-3).
2. Editor handlers — wire the templates to the domain (Tasks 4-6).
3. Front controller — wire the handlers to the URL space (Task 7).
4. Full-flow integration test — exercise the whole stack (Task 8).
5. CI verification and final review (Tasks 9-10).
## Execution Preflight
Execute this plan in an isolated worktree on `feature/editor-handlers-and-views`, branched from `develop`. The implementation branch will be submitted as a pull request into `develop` only after every completion check is green.
### Task 1: Shared `layout.php` chrome
**Files:**
- Create: `src/Views/layout.php`
**Interfaces:**
- Produces the shared template that all other templates `require` to set up the document chrome. The layout is invoked by a template that has already started buffering its body content; the layout's signature is `render_layout(callable $body, string $csrf, string $title, string $extraHead = ''): void`.
The layout prints:
- The HTML5 doctype.
- `<html lang="en">`.
- `<head>` with charset, viewport, `<title>`, the stylesheet link, the CSRF meta tag, and any extra head content from the template.
- `<body>` followed by a call to `$body()` to inject the template's body, then `</body></html>`.
- All output escaped via `Escape::html`, `Escape::attr`.
- [ ] **Step 1: Implement `layout.php`**
Create `src/Views/layout.php`:
```php
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/**
* Render the shared document chrome around a body closure.
*
* @param callable(): void $body Emits the template's body content.
* @param string $csrf The pre-issued CSRF token (HMAC-verified by the front controller).
* @param string $title Page title; HTML-escaped by the layout.
* @param string $extraHead Optional extra `<head>` content (e.g. a per-template script tag).
*/
function render_layout(callable $body, string $csrf, string $title, string $extraHead = ''): void
{
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $e($title) ?></title>
<link rel="stylesheet" href="<?= $a('/assets/styles.css') ?>">
<meta name="csrf-token" content="<?= $a($csrf) ?>">
<?= $extraHead /* trusted raw HTML for the optional extra-head block */ ?>
</head>
<body>
<?php $body(); ?>
</body>
</html>
<?php
}
```
The function uses PHP 8.3+ first-class callable syntax (`static fn (string $v): string => Escape::html($v)`) for the escape closures. The function is in the global namespace (not a class) so templates can call it without imports.
- [ ] **Step 2: Verify the file is syntactically valid**
Run: `php -l src/Views/layout.php`
Expected: `No syntax errors detected in src/Views/layout.php`.
- [ ] **Step 3: Run the full quality suite**
Run: `vendor/bin/phpunit`
Expected: 177/177 tests pass (Plan 1+2+3a baseline unchanged). PHPCS may emit a warning on the long line in the `<?=` for the extra head block; if so, reflow the line. PHPStan excludes `src/Views/*` (per Plan 3a Task 1's `phpstan.neon`), so the file is not analyzed.
- [ ] **Step 4: Commit**
```powershell
git add src/Views/layout.php
git commit -m "feat: add shared layout template"
```
### Task 2: Home page and team editor templates
**Files:**
- Create: `src/Views/home.php`
- Create: `src/Views/team-editor.php`
**Interfaces:**
- Produces two templates. The home template prints the home page; the team editor template prints the team editor form. Both call `render_layout(...)` and use `Escape::*` for every dynamic value.
- [ ] **Step 1: Implement `home.php`**
Create `src/Views/home.php`:
```php
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/** @var string $csrf Provided by the front controller. */
?><?php
render_layout(static function () use ($csrf): 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>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');
```
Note the structure: `<?php` opens, then the `render_layout(...)` call passes a closure as the body. The closure uses heredoc-free `?><?php ... ?>` blocks to print HTML. The layout prints the document chrome; the closure prints the body content. The `$csrf` variable is captured by the closure via `use ($csrf)`.
The home page's `<h2>Recent scenarios</h2>` and `<div id="recent">` are placeholders that Plan 3c's `storage.js` populates from `localStorage`.
- [ ] **Step 2: Implement `team-editor.php`**
Create `src/Views/team-editor.php`:
```php
<?php
declare(strict_types=1);
use BattleForge\Application\ArchetypeCatalog;
use BattleForge\Domain\Archetype;
use BattleForge\Http\Escape;
/**
* @var string $csrf Provided by the front controller.
* @var ?string $error Optional validator error message; HTML-escaped on output.
* @var array<string, string> $old Optional old form values, keyed by field name; HTML-escaped on output.
* @var array<string, mixed> $post Raw form data (for repopulating per-unit fields).
*/
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
$archetypes = ArchetypeCatalog::templates();
?><?php
render_layout(static function () use ($csrf, $e, $a, $archetypes, $error, $old, $post): void {
?>
<h1>Team editor</h1>
<?php if ($error !== null): ?>
<div class="bf-errors"><p><?= $e($error) ?></p></div>
<?php endif; ?>
<form method="post" action="<?= $a('/scenarios/' . $e($old['id'] ?? 'new') . '/edit/team') ?>" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
<fieldset>
<legend>Scenario</legend>
<label>Id: <input type="text" name="id" value="<?= $e($old['id'] ?? '') ?>" required></label>
<label>Name: <input type="text" name="name" value="<?= $e($old['name'] ?? '') ?>" required></label>
<label>Battlefield width: <input type="number" name="battlefieldWidth" min="8" max="16" value="<?= $e($old['battlefieldWidth'] ?? '8') ?>" required></label>
<label>Battlefield height: <input type="number" name="battlefieldHeight" min="8" max="16" value="<?= $e($old['battlefieldHeight'] ?? '8') ?>" required></label>
</fieldset>
<fieldset>
<legend>Team A</legend>
<?php for ($i = 0; $i < 3; $i++): $row = $post['teamA']['units'][$i] ?? null; ?>
<div class="bf-unit">
<input type="text" name="teamA[units][<?= $i ?>][id]" placeholder="a-<?= $i + 1 ?>" value="<?= $e($row['id'] ?? '') ?>" required>
<select name="teamA[units][<?= $i ?>][archetype]" required>
<?php foreach ($archetypes as $key => $template): ?>
<option value="<?= $a($key) ?>"<?= (($row['archetype'] ?? '') === $key) ? ' selected' : '' ?>><?= $e($key) ?></option>
<?php endforeach; ?>
</select>
<input type="number" name="teamA[units][<?= $i ?>][maxHealth]" placeholder="maxHealth" required>
<input type="number" name="teamA[units][<?= $i ?>][attack]" placeholder="attack" required>
<input type="number" name="teamA[units][<?= $i ?>][defense]" placeholder="defense" required>
<input type="number" name="teamA[units][<?= $i ?>][speed]" placeholder="speed" required>
<input type="text" name="teamA[units][<?= $i ?>][x]" placeholder="x" value="<?= $e($row['x'] ?? '0') ?>" required>
<input type="text" name="teamA[units][<?= $i ?>][y]" placeholder="y" value="<?= $e($row['y'] ?? '0') ?>" required>
<input type="file" name="teamA[units][<?= $i ?>][image]" accept="image/*">
<input type="hidden" name="teamA[units][<?= $i ?>][imageUrl]" value="<?= $e($row['imageUrl'] ?? '') ?>">
</div>
<?php endfor; ?>
</fieldset>
<fieldset>
<legend>Team B</legend>
<?php for ($i = 0; $i < 3; $i++): $row = $post['teamB']['units'][$i] ?? null; ?>
<div class="bf-unit">
<input type="text" name="teamB[units][<?= $i ?>][id]" placeholder="b-<?= $i + 1 ?>" value="<?= $e($row['id'] ?? '') ?>" required>
<select name="teamB[units][<?= $i ?>][archetype]" required>
<?php foreach ($archetypes as $key => $template): ?>
<option value="<?= $a($key) ?>"<?= (($row['archetype'] ?? '') === $key) ? ' selected' : '' ?>><?= $e($key) ?></option>
<?php endforeach; ?>
</select>
<input type="number" name="teamB[units][<?= $i ?>][maxHealth]" placeholder="maxHealth" required>
<input type="number" name="teamB[units][<?= $i ?>][attack]" placeholder="attack" required>
<input type="number" name="teamB[units][<?= $i ?>][defense]" placeholder="defense" required>
<input type="number" name="teamB[units][<?= $i ?>][speed]" placeholder="speed" required>
<input type="text" name="teamB[units][<?= $i ?>][x]" placeholder="x" value="<?= $e($row['x'] ?? '7') ?>" required>
<input type="text" name="teamB[units][<?= $i ?>][y]" placeholder="y" value="<?= $e($row['y'] ?? '7') ?>" required>
<input type="file" name="teamB[units][<?= $i ?>][image]" accept="image/*">
<input type="hidden" name="teamB[units][<?= $i ?>][imageUrl]" value="<?= $e($row['imageUrl'] ?? '') ?>">
</div>
<?php endfor; ?>
</fieldset>
<fieldset>
<legend>Victory</legend>
<label><input type="radio" name="victoryCondition" value="eliminate_all"<?= (($old['victoryCondition'] ?? 'eliminate_all') === 'eliminate_all') ? ' checked' : '' ?>> Eliminate all</label>
<label><input type="radio" name="victoryCondition" value="hold_objective"<?= (($old['victoryCondition'] ?? '') === 'hold_objective') ? ' checked' : '' ?>> Hold objective</label>
<label>Hold rounds: <input type="number" name="holdRoundsRequired" min="1" max="10" value="<?= $e($old['holdRoundsRequired'] ?? '1') ?>"></label>
</fieldset>
<button type="submit">Save</button>
</form>
<?php
}, $csrf, 'Team editor');
```
The form fields follow the names the existing `ScenarioDraft::fromPost` expects. The `archetype` dropdown is populated from `ArchetypeCatalog::templates()` and the selected value is the catalog key (e.g. `'defender'`). The `image` file input is a per-unit upload; the `imageUrl` hidden field is set by Plan 3c's `storage.js` after the upload completes. The submit POSTs to `/scenarios/{id}/edit/team` (the form's `action` attribute).
- [ ] **Step 3: Verify the templates are syntactically valid**
Run: `php -l src/Views/home.php && php -l src/Views/team-editor.php`
Expected: `No syntax errors detected in ...` for each file.
- [ ] **Step 4: Run the full quality suite**
Run: `vendor/bin/phpunit`
Expected: 177/177 tests pass. No regressions.
- [ ] **Step 5: Commit**
```powershell
git add src/Views/home.php src/Views/team-editor.php
git commit -m "feat: add home and team editor templates"
```
### Task 3: Battlefield editor, match stub, and upload result templates
**Files:**
- Create: `src/Views/battlefield-editor.php`
- Create: `src/Views/match-stub.php`
- Create: `src/Views/upload-result.php`
**Interfaces:**
- Produces three templates. The battlefield editor renders the empty grid that Plan 3c's `grid-editor.js` populates from `localStorage`. The match stub is the Plan 4 placeholder. The upload result is an iframe-style response for the upload form.
- [ ] **Step 1: Implement `battlefield-editor.php`**
Create `src/Views/battlefield-editor.php`:
```php
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/**
* @var string $csrf Provided by the front controller.
* @var string $scenarioId Provided by the front controller from the URL.
* @var int $width Battlefield width (8-16).
* @var int $height Battlefield height (8-16).
*/
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
$terrain = ['open', 'forest', 'rough', 'water', 'blocking'];
?><?php
render_layout(static function () use ($csrf, $e, $a, $scenarioId, $width, $height, $terrain): void {
?>
<h1>Battlefield editor</h1>
<p>Scenario: <strong><?= $e($scenarioId) ?></strong></p>
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded">
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
<fieldset>
<legend>Battlefield</legend>
<label>Width: <input type="number" name="battlefieldWidth" min="8" max="16" value="<?= $e((string) $width) ?>" required></label>
<label>Height: <input type="number" name="battlefieldHeight" min="8" max="16" value="<?= $e((string) $height) ?>" required></label>
</fieldset>
<fieldset>
<legend>Terrain palette</legend>
<?php foreach ($terrain as $t): ?>
<button type="button" data-paint="<?= $a($t) ?>" class="bf-paint"><?= $e($t) ?></button>
<?php endforeach; ?>
</fieldset>
<fieldset>
<legend>Grid (<?= $e((string) $width) ?> × <?= $e((string) $height) ?>)</legend>
<table class="bf-grid" id="bf-grid">
<?php for ($y = 0; $y < $height; $y++): ?>
<tr>
<?php for ($x = 0; $x < $width; $x++): ?>
<td><button type="button" class="bf-tile" data-x="<?= $e((string) $x) ?>" data-y="<?= $e((string) $y) ?>" data-paint="open">.</button></td>
<?php endfor; ?>
</tr>
<?php endfor; ?>
</table>
</fieldset>
<fieldset>
<legend>Deployment zones</legend>
<label><input type="radio" name="zoneMode" value="alpha" checked> Place team A zones</label>
<label><input type="radio" name="zoneMode" value="bravo"> Place team B zones</label>
<label><input type="radio" name="zoneMode" value="objective"> Place objective</label>
</fieldset>
<button type="submit">Save</button>
</form>
<script type="module" src="<?= $a('/js/grid-editor.js') ?>"></script>
<?php
}, $csrf, 'Battlefield editor');
```
The grid is rendered as an HTML table; Plan 3c's `grid-editor.js` populates the tiles' `data-paint` attributes from `localStorage` and posts the assembled JSON to the form's `action` URL on save.
- [ ] **Step 2: Implement `match-stub.php`**
Create `src/Views/match-stub.php`:
```php
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/**
* @var string $csrf Provided by the front controller.
* @var ?string $matchJson The match:current JSON from localStorage, encoded as a string.
* Null if no match is in progress.
*/
$e = static fn (string $v): string => Escape::html($v);
$matchInfo = $matchJson !== null ? "Match loaded." : "No match in progress.";
?><?php
render_layout(static function () use ($e, $matchInfo, $matchJson): void {
?>
<h1>Match</h1>
<p><?= $e($matchInfo) ?></p>
<?php if ($matchJson !== null): ?>
<details>
<summary>Match state (debug)</summary>
<pre><?= $e($matchJson) ?></pre>
</details>
<?php endif; ?>
<p>Battle interface coming in Plan 4.</p>
<p><a href="/">Back to home</a></p>
<?php
}, $csrf, 'Match');
```
The match stub is intentionally simple. Plan 4 replaces this with the real battle interface.
- [ ] **Step 3: Implement `upload-result.php`**
Create `src/Views/upload-result.php`:
```php
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/**
* @var string $csrf Provided by the front controller.
* @var string $url The uploaded image's URL, written into the parent form's hidden field.
*/
$e = static fn (string $v): string => Escape::html($v);
$json = json_encode(['url' => $url], JSON_THROW_ON_ERROR);
?><?php
render_layout(static function () use ($e, $url, $json): void {
?>
<p>Uploaded: <code><?= $e($url) ?></code></p>
<script>
(function () {
var data = <?= $json /* trusted raw JSON; $url was validated server-side */ ?>;
if (window.parent && window.parent !== window) {
window.parent.postMessage(Object.assign({type: 'bf-upload'}, data), '*');
}
})();
</script>
<?php
}, $csrf, 'Upload complete');
```
The page is rendered as the response of the upload form. The inline script calls `window.parent.postMessage` so the parent form can read the URL and set its hidden `imageUrl` field. Plan 3c's `storage.js` (or a smaller upload helper) wires this up.
- [ ] **Step 4: Verify the templates are syntactically valid**
Run: `php -l src/Views/battlefield-editor.php && php -l src/Views/match-stub.php && php -l src/Views/upload-result.php`
Expected: `No syntax errors detected in ...` for each file.
- [ ] **Step 5: Run the full quality suite**
Run: `vendor/bin/phpunit`
Expected: 177/177 tests pass. No regressions.
- [ ] **Step 6: Commit**
```powershell
git add src/Views/battlefield-editor.php src/Views/match-stub.php src/Views/upload-result.php
git commit -m "feat: add battlefield, match stub, and upload result templates"
```
### Task 4: Team editor GET and POST handlers
**Files:**
- Create: `src/Http/Handlers/GetTeamEditor.php`
- Create: `src/Http/Handlers/PostTeamEditor.php`
- Test: `tests/Integration/GetTeamEditorTest.php`
- Test: `tests/Integration/PostTeamEditorTest.php`
**Interfaces:**
- Produces two handlers:
- `GetTeamEditor::handle(Request $request, array $params): Response` — returns `Response::html(200, …)` rendering the team editor template. The form is rendered empty; Plan 3c's `storage.js` populates it from `localStorage` on load. The template receives `$scenarioId` (the URL param, default `'new'`), `$csrf` (the pre-issued token), and renders the team editor template.
- `PostTeamEditor::handle(Request $request, array $params): Response` — verifies the CSRF token, builds a `ScenarioDraft` from `$request->post`, calls `toScenario()`, runs `ScenarioValidator::validate()`. On success, renders a "Saved" template that includes a `<script type="module">` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))`. On failure, re-renders the team editor with errors and original form values.
- [ ] **Step 1: Write the failing test for `GetTeamEditor`**
Create `tests/Integration/GetTeamEditorTest.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetTeamEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetTeamEditorTest extends TestCase
{
public function testItReturnsTheTeamEditorPageWithSecurityHeaders(): void
{
$handler = new GetTeamEditor();
$request = new Request([], [], [], [], [], '', 'GET', '/scenarios/demo/edit/team', 'text/html', '');
$response = $handler->handle($request, ['id' => 'demo']);
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('name="_csrf"', $response->body);
self::assertStringContainsString('name="id"', $response->body);
self::assertStringContainsString('name="victoryCondition"', $response->body);
self::assertStringContainsString('value="defender"', $response->body);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `vendor/bin/phpunit tests/Integration/GetTeamEditorTest.php`
Expected: FAIL because `BattleForge\Http\Handlers\GetTeamEditor` does not exist.
- [ ] **Step 3: Implement `GetTeamEditor`**
Create `src/Http/Handlers/GetTeamEditor.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetTeamEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$csrf = $request->cookies['__csrf'] ?? '';
$scenarioId = $params['id'] ?? 'new';
ob_start();
require __DIR__ . '/../../Views/team-editor.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
}
```
The handler captures `$csrf` and `$scenarioId` as locals, then `require`s the template. The template's `<?php` block at the top pulls in `use` statements and reads `$csrf`, `$scenarioId` (and optional `$error`, `$old`, `$post` for re-rendering after a failed save). Plan 3c's JS pre-fills the form from `localStorage` on load.
- [ ] **Step 4: Run the test to verify it passes**
Run: `vendor/bin/phpunit tests/Integration/GetTeamEditorTest.php`
Expected: PASS.
- [ ] **Step 5: Write the failing test for `PostTeamEditor`**
Create `tests/Integration/PostTeamEditorTest.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Domain\Archetype;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostTeamEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostTeamEditorTest extends TestCase
{
private const SECRET = 'unit-test-secret';
public function testItRejectsARequestWithAMissingCsrfToken(): void
{
$handler = new PostTeamEditor();
$request = $this->buildRequest(post: ['id' => 'demo']);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
self::assertStringContainsString('Forbidden', $response->body);
}
public function testItRejectsARequestWithAnInvalidCsrfToken(): void
{
$handler = new PostTeamEditor();
$request = $this->buildRequest(post: ['id' => 'demo', '_csrf' => 'tampered']);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
}
public function testItSavesAValidScenarioAndReturnsTheLocalStorageSnippet(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostTeamEditor();
$request = $this->buildRequest(
post: $this->validPost($token),
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
self::assertStringContainsString('localStorage.setItem', $response->body);
self::assertStringContainsString('scenario:demo', $response->body);
}
public function testItRejectsAnOutOfBoundsStatAndReRendersTheForm(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostTeamEditor();
$post = $this->validPost($token);
$post['teamA']['units'][0]['maxHealth'] = '9999';
$request = $this->buildRequest(
post: $post,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
self::assertStringContainsString('bf-errors', $response->body);
self::assertStringContainsString('outside archetype bounds', $response->body);
}
/** @param array<string, mixed> $post */
private function buildRequest(array $post, array $cookies = [], array $server = []): Request
{
return new Request([], $post, [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/team', 'application/x-www-form-urlencoded', '');
}
/** @return array<string, mixed> */
private function validPost(string $token): array
{
return [
'_csrf' => $token,
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => '8',
'battlefieldHeight' => '8',
'teamA' => [
'units' => [
['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '0', 'y' => '0'],
['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '1', 'y' => '0'],
['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '2', 'y' => '0'],
],
],
'teamB' => [
'units' => [
['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '7', 'y' => '7'],
['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '2', 'speed' => '3', 'x' => '6', 'y' => '7'],
['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '5', 'y' => '7'],
],
],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => '1',
];
}
}
```
Note: the `validPost` for team B has a typo (duplicate `'speed' => '2'`). The implementer should fix this typo on the way through — see Step 3 below.
- [ ] **Step 6: Run the test to verify it fails**
Run: `vendor/bin/phpunit tests/Integration/PostTeamEditorTest.php`
Expected: FAIL because `BattleForge\Http\Handlers\PostTeamEditor` does not exist.
- [ ] **Step 7: Implement `PostTeamEditor`**
Create `src/Http/Handlers/PostTeamEditor.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioDraft;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Escape;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostTeamEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->post['_csrf'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return Response::html(403, '<h1>Forbidden</h1>');
}
try {
$draft = ScenarioDraft::fromPost($request->post);
$scenario = $draft->toScenario();
ScenarioValidator::validate($scenario);
} catch (\InvalidArgumentException $exception) {
return $this->renderForm($request, $params['id'] ?? 'new', $request->cookies['__csrf'] ?? '', $exception->getMessage(), $request->post);
}
$json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR);
$url = (string) ($params['id'] ?? $scenario->id);
$body = <<<HTML
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Saved</title></head>
<body>
<p>Scenario saved.</p>
<script type="module">
localStorage.setItem('scenario:{$url}', $json);
</script>
</body>
</html>
HTML;
return Response::html(200, $body);
}
/** @param array<string, mixed> $post */
private function renderForm(Request $request, string $scenarioId, string $csrf, string $error, array $post): Response
{
$error = Escape::html($error);
$csrf = Escape::html($csrf);
$scenarioId = Escape::html($scenarioId);
$teamA = $post['teamA']['units'] ?? [];
$teamB = $post['teamB']['units'] ?? [];
ob_start();
require __DIR__ . '/../../Views/team-editor.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
/** @return array<string, mixed> */
private function scenarioToArray(\BattleForge\Domain\Scenario $scenario): array
{
return \BattleForge\Application\ScenarioSerializer::scenarioToArray($scenario);
}
}
```
The `scenarioToArray` private method is a thin wrapper that calls the Plan 3a `ScenarioSerializer::scenarioToArray`. Both methods exist in the same `BattleForge\Application` namespace; the wrapper is just to keep the handler self-contained.
- [ ] **Step 8: Fix the test typo in `validPost`**
The Step 5 `validPost` has a duplicate `'speed' => '2'` row for team B unit 2. Remove one. (Plan 3a's Task 9 had a similar typo in test data; the implementer should fix it on first run, mark `DONE_WITH_CONCERNS`, and document.)
- [ ] **Step 9: Run the test to verify it passes**
Run: `vendor/bin/phpunit tests/Integration/PostTeamEditorTest.php`
Expected: 4/4 tests pass.
- [ ] **Step 10: Run the full quality suite and commit**
Run: `composer check`
Expected: all checks green. (PHPCS may emit line-length warnings on the team editor's long `<option>` lines; if so, reflow.)
```powershell
git add src/Http/Handlers/GetTeamEditor.php src/Http/Handlers/PostTeamEditor.php tests/Integration/GetTeamEditorTest.php tests/Integration/PostTeamEditorTest.php
git commit -m "feat: add team editor GET and POST handlers"
```
### Task 5: Battlefield editor GET and POST handlers
**Files:**
- Create: `src/Http/Handlers/GetBattlefieldEditor.php`
- Create: `src/Http/Handlers/PostBattlefieldEditor.php`
- Test: `tests/Integration/GetBattlefieldEditorTest.php`
- Test: `tests/Integration/PostBattlefieldEditorTest.php`
**Interfaces:**
- Produces two handlers:
- `GetBattlefieldEditor::handle(Request, array): Response` — returns `Response::html(200, …)` rendering the battlefield editor template with an empty `<table class="bf-grid">` (Plan 3c populates it from `localStorage`).
- `PostBattlefieldEditor::handle(Request, array): Response` — verifies the CSRF token from the `X-CSRF-Token` header, decodes `$request->rawBody` as JSON, builds a `ScenarioDraft`, calls `toScenario()`, runs `ScenarioValidator::validate()`. On success, returns `Response::json(200, ['ok' => true, 'scenario' => $array])`. On failure, returns `Response::json(400, ['ok' => false, 'errors' => [...]])`.
- [ ] **Step 1: Write the failing tests**
Create `tests/Integration/GetBattlefieldEditorTest.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetBattlefieldEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetBattlefieldEditorTest extends TestCase
{
public function testItReturnsTheBattlefieldEditorPageWithAnEmptyGrid(): void
{
$handler = new GetBattlefieldEditor();
$request = new Request([], [], [], [], [], '', 'GET', '/scenarios/demo/edit/battlefield', 'text/html', '');
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
self::assertStringContainsString('table.bf-grid', $response->body);
self::assertStringContainsString('name="_csrf"', $response->body);
}
}
```
Create `tests/Integration/PostBattlefieldEditorTest.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostBattlefieldEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostBattlefieldEditorTest extends TestCase
{
private const SECRET = 'unit-test-secret';
public function testItRejectsARequestWithAMissingCsrfHeader(): void
{
$handler = new PostBattlefieldEditor();
$request = $this->buildRequest(rawBody: '{}', csrfHeader: '');
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
self::assertStringContainsString('csrf', $response->body);
}
public function testItAcceptsAValidBattlefieldJsonAndReturnsOk(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostBattlefieldEditor();
$request = $this->buildRequest(
rawBody: json_encode($this->validBattlefield(), JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertSame(true, $body['ok'] ?? null);
self::assertSame('demo', $body['scenario']['id'] ?? null);
}
public function testItReturns400OnAnInvalidShape(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostBattlefieldEditor();
$request = $this->buildRequest(
rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(400, $response->status);
$body = json_decode($response->body, true);
self::assertSame(false, $body['ok'] ?? null);
self::assertNotEmpty($body['errors'] ?? []);
}
/** @param array<string, string> $cookies */
private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = [], array $server = []): Request
{
$server['HTTP_X_CSRF_TOKEN'] = $csrfHeader;
$server['CONTENT_TYPE'] = 'application/json';
return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/battlefield', 'application/json', $rawBody);
}
/** @return array<string, mixed> */
private function validBattlefield(): array
{
return [
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => 8,
'battlefieldHeight' => 8,
'battlefieldTerrain' => [],
'teamA' => [
'units' => [
['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0],
['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0],
['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0],
],
],
'teamB' => [
'units' => [
['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7],
['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7],
['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7],
],
],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `vendor/bin/phpunit tests/Integration/GetBattlefieldEditorTest.php tests/Integration/PostBattlefieldEditorTest.php`
Expected: FAIL because the handlers do not exist.
- [ ] **Step 3: Implement `GetBattlefieldEditor`**
Create `src/Http/Handlers/GetBattlefieldEditor.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetBattlefieldEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$csrf = $request->cookies['__csrf'] ?? '';
$scenarioId = $params['id'] ?? 'new';
$width = 8;
$height = 8;
ob_start();
require __DIR__ . '/../../Views/battlefield-editor.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
}
```
The handler passes `$csrf`, `$scenarioId`, `$width`, and `$height` to the template. Plan 3c's `grid-editor.js` overwrites the empty tiles from `localStorage`. The `$width` and `$height` defaults to 8 may be replaced with a per-scenario value once the assembled scenario is loaded; for now, the empty editor starts at 8×8 and the JS can resize.
- [ ] **Step 4: Implement `PostBattlefieldEditor`**
Create `src/Http/Handlers/PostBattlefieldEditor.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioDraft;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostBattlefieldEditor
{
/** @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);
} catch (\JsonException $exception) {
return Response::json(400, ['ok' => false, 'errors' => ['Malformed JSON.']]);
}
try {
$draft = ScenarioDraft::fromPost($payload);
$scenario = $draft->toScenario();
ScenarioValidator::validate($scenario);
} catch (\InvalidArgumentException $exception) {
return Response::json(400, ['ok' => false, 'errors' => [$exception->getMessage()]]);
}
return Response::json(200, ['ok' => true, 'scenario' => ScenarioSerializer::scenarioToArray($scenario)]);
}
}
```
The handler reads `HTTP_X_CSRF_TOKEN` from `$request->server` (PHP normalizes `X-CSRF-Token: …` to `HTTP_X_CSRF_TOKEN`). On success, it returns the assembled `Scenario` as an array via `ScenarioSerializer::scenarioToArray` so the JS can write it to `localStorage` without needing to round-trip through `ScenarioDraft`.
- [ ] **Step 5: Run the tests to verify they pass**
Run: `vendor/bin/phpunit tests/Integration/GetBattlefieldEditorTest.php tests/Integration/PostBattlefieldEditorTest.php`
Expected: 4/4 tests pass.
- [ ] **Step 6: Run the full quality suite and commit**
Run: `composer check`
Expected: all checks green.
```powershell
git add src/Http/Handlers/GetBattlefieldEditor.php src/Http/Handlers/PostBattlefieldEditor.php tests/Integration/GetBattlefieldEditorTest.php tests/Integration/PostBattlefieldEditorTest.php
git commit -m "feat: add battlefield editor GET and POST handlers"
```
### Task 6: Image upload and start-match POST handlers
**Files:**
- Create: `src/Http/Handlers/PostImageUpload.php`
- Create: `src/Http/Handlers/PostStartMatch.php`
- Test: `tests/Integration/PostImageUploadTest.php`
- Test: `tests/Integration/PostStartMatchTest.php`
**Interfaces:**
- Produces two handlers:
- `PostImageUpload::handle(Request, array): Response` — verifies the CSRF token from the form field, reads `$_FILES['image']`, computes the `userToken` (derived from the CSRF secret), constructs `ImageUploadService($userToken, $uploadsRoot)`, calls `store($tempPath, $declaredMime)`, returns `Response::json(200, ['url' => $url])` on success or `Response::json(400, ['error' => $message])` on failure.
- `PostStartMatch::handle(Request, array): Response` — verifies the CSRF token from the `X-CSRF-Token` header, decodes the JSON body as a `Scenario` via `ScenarioSerializer::scenarioFromArray`, runs `ScenarioValidator::validate()` (defense in depth), calls `Scenario::startMatch('alpha')`, returns `Response::json(200, ['match' => $array])` on success or `Response::json(400, ['errors' => [...]])` on failure.
- [ ] **Step 1: Write the failing tests**
Create `tests/Integration/PostImageUploadTest.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostImageUpload;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostImageUploadTest extends TestCase
{
private const SECRET = 'unit-test-secret';
private string $uploadsRoot;
protected function setUp(): void
{
$this->uploadsRoot = sys_get_temp_dir() . '/bf-uploads-' . bin2hex(random_bytes(4));
mkdir($this->uploadsRoot, 0700, true);
}
protected function tearDown(): void
{
if (is_dir($this->uploadsRoot)) {
foreach (glob($this->uploadsRoot . '/*/*') as $file) {
unlink($file);
}
foreach (glob($this->uploadsRoot . '/*') as $dir) {
rmdir($dir);
}
rmdir($this->uploadsRoot);
}
}
public function testItRejectsARequestWithAMissingCsrfField(): void
{
$handler = new PostImageUpload($this->uploadsRoot);
$request = new Request([], [], [], [], [], '', 'POST', '/assets/upload', 'multipart/form-data', '');
$response = $handler->handle($request, []);
self::assertSame(403, $response->status);
}
public function testItAcceptsAValidImageAndReturnsAUrl(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostImageUpload($this->uploadsRoot);
$tmp = tempnam(sys_get_temp_dir(), 'bf-up');
file_put_contents($tmp, $this->validPng(8, 8));
$request = new Request(
get: [],
post: ['_csrf' => $token],
files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 70, 'type' => 'image/png']],
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
queryString: '',
method: 'POST',
path: '/assets/upload',
contentType: 'multipart/form-data',
rawBody: '',
);
$response = $handler->handle($request, []);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertStringStartsWith('/assets/', $body['url'] ?? '');
self::assertStringEndsWith('.png', $body['url'] ?? '');
}
public function testItReturns400OnAnInvalidImage(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostImageUpload($this->uploadsRoot);
$tmp = tempnam(sys_get_temp_dir(), 'bf-up');
file_put_contents($tmp, 'not an image');
$request = new Request(
get: [],
post: ['_csrf' => $token],
files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 12, 'type' => 'image/png']],
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
queryString: '',
method: 'POST',
path: '/assets/upload',
contentType: 'multipart/form-data',
rawBody: '',
);
$response = $handler->handle($request, []);
self::assertSame(400, $response->status);
}
private function validPng(int $width, int $height): string
{
if ($width === 8 && $height === 8) {
return base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' .
'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC',
true,
);
}
throw new \InvalidArgumentException('Only 8x8 base PNG supported.');
}
}
```
Create `tests/Integration/PostStartMatchTest.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostStartMatch;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostStartMatchTest extends TestCase
{
private const SECRET = 'unit-test-secret';
public function testItRejectsARequestWithAMissingCsrfHeader(): void
{
$handler = new PostStartMatch();
$request = $this->buildRequest('{}', '');
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
}
public function testItAcceptsAValidScenarioAndReturnsTheInitialMatch(): 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);
self::assertSame('alpha', $body['match']['activeTeamId'] ?? null);
self::assertSame(1, $body['match']['round'] ?? null);
self::assertCount(6, $body['match']['units'] ?? []);
}
public function testItReturns400OnAnInvalidScenario(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostStartMatch();
$request = $this->buildRequest(
rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(400, $response->status);
}
/** @param array<string, string> $cookies */
private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = []): Request
{
$server = [
'HTTP_X_CSRF_TOKEN' => $csrfHeader,
'__csrf_secret' => self::SECRET,
'CONTENT_TYPE' => 'application/json',
];
return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/start', 'application/json', $rawBody);
}
/** @return array<string, mixed> */
private function validScenario(): array
{
return [
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => 8,
'battlefieldHeight' => 8,
'battlefieldTerrain' => [],
'teamA' => [
'units' => [
['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0],
['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0],
['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0],
],
],
'teamB' => [
'units' => [
['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7],
['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7],
['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7],
],
],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `vendor/bin/phpunit tests/Integration/PostImageUploadTest.php tests/Integration/PostStartMatchTest.php`
Expected: FAIL because the handlers do not exist.
- [ ] **Step 3: Implement `PostImageUpload`**
Create `src/Http/Handlers/PostImageUpload.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ImageUploadService;
use BattleForge\Application\InvalidImageException;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostImageUpload
{
public function __construct(private readonly string $uploadsRoot)
{
}
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->post['_csrf'] ?? '');
$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']);
}
$file = $request->files['image'] ?? null;
if ($file === null) {
return Response::json(400, ['error' => 'No image uploaded.']);
}
$tempPath = (string) ($file['tmp_name'] ?? '');
$declaredMime = (string) ($file['type'] ?? '');
if ($tempPath === '' || !is_uploaded_file($tempPath)) {
return Response::json(400, ['error' => 'Upload is not a file.']);
}
$userToken = hash_hmac('sha256', $secret, 'bf-uploads');
try {
$service = new ImageUploadService($userToken, $this->uploadsRoot);
$url = $service->store($tempPath, $declaredMime);
} catch (InvalidImageException $exception) {
return Response::json(400, ['error' => $exception->getMessage()]);
}
return Response::json(200, ['url' => $url]);
}
}
```
The handler computes `$userToken` from the same secret used for CSRF. The `ImageUploadService::store` method does the content validation, dimension check, and atomic file move. The `is_uploaded_file` check is the standard PHP defense against the temp-file path being tampered with.
- [ ] **Step 4: Implement `PostStartMatch`**
Create `src/Http/Handlers/PostStartMatch.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioSerializer;
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);
$scenario = ScenarioSerializer::scenarioFromArray($payload);
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()]]);
}
return Response::json(200, ['match' => ScenarioSerializer::matchToArray($match)]);
}
}
```
The handler decodes the JSON, validates it, and produces the initial `MatchState`. The `'alpha'` team is hard-coded because the spec's editor always starts with the first team.
- [ ] **Step 5: Run the tests to verify they pass**
Run: `vendor/bin/phpunit tests/Integration/PostImageUploadTest.php tests/Integration/PostStartMatchTest.php`
Expected: 6/6 tests pass.
- [ ] **Step 6: Run the full quality suite and commit**
Run: `composer check`
Expected: all checks green.
```powershell
git add src/Http/Handlers/PostImageUpload.php src/Http/Handlers/PostStartMatch.php tests/Integration/PostImageUploadTest.php tests/Integration/PostStartMatchTest.php
git commit -m "feat: add image upload and start-match handlers"
```
### Task 7: Front controller
**Files:**
- Modify: `public/index.php`
**Interfaces:**
- Produces a real front controller that:
1. Reads `$_GET`, `$_POST`, `$_FILES`, `$_COOKIE`, `$_SERVER`.
2. Derives the request method, path, query string, and content type.
3. Reads the app secret from `BATTLEFORGE_SECRET` (env var) or, if absent, from `var/secret.key` (a 32-byte binary file created on first run).
4. Issues a CSRF token on first visit (when the `__csrf` cookie is absent) and ensures the cookie is set on every response with `HttpOnly`, `SameSite=Lax`, `Path=/`, `Expires=time()+86400`.
5. Computes the `__uploads_token` cookie as `hash_hmac('sha256', $secret, 'bf-uploads')` and threads it via a request attribute.
6. Configures the `Router` with the eight routes.
7. Builds a `Request` value object with the request data, the CSRF secret (in `__csrf_secret`), the upload token (in `__uploads_token`), and the request method, path, and content type.
8. Dispatches and emits the response (status, headers, body).
- [ ] **Step 1: Replace the stub with the real front controller**
Replace `public/index.php` with:
```php
<?php
declare(strict_types=1);
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\GetAssets;
use BattleForge\Http\Handlers\GetBattlefieldEditor;
use BattleForge\Http\Handlers\GetHomePage;
use BattleForge\Http\Handlers\GetTeamEditor;
use BattleForge\Http\Handlers\PostBattlefieldEditor;
use BattleForge\Http\Handlers\PostImageUpload;
use BattleForge\Http\Handlers\PostStartMatch;
use BattleForge\Http\Handlers\PostTeamEditor;
use BattleForge\Http\Request;
use BattleForge\Http\Router;
require __DIR__ . '/../vendor/autoload.php';
// 1. Read the app secret.
$secret = getenv('BATTLEFORGE_SECRET');
if ($secret === false || $secret === '') {
$secretFile = __DIR__ . '/../var/secret.key';
if (!is_file($secretFile)) {
if (!is_dir(dirname($secretFile))) {
mkdir(dirname($secretFile), 0700, true);
}
file_put_contents($secretFile, random_bytes(32));
chmod($secretFile, 0600);
}
$secret = file_get_contents($secretFile);
if ($secret === false) {
http_response_code(500);
echo "Failed to read app secret.\n";
return;
}
}
// 2. Ensure the __csrf cookie is set. If absent, issue a fresh token.
$cookies = $_COOKIE;
$existingCookie = (string) ($cookies['__csrf'] ?? '');
if ($existingCookie === '') {
[$token, $cookie] = CsrfToken::issue($secret);
setcookie('__csrf', $cookie, [
'expires' => time() + 86400,
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
$existingCookie = $cookie;
$cookies['__csrf'] = $cookie;
}
// 3. Compute the upload-endpoint user token (derived from the same secret).
$uploadsToken = hash_hmac('sha256', $secret, 'bf-uploads');
// 4. Determine the request method, path, and content type.
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
$queryString = (string) (parse_url($uri, PHP_URL_QUERY) ?? '');
$contentType = isset($_SERVER['CONTENT_TYPE']) ? (string) $_SERVER['CONTENT_TYPE'] : null;
if ($contentType !== null) {
$semicolon = strpos($contentType, ';');
if ($semicolon !== false) {
$contentType = substr($contentType, 0, $semicolon);
}
$contentType = trim($contentType);
}
// 5. Read the raw body for fetch POSTs that submit JSON.
$rawBody = (string) file_get_contents('php://input');
// 6. Build a Request with the request data, the CSRF secret, and the upload token.
$server = $_SERVER;
$server['__csrf_secret'] = $secret;
$server['__uploads_token'] = $uploadsToken;
$request = new Request(
get: $_GET,
post: $_POST,
files: $_FILES,
cookies: $cookies,
server: $server,
queryString: $queryString,
method: $method,
path: $path,
contentType: $contentType,
rawBody: $rawBody,
);
// 7. Configure the router with the eight routes.
$uploadsRoot = __DIR__ . '/../var/uploads';
$placeholderDir = __DIR__ . '/assets/placeholders';
$router = new Router();
$router->add('GET', '/', new GetHomePage());
$router->add('GET', '/scenarios/{id}/edit/team', new GetTeamEditor());
$router->add('POST', '/scenarios/{id}/edit/team', new PostTeamEditor());
$router->add('GET', '/scenarios/{id}/edit/battlefield', new GetBattlefieldEditor());
$router->add('POST', '/scenarios/{id}/edit/battlefield', new PostBattlefieldEditor());
$router->add('POST', '/scenarios/{id}/start', new PostStartMatch());
$router->add('POST', '/assets/upload', new PostImageUpload($uploadsRoot));
$router->add('GET', '/assets/{kind}/{filename}', new GetAssets($placeholderDir, $uploadsRoot));
// 8. Dispatch and emit.
$response = $router->dispatch($request);
http_response_code($response->status);
foreach ($response->headers as $name => $value) {
header($name . ': ' . $value);
}
echo $response->body;
```
The front controller is the only place that reads superglobals. It threads the CSRF secret via `$server['__csrf_secret']` (so handlers read `$request->server['__csrf_secret']`) and the upload token via `$server['__uploads_token']` (so `GetAssets` reads `$request->server['__uploads_token']`). The `GetAssets` task in Plan 3a already validates this; Task 8 (next) adds the strict-format regex on the `userToken` path param.
- [ ] **Step 2: Add the strict-format regex to `GetAssets`**
Edit `src/Http/Handlers/GetAssets.php` to add a regex check on the `userToken` path param. After the `$kind` and `$filename` extractions, add:
```php
if ($kind === 'uploads') {
$userToken = $params['userToken'] ?? '';
if (!preg_match('/^[a-f0-9]{32,}$/', $userToken)) {
return Response::html(404, '<h1>Not found</h1>');
}
// ... existing code
}
```
This is a one-line addition (plus the regex pattern) that hardens the path-traversal surface called out in the design spec.
- [ ] **Step 3: Verify the front controller works**
Run: `php -l public/index.php`
Expected: `No syntax errors detected in public/index.php`.
- [ ] **Step 4: Run the full quality suite**
Run: `vendor/bin/phpunit`
Expected: 177 + the new Task 4-6 tests pass. PHPStan clean.
- [ ] **Step 5: Commit**
```powershell
git add public/index.php src/Http/Handlers/GetAssets.php
git commit -m "feat: wire the front controller and harden userToken path"
```
### Task 8: Full-flow integration test
**Files:**
- Create: `tests/Integration/FullFlowTest.php`
**Interfaces:**
- Produces a single integration test that walks the full create-and-save flow:
1. Bootstraps the front controller in-process (no `php -S` boot).
2. Issues a CSRF token.
3. GET home page.
4. POST team editor with a valid form body, asserts the response says "Saved" and contains the `localStorage.setItem` snippet.
5. POST battlefield editor with a valid JSON body, asserts `{ok: true, ...}`.
6. POST start-match with the assembled scenario, asserts the response contains the initial match state.
The "bootstrap" approach uses PHP's output buffering and superglobal injection via `$_GET` / `$_POST` / `$_FILES` / `$_COOKIE` / `$_SERVER`. The test does NOT actually boot `php -S`; it requires `public/index.php` and captures its output.
- [ ] **Step 1: Write the failing test**
Create `tests/Integration/FullFlowTest.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use PHPUnit\Framework\TestCase;
final class FullFlowTest extends TestCase
{
private const SECRET = 'unit-test-secret';
protected function setUp(): void
{
// Reset superglobals between requests.
$_GET = [];
$_POST = [];
$_FILES = [];
$_COOKIE = [];
$_SERVER = [];
}
public function testTheFullCreateAndSaveFlow(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$uploadsRoot = sys_get_temp_dir() . '/bf-fullflow-' . bin2hex(random_bytes(4));
mkdir($uploadsRoot, 0700, true);
try {
// Step 1: GET home page.
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/';
$homeBody = $this->runFrontController();
self::assertStringContainsString('New scenario', $homeBody);
self::assertStringContainsString('name="csrf-token"', $homeBody);
// Step 2: POST team editor.
$_COOKIE['__csrf'] = $cookie;
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/team';
$_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
$_SERVER['__csrf_secret'] = self::SECRET;
$_POST = $this->validTeamPost($token);
$teamBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
self::assertStringContainsString('localStorage.setItem', $teamBody);
self::assertStringContainsString('scenario:demo', $teamBody);
// Step 3: POST battlefield editor.
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/battlefield';
$_SERVER['CONTENT_TYPE'] = 'application/json';
$_SERVER['HTTP_X_CSRF_TOKEN'] = $token;
$_POST = [];
$this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR);
$bfBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
$bfJson = json_decode($bfBody, true);
self::assertSame(true, $bfJson['ok'] ?? null);
// Step 4: POST start-match.
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/scenarios/demo/start';
$this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR);
$startBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
$startJson = json_decode($startBody, true);
self::assertSame('alpha', $startJson['match']['activeTeamId'] ?? null);
self::assertSame(1, $startJson['match']['round'] ?? null);
self::assertCount(6, $startJson['match']['units'] ?? []);
} finally {
if (is_dir($uploadsRoot)) {
foreach (glob($uploadsRoot . '/*/*') as $file) {
unlink($file);
}
foreach (glob($uploadsRoot . '/*') as $dir) {
rmdir($dir);
}
rmdir($uploadsRoot);
}
}
}
private string $rawBody = '';
private int $lastStatus = 0;
private function runFrontController(): string
{
$this->lastStatus = 0;
$body = $this->captureOutput(static function (): void {
$this->lastStatus = (require __DIR__ . '/../../public/index.php') ?? http_response_code();
});
return $body;
}
/** @param callable(): void $fn */
private function captureOutput(callable $fn): string
{
ob_start();
try {
$fn();
} finally {
$output = (string) ob_get_clean();
}
return $output ?? '';
}
/** @return array<string, mixed> */
private function validTeamPost(string $token): array
{
return [
'_csrf' => $token,
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => '8',
'battlefieldHeight' => '8',
'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)],
'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => '1',
];
}
/** @return array<string, mixed> */
private function validBattlefieldPayload(): array
{
return [
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => 8,
'battlefieldHeight' => 8,
'battlefieldTerrain' => [],
'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)],
'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
}
/**
* @return list<array<string, string|int>>
*/
private function unitRows(string $teamPrefix, int $xStart, int $xEnd, int $y): array
{
$rows = [];
for ($i = 0; $i <= $xEnd - $xStart; $i++) {
$rows[] = [
'id' => "{$teamPrefix}{$i}",
'archetype' => 'defender',
'maxHealth' => 12,
'attack' => 3,
'defense' => 4,
'speed' => 2,
'x' => $xStart + $i,
'y' => $y,
];
}
return $rows;
}
}
```
Note: `validTeamPost` uses `teamA` rows 0-2 at x=0-2, y=0 (Defenders). `validBattlefieldPayload` uses the same shape but as integers (the JSON path). `validTeamPost` is the form-encoded version with strings (form fields are always strings). The test runs the same flow end-to-end.
- [ ] **Step 2: Run the test to verify it fails**
Run: `vendor/bin/phpunit tests/Integration/FullFlowTest.php`
Expected: FAIL because the test's `require` path for `public/index.php` does not exist yet (or because the full flow is not wired end-to-end).
- [ ] **Step 3: Implement the test**
The test is already written. The implementer should run it, fix any wiring issues (e.g. the front controller's `require __DIR__ . '/../vendor/autoload.php';` works from `public/`, so the test's bootstrap from `tests/Integration/FullFlowTest.php` requires `__DIR__ . '/../../public/index.php'` which is correct), and verify the full flow.
- [ ] **Step 4: Run the test to verify it passes**
Run: `vendor/bin/phpunit tests/Integration/FullFlowTest.php`
Expected: PASS.
- [ ] **Step 5: Run the full quality suite and commit**
Run: `composer check`
Expected: all checks green.
```powershell
git add tests/Integration/FullFlowTest.php
git commit -m "test: cover full create-and-save flow end-to-end"
```
### Task 9: CI verification
**Files:**
- Modify: none (verify only)
**Interfaces:**
- Verifies the existing CI workflow covers Plan 3b's changes. No workflow change is required.
- [ ] **Step 1: Verify the existing workflow**
Run: `cat .github/workflows/ci.yml`
Expected: the file is the same as the Plan 1+2 baseline (runs `composer validate --strict`, `composer install`, `composer check`). No new dependencies land in Plan 3b; no new lint step is required.
- [ ] **Step 2: Run the full quality suite locally**
Run: `composer check`
Expected: PHPUnit passes (now includes the Plan 3b integration tests, so 187+ tests depending on Task 6-8 additions), PHPStan level 6 clean, PHPCS clean on the new files.
- [ ] **Step 3: Document the verification**
No commit is required. If the CI workflow is already in place, this task is complete. If a follow-up plan revision identifies a need for a new CI step, it lands in a future plan.
### Task 10: Final whole-branch code review
Dispatch a fresh subagent with the merge-base diff for the entire feature branch. The reviewer checks for:
- Spec compliance (every in-scope capability from the Plan 3b spec is implemented).
- Code quality (clean separation of concerns, proper error handling, type safety).
- Production readiness (backward compatibility, no obvious bugs).
- Security: CSRF model, output escaping, image upload, path-traversal hardening on `userToken`.
After the review, address Critical and Important findings. Minor findings can be deferred to Plan 3c.
## Completion Check
Run:
```powershell
composer validate --strict
composer check
git status --short
```
Expected:
- Composer reports a valid manifest.
- PHPCS reports no coding-standard violations on the new files.
- PHPStan reports no errors at level 6.
- PHPUnit passes all tests (unit + integration). The full-flow test is included.
- Git reports no untracked files except `.worktrees/` (git-ignored).
- The web app is fully functional from the browser: `php -S 0.0.0.0:8000 -t public` from the repo root serves the home page, the team editor, the battlefield editor, the image upload endpoint, and the asset-serving endpoint. The match-stub page renders a placeholder; Plan 4 replaces it with the real battle interface.