73 KiB
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
_csrffield; fetch uses anX-CSRF-Tokenheader. - All rendered output is escaped for its output context via the
Escape::html,Escape::attr, andEscape::urlhelpers. 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) andindex.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.alreadyNarrowedTypeannotations 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
userTokenvalidation inGetAssetsuses a strict format regex (/^[a-f0-9]{32,}$/) to harden the path-traversal surface. TheBATTLEFORGE_SECRETenv var takes precedence over the per-install filevar/secret.key(a 32-byte binary file created on first run withrandom_bytes(32)andchmod 0600; git-ignored). - Domain code has no dependency on HTTP, sessions, storage, or browser code (unchanged from Plans 1, 2, 3a).
File Structure
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.
- View templates — establish the rendering foundation (Tasks 1-3).
- Editor handlers — wire the templates to the domain (Tasks 4-6).
- Front controller — wire the handlers to the URL space (Task 7).
- Full-flow integration test — exercise the whole stack (Task 8).
- 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
requireto set up the document chrome. The layout is invoked by a template that has already started buffering its body content; the layout's signature isrender_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
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
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 useEscape::*for every dynamic value. -
Step 1: Implement
home.php
Create src/Views/home.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
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
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.jspopulates fromlocalStorage. 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
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
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
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
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— returnsResponse::html(200, …)rendering the team editor template. The form is rendered empty; Plan 3c'sstorage.jspopulates it fromlocalStorageon 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 aScenarioDraftfrom$request->post, callstoScenario(), runsScenarioValidator::validate(). On success, renders a "Saved" template that includes a<script type="module">block that callslocalStorage.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
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
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 requires 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
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
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.)
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— returnsResponse::html(200, …)rendering the battlefield editor template with an empty<table class="bf-grid">(Plan 3c populates it fromlocalStorage).PostBattlefieldEditor::handle(Request, array): Response— verifies the CSRF token from theX-CSRF-Tokenheader, decodes$request->rawBodyas JSON, builds aScenarioDraft, callstoScenario(), runsScenarioValidator::validate(). On success, returnsResponse::json(200, ['ok' => true, 'scenario' => $array]). On failure, returnsResponse::json(400, ['ok' => false, 'errors' => [...]]).
-
Step 1: Write the failing tests
Create tests/Integration/GetBattlefieldEditorTest.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
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
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
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.
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 theuserToken(derived from the CSRF secret), constructsImageUploadService($userToken, $uploadsRoot), callsstore($tempPath, $declaredMime), returnsResponse::json(200, ['url' => $url])on success orResponse::json(400, ['error' => $message])on failure.PostStartMatch::handle(Request, array): Response— verifies the CSRF token from theX-CSRF-Tokenheader, decodes the JSON body as aScenarioviaScenarioSerializer::scenarioFromArray, runsScenarioValidator::validate()(defense in depth), callsScenario::startMatch('alpha'), returnsResponse::json(200, ['match' => $array])on success orResponse::json(400, ['errors' => [...]])on failure.
-
Step 1: Write the failing tests
Create tests/Integration/PostImageUploadTest.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
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
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
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.
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:
- Reads
$_GET,$_POST,$_FILES,$_COOKIE,$_SERVER. - Derives the request method, path, query string, and content type.
- Reads the app secret from
BATTLEFORGE_SECRET(env var) or, if absent, fromvar/secret.key(a 32-byte binary file created on first run). - Issues a CSRF token on first visit (when the
__csrfcookie is absent) and ensures the cookie is set on every response withHttpOnly,SameSite=Lax,Path=/,Expires=time()+86400. - Computes the
__uploads_tokencookie ashash_hmac('sha256', $secret, 'bf-uploads')and threads it via a request attribute. - Configures the
Routerwith the eight routes. - Builds a
Requestvalue 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. - Dispatches and emits the response (status, headers, body).
- Reads
-
Step 1: Replace the stub with the real front controller
Replace public/index.php with:
<?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:
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
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:
- Bootstraps the front controller in-process (no
php -Sboot). - Issues a CSRF token.
- GET home page.
- POST team editor with a valid form body, asserts the response says "Saved" and contains the
localStorage.setItemsnippet. - POST battlefield editor with a valid JSON body, asserts
{ok: true, ...}. - POST start-match with the assembled scenario, asserts the response contains the initial match state.
- Bootstraps the front controller in-process (no
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
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.
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:
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 publicfrom 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.