2660 lines
93 KiB
Markdown
2660 lines
93 KiB
Markdown
# 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>', '<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
|
||
<?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"
|
||
```
|