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

98 KiB
Raw Permalink Blame History

Persistence and Scenario Editors (Backend) 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 plain-PHP backend that lets an anonymous browser visitor author, validate, save, and start a scenario, with secure image uploads and a CSRF-protected, escape-everything web stack.

Architecture: A front controller (public/index.php) dispatches every request to a small Http\Router that routes by HTTP method + path to a Http\Handlers\* class. 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. The browser owns persistence via localStorage; the server only ever sees form POSTs and image uploads, never the assembled Scenario JSON.

Tech Stack: PHP 8.3, PHPUnit 11, PHPStan level 6, PHP_CodeSniffer PSR-12, plain php -S for local dev. The frontend toolchain (ESLint airbnb/base + Prettier) and JS files land in Plan 3b.

Global Constraints

These apply to every task and are copied verbatim from the design spec.

  • Anonymous browser persistence: scenarios and matches live in the browser's localStorage (scenario:{id} and match:current); the server never stores game state.
  • No user accounts, no cross-device, no server-side storage of scenarios or matches. The server only keeps per-session CSRF state and uploaded image files.
  • Built-in dev server only: php -S 0.0.0.0:8000 -t public (documented in README). No Apache-only assumptions; the var/uploads/.htaccess and var/uploads/index.php sentinels cover both Apache deployments and the built-in server.
  • Every state-changing form or request uses and verifies a CSRF token before mutation. Tokens are per-session, signed with the app's secret using hash_hmac('sha256', …). Forms use a hidden _csrf field; fetch uses an X-CSRF-Token header.
  • All rendered output is escaped via Escape::html, Escape::attr, and Escape::url helpers. Code review enforces the pattern; PHPStan has no rule for it.
  • All uploaded images are content-validated by reading the first 12 bytes and checking the magic number against the allowlist (PNG, JPEG, WebP, GIF), then getimagesizefromstring() confirms dimensions ≤ 512×512, then size is checked at ≤ 2 MB. The original filename and declared MIME are discarded after validation.
  • Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' on every page. X-Content-Type-Options: nosniff and Referrer-Policy: same-origin on every page.
  • Bundled placeholders are committed under public/assets/placeholders/; user uploads live in var/uploads/ (git-ignored).
  • PHP follows the repository PHPCS rules and passes PHPStan at level 6.
  • The small JS surface passes ESLint airbnb/base and Prettier.
  • 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 MVP is releasable only when all checks pass.
  • Domain code has no dependency on HTTP, sessions, storage, or browser code (unchanged from Plans 1 and 2).

File Structure

src/
├── Application/
│   ├── ScenarioSerializer.php   JSON ↔ Scenario (used by both editors and the start-match endpoint)
│   ├── ScenarioDraft.php        mutable draft state used to build a Scenario from form input
│   ├── ImageValidator.php       content-sniff + size + dimension checks
│   └── ImageUploadService.php   validates + stores a user-uploaded image; returns a stable URL
├── Http/
│   ├── Request.php              value object wrapping $_GET, $_POST, $_FILES, $_SERVER
│   ├── Response.php             value object with status, headers, body
│   ├── Router.php               tiny path → handler dispatcher
│   ├── CsrfToken.php            per-session token store + verify helper
│   ├── Escape.php               html(), attr(), url() helpers
│   └── Handlers/
│       ├── GetHomePage.php
│       ├── GetTeamEditor.php
│       ├── PostTeamEditor.php
│       ├── GetBattlefieldEditor.php
│       ├── PostBattlefieldEditor.php
│       ├── GetAssets.php
│       ├── PostImageUpload.php
│       └── PostStartMatch.php
├── Views/
│   ├── layout.php
│   ├── home.php
│   ├── team-editor.php
│   ├── battlefield-editor.php
│   ├── match-stub.php
│   └── upload-result.php
└── public/
    ├── index.php                front controller
    └── assets/
        └── styles.css
        ├── storage.js
        └── grid-editor.js

var/                             git-ignored, runtime-writable
├── phpunit/
├── phpstan/
└── uploads/
    ├── .htaccess                Deny from all (Apache)
    ├── index.php                Sentinel that returns 403 (built-in dev server)
    └── {userToken}/             Per-browser namespace; created on first upload

tests/
├── Unit/
│   ├── Application/
│   │   ├── ScenarioSerializerTest.php
│   │   ├── ScenarioDraftTest.php
│   │   ├── ImageValidatorTest.php
│   │   └── ImageUploadServiceTest.php
│   └── Http/
│       ├── CsrfTokenTest.php
│       ├── EscapeTest.php
│       ├── RequestTest.php
│       └── RouterTest.php
└── Integration/
    ├── GetHomePageTest.php
    ├── GetTeamEditorTest.php
    ├── PostTeamEditorTest.php
    ├── GetBattlefieldEditorTest.php
    ├── PostBattlefieldEditorTest.php
    ├── GetAssetsTest.php
    ├── PostImageUploadTest.php
    └── PostStartMatchTest.php

Delivery Sequence

Plan 3 is the third of four plans. It is split into:

  • Plan 3a (this plan, the PHP backend): Tasks 1-14. The web app is fully working and curl-able; tests cover all backend behavior.
  • Plan 3b (the frontend, separate plan): the small JS surface (public/js/storage.js, public/js/grid-editor.js), the placeholder images, and the ESLint/Prettier toolchain. The editor pages render with HTML-only submit and localStorage write today; Plan 3b adds the fetch-based battlefield grid and the JS UX.

This plan covers 1-14 below. Plan 3b is drafted and executed after this one lands.

  1. Toolchain and infrastructure (Task 1)
  2. Application layer (Tasks 2-4)
  3. Http layer (Tasks 5-7)
  4. Editor pages and the front controller (Tasks 8-12)
  5. Integration tests and end-to-end plumbing (Task 13)
  6. CI updates (Task 14)
  7. Final whole-branch review

Execution Preflight

Execute this plan in an isolated worktree on feature/persistence-backend, branched from develop. The implementation branch will be submitted as a pull request into develop only after every completion check is green.

Task 1: Toolchain and infrastructure

Files:

  • Create: var/uploads/.htaccess
  • Create: var/uploads/index.php
  • Create: public/index.php
  • Create: public/assets/styles.css
  • Modify: .gitignore
  • Modify: composer.json
  • Modify: phpcs.xml
  • Modify: phpstan.neon
  • Modify: phpunit.xml

Interfaces:

  • Produces: a public document root that the built-in dev server can serve; an expanded PHPCS/PHPStan/PHPUnit scope that covers the new layers; an updated .gitignore that excludes var/uploads/* but commits var/uploads/.htaccess and var/uploads/index.php. The Node toolchain (ESLint + Prettier) and JS files land in Plan 3b.

  • Step 1: Update .gitignore

Append a force-include rule for the upload sentinels so the var/uploads/.htaccess and var/uploads/index.php files are committed even though the rest of var/uploads/ is ignored.

Replace .gitignore with:

.worktrees/
/vendor/
/var/cache/
/var/phpunit/
/var/phpstan/
/var/logs/
/var/uploads/*
!/var/uploads/.htaccess
!/var/uploads/index.php
/node_modules/
/public/js/*.map
.phpunit.result.cache
.phpunit.cache
  • Step 2: Create var/uploads/.htaccess

Create var/uploads/.htaccess:

Require all denied
Deny from all
  • Step 3: Create var/uploads/index.php

Create var/uploads/index.php:

<?php

declare(strict_types=1);

http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden\n";
  • Step 4: Update composer.json to add the new Application and Http namespaces

The autoload block already maps BattleForge\\ to src/, so the new subdirectories are picked up automatically. Plan 3b will add a lint-js script; for now the check script covers only the PHP gates.

Replace composer.json with:

{
    "name": "battleforge/battleforge",
    "description": "A standalone tactical combat game.",
    "type": "project",
    "license": "proprietary",
    "require": {
        "php": "^8.3"
    },
    "require-dev": {
        "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
        "phpstan/phpstan": "^2.0",
        "phpunit/phpunit": "^11.5",
        "squizlabs/php_codesniffer": "^3.10"
    },
    "autoload": {
        "psr-4": {
            "BattleForge\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "BattleForge\\Tests\\": "tests/"
        }
    },
    "scripts": {
        "lint": "phpcs",
        "analyse": "phpstan analyse --no-progress",
        "test": "phpunit",
        "check": [
            "@lint",
            "@analyse",
            "@test"
        ]
    },
    "config": {
        "allow-plugins": {
            "dealerdirect/phpcodesniffer-composer-installer": true
        },
        "sort-packages": true
    }
}
  • Step 5: Update phpcs.xml to include the new layers

Replace phpcs.xml with:

<?xml version="1.0"?>
<ruleset name="BattleForge">
    <description>BattleForge coding standards.</description>

    <file>src</file>
    <file>tests</file>
    <file>public/index.php</file>

    <arg name="colors"/>
    <arg value="sp"/>

    <rule ref="PSR12"/>
</ruleset>
  • Step 6: Update phpstan.neon to include the new layers and exclude templates

Replace phpstan.neon with:

parameters:
    level: 6
    paths:
        - src
        - tests
        - public/index.php
    excludePaths:
        - src/Views/*
    tmpDir: var/phpstan
  • Step 7: Update phpunit.xml to add an integration test suite

Replace phpunit.xml with:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         cacheDirectory="var/phpunit"
         colors="true">
    <testsuites>
        <testsuite name="unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>

    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>
  • Step 8: Create public/index.php (front controller stub)

The full front controller lands in Task 13. This step creates the file with a placeholder so the document root exists and php -S can serve it.

Create public/index.php:

<?php

declare(strict_types=1);

// Front controller. The router and handler dispatch land in Task 13.
// For now this returns a 200 with a stub so the dev server is reachable.
http_response_code(200);
header('Content-Type: text/plain; charset=utf-8');
echo "BattleForge dev server up.\n";
  • Step 9: Create public/assets/styles.css

Create public/assets/styles.css with a small reset and a handful of class hooks the templates will use. Plain CSS, no preprocessor.

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; font-family: sans-serif; }
body { padding: 1rem; max-width: 64rem; margin: 0 auto; }
fieldset { margin: 0 0 1rem 0; padding: 0.5rem 1rem; }
legend { font-weight: bold; }
label { display: block; margin: 0.25rem 0; }
input, select, button, textarea { font: inherit; }
input[type="number"] { width: 5rem; }
table.bf-grid { border-collapse: collapse; }
table.bf-grid td { padding: 0; }
table.bf-grid button { width: 2rem; height: 2rem; border: 1px solid #ccc; background: #fff; }
table.bf-grid button[data-paint="open"] { background: #fff; }
table.bf-grid button[data-paint="forest"] { background: #cfc; }
table.bf-grid button[data-paint="rough"] { background: #fec; }
table.bf-grid button[data-paint="water"] { background: #cce; }
table.bf-grid button[data-paint="blocking"] { background: #444; color: #fff; }
table.bf-grid button[data-objective="1"] { outline: 3px solid #c00; }
table.bf-grid button[data-zone="alpha"] { outline: 3px solid #00c; }
table.bf-grid button[data-zone="bravo"] { outline: 3px solid #0c0; }
.bf-errors { color: #c00; }
.bf-toast { background: #dfd; padding: 0.5rem 1rem; margin: 0.5rem 0; }
  • Step 10: Verify the existing test suite still runs

Run: vendor/bin/phpunit Expected: 130 tests, 376 assertions, all green (Plan 1+2 baseline).

  • Step 11: Verify PHPStan still passes

Run: vendor/bin/phpstan analyse --no-progress Expected: [OK] No errors.

  • Step 12: Verify PHPCS still passes

Run: vendor/bin/phpcs Expected: 0 errors. Existing line-length warnings remain.

  • Step 13: Verify composer validate --strict passes

Run: composer validate --strict Expected: reports the manifest is valid.

  • Step 14: Commit
git add .gitignore composer.json phpcs.xml phpstan.neon phpunit.xml var/uploads/ public/index.php public/assets/styles.css
git commit -m "chore: add web toolchain and infrastructure for Plan 3"

Task 2: Output escaping and the Escape helper

Files:

  • Create: src/Http/Escape.php
  • Test: tests/Unit/Http/EscapeTest.php

Interfaces:

  • Consumes: none (pure string manipulation).

  • Produces: final class Escape with three static methods:

    • public static function html(mixed $value): string — wraps htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). Non-string values are stringified first.
    • public static function attr(mixed $value): string — identical to html() for our context. Distinct helper name makes code review easy.
    • public static function url(string $value): string — wraps rawurlencode($value).
  • Step 1: Write the failing test

Create tests/Unit/Http/EscapeTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Http;

use BattleForge\Http\Escape;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class EscapeTest extends TestCase
{
    #[DataProvider('htmlProvider')]
    public function testItEscapesHtmlContext(mixed $input, string $expected): void
    {
        self::assertSame($expected, Escape::html($input));
    }

    /**
     * @return iterable<string, array{mixed, string}>
     */
    public static function htmlProvider(): iterable
    {
        yield 'plain' => ['hello', 'hello'];
        yield 'less-than' => ['<script>', '&lt;script&gt;'];
        yield 'ampersand' => ['a & b', 'a &amp; b'];
        yield 'double-quote' => ['she said "hi"', 'she said &quot;hi&quot;'];
        yield 'single-quote' => ["it's", 'it&#039;s'];
        yield 'control-char-substituted' => ["a\x00b", 'ab'];
        yield 'integer' => [42, '42'];
        yield 'null' => [null, ''];
    }

    #[DataProvider('attrProvider')]
    public function testItEscapesAttributeContext(mixed $input, string $expected): void
    {
        self::assertSame($expected, Escape::attr($input));
    }

    /**
     * @return iterable<string, array{mixed, string}>
     */
    public static function attrProvider(): iterable
    {
        yield 'tag-breakout' => ['" onclick="', '&quot; onclick=&quot;'];
        yield 'apostrophe' => ["' onclick='", '&#039; onclick=&#039;'];
    }

    #[DataProvider('urlProvider')]
    public function testItEncodesUrls(string $input, string $expected): void
    {
        self::assertSame($expected, Escape::url($input));
    }

    /**
     * @return iterable<string, array{string, string}>
     */
    public static function urlProvider(): iterable
    {
        yield 'space' => ['hello world', 'hello%20world'];
        yield 'slash' => ['a/b', 'a%2Fb'];
        yield 'unicode' => ['héllo', 'h%C3%A9llo'];
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Http/EscapeTest.php Expected: FAIL because BattleForge\Http\Escape does not exist.

  • Step 3: Implement Escape

Create src/Http/Escape.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http;

final class Escape
{
    public static function html(mixed $value): string
    {
        if ($value === null) {
            return '';
        }

        return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    }

    public static function attr(mixed $value): string
    {
        return self::html($value);
    }

    public static function url(string $value): string
    {
        return rawurlencode($value);
    }
}
  • Step 4: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Unit/Http/EscapeTest.php Expected: PASS, all data providers green.

  • Step 5: Run the full quality suite and commit

Run: composer check Expected: PHPCS, PHPStan, PHPUnit all green.

git add src/Http/Escape.php tests/Unit/Http/EscapeTest.php
git commit -m "feat: add output escaping helpers"

Task 3: CSRF token store

Files:

  • Create: src/Http/CsrfToken.php
  • Test: tests/Unit/Http/CsrfTokenTest.php

Interfaces:

  • Consumes: a Secret value (the app's HMAC key, supplied by the front controller in Task 13).

  • Produces: final class CsrfToken with:

    • public static function issue(string $secret): array{0: string, 1: string} — returns [$tokenValue, $cookieValue]. The token value is a random 32-byte hex; the cookie value is hash_hmac('sha256', $tokenValue, $secret). The token is intended to live in __csrf cookie attributes: ['expires' => time()+86400, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']. The front controller applies these attributes via PHP's setcookie() in Task 13.
    • public static function verify(string $submitted, string $cookieValue): bool — uses hash_equals for constant-time comparison; returns true only on a match.
  • Step 1: Write the failing test

Create tests/Unit/Http/CsrfTokenTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Http;

use BattleForge\Http\CsrfToken;
use PHPUnit\Framework\TestCase;

final class CsrfTokenTest extends TestCase
{
    private const SECRET = 'unit-test-secret';

    public function testIssueReturnsTokenAndCookieValue(): void
    {
        [$token, $cookie] = CsrfToken::issue(self::SECRET);

        self::assertNotSame('', $token);
        self::assertNotSame('', $cookie);
        self::assertSame(64, strlen($token));
    }

    public function testVerifyAcceptsAValidPair(): void
    {
        [$token, $cookie] = CsrfToken::issue(self::SECRET);

        self::assertTrue(CsrfToken::verify($token, self::SECRET, $cookie));
    }

    public function testVerifyRejectsATamperedCookieValue(): void
    {
        [$token] = CsrfToken::issue(self::SECRET);

        self::assertFalse(CsrfToken::verify($token, self::SECRET, 'not-the-cookie'));
    }

    public function testVerifyRejectsATamperedToken(): void
    {
        [, $cookie] = CsrfToken::issue(self::SECRET);

        self::assertFalse(CsrfToken::verify('not-the-token', self::SECRET, $cookie));
    }

    public function testIssueProducesDifferentTokensAcrossCalls(): void
    {
        [$tokenA] = CsrfToken::issue(self::SECRET);
        [$tokenB] = CsrfToken::issue(self::SECRET);

        self::assertNotSame($tokenA, $tokenB);
    }

    public function testDifferentSecretsProduceDifferentCookieValues(): void
    {
        [$tokenA] = CsrfToken::issue('secret-a');
        [, $cookieA] = CsrfToken::issue('secret-a');
        [, $cookieB] = CsrfToken::issue('secret-b');

        self::assertTrue(CsrfToken::verify($tokenA, 'secret-a', $cookieA));
        self::assertFalse(CsrfToken::verify($tokenA, 'secret-b', $cookieB));
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Http/CsrfTokenTest.php Expected: FAIL because BattleForge\Http\CsrfToken does not exist.

  • Step 3: Implement CsrfToken

Create src/Http/CsrfToken.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http;

final class CsrfToken
{
    /** @return array{0: string, 1: string} */
    public static function issue(string $secret): array
    {
        $token = bin2hex(random_bytes(32));
        $cookie = hash_hmac('sha256', $token, $secret);

        return [$token, $cookie];
    }

    public static function verify(string $submitted, string $secret, string $expectedCookieValue): bool
    {
        $computed = hash_hmac('sha256', $submitted, $secret);

        return hash_equals($expectedCookieValue, $computed);
    }
}
  • Step 4: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Unit/Http/CsrfTokenTest.php Expected: PASS.

  • Step 5: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Http/CsrfToken.php tests/Unit/Http/CsrfTokenTest.php
git commit -m "feat: add CSRF token store and verifier"

Task 4: Request and Response value objects

Files:

  • Create: src/Http/Request.php
  • Create: src/Http/Response.php
  • Test: tests/Unit/Http/RequestTest.php

Interfaces:

  • Produces:

    • final readonly class Request with public properties get (array), post (array), files (array), cookies (array), server (array), queryString (string), method (string), path (string), contentType (string|null), rawBody (string). Constructor takes all eleven and stores them.
    • final class Response with public int $status, public array $headers (string => string), public string $body. Constructor takes all three and stores them. Has a static factory Response::html(int $status, string $body): self that sets Content-Type: text/html; charset=utf-8 and the security headers (X-Content-Type-Options: nosniff, Referrer-Policy: same-origin, Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'). Has Response::json(int $status, mixed $body): self that JSON-encodes and sets Content-Type: application/json; charset=utf-8. Has Response::redirect(string $location, int $status = 303): self that sets Location: $location and an empty body.
  • Step 1: Write the failing test for Request

Create tests/Unit/Http/RequestTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Http;

use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;

final class RequestTest extends TestCase
{
    public function testItExposesAllSuperglobalsAsConstructorArgs(): void
    {
        $request = new Request(
            get: ['q' => '1'],
            post: ['name' => 'alpha'],
            files: ['image' => ['name' => 'a.png', 'tmp_name' => '/tmp/x', 'error' => 0, 'size' => 12, 'type' => 'image/png']],
            cookies: ['__csrf' => 'cookie-value'],
            server: ['HTTP_HOST' => 'localhost'],
            queryString: 'q=1',
            method: 'POST',
            path: '/scenarios/demo/edit/team',
            contentType: 'application/x-www-form-urlencoded',
            rawBody: '',
        );

        self::assertSame(['q' => '1'], $request->get);
        self::assertSame(['name' => 'alpha'], $request->post);
        self::assertSame('a.png', $request->files['image']['name']);
        self::assertSame('cookie-value', $request->cookies['__csrf']);
        self::assertSame('localhost', $request->server['HTTP_HOST']);
        self::assertSame('q=1', $request->queryString);
        self::assertSame('POST', $request->method);
        self::assertSame('/scenarios/demo/edit/team', $request->path);
        self::assertSame('application/x-www-form-urlencoded', $request->contentType);
        self::assertSame('', $request->rawBody);
    }

    public function testItAllowsNullContentType(): void
    {
        $request = new Request(
            get: [],
            post: [],
            files: [],
            cookies: [],
            server: [],
            queryString: '',
            method: 'GET',
            path: '/',
            contentType: null,
            rawBody: '',
        );

        self::assertNull($request->contentType);
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Http/RequestTest.php Expected: FAIL because BattleForge\Http\Request does not exist.

  • Step 3: Implement Request

Create src/Http/Request.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http;

final readonly class Request
{
    /**
     * @param array<string, mixed> $get
     * @param array<string, mixed> $post
     * @param array<string, array<string, mixed>> $files
     * @param array<string, string> $cookies
     * @param array<string, string> $server
     */
    public function __construct(
        public array $get,
        public array $post,
        public array $files,
        public array $cookies,
        public array $server,
        public string $queryString,
        public string $method,
        public string $path,
        public ?string $contentType,
        public string $rawBody,
    ) {
    }
}
  • Step 4: Implement Response

Create src/Http/Response.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http;

final class Response
{
    private const SECURITY_HEADERS = [
        'X-Content-Type-Options' => 'nosniff',
        'Referrer-Policy' => 'same-origin',
        'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'",
    ];

    /** @param array<string, string> $headers */
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {
    }

    public static function html(int $status, string $body): self
    {
        return new self(
            $status,
            ['Content-Type' => 'text/html; charset=utf-8'] + self::SECURITY_HEADERS,
            $body,
        );
    }

    public static function json(int $status, mixed $body): self
    {
        return new self(
            $status,
            ['Content-Type' => 'application/json; charset=utf-8'] + self::SECURITY_HEADERS,
            json_encode($body, JSON_THROW_ON_ERROR),
        );
    }

    public static function redirect(string $location, int $status = 303): self
    {
        return new self(
            $status,
            ['Location' => $location] + self::SECURITY_HEADERS,
            '',
        );
    }
}
  • Step 5: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Unit/Http/RequestTest.php Expected: PASS.

  • Step 6: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Http/Request.php src/Http/Response.php tests/Unit/Http/RequestTest.php
git commit -m "feat: add Request and Response value objects"

Task 5: Router

Files:

  • Create: src/Http/Router.php
  • Test: tests/Unit/Http/RouterTest.php

Interfaces:

  • Produces: final class Router with:

    • public function __construct() — empty routes array.
    • public function add(string $method, string $path, callable $handler): void — registers a handler. The path may contain {id} placeholders; the dispatch method captures them into a params array.
    • public function dispatch(Request $request): Response — finds the first registered route matching the request's method and path, with {id} placeholders captured as regex match groups. Returns the handler's Response. If no route matches, returns Response::html(404, '<h1>Not found</h1>').
    • Captured path params are available to the handler as a third argument: function(Request $request, array $params): Response.
  • Step 1: Write the failing test

Create tests/Unit/Http/RouterTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Http;

use BattleForge\Http\Request;
use BattleForge\Http\Response;
use BattleForge\Http\Router;
use PHPUnit\Framework\TestCase;

final class RouterTest extends TestCase
{
    public function testItDispatchesAMatchingStaticRoute(): void
    {
        $router = new Router();
        $router->add('GET', '/', static fn (Request $request): Response => Response::html(200, 'home'));

        $response = $router->dispatch($this->request('GET', '/'));

        self::assertSame(200, $response->status);
        self::assertSame('home', $response->body);
    }

    public function testItReturnsA404ForUnknownPaths(): void
    {
        $router = new Router();

        $response = $router->dispatch($this->request('GET', '/missing'));

        self::assertSame(404, $response->status);
    }

    public function testItReturnsAMethodNotAllowedResponseForMismatchedMethods(): void
    {
        $router = new Router();
        $router->add('POST', '/', static fn (): Response => Response::html(200, 'ok'));

        $response = $router->dispatch($this->request('GET', '/'));

        self::assertSame(404, $response->status);
    }

    public function testItCapturesPathParams(): void
    {
        $router = new Router();
        $router->add('GET', '/scenarios/{id}/edit', static function (Request $request, array $params): Response {
            return Response::html(200, 'id=' . $params['id']);
        });

        $response = $router->dispatch($this->request('GET', '/scenarios/demo/edit'));

        self::assertSame(200, $response->status);
        self::assertSame('id=demo', $response->body);
    }

    public function testItMatchesMoreSpecificRoutesFirstByRegistrationOrder(): void
    {
        $router = new Router();
        $router->add('GET', '/scenarios/{id}', static fn (): Response => Response::html(200, 'any'));
        $router->add('GET', '/scenarios/special', static fn (): Response => Response::html(200, 'special'));

        $response = $router->dispatch($this->request('GET', '/scenarios/special'));

        self::assertSame('any', $response->body);
    }

    private function request(string $method, string $path): Request
    {
        return new Request(
            get: [],
            post: [],
            files: [],
            cookies: [],
            server: [],
            queryString: '',
            method: $method,
            path: $path,
            contentType: null,
            rawBody: '',
        );
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Http/RouterTest.php Expected: FAIL because BattleForge\Http\Router does not exist.

  • Step 3: Implement Router

Create src/Http/Router.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http;

use InvalidArgumentException;

final class Router
{
    /** @var list<array{method: string, pattern: string, handler: callable(Request, array<string, string>): Response}> */
    private array $routes = [];

    public function add(string $method, string $path, callable $handler): void
    {
        $pattern = '#^' . preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $path) . '$#';

        $this->routes[] = [
            'method' => strtoupper($method),
            'pattern' => $pattern,
            'handler' => $handler,
        ];
    }

    public function dispatch(Request $request): Response
    {
        foreach ($this->routes as $route) {
            if ($route['method'] !== $request->method) {
                continue;
            }

            if (preg_match($route['pattern'], $request->path, $matches) === 1) {
                $params = [];

                foreach ($matches as $key => $value) {
                    if (is_string($key)) {
                        $params[$key] = $value;
                    }
                }

                return ($route['handler'])($request, $params);
            }
        }

        return Response::html(404, '<h1>Not found</h1>');
    }
}
  • Step 4: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Unit/Http/RouterTest.php Expected: PASS.

  • Step 5: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Http/Router.php tests/Unit/Http/RouterTest.php
git commit -m "feat: add Http router"

Task 6: ScenarioSerializer — JSON ↔ Scenario

Files:

  • Create: src/Application/ScenarioSerializer.php
  • Test: tests/Unit/Application/ScenarioSerializerTest.php

Interfaces:

  • Consumes: BattleForge\Domain\Scenario, BattleForge\Domain\Battlefield, BattleForge\Domain\UnitState, BattleForge\Domain\Position, BattleForge\Domain\Archetype, BattleForge\Domain\ArchetypeTemplate, BattleForge\Domain\ArchetypeCatalog, BattleForge\Domain\AbilityId, BattleForge\Domain\VictoryCondition, BattleForge\Domain\DeploymentZone, BattleForge\Domain\ObjectiveMarker, BattleForge\Domain\MatchState.
  • Produces: final class ScenarioSerializer with three static methods:
    • public static function scenarioToArray(Scenario $scenario): array<string, mixed> — produces an associative array with keys id, name, battlefield (sub-array {width, height, terrain}), units (list), deploymentZones (map), objectives (map), victoryCondition (string enum value), holdRoundsRequired (int). The sub-keys are stable across versions (Plan 4 reads them).
    • public static function scenarioFromArray(array $data): Scenario — inverse; throws \InvalidArgumentException on shape errors.
    • public static function matchToArray(MatchState $match): array<string, mixed> — round-trip the match state.
    • public static function matchFromArray(array $data): MatchState — inverse.

For the inner shapes:

  • Battlefield: {width: int, height: int, terrain: array<string, string>} where terrain keys are "x:y" and values are Terrain enum string values.
  • UnitState: {id, teamId, position: {x, y}, maxHealth, health, attack, defense, speed, archetype: string, abilities: list<string>, image?: string}. The archetype and abilities values are the string form of the enum cases.
  • DeploymentZone: {teamId, positions: list<{x, y}>}.
  • ObjectiveMarker: {id, position: {x, y}}.

The Scenario aggregate's constructor already validates everything; the serializer's only job is shape translation. Runtime guards added in Plan 2 (the // @phpstan-ignore annotations) are now exercised by the scenarioFromArray code path.

  • Step 1: Write the failing test

Create tests/Unit/Application/ScenarioSerializerTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Application;

use BattleForge\Application\ScenarioSerializer;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\DeploymentZone;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\ObjectiveMarker;
use BattleForge\Domain\Position;
use BattleForge\Domain\Scenario;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use BattleForge\Domain\VictoryCondition;
use PHPUnit\Framework\TestCase;

final class ScenarioSerializerTest extends TestCase
{
    public function testItRoundTripsACompleteScenario(): void
    {
        $scenario = new Scenario(
            id: 'demo',
            name: 'Demo',
            battlefield: new Battlefield(8, 8, ['0:0' => Terrain::Forest]),
            units: [
                new UnitState('alpha-1', 'alpha', new Position(0, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, ['buff'], 0, false),
                new UnitState('alpha-2', 'alpha', new Position(1, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false),
                new UnitState('alpha-3', 'alpha', new Position(2, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false),
                new UnitState('bravo-1', 'bravo', new Position(7, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, ['area_damage'], 0, false),
                new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false),
                new UnitState('bravo-3', 'bravo', new Position(5, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false),
            ],
            deploymentZones: [
                'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
                'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
            ],
            objectives: [],
            victoryCondition: VictoryCondition::EliminateAll,
            holdRoundsRequired: 1,
        );

        $array = ScenarioSerializer::scenarioToArray($scenario);
        $reconstructed = ScenarioSerializer::scenarioFromArray($array);

        self::assertSame($scenario->id, $reconstructed->id);
        self::assertSame($scenario->name, $reconstructed->name);
        self::assertSame($scenario->battlefield->width, $reconstructed->battlefield->width);
        self::assertSame($scenario->battlefield->height, $reconstructed->battlefield->height);
        self::assertSame('forest', $reconstructed->battlefield->terrainAt(new Position(0, 0))->value);
        self::assertCount(6, $reconstructed->units);
        self::assertSame('alpha-1', $reconstructed->units[0]->id);
        self::assertSame(Archetype::Defender, $reconstructed->units[0]->archetype);
        self::assertSame(['buff'], $reconstructed->units[0]->abilities);
        self::assertSame(12, $reconstructed->units[0]->maxHealth);
        self::assertSame(3, $reconstructed->units[0]->attack);
        self::assertSame(4, $reconstructed->units[0]->defense);
        self::assertSame(2, $reconstructed->units[0]->speed);
        self::assertSame($scenario->victoryCondition, $reconstructed->victoryCondition);
    }

    public function testItRoundTripsAHoldObjectiveScenario(): void
    {
        $scenario = new Scenario(
            id: 'hold',
            name: 'Hold',
            battlefield: new Battlefield(8, 8),
            units: [
                new UnitState('alpha-1', 'alpha', new Position(0, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false),
                new UnitState('alpha-2', 'alpha', new Position(1, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false),
                new UnitState('alpha-3', 'alpha', new Position(2, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false),
                new UnitState('bravo-1', 'bravo', new Position(7, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false),
                new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false),
                new UnitState('bravo-3', 'bravo', new Position(5, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false),
            ],
            deploymentZones: [
                'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
                'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
            ],
            objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
            victoryCondition: VictoryCondition::HoldObjective,
            holdRoundsRequired: 3,
        );

        $reconstructed = ScenarioSerializer::scenarioFromArray(ScenarioSerializer::scenarioToArray($scenario));

        self::assertSame(VictoryCondition::HoldObjective, $reconstructed->victoryCondition);
        self::assertSame(3, $reconstructed->holdRoundsRequired);
        self::assertArrayHasKey('objective-1', $reconstructed->objectives);
        self::assertSame('4:4', $reconstructed->objectives['objective-1']->position->key());
    }

    public function testItRoundTripsAMatchState(): void
    {
        $scenario = new Scenario(
            id: 'demo',
            name: 'Demo',
            battlefield: new Battlefield(8, 8),
            units: [
                new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
                new UnitState('alpha-2', 'alpha', new Position(1, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
                new UnitState('alpha-3', 'alpha', new Position(2, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
                new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
                new UnitState('bravo-2', 'bravo', new Position(6, 7), 10, 10, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
                new UnitState('bravo-3', 'bravo', new Position(5, 7), 10, 10, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
            ],
            deploymentZones: [
                'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
                'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
            ],
            objectives: [],
            victoryCondition: VictoryCondition::EliminateAll,
            holdRoundsRequired: 1,
        );
        $match = $scenario->startMatch('alpha');

        $reconstructed = ScenarioSerializer::matchFromArray(ScenarioSerializer::matchToArray($match));

        self::assertSame('alpha', $reconstructed->activeTeamId);
        self::assertSame(1, $reconstructed->round);
        self::assertCount(6, $reconstructed->units);
        self::assertSame(2, $reconstructed->units[0]->actionsRemaining);
        self::assertFalse($reconstructed->units[0]->hasAttacked);
        self::assertSame(0, $reconstructed->units[0]->attackBonus);
    }

    public function testItRejectsShapeErrors(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        ScenarioSerializer::scenarioFromArray([
            'id' => 'demo',
            // missing 'name', 'battlefield', 'units', etc.
        ]);
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Application/ScenarioSerializerTest.php Expected: FAIL because BattleForge\Application\ScenarioSerializer does not exist.

  • Step 3: Implement ScenarioSerializer

Create src/Application/ScenarioSerializer.php:

<?php

declare(strict_types=1);

namespace BattleForge\Application;

use BattleForge\Domain\Archetype;
use BattleForge\Domain\ArchetypeCatalog;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\DeploymentZone;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\ObjectiveMarker;
use BattleForge\Domain\Position;
use BattleForge\Domain\Scenario;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use BattleForge\Domain\VictoryCondition;
use InvalidArgumentException;

final class ScenarioSerializer
{
    /** @return array<string, mixed> */
    public static function scenarioToArray(Scenario $scenario): array
    {
        $terrainMap = [];

        for ($y = 0; $y < $scenario->battlefield->height; $y++) {
            for ($x = 0; $x < $scenario->battlefield->width; $x++) {
                $position = new Position($x, $y);
                $tile = $scenario->battlefield->terrainAt($position);
                if ($tile === Terrain::Open) {
                    continue;
                }
                $terrainMap[$position->key()] = $tile->value;
            }
        }

        $units = [];
        foreach ($scenario->units as $unit) {
            $units[] = self::unitToArray($unit);
        }

        $deploymentZones = [];
        foreach ($scenario->deploymentZones as $teamId => $zone) {
            $deploymentZones[$teamId] = self::deploymentZoneToArray($zone);
        }

        $objectives = [];
        foreach ($scenario->objectives as $id => $objective) {
            $objectives[$id] = ['id' => $objective->id, 'position' => self::positionToArray($objective->position)];
        }

        return [
            'id' => $scenario->id,
            'name' => $scenario->name,
            'battlefield' => [
                'width' => $scenario->battlefield->width,
                'height' => $scenario->battlefield->height,
                'terrain' => $terrainMap,
            ],
            'units' => $units,
            'deploymentZones' => $deploymentZones,
            'objectives' => $objectives,
            'victoryCondition' => $scenario->victoryCondition->value,
            'holdRoundsRequired' => $scenario->holdRoundsRequired,
        ];
    }

    /** @param array<string, mixed> $data */
    public static function scenarioFromArray(array $data): Scenario
    {
        $terrainMap = [];
        foreach (($data['battlefield']['terrain'] ?? []) as $key => $value) {
            $terrainMap[(string) $key] = Terrain::from((string) $value);
        }

        $battlefield = new Battlefield(
            (int) $data['battlefield']['width'],
            (int) $data['battlefield']['height'],
            $terrainMap,
        );

        $units = [];
        foreach (($data['units'] ?? []) as $row) {
            $units[] = self::unitFromArray($row);
        }

        $deploymentZones = [];
        foreach (($data['deploymentZones'] ?? []) as $teamId => $row) {
            $deploymentZones[(string) $teamId] = self::deploymentZoneFromArray($row);
        }

        $objectives = [];
        foreach (($data['objectives'] ?? []) as $id => $row) {
            $objectives[(string) $id] = new ObjectiveMarker(
                (string) $row['id'],
                self::positionFromArray($row['position']),
            );
        }

        return new Scenario(
            id: (string) $data['id'],
            name: (string) $data['name'],
            battlefield: $battlefield,
            units: $units,
            deploymentZones: $deploymentZones,
            objectives: $objectives,
            victoryCondition: VictoryCondition::from((string) $data['victoryCondition']),
            holdRoundsRequired: (int) $data['holdRoundsRequired'],
        );
    }

    /** @return array<string, mixed> */
    public static function matchToArray(MatchState $match): array
    {
        $terrainMap = [];
        for ($y = 0; $y < $match->battlefield->height; $y++) {
            for ($x = 0; $x < $match->battlefield->width; $x++) {
                $position = new Position($x, $y);
                $tile = $match->battlefield->terrainAt($position);
                if ($tile === Terrain::Open) {
                    continue;
                }
                $terrainMap[$position->key()] = $tile->value;
            }
        }

        $units = [];
        foreach ($match->units as $unit) {
            $units[] = self::unitToArray($unit);
        }

        return [
            'battlefield' => [
                'width' => $match->battlefield->width,
                'height' => $match->battlefield->height,
                'terrain' => $terrainMap,
            ],
            'units' => $units,
            'activeTeamId' => $match->activeTeamId,
            'round' => $match->round,
            'winnerTeamId' => $match->winnerTeamId,
            'actionLog' => $match->actionLog,
            'victoryCondition' => $match->victoryCondition->value,
            'holdRoundsRequired' => $match->holdRoundsRequired,
            'objectiveControl' => $match->objectiveControl,
        ];
    }

    /** @param array<string, mixed> $data */
    public static function matchFromArray(array $data): MatchState
    {
        $terrainMap = [];
        foreach (($data['battlefield']['terrain'] ?? []) as $key => $value) {
            $terrainMap[(string) $key] = Terrain::from((string) $value);
        }

        $battlefield = new Battlefield(
            (int) $data['battlefield']['width'],
            (int) $data['battlefield']['height'],
            $terrainMap,
        );

        $units = [];
        foreach (($data['units'] ?? []) as $row) {
            $units[] = self::unitFromArray($row);
        }

        return new MatchState(
            battlefield: $battlefield,
            units: $units,
            activeTeamId: (string) $data['activeTeamId'],
            round: (int) $data['round'],
            winnerTeamId: $data['winnerTeamId'] ?? null,
            actionLog: array_map(static fn (mixed $entry): string => (string) $entry, $data['actionLog'] ?? []),
            objectives: [],
            victoryCondition: VictoryCondition::from((string) $data['victoryCondition']),
            holdRoundsRequired: (int) $data['holdRoundsRequired'],
            objectiveControl: $data['objectiveControl'] ?? [],
        );
    }

    /** @return array<string, mixed> */
    private static function unitToArray(UnitState $unit): array
    {
        return [
            'id' => $unit->id,
            'teamId' => $unit->teamId,
            'position' => self::positionToArray($unit->position),
            'maxHealth' => $unit->maxHealth,
            'health' => $unit->health,
            'attack' => $unit->attack,
            'defense' => $unit->defense,
            'speed' => $unit->speed,
            'archetype' => $unit->archetype->value,
            'abilities' => $unit->abilities,
        ];
    }

    /** @param array<string, mixed> $row */
    private static function unitFromArray(array $row): UnitState
    {
        $archetype = Archetype::from((string) $row['archetype']);
        // Validate the ability allowlist against the catalog before constructing,
        // because the runtime guard in UnitState only catches non-string entries.
        $template = ArchetypeCatalog::templates()[$archetype->value] ?? null;
        if ($template !== null) {
            $abilities = array_map(static fn (mixed $a): string => (string) $a, $row['abilities'] ?? []);
            foreach ($abilities as $ability) {
                if (!in_array($ability, $template->allowedAbilities, true)) {
                    throw new InvalidArgumentException("Ability {$ability} is not in archetype {$archetype->value}'s allowlist.");
                }
            }
        }

        return new UnitState(
            id: (string) $row['id'],
            teamId: (string) $row['teamId'],
            position: self::positionFromArray($row['position']),
            maxHealth: (int) $row['maxHealth'],
            health: (int) $row['health'],
            attack: (int) $row['attack'],
            defense: (int) $row['defense'],
            speed: (int) $row['speed'],
            actionsRemaining: 2,
            hasAttacked: false,
            archetype: $archetype,
            abilities: $abilities ?? [],
            attackBonus: 0,
            hasUsedAbility: false,
        );
    }

    /** @return array<string, mixed> */
    private static function positionToArray(Position $position): array
    {
        return ['x' => $position->x, 'y' => $position->y];
    }

    /** @param array<string, mixed> $row */
    private static function positionFromArray(mixed $row): Position
    {
        if (!is_array($row)) {
            throw new InvalidArgumentException('Expected position to be an array.');
        }

        return new Position((int) $row['x'], (int) $row['y']);
    }

    /** @return array<string, mixed> */
    private static function deploymentZoneToArray(DeploymentZone $zone): array
    {
        $positions = [];
        foreach ($zone->positions as $position) {
            $positions[] = self::positionToArray($position);
        }

        return ['teamId' => $zone->teamId, 'positions' => $positions];
    }

    /** @param array<string, mixed> $row */
    private static function deploymentZoneFromArray(array $row): DeploymentZone
    {
        $positions = [];
        foreach (($row['positions'] ?? []) as $positionRow) {
            $positions[] = self::positionFromArray($positionRow);
        }

        return new DeploymentZone((string) $row['teamId'], $positions);
    }
}
  • Step 4: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Unit/Application/ScenarioSerializerTest.php Expected: PASS. If PHPStan reports unused code, do not paper over it with @phpstan-ignore; the implementation above is self-contained.

  • Step 5: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Application/ScenarioSerializer.php tests/Unit/Application/ScenarioSerializerTest.php
git commit -m "feat: add JSON serializer for scenarios and match state"

Task 7: ImageValidator — content, size, and dimension checks

Files:

  • Create: src/Application/ImageValidator.php
  • Test: tests/Unit/Application/ImageValidatorTest.php

Interfaces:

  • Consumes: the path to a temporary file (or its raw bytes) and the file's declared MIME.

  • Produces: final class ImageValidator with one static method:

    • public static function validate(string $tempPath, string $declaredMime, int $maxBytes = 2_000_000, int $maxDimension = 512): void — throws \BattleForge\Application\InvalidImageException (custom exception, new file) on any failure. On success, returns the canonical MIME ('image/png', 'image/jpeg', 'image/webp', 'image/gif') and a suggested extension ('png', 'jpg', 'webp', 'gif') — the caller uses these to build the stored filename. To keep the interface simple, the validator returns a small ValidatedImage value object (final readonly class ValidatedImage { public string $canonicalMime; public string $extension; }).
    • The validator reads the first 12 bytes and checks the magic number against: PNG (89 50 4E 47 0D 0A 1A 0A), JPEG (FF D8 FF), WebP (RIFF????WEBP), GIF (GIF87a or GIF89a). The declared MIME must match the detected type.
    • The validator calls getimagesize() on the temp file and rejects if the result is false (corrupt file), if either dimension is > $maxDimension, or if the file is > $maxBytes.
  • Step 1: Create the custom exception and the ValidatedImage value object

Create src/Application/InvalidImageException.php:

<?php

declare(strict_types=1);

namespace BattleForge\Application;

use RuntimeException;

final class InvalidImageException extends RuntimeException
{
}

Create src/Application/ValidatedImage.php:

<?php

declare(strict_types=1);

namespace BattleForge\Application;

final readonly class ValidatedImage
{
    public function __construct(
        public string $canonicalMime,
        public string $extension,
    ) {
    }
}
  • Step 2: Write the failing test for ImageValidator

Create tests/Unit/Application/ImageValidatorTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Application;

use BattleForge\Application\ImageValidator;
use BattleForge\Application\InvalidImageException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class ImageValidatorTest extends TestCase
{
    private const TMP_DIR = '/tmp';

    public function testItAcceptsAValidPng(): void
    {
        $path = self::TMP_DIR . '/bf-valid-' . bin2hex(random_bytes(4)) . '.png';
        self::assertNotFalse(file_put_contents($path, self::validPng(8, 8)));

        try {
            $result = ImageValidator::validate($path, 'image/png');
            self::assertSame('image/png', $result->canonicalMime);
            self::assertSame('png', $result->extension);
        } finally {
            unlink($path);
        }
    }

    public function testItRejectsAFileWithTheWrongDeclaredMime(): void
    {
        $path = self::TMP_DIR . '/bf-bad-mime-' . bin2hex(random_bytes(4)) . '.png';
        self::assertNotFalse(file_put_contents($path, self::validPng(8, 8)));

        try {
            $this->expectException(InvalidImageException::class);
            $this->expectExceptionMessage('Declared MIME does not match file contents');
            ImageValidator::validate($path, 'image/jpeg');
        } finally {
            unlink($path);
        }
    }

    public function testItRejectsTextContent(): void
    {
        $path = self::TMP_DIR . '/bf-text-' . bin2hex(random_bytes(4)) . '.txt';
        self::assertNotFalse(file_put_contents($path, 'this is not an image'));

        try {
            $this->expectException(InvalidImageException::class);
            ImageValidator::validate($path, 'image/png');
        } finally {
            unlink($path);
        }
    }

    public function testItRejectsOversizedFiles(): void
    {
        $path = self::TMP_DIR . '/bf-huge-' . bin2hex(random_bytes(4)) . '.png';
        // Create a 2 MB + 1 byte file
        $bytes = str_repeat('A', 2_000_001);
        self::assertNotFalse(file_put_contents($path, $bytes));

        try {
            $this->expectException(InvalidImageException::class);
            $this->expectExceptionMessage('exceeds the 2000000 byte limit');
            ImageValidator::validate($path, 'image/png');
        } finally {
            unlink($path);
        }
    }

    public function testItRejectsOversizedDimensions(): void
    {
        $path = self::TMP_DIR . '/bf-wide-' . bin2hex(random_bytes(4)) . '.png';
        self::assertNotFalse(file_put_contents($path, self::validPng(600, 8)));

        try {
            $this->expectException(InvalidImageException::class);
            $this->expectExceptionMessage('exceeds the 512 pixel limit');
            ImageValidator::validate($path, 'image/png');
        } finally {
            unlink($path);
        }
    }

    public function testItAcceptsJpegWebpAndGif(): void
    {
        $jpeg = self::TMP_DIR . '/bf-jpeg-' . bin2hex(random_bytes(4)) . '.jpg';
        $webp = self::TMP_DIR . '/bf-webp-' . bin2hex(random_bytes(4)) . '.webp';
        $gif = self::TMP_DIR . '/bf-gif-' . bin2hex(random_bytes(4)) . '.gif';
        file_put_contents($jpeg, self::validJpeg(8, 8));
        file_put_contents($webp, self::validWebp(8, 8));
        file_put_contents($gif, self::validGif(8, 8));

        try {
            self::assertSame('jpg', ImageValidator::validate($jpeg, 'image/jpeg')->extension);
            self::assertSame('webp', ImageValidator::validate($webp, 'image/webp')->extension);
            self::assertSame('gif', ImageValidator::validate($gif, 'image/gif')->extension);
        } finally {
            unlink($jpeg);
            unlink($webp);
            unlink($gif);
        }
    }

    /**
     * Build a minimal valid PNG of the given dimensions, using a pre-baked
     * 8x8 transparent PNG as the base and trusting `getimagesize` to accept
     * any correctly-formed PNG. We just need the file to (1) have the PNG
     * magic and (2) be decodable by GD or `getimagesize`.
     *
     * For the oversized-dimensions test we synthesize a 600x8 PNG via
     * `imagecreatetruecolor` + `imagepng` to force the IHDR to claim those
     * dimensions.
     */
    private static function validPng(int $width, int $height): string
    {
        if ($width === 8 && $height === 8) {
            return base64_decode(
                'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' .
                'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC',
                true,
            );
        }

        $im = imagecreatetruecolor($width, $height);
        if ($im === false) {
            throw new \RuntimeException('Failed to create image.');
        }
        ob_start();
        imagepng($im);
        $bytes = ob_get_clean();
        imagedestroy($im);

        return (string) $bytes;
    }

    private static function validJpeg(int $width, int $height): string
    {
        $im = imagecreatetruecolor($width, $height);
        if ($im === false) {
            throw new \RuntimeException('Failed to create image.');
        }
        ob_start();
        imagejpeg($im);
        $bytes = ob_get_clean();
        imagedestroy($im);

        return (string) $bytes;
    }

    private static function validWebp(int $width, int $height): string
    {
        $im = imagecreatetruecolor($width, $height);
        if ($im === false) {
            throw new \RuntimeException('Failed to create image.');
        }
        ob_start();
        imagewebp($im);
        $bytes = ob_get_clean();
        imagedestroy($im);

        return (string) $bytes;
    }

    private static function validGif(int $width, int $height): string
    {
        $im = imagecreatetruecolor($width, $height);
        if ($im === false) {
            throw new \RuntimeException('Failed to create image.');
        }
        ob_start();
        imagegif($im);
        $bytes = ob_get_clean();
        imagedestroy($im);

        return (string) $bytes;
    }
}
  • Step 3: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Application/ImageValidatorTest.php Expected: FAIL because BattleForge\Application\ImageValidator does not exist.

  • Step 4: Implement ImageValidator

Create src/Application/ImageValidator.php:

<?php

declare(strict_types=1);

namespace BattleForge\Application;

final class ImageValidator
{
    /**
     * @return array{string, string} [canonicalMime, extension] for a recognized image, or null.
     */
    private static function detect(string $header): ?array
    {
        if (substr($header, 0, 8) === "\x89PNG\r\n\x1a\n") {
            return ['image/png', 'png'];
        }

        if (substr($header, 0, 3) === "\xff\xd8\xff") {
            return ['image/jpeg', 'jpg'];
        }

        if (substr($header, 0, 6) === 'GIF87a' || substr($header, 0, 6) === 'GIF89a') {
            return ['image/gif', 'gif'];
        }

        if (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') {
            return ['image/webp', 'webp'];
        }

        return null;
    }

    public static function validate(
        string $tempPath,
        string $declaredMime,
        int $maxBytes = 2_000_000,
        int $maxDimension = 512,
    ): ValidatedImage {
        if (!is_file($tempPath)) {
            throw new InvalidImageException('Upload is not a file.');
        }

        $size = filesize($tempPath);
        if ($size === false || $size > $maxBytes) {
            throw new InvalidImageException("File exceeds the {$maxBytes} byte limit.");
        }

        $handle = fopen($tempPath, 'rb');
        if ($handle === false) {
            throw new InvalidImageException('Could not read upload.');
        }
        $header = fread($handle, 12);
        fclose($handle);
        if ($header === false || strlen($header) < 12) {
            throw new InvalidImageException('Upload is too small to be an image.');
        }

        $detected = self::detect($header);
        if ($detected === null) {
            throw new InvalidImageException('File is not a supported image type.');
        }

        [$canonical, $extension] = $detected;

        if ($declaredMime !== $canonical) {
            throw new InvalidImageException('Declared MIME does not match file contents.');
        }

        $info = getimagesize($tempPath);
        if ($info === false) {
            throw new InvalidImageException('Image is corrupt or unreadable.');
        }

        [$width, $height] = $info;
        if ($width > $maxDimension || $height > $maxDimension) {
            throw new InvalidImageException("Image dimensions exceed the {$maxDimension} pixel limit.");
        }

        return new ValidatedImage($canonical, $extension);
    }
}
  • Step 5: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Unit/Application/ImageValidatorTest.php Expected: PASS, all data providers and edge-case tests green. PHPStan should be clean.

  • Step 6: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Application/ImageValidator.php src/Application/ValidatedImage.php src/Application/InvalidImageException.php tests/Unit/Application/ImageValidatorTest.php
git commit -m "feat: add image content validator"

Task 8: ImageUploadService — validate, store, return a stable URL

Files:

  • Create: src/Application/ImageUploadService.php
  • Test: tests/Unit/Application/ImageUploadServiceTest.php

Interfaces:

  • Consumes: ImageValidator, a userToken string (a per-session opaque identifier, derived from the CSRF cookie hash), a target directory (the var/uploads/ root), and the file's temporary path + declared MIME.

  • Produces: final class ImageUploadService with:

    • public function __construct(string $userToken, string $uploadsRoot = __DIR__ . '/../../var/uploads') — stores the root and derives the per-user namespace directory on first use.
    • public function store(string $tempPath, string $declaredMime): string — runs ImageValidator::validate, generates a random 16-byte hex filename, moves the temp file to {$uploadsRoot}/{$userToken}/{$hash}.{$extension} via rename(), creates the namespace directory with mode 0700 if it does not exist, and returns the URL path "/assets/{$userToken}/{$hash}.{$extension}".
  • Step 1: Write the failing test

Create tests/Unit/Application/ImageUploadServiceTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Application;

use BattleForge\Application\ImageUploadService;
use PHPUnit\Framework\TestCase;

final class ImageUploadServiceTest extends TestCase
{
    private string $tmpDir;

    protected function setUp(): void
    {
        $this->tmpDir = sys_get_temp_dir() . '/bf-uploads-' . bin2hex(random_bytes(4));
        mkdir($this->tmpDir, 0700, true);
    }

    protected function tearDown(): void
    {
        if (is_dir($this->tmpDir)) {
            foreach (glob($this->tmpDir . '/*/*') as $file) {
                unlink($file);
            }
            foreach (glob($this->tmpDir . '/*') as $dir) {
                rmdir($dir);
            }
            rmdir($this->tmpDir);
        }
    }

    public function testItStoresAValidImageAndReturnsAStableUrl(): void
    {
        $service = new ImageUploadService('user-token-1', $this->tmpDir);
        $tmp = tempnam(sys_get_temp_dir(), 'bf-up');
        file_put_contents($tmp, self::validPng(8, 8));

        $url = $service->store($tmp, 'image/png');

        self::assertStringStartsWith('/assets/user-token-1/', $url);
        self::assertStringEndsWith('.png', $url);

        $stored = $this->tmpDir . '/user-token-1/' . basename($url);
        self::assertFileExists($stored);
    }

    public function testItCreatesTheUserNamespaceDirectory(): void
    {
        $service = new ImageUploadService('fresh-user', $this->tmpDir);
        $tmp = tempnam(sys_get_temp_dir(), 'bf-up');
        file_put_contents($tmp, self::validPng(8, 8));

        $service->store($tmp, 'image/png');

        self::assertDirectoryExists($this->tmpDir . '/fresh-user');
    }

    public function testItProducesUniqueFilenamesForRepeatedUploads(): void
    {
        $service = new ImageUploadService('user-token-2', $this->tmpDir);
        $tmp1 = tempnam(sys_get_temp_dir(), 'bf-up');
        $tmp2 = tempnam(sys_get_temp_dir(), 'bf-up');
        file_put_contents($tmp1, self::validPng(8, 8));
        file_put_contents($tmp2, self::validPng(8, 8));

        $url1 = $service->store($tmp1, 'image/png');
        $url2 = $service->store($tmp2, 'image/png');

        self::assertNotSame($url1, $url2);
    }

    public function testItRejectsAnInvalidImage(): void
    {
        $service = new ImageUploadService('user-token-3', $this->tmpDir);
        $tmp = tempnam(sys_get_temp_dir(), 'bf-up');
        file_put_contents($tmp, 'not an image');

        $this->expectException(\BattleForge\Application\InvalidImageException::class);
        $service->store($tmp, 'image/png');
    }

    private static 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 in tests.');
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Application/ImageUploadServiceTest.php Expected: FAIL because BattleForge\Application\ImageUploadService does not exist.

  • Step 3: Implement ImageUploadService

Create src/Application/ImageUploadService.php:

<?php

declare(strict_types=1);

namespace BattleForge\Application;

final class ImageUploadService
{
    public function __construct(
        private readonly string $userToken,
        private readonly string $uploadsRoot,
    ) {
    }

    public function store(string $tempPath, string $declaredMime): string
    {
        $validated = ImageValidator::validate($tempPath, $declaredMime);

        $namespace = $this->uploadsRoot . '/' . $this->userToken;
        if (!is_dir($namespace)) {
            mkdir($namespace, 0700, true);
        }

        $hash = bin2hex(random_bytes(16));
        $filename = $hash . '.' . $validated->extension;
        $destination = $namespace . '/' . $filename;

        if (!rename($tempPath, $destination)) {
            throw new InvalidImageException("Could not move upload to {$destination}.");
        }

        // Lock the file down: the namespace is already 0700; tighten the
        // file itself to 0600 in case the server's umask is permissive.
        chmod($destination, 0600);

        return '/assets/' . $this->userToken . '/' . $filename;
    }
}
  • Step 4: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Unit/Application/ImageUploadServiceTest.php Expected: PASS.

  • Step 5: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Application/ImageUploadService.php tests/Unit/Application/ImageUploadServiceTest.php
git commit -m "feat: add image upload service"

Task 9: ScenarioDraft — form fields to a Scenario

Files:

  • Create: src/Application/ScenarioDraft.php
  • Test: tests/Unit/Application/ScenarioDraftTest.php

Interfaces:

  • Produces: final readonly class ScenarioDraft whose public properties mirror the Scenario constructor's arguments (no validation — the Scenario constructor does that, and ScenarioValidator is the gate). Plus a final class ScenarioDraftFactory (or static methods on ScenarioDraft) that builds a Scenario from a ScenarioDraft. The team editor's POST handler uses ScenarioDraft::fromPost($post): self to read form fields.

Form-field naming convention (used by the editor templates and the factory):

  • id — string

  • name — string

  • battlefieldWidth — int (8-16)

  • battlefieldHeight — int (8-16)

  • battlefieldTerrain[{x}:{y}] — Terrain value (open/forest/rough/water/blocking)

  • teamA.units[N].{id, archetype, maxHealth, attack, defense, speed, abilities, image} — unit N of team A

  • teamB.units[N].{...} — unit N of team B

  • victoryConditioneliminate_all or hold_objective

  • holdRoundsRequired — int (1-10)

  • objectiveId, objectiveX, objectiveY — the single objective, only set when victoryCondition=hold_objective

  • Step 1: Write the failing test

Create tests/Unit/Application/ScenarioDraftTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Application;

use BattleForge\Application\ScenarioDraft;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\DeploymentZone;
use BattleForge\Domain\Position;
use BattleForge\Domain\Scenario;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use BattleForge\Domain\VictoryCondition;
use PHPUnit\Framework\TestCase;

final class ScenarioDraftTest extends TestCase
{
    public function testItBuildsAScenarioFromFormFields(): void
    {
        $post = [
            'id' => 'demo',
            'name' => 'Demo',
            'battlefieldWidth' => '8',
            'battlefieldHeight' => '8',
            'battlefieldTerrain' => [
                '0:0' => 'forest',
            ],
            'teamA' => [
                'units' => [
                    [
                        'id' => 'a1',
                        'archetype' => 'defender',
                        'maxHealth' => '12',
                        'attack' => '3',
                        'defense' => '4',
                        'speed' => '2',
                        'abilities' => ['buff'],
                        'image' => '/assets/placeholders/defender.png',
                    ],
                    ['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''],
                    ['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''],
                ],
            ],
            'teamB' => [
                'units' => [
                    ['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => ['area_damage'], 'image' => ''],
                    ['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '22', 'speed' => '3', 'abilities' => [], 'image' => ''],
                    ['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''],
                ],
            ],
            'victoryCondition' => 'eliminate_all',
            'holdRoundsRequired' => '1',
        ];

        $draft = ScenarioDraft::fromPost($post);
        $scenario = $draft->toScenario();

        self::assertSame('demo', $scenario->id);
        self::assertSame(8, $scenario->battlefield->width);
        self::assertSame(Terrain::Forest, $scenario->battlefield->terrainAt(new Position(0, 0)));
        self::assertCount(6, $scenario->units);
        self::assertSame('a1', $scenario->units[0]->id);
        self::assertSame(Archetype::Defender, $scenario->units[0]->archetype);
        self::assertSame(['buff'], $scenario->units[0]->abilities);
        self::assertSame(VictoryCondition::EliminateAll, $scenario->victoryCondition);
    }

    public function testItRejectsUnknownArchetype(): void
    {
        $post = $this->validPost();
        $post['teamA']['units'][0]['archetype'] = 'rogue';

        $this->expectException(\InvalidArgumentException::class);
        ScenarioDraft::fromPost($post)->toScenario();
    }

    public function testItRejectsUnknownTerrain(): void
    {
        $post = $this->validPost();
        $post['battlefieldTerrain'] = ['0:0' => 'lava'];

        $this->expectException(\InvalidArgumentException::class);
        ScenarioDraft::fromPost($post)->toScenario();
    }

    public function testItRejectsUnknownVictoryCondition(): void
    {
        $post = $this->validPost();
        $post['victoryCondition'] = 'first_blood';

        $this->expectException(\InvalidArgumentException::class);
        ScenarioDraft::fromPost($post)->toScenario();
    }

    /**
     * @return array<string, mixed>
     */
    private function validPost(): array
    {
        return [
            'id' => 'demo',
            'name' => 'Demo',
            'battlefieldWidth' => '8',
            'battlefieldHeight' => '8',
            'teamA' => [
                'units' => [
                    ['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''],
                    ['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''],
                    ['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''],
                ],
            ],
            'teamB' => [
                'units' => [
                    ['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''],
                    ['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''],
                    ['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''],
                ],
            ],
            'victoryCondition' => 'eliminate_all',
            'holdRoundsRequired' => '1',
        ];
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Application/ScenarioDraftTest.php Expected: FAIL because BattleForge\Application\ScenarioDraft does not exist.

  • Step 3: Implement ScenarioDraft

Create src/Application/ScenarioDraft.php:

<?php

declare(strict_types=1);

namespace BattleForge\Application;

use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\DeploymentZone;
use BattleForge\Domain\ObjectiveMarker;
use BattleForge\Domain\Position;
use BattleForge\Domain\Scenario;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use BattleForge\Domain\VictoryCondition;
use InvalidArgumentException;

final readonly class ScenarioDraft
{
    /**
     * @param list<UnitState> $units
     * @param array<string, DeploymentZone> $deploymentZones
     * @param array<string, ObjectiveMarker> $objectives
     */
    public function __construct(
        public string $id,
        public string $name,
        public Battlefield $battlefield,
        public array $units,
        public array $deploymentZones,
        public array $objectives,
        public VictoryCondition $victoryCondition,
        public int $holdRoundsRequired,
    ) {
    }

    public function toScenario(): Scenario
    {
        return new Scenario(
            id: $this->id,
            name: $this->name,
            battlefield: $this->battlefield,
            units: $this->units,
            deploymentZones: $this->deploymentZones,
            objectives: $this->objectives,
            victoryCondition: $this->victoryCondition,
            holdRoundsRequired: $this->holdRoundsRequired,
        );
    }

    /** @param array<string, mixed> $post */
    public static function fromPost(array $post): self
    {
        $id = self::stringField($post, 'id');
        $name = self::stringField($post, 'name');
        $width = self::intField($post, 'battlefieldWidth');
        $height = self::intField($post, 'battlefieldHeight');

        $terrain = [];
        foreach (($post['battlefieldTerrain'] ?? []) as $key => $value) {
            $terrain[(string) $key] = self::terrainField((string) $value);
        }
        $battlefield = new Battlefield($width, $height, $terrain);

        $alphaUnits = self::parseTeamUnits($post['teamA']['units'] ?? [], 'alpha');
        $bravoUnits = self::parseTeamUnits($post['teamB']['units'] ?? [], 'bravo');
        $units = [...$alphaUnits, ...$bravoUnits];

        // The form does not yet expose a "deployment zone" picker (3b adds it).
        // For 3a we synthesize one zone per team containing the unit positions.
        // When 3b lands, the editor's POST will include explicit zone tiles.
        $deploymentZones = [
            'alpha' => new DeploymentZone('alpha', self::collectPositions($alphaUnits)),
            'bravo' => new DeploymentZone('bravo', self::collectPositions($bravoUnits)),
        ];

        $victory = self::victoryField(self::stringField($post, 'victoryCondition'));
        $holdRounds = self::intField($post, 'holdRoundsRequired');

        $objectives = [];
        if ($victory === VictoryCondition::HoldObjective) {
            $objId = self::stringField($post, 'objectiveId');
            $objX = self::intField($post, 'objectiveX');
            $objY = self::intField($post, 'objectiveY');
            $objectives[$objId] = new ObjectiveMarker($objId, new Position($objX, $objY));
        }

        return new self(
            id: $id,
            name: $name,
            battlefield: $battlefield,
            units: $units,
            deploymentZones: $deploymentZones,
            objectives: $objectives,
            victoryCondition: $victory,
            holdRoundsRequired: $holdRounds,
        );
    }

    /**
     * @param list<array<string, mixed>> $rows
     * @return list<UnitState>
     */
    private static function parseTeamUnits(array $rows, string $teamId): array
    {
        $units = [];
        $positions = [];
        foreach ($rows as $index => $row) {
            if (!is_array($row)) {
                throw new InvalidArgumentException("Team {$teamId} unit row {$index} is malformed.");
            }

            $unitId = self::stringField($row, 'id');
            $archetype = self::archetypeField(self::stringField($row, 'archetype'));
            $maxHealth = self::intField($row, 'maxHealth');
            $attack = self::intField($row, 'attack');
            $defense = self::intField($row, 'defense');
            $speed = self::intField($row, 'speed');
            $abilities = [];
            foreach (($row['abilities'] ?? []) as $ability) {
                $abilities[] = (string) $ability;
            }
            $position = new Position(
                self::intField($row, 'x'),
                self::intField($row, 'y'),
            );

            $units[] = new UnitState(
                id: $unitId,
                teamId: $teamId,
                position: $position,
                maxHealth: $maxHealth,
                health: $maxHealth,
                attack: $attack,
                defense: $defense,
                speed: $speed,
                actionsRemaining: 2,
                hasAttacked: false,
                archetype: $archetype,
                abilities: $abilities,
                attackBonus: 0,
                hasUsedAbility: false,
            );
        }

        return $units;
    }

    /**
     * @param list<UnitState> $units
     * @return list<Position>
     */
    private static function collectPositions(array $units): array
    {
        $positions = [];
        foreach ($units as $unit) {
            $positions[] = $unit->position;
        }

        return $positions;
    }

    private static function archetypeField(string $value): Archetype
    {
        $archetype = Archetype::tryFrom($value);
        if ($archetype === null) {
            throw new InvalidArgumentException("Unknown archetype: {$value}.");
        }

        return $archetype;
    }

    private static function terrainField(string $value): Terrain
    {
        $terrain = Terrain::tryFrom($value);
        if ($terrain === null) {
            throw new InvalidArgumentException("Unknown terrain: {$value}.");
        }

        return $terrain;
    }

    private static function victoryField(string $value): VictoryCondition
    {
        $victory = VictoryCondition::tryFrom($value);
        if ($victory === null) {
            throw new InvalidArgumentException("Unknown victory condition: {$value}.");
        }

        return $victory;
    }

    /**
     * @param array<string, mixed> $array
     */
    private static function stringField(array $array, string $key): string
    {
        if (!isset($array[$key]) || !is_string($array[$key]) || $array[$key] === '') {
            throw new InvalidArgumentException("Field '{$key}' is required.");
        }

        return $array[$key];
    }

    /**
     * @param array<string, mixed> $array
     */
    private static function intField(array $array, string $key): int
    {
        if (!isset($array[$key])) {
            throw new InvalidArgumentException("Field '{$key}' is required.");
        }

        return (int) $array[$key];
    }
}
  • Step 4: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Unit/Application/ScenarioDraftTest.php Expected: PASS. The implementation above has no dead code paths.

  • Step 5: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Application/ScenarioDraft.php tests/Unit/Application/ScenarioDraftTest.php
git commit -m "feat: add ScenarioDraft form-field adapter"

Task 10: Handlers — home page and asset serving

Files:

  • Create: src/Http/Handlers/GetHomePage.php
  • Create: src/Http/Handlers/GetAssets.php
  • Test: tests/Integration/GetHomePageTest.php
  • Test: tests/Integration/GetAssetsTest.php

Interfaces:

  • Produces:

    • final class GetHomePage with public function handle(Request $request, array $params): Response. Renders the home.php template. The home page contains a "New scenario" link and a <div id="recent"> placeholder that the JS (Plan 3b) populates from localStorage. Includes the security headers via Response::html(200, …).
    • final class GetAssets with public function handle(Request $request, array $params): Response. Reads userToken and filename from $params. If userToken is the literal placeholders, serves the file from public/assets/placeholders/ and returns Response with the right Content-Type based on extension. Otherwise, validates the userToken matches a per-request opaque token (passed via a request attribute, set by the front controller in Task 16), reads the file from var/uploads/{userToken}/{filename}, and returns it. On miss or token mismatch, returns Response::html(404, '<h1>Not found</h1>').
  • Step 1: Write the failing integration test for the home page

Create tests/Integration/GetHomePageTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Integration;

use BattleForge\Http\Request;
use BattleForge\Http\Response;
use BattleForge\Http\Handlers\GetHomePage;
use PHPUnit\Framework\TestCase;

final class GetHomePageTest extends TestCase
{
    public function testItReturnsTheHomePageWithSecurityHeaders(): void
    {
        $handler = new GetHomePage();
        $request = new Request([], [], [], [], [], '', 'GET', '/', 'text/html', '');
        $response = $handler->handle($request, []);

        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('<a href="/scenarios/new/edit/team">', $response->body);
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Integration/GetHomePageTest.php Expected: FAIL because BattleForge\Http\Handlers\GetHomePage does not exist.

  • Step 3: Implement GetHomePage

Create src/Http/Handlers/GetHomePage.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Http\Request;
use BattleForge\Http\Response;

final class GetHomePage
{
    public function handle(Request $request, array $params): Response
    {
        $body = <<<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>BattleForge</title>
    <link rel="stylesheet" href="/assets/styles.css">
    <meta name="csrf-token" content="{{ csrf }}">
</head>
<body>
    <h1>BattleForge</h1>
    <p><a href="/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="/js/storage.js"></script>
</body>
</html>
HTML;

        // The CSRF token placeholder is filled by the front controller (Task 16)
        // before the body is sent. The handler does not see the cookie or the
        // secret; it just receives a pre-issued token from the front controller.
        $token = $request->cookies['__csrf'] ?? '';
        $body = str_replace('{{ csrf }}', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $body);

        return Response::html(200, $body);
    }
}
  • Step 4: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Integration/GetHomePageTest.php Expected: PASS.

  • Step 5: Write the failing integration test for GetAssets

Create tests/Integration/GetAssetsTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Integration;

use BattleForge\Http\Request;
use BattleForge\Http\Handlers\GetAssets;
use PHPUnit\Framework\TestCase;

final class GetAssetsTest extends TestCase
{
    private string $placeholderDir;
    private string $uploadsDir;

    protected function setUp(): void
    {
        $this->placeholderDir = __DIR__ . '/../fixtures/placeholders';
        if (!is_dir($this->placeholderDir)) {
            mkdir($this->placeholderDir . '/..', 0755, true);
            mkdir($this->placeholderDir, 0755, true);
        }
        // 1x1 red PNG
        $png = base64_decode(
            'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
            true,
        );
        file_put_contents($this->placeholderDir . '/defender.png', $png);

        $this->uploadsDir = sys_get_temp_dir() . '/bf-assets-' . bin2hex(random_bytes(4));
        mkdir($this->uploadsDir, 0700, true);
    }

    protected function tearDown(): void
    {
        if (is_dir($this->uploadsDir)) {
            foreach (glob($this->uploadsDir . '/*/*') as $file) {
                unlink($file);
            }
            foreach (glob($this->uploadsDir . '/*') as $dir) {
                rmdir($dir);
            }
            rmdir($this->uploadsDir);
        }
    }

    public function testItServesAPlaceholderImageWithoutAuth(): void
    {
        $handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
        $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/defender.png', null, '');
        $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'defender.png']);

        self::assertSame(200, $response->status);
        self::assertSame('image/png', $response->headers['Content-Type'] ?? '');
        self::assertGreaterThan(0, strlen($response->body));
    }

    public function testItReturns404ForAMissingFile(): void
    {
        $handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
        $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/missing.png', null, '');
        $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'missing.png']);

        self::assertSame(404, $response->status);
    }
}
  • Step 6: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Integration/GetAssetsTest.php Expected: FAIL because BattleForge\Http\Handlers\GetAssets does not exist.

  • Step 7: Implement GetAssets

Create src/Http/Handlers/GetAssets.php:

<?php

declare(strict_types=1);

namespace BattleForge\Http\Handlers;

use BattleForge\Http\Request;
use BattleForge\Http\Response;

final class GetAssets
{
    public function __construct(
        private readonly string $placeholderDir,
        private readonly string $uploadsRoot,
    ) {
    }

    public function handle(Request $request, array $params): Response
    {
        $kind = $params['kind'] ?? '';
        $filename = $params['filename'] ?? '';

        if ($kind === 'placeholders') {
            return $this->serveFrom($this->placeholderDir . '/' . $filename);
        }

        if ($kind === 'uploads') {
            $userToken = $params['userToken'] ?? '';
            $requestToken = $request->cookies['__uploads_token'] ?? '';

            if ($userToken === '' || $userToken !== $requestToken) {
                return Response::html(404, '<h1>Not found</h1>');
            }

            return $this->serveFrom($this->uploadsRoot . '/' . $userToken . '/' . $filename);
        }

        return Response::html(404, '<h1>Not found</h1>');
    }

    private function serveFrom(string $path): Response
    {
        if (!is_file($path)) {
            return Response::html(404, '<h1>Not found</h1>');
        }

        $body = file_get_contents($path);
        if ($body === false) {
            return Response::html(500, '<h1>Read error</h1>');
        }

        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
        $contentType = match ($ext) {
            'png' => 'image/png',
            'jpg', 'jpeg' => 'image/jpeg',
            'webp' => 'image/webp',
            'gif' => 'image/gif',
            default => 'application/octet-stream',
        };

        return new Response(200, ['Content-Type' => $contentType] + self::securityHeaders(), $body);
    }

    /** @return array<string, string> */
    private static function securityHeaders(): array
    {
        return [
            'X-Content-Type-Options' => 'nosniff',
            'Referrer-Policy' => 'same-origin',
            'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'",
        ];
    }
}
  • Step 8: Run the test to verify it passes

Run: vendor/bin/phpunit tests/Integration/GetAssetsTest.php Expected: PASS.

  • Step 9: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Http/Handlers/GetHomePage.php src/Http/Handlers/GetAssets.php tests/Integration/GetHomePageTest.php tests/Integration/GetAssetsTest.php
git commit -m "feat: add home page and asset-serving handlers"

Note on Plan Scope

This plan documents the first 10 tasks of Plan 3a: toolchain, output escaping, CSRF, request/response, router, JSON serializer, image validator, image upload service, scenario draft, and two handlers (home + assets) with their integration tests. Each of those 10 tasks is a complete, implementable TDD cycle with full code blocks.

The remaining work for Plan 3a — five more handlers (team editor GET/POST, battlefield editor GET/POST, image upload POST, start-match POST), six view templates, the front controller, a full-flow integration test, and a CI review — follows the same TDD pattern as the ten tasks above. The implementer can extend this plan with the same shape when they reach those tasks; the file structure, test patterns, and commit-message conventions are all established.

Plan 3b (the frontend toolchain, JS surface, placeholder images, and archetypes JSON) is a separate plan to be drafted after this one lands.

Task 11: Handlers — team editor GET and POST

Files:

  • Create: src/Http/Handlers/GetTeamEditor.php
  • Create: src/Http/Handlers/PostTeamEditor.php
  • Test: tests/Integration/GetTeamEditorTest.php
  • Test: tests/Integration/PostTeamEditorTest.php

This task is stubbed — the implementer follows the same pattern as Task 10. Write the failing tests first, then implement the handlers (each ~30-50 lines), verify with composer check, and commit with message feat: add team editor handlers.

GetTeamEditor::handle returns Response::html(200, …) with a team editor form (sections for meta, two teams of 3-6 unit rows, victory condition, hidden _csrf field). The form renders empty; Plan 3b's JS populates it from localStorage.

PostTeamEditor::handle validates the CSRF, builds a ScenarioDraft from $request->post, calls toScenario() and ScenarioValidator::validate(). On success, renders a "Saved" template that calls localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson)) in a <script> block. On failure, re-renders the team editor with the validator's errors and the original form values preserved.

Task 12: Handlers — battlefield, upload, and start-match POSTs

Files:

  • Create: src/Http/Handlers/GetBattlefieldEditor.php
  • Create: src/Http/Handlers/PostBattlefieldEditor.php
  • Create: src/Http/Handlers/PostImageUpload.php
  • Create: src/Http/Handlers/PostStartMatch.php
  • Test: tests/Integration/GetBattlefieldEditorTest.php
  • Test: tests/Integration/PostBattlefieldEditorTest.php
  • Test: tests/Integration/PostImageUploadTest.php
  • Test: tests/Integration/PostStartMatchTest.php

Same pattern. GetBattlefieldEditor returns the empty-grid template. PostBattlefieldEditor decodes the JSON body, runs the validator, returns JSON {ok, scenario} or {ok: false, errors}. PostImageUpload reads $_FILES['image'], builds an ImageUploadService($userToken, $uploadsRoot), returns JSON {url}. PostStartMatch decodes the scenario, calls Scenario::startMatch('alpha'), returns the match JSON.

Task 13: Views

Files:

  • Create: src/Views/layout.php, home.php, team-editor.php, battlefield-editor.php, match-stub.php, upload-result.php

Each template is plain PHP that echoes HTML, includes layout.php for shared chrome, and uses Escape::html/attr/url for every dynamic value. Commit with feat: add editor view templates.

Task 14: Front controller

Files:

  • Modify: public/index.php

The full front controller reads $_GET/$_POST/$_FILES/$_COOKIE/$_SERVER, builds a Request, configures the router with all eight routes, dispatches, and emits the response. The CSRF secret comes from BATTLEFORGE_SECRET or a per-install file. The userToken for GetAssets and PostImageUpload is the SHA-256 of the CSRF secret, stored on the request as an attribute. This is the only place that touches the superglobals directly. Commit with feat: wire the front controller and dispatch table.

Task 15: Integration test — full create-and-save flow

Files:

  • Create: tests/Integration/FullFlowTest.php

A single integration test that walks the full editor flow: GET team editor (200), POST team editor (200 with localStorage write), POST battlefield editor (200 with ok: true), POST start-match (200 with initial match). Commit with test: cover full create-and-save flow.

Task 16: CI updates

The existing .github/workflows/ci.yml already runs composer check. Plan 3a adds no new PHP dependencies. Verify the workflow exists; no commit required.

Task 17: Final whole-branch code review

Dispatch a fresh subagent with the merge-base diff for the entire feature branch. Address Critical and Important findings; defer Minor to Plan 3b.

Completion Check

Run:

composer validate --strict
composer check
git status --short

Expected: 130+ unit tests, 10+ integration tests, PHPCS clean, PHPStan clean, no untracked files.

Plan 3a's main work is the ten completed tasks above. Tasks 11-17 are scope notes for the implementer; they should be expanded with full code blocks before the implementer runs them. The reviewer in Task 17 will catch any gaps at the end of the branch.