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

2660 lines
93 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```text
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
<?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:
```json
{
"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
<?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:
```neon
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
<?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
<?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.
```css
* { 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**
```powershell
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
<?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
<?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.
```powershell
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
<?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
<?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.
```powershell
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
<?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
<?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
<?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.
```powershell
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
<?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
<?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.
```powershell
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
<?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
<?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.
```powershell
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
<?php
declare(strict_types=1);
namespace BattleForge\Application;
use RuntimeException;
final class InvalidImageException extends RuntimeException
{
}
```
Create `src/Application/ValidatedImage.php`:
```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
<?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
<?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.
```powershell
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
<?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
<?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.
```powershell
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
- `victoryCondition``eliminate_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
<?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
<?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.
```powershell
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
<?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
<?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
<?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
<?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.
```powershell
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"
```