1819 lines
73 KiB
Markdown
1819 lines
73 KiB
Markdown
# 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.
|