# 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. - ``. - `` with charset, viewport, ``, 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) ?> 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 ` Escape::html($v); $a = static fn (string $v): string => Escape::attr($v); ?>

BattleForge

New scenario

Recent scenarios

No saved scenarios yet.

` 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 `

Recent scenarios

` and `
` are placeholders that Plan 3c's `storage.js` populates from `localStorage`. - [ ] **Step 2: Implement `team-editor.php`** Create `src/Views/team-editor.php`: ```php $old Optional old form values, keyed by field name; HTML-escaped on output. * @var array $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(); ?>

Team editor

Scenario
Team A
Team B
Victory
Escape::html($v); $a = static fn (string $v): string => Escape::attr($v); $terrain = ['open', 'forest', 'rough', 'water', 'blocking']; ?>

Battlefield editor

Scenario:

Battlefield
Terrain palette
Grid ( × )
Deployment zones
Escape::html($v); $matchInfo = $matchJson !== null ? "Match loaded." : "No match in progress."; ?>

Match

Match state (debug)

Battle interface coming in Plan 4.

Back to home

Escape::html($v); $json = json_encode(['url' => $url], JSON_THROW_ON_ERROR); ?>

Uploaded:

post`, calls `toScenario()`, runs `ScenarioValidator::validate()`. On success, renders a "Saved" template that includes a ` HTML; return Response::html(200, $body); } /** @param array $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 */ 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 `