98 KiB
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}andmatch: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; thevar/uploads/.htaccessandvar/uploads/index.phpsentinels 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_csrffield; fetch uses anX-CSRF-Tokenheader. - All rendered output is escaped via
Escape::html,Escape::attr, andEscape::urlhelpers. 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: nosniffandReferrer-Policy: same-originon every page.- Bundled placeholders are committed under
public/assets/placeholders/; user uploads live invar/uploads/(git-ignored). - PHP follows the repository PHPCS rules and passes PHPStan at level 6.
- The small JS surface passes ESLint
airbnb/baseand 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 andlocalStoragewrite 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.
- Toolchain and infrastructure (Task 1)
- Application layer (Tasks 2-4)
- Http layer (Tasks 5-7)
- Editor pages and the front controller (Tasks 8-12)
- Integration tests and end-to-end plumbing (Task 13)
- CI updates (Task 14)
- 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
.gitignorethat excludesvar/uploads/*but commitsvar/uploads/.htaccessandvar/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.jsonto add the newApplicationandHttpnamespaces
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.xmlto 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.neonto 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.xmlto 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 --strictpasses
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 Escapewith three static methods:public static function html(mixed $value): string— wrapshtmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). Non-string values are stringified first.public static function attr(mixed $value): string— identical tohtml()for our context. Distinct helper name makes code review easy.public static function url(string $value): string— wrapsrawurlencode($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>', '<script>'];
yield 'ampersand' => ['a & b', 'a & b'];
yield 'double-quote' => ['she said "hi"', 'she said "hi"'];
yield 'single-quote' => ["it's", 'it's'];
yield 'control-char-substituted' => ["a\x00b", 'a�b'];
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="', '" onclick="'];
yield 'apostrophe' => ["' onclick='", '' onclick=''];
}
#[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
Secretvalue (the app's HMAC key, supplied by the front controller in Task 13). -
Produces:
final class CsrfTokenwith: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 ishash_hmac('sha256', $tokenValue, $secret). The token is intended to live in__csrfcookie attributes:['expires' => time()+86400, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']. The front controller applies these attributes via PHP'ssetcookie()in Task 13.public static function verify(string $submitted, string $cookieValue): bool— useshash_equalsfor constant-time comparison; returnstrueonly 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 Requestwith public propertiesget(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 Responsewith publicint $status, publicarray $headers(string => string), publicstring $body. Constructor takes all three and stores them. Has a static factoryResponse::html(int $status, string $body): selfthat setsContent-Type: text/html; charset=utf-8and 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'). HasResponse::json(int $status, mixed $body): selfthat JSON-encodes and setsContent-Type: application/json; charset=utf-8. HasResponse::redirect(string $location, int $status = 303): selfthat setsLocation: $locationand 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 Routerwith: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; thedispatchmethod captures them into aparamsarray.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'sResponse. If no route matches, returnsResponse::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 ScenarioSerializerwith three static methods:public static function scenarioToArray(Scenario $scenario): array<string, mixed>— produces an associative array with keysid,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\InvalidArgumentExceptionon 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>}whereterrainkeys are"x:y"and values areTerrainenum string values.UnitState:{id, teamId, position: {x, y}, maxHealth, health, attack, defense, speed, archetype: string, abilities: list<string>, image?: string}. Thearchetypeandabilitiesvalues 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 ImageValidatorwith 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 smallValidatedImagevalue 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 (GIF87aorGIF89a). The declared MIME must match the detected type. - The validator calls
getimagesize()on the temp file and rejects if the result isfalse(corrupt file), if either dimension is> $maxDimension, or if the file is> $maxBytes.
-
Step 1: Create the custom exception and the
ValidatedImagevalue 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, auserTokenstring (a per-session opaque identifier, derived from the CSRF cookie hash), a target directory (thevar/uploads/root), and the file's temporary path + declared MIME. -
Produces:
final class ImageUploadServicewith: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— runsImageValidator::validate, generates a random 16-byte hex filename, moves the temp file to{$uploadsRoot}/{$userToken}/{$hash}.{$extension}viarename(), 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 ScenarioDraftwhose public properties mirror theScenarioconstructor's arguments (no validation — theScenarioconstructor does that, andScenarioValidatoris the gate). Plus afinal class ScenarioDraftFactory(or static methods onScenarioDraft) that builds aScenariofrom aScenarioDraft. The team editor's POST handler usesScenarioDraft::fromPost($post): selfto 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 -
victoryCondition—eliminate_allorhold_objective -
holdRoundsRequired— int (1-10) -
objectiveId,objectiveX,objectiveY— the single objective, only set whenvictoryCondition=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 GetHomePagewithpublic function handle(Request $request, array $params): Response. Renders thehome.phptemplate. The home page contains a "New scenario" link and a<div id="recent">placeholder that the JS (Plan 3b) populates fromlocalStorage. Includes the security headers viaResponse::html(200, …).final class GetAssetswithpublic function handle(Request $request, array $params): Response. ReadsuserTokenandfilenamefrom$params. IfuserTokenis the literalplaceholders, serves the file frompublic/assets/placeholders/and returnsResponsewith the rightContent-Typebased on extension. Otherwise, validates theuserTokenmatches a per-request opaque token (passed via a request attribute, set by the front controller in Task 16), reads the file fromvar/uploads/{userToken}/{filename}, and returns it. On miss or token mismatch, returnsResponse::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.