Compare commits

...
21 Commits
Author SHA1 Message Date
Keith Solomon ced4363d20 Merge feature/frontend-js into develop
CI / php (push) Failing after 1m12s
Plan 3c: JavaScript frontend (storage.js, grid-editor.js, toolchain).

Adds the Node toolchain (ESLint airbnb-base + Prettier) with a CI step,
public/js/storage.js (bfStorage module for localStorage pre-fill and the home
page's recent-scenarios list), public/js/grid-editor.js (battlefield grid
interactions and JSON fetch save), public/assets/archetypes.json (the four
ArchetypeTemplate definitions), four placeholder PNGs at
public/assets/placeholders/, and updated <script> tags in home.php and
team-editor.php and a data-scenario-id attribute + script tag in
battlefield-editor.php. Also adds *.js and *.json to .gitattributes to
enforce LF line endings on those file types.

196/196 tests pass; PHPStan level 6 clean; ESLint 0 errors; Prettier clean.
Two fix commits were made during the per-task review loop: brief's URL
parsing slice index in storage.js was off-by-one (slice(-2,-1) returned
'edit' instead of the id), and brief's buildScenarioJson emitted a nested
objective shape that didn't match the backend's flat objectiveId/X/Y
fields. Both were corrected.
2026-07-12 21:36:54 -05:00
Keith Solomon 02bf9aa390 feat: add battlefield grid editor and CI lint step 2026-07-12 20:47:58 -05:00
Keith Solomon facbb1a311 feat: add localStorage helpers and wire team editor pre-fill 2026-07-12 20:10:04 -05:00
Keith Solomon 8eefd749be chore: add Node toolchain, archetypes.json, and placeholder images 2026-07-12 19:46:29 -05:00
Keith Solomon 78565cfae9 docs: add Plan 3c implementation plan (JavaScript frontend) 2026-07-12 19:24:12 -05:00
Keith Solomon 7acda302e8 docs: add Plan 3c design spec (JavaScript frontend) 2026-07-12 19:15:01 -05:00
Keith Solomon eef8724626 Merge feature/editor-handlers-and-views into develop
Plan 3b: editor handlers, views, and front controller.

Adds the 6 view templates (layout, home, team-editor, battlefield-editor,
match-stub deleted, upload-result deleted), the 5 remaining HTTP handlers
(GetTeamEditor, PostTeamEditor, GetBattlefieldEditor, PostBattlefieldEditor,
PostImageUpload, PostStartMatch), the real front controller in
public/index.php, and the FullFlowTest integration test. The final
whole-branch review found 3 Critical issues (broken upload-asset URL contract,
upload token source mismatch, and an XSS in the PostTeamEditor success page)
plus 2 Important issues (dead home.php/match-stub.php/upload-result.php);
all were fixed in 4 follow-up commits before this merge.

196/196 tests pass; PHPStan level 6 clean; PHPCS 0 errors on new files.
The web app is fully functional from the browser: php -S 0.0.0.0:8000 -t public
serves the home page, team editor, battlefield editor, image upload endpoint,
and asset-serving endpoint end-to-end.
2026-07-07 10:11:27 -05:00
Keith Solomon b4495f24af chore: remove unused match-stub and upload-result view templates
Neither src/Views/match-stub.php nor src/Views/upload-result.php is
referenced by any handler or route in Plan 3b:

- match-stub is the Plan 4 battle-interface placeholder; Plan 3b does
  not register a /match route.
- upload-result is the iframe-style post-upload response; Plan 3b
  returns JSON from POST /assets/upload and Plan 3c will wire the
  client-side upload form.

Both files were over-eager scaffolding from Task 3 (6a0b147). Drop
them in a follow-up commit rather than amend the shared Task 3 commit.
2026-07-06 23:24:27 -05:00
Keith Solomon ba8fd00411 refactor: route GetHomePage through the home.php template
The handler had a stale inline heredoc that used a '{{ csrf }}' string
substitution to inject the token. The home.php template created in Task 2
is the canonical version (uses render_layout + Escape::attr). Switching
to the template removes the duplicated markup and the ad-hoc str_replace
escape path.
2026-07-06 23:23:32 -05:00
Keith Solomon d15dfc6835 fix: escape scenario id when serialising saved page
PostTeamEditor interpolated the scenario id into a <script> block as
'localStorage.setItem("scenario:<id>", ...)', so an id containing
'</script><script>...</script>' would break out of the JS-string context
and execute in the browser. Use json_encode with JSON_HEX_TAG / HEX_AMP /
HEX_APOS / HEX_QUOT so the id is always a safe JS string literal.

Adds a PostTeamEditorTest case that submits '</script><script>alert(1)</script>'
and asserts the literal '<script>alert(1)</script>' substring is absent
from the response. Updates the existing happy-path and FullFlowTest
assertions to match the new 'scenario:"demo"' (quoted) form.
2026-07-06 23:22:42 -05:00
Keith Solomon 61abb93e59 fix: align upload-asset URL contract end-to-end
- ImageUploadService::store() now returns /assets/uploads/<token>/<file>
  so the URL the upload handler emits matches the new GET route.
- public/index.php registers a dedicated GET /assets/uploads/{userToken}/{filename}
  route; the legacy /assets/{kind}/{filename} is kept as a fallback.
- GetAssets reads the upload token from $request->server['__uploads_token']
  (the front controller threads it that way) instead of the cookies bag.
- Adds GetAssets unit tests for the upload-token happy path and a
  token-mismatch 404, plus a FullFlowTest step that uploads and re-fetches
  the asset through the front controller.
2026-07-06 23:21:33 -05:00
Keith Solomon 5d9af7a4fd test: cover full create-and-save flow end-to-end
Add FullFlowTest, a single end-to-end test that exercises the home page, the team editor POST, the battlefield editor POST, and the start-match POST through the front controller (no php -S).

Wiring fixes in public/index.php (called out in the test brief):

- Honor $_SERVER['__csrf_secret'] when set so the test can supply its own secret; fall back to BATTLEFORGE_SECRET / var/secret.key otherwise.
- Read the raw body from $_SERVER['__raw_body'] when set; fall back to php://input otherwise. PHP CLI's php://input is empty, so an explicit hook is needed for JSON bodies.
- Return the response status from the front controller so the test can assert the HTTP status it set via http_response_code().

Test fixes:

- Drop the static keyword on the runFrontController closure; static forbids $this access and the closure must capture $this to record lastStatus.
- Drop the redundant '?? ''' on the ob_get_clean() result; (string) ob_get_clean() is always a string.
- Set $_SERVER['__raw_body'] alongside $this->rawBody for the JSON requests so the front controller can read the body.
2026-07-06 22:59:18 -05:00
Keith Solomon d387caca86 feat: wire the front controller and harden userToken path 2026-07-06 22:36:34 -05:00
Keith Solomon 2dc7bc1130 feat: add image upload and start-match handlers 2026-07-06 22:24:07 -05:00
Keith Solomon 7cb670b512 feat: add battlefield editor GET and POST handlers 2026-07-06 22:05:47 -05:00
Keith Solomon 12b1547fac feat: add team editor GET and POST handlers 2026-07-06 21:54:59 -05:00
Keith Solomon 6a0b147e4e feat: add battlefield, match stub, and upload result templates 2026-07-06 21:41:18 -05:00
Keith Solomon 7e6240733b feat: add home and team editor templates 2026-07-06 21:30:17 -05:00
Keith Solomon c2233c1639 feat: add shared layout template 2026-07-06 21:21:17 -05:00
Keith Solomon 58b3bc8ece docs: add Plan 3b implementation plan (handlers, views, front controller) 2026-07-06 21:10:59 -05:00
Keith Solomon db12577e78 docs: add Plan 3b design spec (handlers, views, front controller) 2026-07-06 21:00:20 -05:00
41 changed files with 8375 additions and 33 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"env": {
"browser": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"extends": "airbnb-base"
}
+3 -1
View File
@@ -1 +1,3 @@
*.php text eol=lf *.php text eol=lf
*.js text eol=lf
*.json text eol=lf
+3
View File
@@ -25,3 +25,6 @@ jobs:
- run: composer validate --strict - run: composer validate --strict
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: composer check - run: composer check
- run: npm ci --no-audit --no-fund
- run: npm run lint
- run: npm run format
+1
View File
@@ -7,6 +7,7 @@
/var/uploads/* /var/uploads/*
!/var/uploads/.htaccess !/var/uploads/.htaccess
!/var/uploads/index.php !/var/uploads/index.php
/var/secret.key
/node_modules/ /node_modules/
/public/js/*.map /public/js/*.map
.phpunit.result.cache .phpunit.result.cache
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,912 @@
# Plan 3c: JavaScript Frontend 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:** Add the small JavaScript surface that lets the home page list recent scenarios from `localStorage`, the team editor pre-fill from `localStorage`, and the battlefield editor run click-paint grid interactions with JSON fetch save.
**Architecture:** Two pure-ESM JavaScript files (`public/js/storage.js` and `public/js/grid-editor.js`) attach `window.bfStorage` and run `DOMContentLoaded` on their respective pages. The team editor's form continues to POST to the existing Plan 3b `PostTeamEditor` handler; the battlefield editor's form submit is intercepted by the JS to send a JSON fetch. The Node toolchain (ESLint `airbnb-base` + Prettier) is added with a new `npm run lint` and `npm run format` step in CI.
**Tech Stack:** PHP 8.3 (unchanged), vanilla ESM JavaScript (no bundler, no transpiler), Node 20+ with ESLint `^8.57.0`, `eslint-config-airbnb-base ^15.0.0`, `eslint-plugin-import ^2.29.0`, Prettier `^3.3.0`.
## 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.
- Every state-changing form or request uses and verifies a CSRF token before mutation. The token is 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 for its output context via the `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Templates do not interpolate user input without one of these helpers. The JS uses `textContent` and `value` setters, never `innerHTML` (with one exception: building the home page's recent-scenarios list uses `appendChild` on `<a>` elements with `textContent`, never `innerHTML`).
- Bundled placeholder images are committed under `public/assets/placeholders/`; user uploads live in `var/uploads/` (git-ignored).
- The single small JS file (`public/js/grid-editor.js` and `public/js/storage.js`) passes ESLint `airbnb/base` and Prettier.
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
- Composer configuration passes `composer validate --strict`.
- Lint, static analysis, unit and integration tests, the end-to-end smoke test, and Composer validation are required CI checks.
- No third-party JS libraries. The two JS files are pure ESM, no dependencies, no `eval`, no `Function` constructor.
- The data shape for scenarios in `localStorage` is the same canonical shape that `BattleForge\Application\ScenarioSerializer::scenarioToArray` produces.
- `var/secret.key` is added to `.gitignore` (the Plan 3b `FullFlowTest` creates it on first run).
## File Structure
```text
public/
├── assets/
│ ├── archetypes.json (NEW) — 4 ArchetypeTemplate definitions
│ ├── placeholders/ (NEW)
│ │ ├── defender.png
│ │ ├── striker.png
│ │ ├── support.png
│ │ └── scout.png
│ ├── styles.css (unchanged)
│ └── js/ (NEW)
│ ├── storage.js (NEW) — bfStorage module
│ └── grid-editor.js (NEW) — battlefield grid interactions
src/Views/ (updated — add <script> tags only)
├── home.php (add <script type="module" src="/js/storage.js">)
├── team-editor.php (add <script type="module" src="/js/storage.js">)
└── battlefield-editor.php (add <script type="module" src="/js/grid-editor.js"></script>)
package.json (NEW)
.eslintrc.json (NEW)
.prettierrc (NEW)
.github/workflows/ci.yml (updated — add npm ci, lint, format steps)
.gitignore (updated — add var/secret.key, node_modules/)
```
## Delivery Sequence
Plan 3c is the third of four plans. It is the last plan in the original "Plan 3" deliverable. It depends on Plan 3a+3b's PHP web layer being merged (which it is on `develop`).
1. Toolchain + assets (Task 1)
2. `storage.js` and template `<script>` tags (Task 2)
3. `grid-editor.js` and CI update (Task 3)
4. Final whole-branch code review (Task 4)
## Execution Preflight
Execute this plan in an isolated worktree on `feature/frontend-js`, branched from `develop`. The implementation branch will be submitted as a pull request into `develop` only after every completion check is green.
### Task 1: Node toolchain + `archetypes.json` + placeholder images
**Files:**
- Create: `package.json`
- Create: `.eslintrc.json`
- Create: `.prettierrc`
- Create: `public/assets/archetypes.json`
- Create: `public/assets/placeholders/defender.png`
- Create: `public/assets/placeholders/striker.png`
- Create: `public/assets/placeholders/support.png`
- Create: `public/assets/placeholders/scout.png`
- Modify: `.gitignore`
**Interfaces:**
- Produces: a Node toolchain that lints and formats the JS files (Tasks 2 and 3 land in this toolchain's lint gate). The `archetypes.json` matches `BattleForge\Application\ArchetypeCatalog::templates()` exactly (defender, striker, support, scout). The four placeholder PNGs are valid 32×32 transparent PNGs with simple monochrome silhouettes (a circle for defender, a triangle for striker, a square for support, a diamond for scout — the exact pixel art is up to the implementer; the test only checks that the file is a valid PNG and has the right dimensions). The `.gitignore` excludes `var/secret.key` and `node_modules/`.
- [ ] **Step 1: Update `.gitignore`**
Append `var/secret.key` and `node_modules/` to the existing `.gitignore`. Replace the file with:
```
.worktrees/
/vendor/
/var/cache/
/var/phpunit/
/var/phpstan/
/var/logs/
/var/uploads/*
!/var/uploads/.htaccess
!/var/uploads/index.php
/var/secret.key
/node_modules/
/public/js/*.map
.phpunit.result.cache
.phpunit.cache
```
- [ ] **Step 2: Create `package.json`**
Create `package.json`:
```json
{
"name": "battleforge-battleforge",
"description": "BattleForge frontend toolchain.",
"private": true,
"license": "proprietary",
"scripts": {
"lint": "eslint public/js",
"format": "prettier --check public/js"
},
"devDependencies": {
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.29.0",
"prettier": "^3.3.0"
}
}
```
- [ ] **Step 3: Create `.eslintrc.json`**
Create `.eslintrc.json`:
```json
{
"env": {
"browser": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"extends": "airbnb-base"
}
```
- [ ] **Step 4: Create `.prettierrc`**
Create `.prettierrc`:
```json
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
```
- [ ] **Step 5: Create `public/assets/archetypes.json`**
The `ArchetypeTemplate` values must match `BattleForge\Domain\ArchetypeCatalog::templates()` exactly. The Plan 3a catalog:
- defender: minHealth 10, maxHealth 14, minAttack 2, maxAttack 4, minDefense 3, maxDefense 5, minSpeed 1, maxSpeed 3, allowedAbilities ["buff"]
- striker: minHealth 6, maxHealth 10, minAttack 4, maxAttack 6, minDefense 1, maxDefense 2, minSpeed 2, maxSpeed 4, allowedAbilities ["area_damage"]
- support: minHealth 5, maxHealth 9, minAttack 1, maxAttack 3, minDefense 1, maxDefense 3, minSpeed 2, maxSpeed 4, allowedAbilities ["heal", "buff"]
- scout: minHealth 4, maxHealth 7, minAttack 2, maxAttack 4, minDefense 0, maxDefense 2, minSpeed 4, maxSpeed 6, allowedAbilities []
Create `public/assets/archetypes.json`:
```json
{
"defender": { "minHealth": 10, "maxHealth": 14, "minAttack": 2, "maxAttack": 4, "minDefense": 3, "maxDefense": 5, "minSpeed": 1, "maxSpeed": 3, "allowedAbilities": ["buff"] },
"striker": { "minHealth": 6, "maxHealth": 10, "minAttack": 4, "maxAttack": 6, "minDefense": 1, "maxDefense": 2, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["area_damage"] },
"support": { "minHealth": 5, "maxHealth": 9, "minAttack": 1, "maxAttack": 3, "minDefense": 1, "maxDefense": 3, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["heal", "buff"] },
"scout": { "minHealth": 4, "maxHealth": 7, "minAttack": 2, "maxAttack": 4, "minDefense": 0, "maxDefense": 2, "minSpeed": 4, "maxSpeed": 6, "allowedAbilities": [] }
}
```
- [ ] **Step 6: Create the four placeholder PNGs**
Each placeholder is a 32×32 transparent PNG with a simple silhouette. Generate them programmatically (the implementer can run a one-off PHP or Node script, or use ImageMagick if installed). The exact pixel art is up to the implementer; the test (added in Step 7) only checks that:
- The file is a valid PNG (starts with the PNG magic bytes `\x89PNG\r\n\x1a\n`).
- The file decodes to a 32×32 image via `getimagesize()`.
Suggested silhouettes (a 32×32 grid of black-on-transparent pixels; the implementer can use any simple shape that distinguishes the four archetypes):
- defender (circle): pixels at (16, 8), (12, 10), (20, 10), (8, 14), (24, 14), (8, 18), (24, 18), (12, 22), (20, 22), (16, 24).
- striker (triangle): pixels at (16, 6), (10, 14), (22, 14), (12, 16), (20, 16), (14, 18), (18, 18), (16, 22).
- support (square): pixels at (8, 8), (24, 8), (8, 24), (24, 24), (10, 10), (22, 10), (10, 22), (22, 22).
- scout (diamond): pixels at (16, 6), (12, 10), (20, 10), (8, 16), (24, 16), (12, 22), (20, 22), (16, 26).
The implementer can create these by hand (e.g. via a small PHP script that calls `imagecreatetruecolor` + `imagesetpixel` + `imagepng`, or via a Node script using `sharp` — but per the global constraints, no third-party JS libraries; the implementer can use a hand-rolled PNG generator or just copy a 32×32 transparent PNG from an existing source and write the silhouette bytes by hand).
- [ ] **Step 7: Verify the assets are valid**
Run: `php -r 'foreach (["defender", "striker", "support", "scout"] as $a) { $p = __DIR__ . "/public/assets/placeholders/$a.png"; $i = getimagesize($p); echo "$a: {$i[0]}x{$i[1]} type=$i[2]\n"; }'`
Expected: four lines, each `32x32 type=3` (3 = PNG). If any line shows different dimensions, regenerate that file.
Run: `php -r 'var_dump(json_decode(file_get_contents(__DIR__ . "/public/assets/archetypes.json"), true));' | head -5`
Expected: the four archetypes as objects.
- [ ] **Step 8: Install Node dependencies**
Run: `npm install`
Expected: `node_modules/` is created; `package-lock.json` is generated. `npm run lint` will fail at this point because `public/js/*.js` doesn't exist yet; that's expected and will be fixed in Tasks 2 and 3.
- [ ] **Step 9: Run the full quality suite and commit**
Run: `composer check`
Expected: 196/196 tests pass, PHPStan clean, PHPCS clean on the changed files. The new `package.json`, `.eslintrc.json`, `.prettierrc`, and the four PNGs are not PHP, so PHPCS doesn't check them. PHPStan excludes `src/Views/*` and the JS files.
```powershell
git add .gitignore package.json package-lock.json .eslintrc.json .prettierrc public/assets/archetypes.json public/assets/placeholders/
git commit -m "chore: add Node toolchain, archetypes.json, and placeholder images"
```
### Task 2: `storage.js` and template `<script>` tags
**Files:**
- Create: `public/js/storage.js`
- Modify: `src/Views/home.php` (add `<script type="module" src="/js/storage.js"></script>`)
- Modify: `src/Views/team-editor.php` (add `<script type="module" src="/js/storage.js"></script>` and a "Start match" button)
**Interfaces:**
- Produces `public/js/storage.js`, an ESM module that attaches `window.bfStorage` with eight methods: `load(id)`, `save(id, scenario)`, `delete(id)`, `listAll()`, `currentMatch()`, `setCurrentMatch(match)`, `removeCurrentMatch()`. The module's top-level code runs `populateRecentScenarios()` on `DOMContentLoaded` (for the home page) and `prefillTeamEditor()` on `DOMContentLoaded` (for the team editor), based on which root element is present in the DOM. The "Start match" button on the team editor POSTs to `/scenarios/{id}/start` and stores the response in `localStorage`.
- [ ] **Step 1: Implement `storage.js`**
Create `public/js/storage.js`:
```js
const STORAGE_PREFIX = 'scenario:';
const MATCH_KEY = 'match:current';
function load(id) {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + id);
if (raw === null) {
return null;
}
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function save(id, scenario) {
const stamped = Object.assign({}, scenario, { lastModified: Date.now() });
localStorage.setItem(STORAGE_PREFIX + id, JSON.stringify(stamped));
}
function remove(id) {
localStorage.removeItem(STORAGE_PREFIX + id);
}
function listAll() {
const items = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (key === null || !key.startsWith(STORAGE_PREFIX)) {
continue;
}
const id = key.slice(STORAGE_PREFIX.length);
const scenario = load(id);
if (scenario === null) {
continue;
}
items.push({
id,
name: typeof scenario.name === 'string' ? scenario.name : id,
lastModified: typeof scenario.lastModified === 'number' ? scenario.lastModified : 0,
});
}
items.sort((a, b) => b.lastModified - a.lastModified);
return items;
}
function currentMatch() {
try {
const raw = localStorage.getItem(MATCH_KEY);
if (raw === null) {
return null;
}
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function setCurrentMatch(match) {
localStorage.setItem(MATCH_KEY, JSON.stringify(match));
}
function removeCurrentMatch() {
localStorage.removeItem(MATCH_KEY);
}
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function populateRecentScenarios() {
const root = document.getElementById('recent');
if (!root) {
return;
}
while (root.firstChild) {
root.removeChild(root.firstChild);
}
const items = listAll();
if (items.length === 0) {
const p = document.createElement('p');
p.className = 'bf-empty';
p.appendChild(document.createTextNode('No saved scenarios yet.'));
root.appendChild(p);
return;
}
const ul = document.createElement('ul');
for (const item of items) {
const li = document.createElement('li');
const a = document.createElement('a');
a.setAttribute('href', '/scenarios/' + encodeURIComponent(item.id) + '/edit/team');
a.appendChild(document.createTextNode(item.name + ' (' + item.id + ')'));
li.appendChild(a);
ul.appendChild(li);
}
root.appendChild(ul);
}
async function fetchArchetypes() {
const res = await fetch('/assets/archetypes.json');
return res.json();
}
function prefillTeamEditor() {
const form = document.querySelector('form[action*="/edit/team"]');
if (!form) {
return;
}
const id = decodeURIComponent(window.location.pathname.split('/').slice(-2, -1)[0] || 'new');
if (id === 'new') {
return;
}
const scenario = load(id);
if (scenario === null) {
return;
}
const fields = form.querySelectorAll('input[name], select[name]');
fields.forEach((field) => {
const name = field.getAttribute('name');
if (!name) {
return;
}
const value = resolveField(scenario, name);
if (value !== undefined) {
field.value = String(value);
}
});
}
function resolveField(scenario, path) {
const parts = parsePath(path);
if (parts === null) {
return undefined;
}
let cursor = scenario;
for (const part of parts) {
if (cursor === null || cursor === undefined) {
return undefined;
}
cursor = cursor[part];
}
return cursor;
}
function parsePath(name) {
const match = name.match(/^(teamA|teamB)\[units\]\[(\d+)\]\[(\w+)\]$/);
if (match) {
const [, team, indexStr, key] = match;
const teamKey = team === 'teamA' ? 'teamA' : 'teamB';
return [teamKey, 'units', Number(indexStr), key];
}
const simple = [
'id',
'name',
'battlefieldWidth',
'battlefieldHeight',
'victoryCondition',
'holdRoundsRequired',
];
if (simple.includes(name)) {
return [name];
}
return null;
}
async function startMatch() {
const id = decodeURIComponent(window.location.pathname.split('/').slice(-2, -1)[0] || 'new');
if (id === 'new') {
return;
}
const scenario = load(id);
if (scenario === null) {
return;
}
const csrf = getCsrfToken();
const res = await fetch('/scenarios/' + encodeURIComponent(id) + '/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify(scenario),
});
if (!res.ok) {
alert('Start match failed: HTTP ' + res.status);
return;
}
const data = await res.json();
if (data.match) {
setCurrentMatch(data.match);
window.location.href = '/';
} else {
alert('Start match failed: ' + (data.errors ? data.errors.join(', ') : 'unknown'));
}
}
window.bfStorage = {
load,
save,
delete: remove,
listAll,
currentMatch,
setCurrentMatch,
removeCurrentMatch,
};
document.addEventListener('DOMContentLoaded', () => {
populateRecentScenarios();
prefillTeamEditor();
const startBtn = document.getElementById('bf-start-match');
if (startBtn) {
startBtn.addEventListener('click', (event) => {
event.preventDefault();
startMatch();
});
}
});
```
The module:
- Attaches `window.bfStorage` with the 8 methods.
- On `DOMContentLoaded`, populates the home page's `<div id="recent">`, pre-fills the team editor's form fields, and wires the "Start match" button.
- The "Start match" button calls `POST /scenarios/{id}/start` with the loaded scenario as JSON and `X-CSRF-Token` from the layout's `<meta name="csrf-token">`. On success, stores the match in `localStorage` and navigates to `/`.
- Uses `textContent` and `value` setters throughout. Never `innerHTML` (the recent-scenarios list uses `appendChild` on `<a>` elements with `textContent`).
- Handles corrupt JSON gracefully: `load()` returns `null`, `listAll()` skips keys that fail to parse.
- [ ] **Step 2: Update `src/Views/home.php` to import the JS**
Edit `src/Views/home.php`. After the existing `<div id="recent">` block, add the script import. The exact location depends on the existing template; locate the closing `</body>` tag and add the script before it (or add it after `<div id="recent">` if the template is structured that way).
Add this line at the end of the template body, before the closure that calls `render_layout`:
```php
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
```
The exact context-dependent insertion point will be clear from the file. Use `Escape::attr` to escape the URL attribute.
- [ ] **Step 3: Update `src/Views/team-editor.php` to import the JS and add the Start match button**
Two changes to `src/Views/team-editor.php`:
1. Add the script import at the end of the template body (before the `render_layout` closure):
```php
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
```
2. Add a "Start match" button at the bottom of the form, just after the existing "Save" submit button:
```php
<button type="submit">Save</button>
<button type="button" id="bf-start-match">Start match</button>
</form>
```
The "Start match" button is a `<button type="button">` (not `type="submit"`) so the form's submit handler is not invoked. The JS wires the click event in `storage.js`.
- [ ] **Step 4: Run the lint and format checks**
Run: `npm run lint`
Expected: 0 errors. ESLint may flag a few unused parameters or stylistic issues; if so, fix them.
Run: `npm run format`
Expected: 0 errors. Prettier may flag formatting drift; if so, run `npx prettier --write public/js/storage.js` and commit the result.
- [ ] **Step 5: Run the full quality suite and commit**
Run: `composer check`
Expected: 196/196 tests pass, PHPStan clean, PHPCS clean on the changed files. The new JS file is checked by ESLint and Prettier (not by the PHP suite).
```powershell
git add public/js/storage.js src/Views/home.php src/Views/team-editor.php
git commit -m "feat: add localStorage helpers and wire team editor pre-fill"
```
### Task 3: `grid-editor.js` and CI update
**Files:**
- Create: `public/js/grid-editor.js`
- Modify: `src/Views/battlefield-editor.php` (add `<script type="module" src="/js/grid-editor.js"></script>`)
- Modify: `.github/workflows/ci.yml` (add `npm ci`, `npm run lint`, `npm run format` steps)
**Interfaces:**
- Produces `public/js/grid-editor.js`, an ESM module that runs on `DOMContentLoaded` for the battlefield editor. The module:
- Reads the `data-scenario-id` attribute from the `<form id="battlefield-form">` element.
- Loads the scenario from `bfStorage.load(id)` and populates the in-memory `tileMap`, `deploymentZones`, and `objective` state.
- Paints the grid: each tile button's `data-paint` attribute is updated, the objective tile gets `data-objective="1"`, and the zone tiles get `data-zone="alpha"` or `data-zone="bravo"`.
- Listens for clicks on `.bf-tile` buttons: in the default terrain-paint mode, updates `tileMap`; in zone mode, toggles zone membership; in objective mode, places the objective. Shift-click in any mode removes the paint or zone membership.
- Listens for clicks on the `Save` button (`event.preventDefault()`), builds the canonical scenario JSON, POSTs to `/scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token`. On success, writes the returned `scenario` to `localStorage` and shows a "Saved" toast. On 400 with errors, shows the errors inline in the form.
- [ ] **Step 1: Implement `grid-editor.js`**
Create `public/js/grid-editor.js`:
```js
const TERRAINS = ['open', 'forest', 'rough', 'water', 'blocking'];
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function getCurrentMode() {
const checked = document.querySelector('input[name="zoneMode"]:checked');
return checked ? checked.value : 'terrain';
}
function getSelectedPaint() {
const active = document.querySelector('.bf-paint.active');
if (active) {
return active.getAttribute('data-paint');
}
return 'forest';
}
function loadScenario(id) {
if (window.bfStorage && typeof window.bfStorage.load === 'function') {
return window.bfStorage.load(id);
}
return null;
}
function saveScenario(id, scenario) {
if (window.bfStorage && typeof window.bfStorage.save === 'function') {
window.bfStorage.save(id, scenario);
}
}
function applyTile(button, paint, zone, isObjective) {
button.setAttribute('data-paint', paint);
if (zone) {
button.setAttribute('data-zone', zone);
} else {
button.removeAttribute('data-zone');
}
if (isObjective) {
button.setAttribute('data-objective', '1');
} else {
button.removeAttribute('data-objective');
}
}
function loadGrid(tileMap, deploymentZones, objective) {
document.querySelectorAll('.bf-tile').forEach((button) => {
const x = button.getAttribute('data-x');
const y = button.getAttribute('data-y');
const key = x + ':' + y;
if (tileMap[key]) {
button.setAttribute('data-paint', tileMap[key]);
}
button.removeAttribute('data-zone');
button.removeAttribute('data-objective');
});
for (const [team, positions] of Object.entries(deploymentZones)) {
positions.forEach((pos) => {
const selector = `.bf-tile[data-x="${pos.x}"][data-y="${pos.y}"]`;
const button = document.querySelector(selector);
if (button) {
button.setAttribute('data-zone', team);
}
});
}
if (objective) {
const selector = `.bf-tile[data-x="${objective.x}"][data-y="${objective.y}"]`;
const button = document.querySelector(selector);
if (button) {
button.setAttribute('data-objective', '1');
}
}
}
function readGrid() {
const tileMap = {};
const deploymentZones = { alpha: [], bravo: [] };
let objective = null;
document.querySelectorAll('.bf-tile').forEach((button) => {
const x = Number(button.getAttribute('data-x'));
const y = Number(button.getAttribute('data-y'));
const paint = button.getAttribute('data-paint');
if (paint && paint !== 'open') {
tileMap[x + ':' + y] = paint;
}
const zone = button.getAttribute('data-zone');
if (zone === 'alpha' || zone === 'bravo') {
deploymentZones[zone].push({ x, y });
}
if (button.getAttribute('data-objective') === '1') {
objective = { x, y };
}
});
return { tileMap, deploymentZones, objective };
}
function buildScenarioJson(scenarioId, tileMap, deploymentZones, objective) {
const widthInput = document.querySelector('input[name="battlefieldWidth"]');
const heightInput = document.querySelector('input[name="battlefieldHeight"]');
return {
id: scenarioId,
name: scenarioId,
battlefieldWidth: widthInput ? Number(widthInput.value) : 8,
battlefieldHeight: heightInput ? Number(heightInput.value) : 8,
battlefieldTerrain: tileMap,
teamA: {
units: [],
},
teamB: {
units: [],
},
deploymentZones,
objectives: objective
? { 'objective-1': { id: 'objective-1', position: objective } }
: {},
victoryCondition: objective ? 'hold_objective' : 'eliminate_all',
holdRoundsRequired: objective ? 3 : 1,
};
}
function paintTile(button) {
const mode = getCurrentMode();
if (event.shiftKey) {
applyTile(button, 'open', null, false);
return;
}
if (mode === 'objective') {
document.querySelectorAll('.bf-tile[data-objective]').forEach((other) => {
other.removeAttribute('data-objective');
});
applyTile(button, 'open', null, true);
return;
}
if (mode === 'alpha' || mode === 'bravo') {
const current = button.getAttribute('data-zone');
if (current === mode) {
button.removeAttribute('data-zone');
} else {
button.setAttribute('data-zone', mode);
}
return;
}
applyTile(button, getSelectedPaint(), null, false);
}
function selectPalette(event) {
document.querySelectorAll('.bf-paint').forEach((b) => b.classList.remove('active'));
event.currentTarget.classList.add('active');
}
function showToast(message) {
const toast = document.createElement('div');
toast.className = 'bf-toast';
toast.appendChild(document.createTextNode(message));
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
function showErrors(errors) {
const existing = document.querySelector('.bf-errors');
if (existing) {
existing.remove();
}
const div = document.createElement('div');
div.className = 'bf-errors';
const ul = document.createElement('ul');
errors.forEach((msg) => {
const li = document.createElement('li');
li.appendChild(document.createTextNode(msg));
ul.appendChild(li);
});
div.appendChild(ul);
const form = document.getElementById('battlefield-form');
if (form) {
form.parentNode.insertBefore(div, form);
}
}
async function save(event) {
event.preventDefault();
const form = document.getElementById('battlefield-form');
if (!form) {
return;
}
const id = form.getAttribute('data-scenario-id');
if (!id) {
return;
}
const { tileMap, deploymentZones, objective } = readGrid();
const scenario = buildScenarioJson(id, tileMap, deploymentZones, objective);
const csrf = getCsrfToken();
let res;
try {
res = await fetch('/scenarios/' + encodeURIComponent(id) + '/edit/battlefield', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify(scenario),
});
} catch (e) {
showErrors(['Network error: ' + e.message]);
return;
}
if (res.ok) {
const data = await res.json();
if (data.scenario) {
saveScenario(id, data.scenario);
showToast('Saved.');
} else {
showErrors([(data.errors || ['Unknown error']).join(', ')]);
}
} else if (res.status === 400) {
let data = {};
try {
data = await res.json();
} catch (e) {
// body wasn't JSON
}
showErrors(data.errors || ['Validation failed (HTTP 400).']);
} else {
showErrors(['Save failed: HTTP ' + res.status]);
}
}
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('battlefield-form');
if (!form) {
return;
}
const id = form.getAttribute('data-scenario-id');
if (id) {
const scenario = loadScenario(id);
if (scenario) {
loadGrid(
scenario.battlefieldTerrain || {},
scenario.deploymentZones || { alpha: [], bravo: [] },
scenario.objectives && scenario.objectives['objective-1']
? scenario.objectives['objective-1'].position
: null,
);
}
}
document.querySelectorAll('.bf-tile').forEach((button) => {
button.addEventListener('click', () => paintTile(button));
});
document.querySelectorAll('.bf-paint').forEach((button) => {
button.addEventListener('click', selectPalette);
});
if (document.querySelector('.bf-paint')) {
document.querySelector('.bf-paint').classList.add('active');
}
form.addEventListener('submit', save);
});
```
The module:
- Reads `data-scenario-id` from the form.
- Loads the scenario from `bfStorage.load(id)` and repaints the grid.
- Handles click-paint on tiles, including shift-click to erase.
- Handles palette selection (clicking a paint button sets the active mode).
- On Save, intercepts the form submit, builds the canonical scenario JSON, fetches the existing Plan 3b `PostBattlefieldEditor` endpoint, parses the response, and writes the result back to `localStorage`.
- Handles all four response cases: 200 with `scenario`, 400 with `errors`, network failure, and other HTTP failures.
- [ ] **Step 2: Update `src/Views/battlefield-editor.php`**
Three changes:
1. Add `data-scenario-id="<?= $a($scenarioId) ?>"` to the form element:
```php
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded" data-scenario-id="<?= $a($scenarioId) ?>">
```
2. Add the script import at the end of the template body (before the `render_layout` closure):
```php
<script type="module" src="<?= $a('/js/grid-editor.js') ?>"></script>
```
- [ ] **Step 3: Update `.github/workflows/ci.yml`**
Add `npm ci`, `npm run lint`, and `npm run format` steps after the existing `composer check` step. The workflow uses pinned action SHAs (per Plan 1's baseline).
Append the following steps to the existing `php` job in `.github/workflows/ci.yml`:
```yaml
- run: npm ci --no-audit --no-fund
- run: npm run lint
- run: npm run format
```
The full workflow should now be:
```yaml
name: CI
on:
push:
branches:
- main
- develop
pull_request:
permissions:
contents: read
jobs:
php:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2
with:
php-version: '8.3'
coverage: none
tools: composer:v2
- run: composer validate --strict
- run: composer install --no-interaction --prefer-dist
- run: composer check
- run: npm ci --no-audit --no-fund
- run: npm run lint
- run: npm run format
```
(Use the actual pinned SHAs from the existing workflow; the SHAs above are placeholders showing the shape.)
- [ ] **Step 4: Run the lint and format checks**
Run: `npm run lint`
Expected: 0 errors. ESLint may flag a few unused parameters or stylistic issues; if so, fix them.
Run: `npm run format`
Expected: 0 errors.
- [ ] **Step 5: Run the full quality suite and commit**
Run: `composer check`
Expected: 196/196 tests pass, PHPStan clean, PHPCS clean on the changed files.
```powershell
git add public/js/grid-editor.js src/Views/battlefield-editor.php .github/workflows/ci.yml
git commit -m "feat: add battlefield grid editor and CI lint step"
```
### Task 4: Final whole-branch code review
Dispatch a fresh subagent with the merge-base diff for the entire feature branch. The reviewer checks for:
- Spec compliance (every in-scope capability from the Plan 3c spec is implemented).
- Code quality (clean separation of concerns, proper error handling, type safety, ESLint/Prettier clean).
- Production readiness (backward compatibility, no obvious bugs).
- Security: `bfStorage` reads and writes `localStorage` only, never `eval` or `Function` constructor; the home page's recent-scenarios list uses `textContent`, never `innerHTML`.
After the review, address Critical and Important findings. Minor findings can be deferred to Plan 4.
## Completion Check
Run:
```powershell
composer validate --strict
composer check
npm run lint
npm run format
git status --short
```
Expected:
- Composer reports a valid manifest.
- PHPCS reports no coding-standard violations on the changed files.
- PHPStan reports no errors at level 6.
- PHPUnit passes 196 tests.
- ESLint reports 0 errors on `public/js/*.js`.
- Prettier reports 0 formatting drift.
- Git reports no untracked files except `.worktrees/` (git-ignored).
The web app is fully functional from the browser: `php -S 0.0.0.0:8000 -t public` serves the home page with the recent-scenarios list, the team editor with `localStorage` pre-fill, and the battlefield editor with click-paint grid interactions. The battle interface and bundled scenarios land in Plan 4.
@@ -0,0 +1,254 @@
# Plan 3b: Editor Handlers, Views, and Front Controller Design
## Purpose
Plan 3b of the four-plan BattleForge build. Completes the web layer that Plan 3a started: the seven remaining PHP tasks that ship the editor pages, the front controller, the full-flow integration test, and CI verification.
Plan 3a delivered 10 of 17 written tasks: toolchain, output escaping, CSRF, Request/Response, Router, JSON serializer, image validator, image upload service, scenario draft, and the first two HTTP handlers (home page and asset serving). Plan 3b delivers the remaining 7:
- The five remaining HTTP handlers (team editor GET/POST, battlefield editor GET/POST, image upload POST, start-match POST)
- The six view templates
- The full front controller that wires the routes together and issues the CSRF + upload tokens
- A full-flow integration test
- CI verification
- Final whole-branch code review
The design is a delta on `docs/superpowers/specs/2026-07-06-persistence-editors-design.md` (the Plan 3 spec), which is the authoritative design. This document records only the clarifications and decisions that are specific to Plan 3b; everything else is inherited from the Plan 3 spec.
## Success Criteria
Plan 3b succeeds when a new user, without developer assistance, can:
1. Visit `http://localhost:8000/`, see the home page, and click "New scenario".
2. Fill in two teams of 3-6 units each on the team editor, upload a custom image, and save. The browser shows a "Saved" toast and the scenario lands in `localStorage` under `scenario:{id}`.
3. Navigate to the battlefield editor, paint a small grid, place deployment zones, place an objective (when the victory condition requires it), and save. The same `localStorage` key is updated with the assembled scenario.
4. Click "Start match" and have the initial `MatchState` round-trip through the server's `PostStartMatch` endpoint, land in `localStorage` under `match:current`, and render the "match loaded" stub.
5. Refresh the page, navigate around, and find their scenarios still in their browser.
6. Try a forged cross-site POST and see a 403. Try to access another user's uploaded image and see a 404.
## Out of Scope (deferred to Plan 3c or Plan 4)
- The JavaScript surface: `public/js/storage.js`, `public/js/grid-editor.js`, ESLint, Prettier. The current handlers and views render with HTML form submits and a `<script type="module">` placeholder; Plan 3c adds the fetch-based battlefield grid and the `localStorage` write helpers.
- Bundled placeholder images under `public/assets/placeholders/`.
- The `archetypes.json` asset.
- The hot-seat battle interface (Plan 4).
- Bundled scenarios (Plan 4).
- End-to-end smoke test against a real browser (Plan 4).
## In Scope
### HTTP Handlers (six new files in `src/Http/Handlers/`)
All handlers are `final class` with a public `handle(Request $request, array $params): Response` method. Each handler:
1. Verifies the CSRF token at the top (form POSTs read `_csrf` from `$request->post`; fetch POSTs read `X-CSRF-Token` from `$request->server`; the secret comes from the front controller via a request attribute `__csrf_secret`). Mismatch returns `Response::html(403, '<h1>Forbidden</h1>')` for form POSTs or `Response::json(403, ['error' => 'csrf'])`.
2. Parses the form or JSON body.
3. Calls the relevant `Application` service (`ScenarioDraft::fromPost`, `ScenarioSerializer::scenarioFromArray`, `ImageUploadService::store`, `Scenario::startMatch`).
4. Runs the validator where applicable.
5. Returns a `Response` with the right status, headers, and body.
The handlers:
- **`GetTeamEditor::handle`** — Returns `Response::html(200, …)` rendering the team editor template. The form is rendered empty; Plan 3c's `storage.js` populates it from `localStorage` on load.
- **`PostTeamEditor::handle`** — Reads form fields, builds a `ScenarioDraft`, calls `toScenario()`, runs `ScenarioValidator::validate()`. On success, renders a "Saved" template that contains a `<script type="module">` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))`. On failure, re-renders the editor with errors and original form values.
- **`GetBattlefieldEditor::handle`** — Returns `Response::html(200, …)` rendering the battlefield editor with an empty `<table class="bf-grid">` (Plan 3c populates it from `localStorage`).
- **`PostBattlefieldEditor::handle`** — Reads `Content-Type: application/json` body via `$request->rawBody`, decodes a `ScenarioDraft`-shaped array, runs the validator, returns `Response::json(200, ['ok' => true, 'scenario' => $array])` on success or `Response::json(400, ['ok' => false, 'errors' => [...]])` on failure.
- **`PostImageUpload::handle`** — Reads `$_FILES['image']`, computes the `userToken` (derived from the CSRF secret — see CSRF Model below), constructs `ImageUploadService($userToken, $uploadsRoot)`, calls `store($tempPath, $declaredMime)`, returns `Response::json(200, ['url' => $url])` on success or `Response::json(400, ['error' => $message])` on failure.
- **`PostStartMatch::handle`** — Reads the JSON body, decodes a `Scenario` via `ScenarioSerializer::scenarioFromArray`, runs `ScenarioValidator::validate()` (defense in depth), calls `Scenario::startMatch('alpha')`, returns `Response::json(200, ['match' => $array])` on success or `Response::json(400, ['errors' => [...]])` on failure.
### View Templates (six new files in `src/Views/`)
All templates are plain PHP files. They:
1. Begin with `<?php declare(strict_types=1); ?>`.
2. Use `require __DIR__ . '/layout.php';` for shared chrome (the layout sets up `<meta name="csrf-token" content="…">`, `<link rel="stylesheet" href="/assets/styles.css">`, security headers via `header()` calls in the front controller, and the document body).
3. Print every dynamic value via `Escape::html`, `Escape::attr`, or `Escape::url`.
4. Do not call domain code directly. They receive pre-built data from the handler.
The templates:
- **`layout.php`** — Shared chrome. Sets `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'` (Plan 3c may tighten this), `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`. Outputs the doctype, `<head>` with the CSRF meta and the stylesheet link, the `<body>` open tag, and `</body></html>`.
- **`home.php`** — The home page: heading, "New scenario" link to `/scenarios/new/edit/team`, `<div id="recent">` placeholder (Plan 3c populates it from `localStorage`).
- **`team-editor.php`** — The team editor form. Sections for meta (id, name), team A, team B, victory condition, hidden `_csrf` field, an `<input type="file" name="unit-N-image">` per unit row for the upload form, and a "Save" submit button. The actual archetype list and stat min/max are derived from `ArchetypeCatalog::templates()` and inlined in the HTML so the form is fully functional without JS. Plan 3c adds the JS that pre-fills the form from `localStorage`.
- **`battlefield-editor.php`** — The battlefield editor: width/height number inputs, terrain palette, `<table class="bf-grid">` rendered empty (Plan 3c populates it from `localStorage`), two deployment-zone fieldsets, an objective fieldset (only when `HoldObjective` is the victory condition), hidden `_csrf` field, "Save" submit.
- **`match-stub.php`** — The Plan-3a/3c "Battle interface coming in Plan 4" placeholder. Reads `match:current` from the request's `localStorage` simulation (the integration test sets it via the `__csrf` cookie pattern; the front controller's real version hands the JS a `match:current` key from the dispatch flow).
- **`upload-result.php`** — A small fragment used by the upload form's iframe-style response (so the parent form can read the returned URL). Renders a `<script type="text/javascript">` block that calls `window.parent.postMessage({url: "..."}, "*")` and a fallback link.
### Front Controller (modifies `public/index.php`)
The Task 1 stub is replaced with a real front controller. The front controller is the only place that touches `$_GET`, `$_POST`, `$_FILES`, `$_COOKIE`, `$_SERVER` directly. It:
1. Reads `$_GET`, `$_POST`, `$_FILES`, `$_COOKIE`, `$_SERVER`.
2. Derives the request method, path, query string, and content type.
3. Reads the app secret from `BATTLEFORGE_SECRET` (env var) or, if absent, from `var/secret.key` (a 32-byte random file created on first run and git-ignored).
4. Issues a CSRF token on first visit (when the `__csrf` cookie is absent) by setting the cookie to `CsrfToken::issue($secret)[1]` — the cookie holds the HMAC, the form/header holds the original token value. On every response, the controller ensures the cookie is set with `HttpOnly`, `SameSite=Lax`, `Secure` (production), `Path=/`, `Expires=time()+86400`.
5. Computes the `__uploads_token` cookie as `hash_hmac('sha256', $secret, 'bf-uploads')` (or reads it from the request) and threads it to `GetAssets` via a request attribute `__uploads_token` (read inside the handler via `$request->server['__uploads_token']`).
6. Configures the `Router` with the eight routes:
- `GET /``GetHomePage`
- `GET /scenarios/{id}/edit/team``GetTeamEditor`
- `POST /scenarios/{id}/edit/team``PostTeamEditor`
- `GET /scenarios/{id}/edit/battlefield``GetBattlefieldEditor`
- `POST /scenarios/{id}/edit/battlefield``PostBattlefieldEditor`
- `POST /scenarios/{id}/start``PostStartMatch`
- `POST /assets/upload``PostImageUpload`
- `GET /assets/{kind}/{filename}``GetAssets` (the router pattern handles `{kind}` and `{filename}`)
7. Builds a `Request` value object with the request data, the CSRF secret (in `__csrf_secret`), the upload token (in `__uploads_token`), and the request method, path, and content type.
8. Dispatches and emits the response (status, headers, body).
### CSRF Model
A single `__csrf` cookie per session. The cookie holds `hash_hmac('sha256', $token, $secret)` (the `issue()` method's second return value). The form/header holds the original token (`$token`, the first return value). On every state-changing request:
- Forms: hidden `_csrf` field with `$token` is matched against `CsrfToken::verify($token, $secret, $request->cookies['__csrf'] ?? '')`.
- Fetch: `X-CSRF-Token` header with `$token` is matched the same way.
The upload-endpoint user token is derived from the same secret:
```php
$userToken = hash_hmac('sha256', $secret, 'bf-uploads');
```
`GetAssets` validates `$request->cookies['__uploads_token'] === $params['userToken']`. The front controller sets the `__uploads_token` cookie on first visit.
**Hardening note for the front controller**: the `userToken` path-param in `GetAssets` should be validated against a strict format (e.g. `/^[a-f0-9]{32,}$/`) to prevent directory traversal. The router's `[^/]+` pattern restricts `$filename` already, but `$userToken` is the path-segment before the file, so a defensive regex there is appropriate.
### System Boundaries (delta on the Plan 3 spec)
Plan 3b introduces no new boundaries. The four module boundaries from the Plan 3 spec still hold:
1. **Content library** (Domain, unchanged).
2. **Scenario editor** (web, expanding): now includes the team editor, battlefield editor, and image upload forms.
3. **Rules engine** (Domain, unchanged).
4. **Battle interface** (Plan 4, stubbed in Plan 3b's `match-stub.php`).
The `Application` and `Http` layers from Plan 3a continue to mediate between the web pages and the `Domain`. The front controller in `public/index.php` is the only place that touches the superglobals.
## Data and Action Flow
### Cold-start flow (no change from Plan 3 spec)
### Team editor save flow
1. User edits the form, presses Save.
2. Browser POSTs the form to `POST /scenarios/{id}/edit/team` with `Content-Type: application/x-www-form-urlencoded` and a hidden `_csrf` field.
3. Server's `PostTeamEditor`:
a. Verifies the CSRF token.
b. Reads form fields into a `ScenarioDraft`.
c. Calls `ScenarioDraft::toScenario()` and `ScenarioValidator::validate($scenario)`.
d. On failure: re-renders the editor with errors and original form values.
e. On success: renders a "Saved" template that includes a `<script type="module">` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))`.
### Battlefield editor save flow
1. User clicks tiles; the small JS helper maintains a `tileMap` object.
2. User presses Save; the JS helper POSTs JSON to `POST /scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token` header.
3. Server's `PostBattlefieldEditor`:
a. Verifies the CSRF token from the header.
b. Decodes the JSON body.
c. Runs `ScenarioValidator::validate()`.
d. On failure: returns `Response::json(400, ['ok' => false, 'errors' => [...]])`.
e. On success: returns `Response::json(200, ['ok' => true, 'scenario' => $array])`.
(Note: Plan 3b ships the form-based team editor; the JS-driven fetch is added in Plan 3c. The handler accepts both for compatibility.)
### Image upload flow
1. User picks an image, presses Upload.
2. Browser submits the file to `POST /assets/upload` as `multipart/form-data` with a hidden `_csrf` field.
3. Server's `PostImageUpload`:
a. Verifies the CSRF token.
b. Reads `$_FILES['image']`.
c. Constructs `ImageUploadService($userToken, $uploadsRoot)`, calls `store($tempPath, $declaredMime)`.
d. Returns `Response::json(200, ['url' => $url])` on success or `Response::json(400, ['error' => $message])` on failure.
### Start-match flow
1. User clicks "Start match" on either editor.
2. JS reads the assembled `Scenario` from `localStorage`, POSTs JSON to `POST /scenarios/{id}/start` with `X-CSRF-Token` header.
3. Server's `PostStartMatch`:
a. Verifies the CSRF token.
b. Decodes a `Scenario` via `ScenarioSerializer::scenarioFromArray`.
c. Runs `ScenarioValidator::validate()` (defense in depth).
d. Calls `Scenario::startMatch('alpha')`.
e. Returns `Response::json(200, ['match' => $array])` on success or `Response::json(400, ['errors' => [...]])` on failure.
## Validation and Failure Handling
- `ScenarioValidator` is the single source of truth for scenario validity. Both the server (on every form POST) and the browser (when Plan 3c lands) call it.
- A rejected action never partially mutates the persisted state. `localStorage` writes happen only on a successful server response.
- Failure to save a scenario leaves the user's in-progress form intact; the editor re-renders the same values with errors inline.
- The CSRF token's invalid-or-missing case returns a generic 403 page (form POSTs) or 403 JSON (fetch POSTs) that links back to `GET /`. The user's in-progress form state is preserved in `localStorage` (Plan 3c's responsibility).
- A failed image upload returns 400 JSON with the error message. The unit row keeps whatever `image` field it had.
- Corrupt or incompatible JSON in `localStorage` is rejected on read: the JS helper skips the row and surfaces an error to the user.
## Security and Quality Requirements
Inherited from the Plan 3 spec:
- Every state-changing form or request uses and verifies a CSRF token before mutation. Forms use a hidden `_csrf` field; fetch uses an `X-CSRF-Token` header.
- All rendered output is escaped via `Escape::html`, `Escape::attr`, and `Escape::url`. Code review enforces the pattern.
- All uploaded images are content-validated and by dimensions; the stored file's name is a server-generated random hex; the original filename and declared MIME are discarded after validation.
- `var/uploads/` is denied by `.htaccess` (Apache) and `index.php` (built-in server).
- The dev server is the built-in `php -S`.
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
- Composer configuration passes `composer validate --strict`.
- All output includes `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, and the `Content-Security-Policy` header.
Plan 3b-specific:
- The front controller's `userToken` validation in `GetAssets` uses a strict format regex (`/^[a-f0-9]{32,}$/`) to harden the path-traversal surface.
- The `BATTLEFORGE_SECRET` env var takes precedence over the per-install file. The per-install file is `var/secret.key`, a 32-byte binary file created on first run with `random_bytes(32)` and `chmod 0600`. The file is git-ignored.
- The CSRF secret in the `__csrf` cookie is bound to the browser via the cookie's `HttpOnly` + `SameSite=Lax` attributes, and the secret is HMAC-signed server-side. A cross-site request cannot forge the HMAC.
## Verification Strategy
### Unit tests (in `tests/Integration/` per the established Plan 3a pattern)
The handlers are integration-tested, not unit-tested, because their behavior crosses the HTTP / Application / Domain boundary. Each handler has at least:
- One happy-path test: synthetic `Request` with a valid CSRF token and a valid form/JSON body, asserts on the response status, headers, and body.
- One validation-error test: synthetic `Request` with valid CSRF but out-of-bounds data, asserts the response carries the validator's error messages.
Tests cover (per the Plan 3 spec):
- `GetTeamEditor` — 200 with security headers and a form.
- `PostTeamEditor` — happy path returns 200 with `localStorage.setItem` snippet; out-of-bounds stat returns 200 with the validator's error message inline.
- `GetBattlefieldEditor` — 200 with security headers and an empty grid.
- `PostBattlefieldEditor` — happy path returns `{ok: true, scenario: ...}`; invalid shape returns `{ok: false, errors: [...]}`.
- `PostImageUpload` — 200 with `{url: ...}`; missing file returns 400; invalid file returns 400.
- `PostStartMatch` — 200 with `{match: ...}` containing `activeTeamId: 'alpha'` and `round: 1`.
- Forged CSRF (no `_csrf` field) returns 403 for form POSTs and 403 JSON for fetch POSTs.
- Wrong-user upload (token mismatch) returns 404.
### Full-flow integration test (`FullFlowTest`)
A single integration test that walks the full create-and-save flow:
1. Bootstraps the front controller in-process (no `php -S` boot) by including `public/index.php` with synthetic superglobals.
2. Issues a CSRF token.
3. GET home page.
4. POST team editor with a valid form body, asserts the response says "Saved" and contains the `localStorage.setItem` snippet.
5. POST battlefield editor with a valid JSON body, asserts `{ok: true, ...}`.
6. POST start-match with the assembled scenario, asserts the response contains the initial match state.
This test exercises the actual `public/index.php` front controller end-to-end and is the closest the MVP gets to a true E2E test (a real browser-driven E2E is in Plan 4).
### CI verification (Task 16)
The existing `.github/workflows/ci.yml` already runs `composer check` (PHPCS + PHPStan + PHPUnit). Plan 3b adds no new dependencies. No workflow change is required; just verify the existing workflow is present.
### Static analysis / lint
- PHPCS scope: `src/Http/Handlers/`, `src/Views/` (excluded by `phpstan.neon` but still checked by PHPCS), `public/index.php`. Already covered by the Task 1 `phpcs.xml`.
- PHPStan level 6 stays. `src/Views/*` is excluded (templates are not statically analyzed). `public/index.php` is included.
- The pre-existing line-length warnings from Plan 1+2 are not addressed in Plan 3b. They remain as a known follow-up. Plan 3b's new files should not introduce new warnings.
### Manual usability check
A new tester with no BattleForge context can: open the dev URL, see the home page, click "New scenario", fill in two teams with mixed archetypes, upload a custom image, save the team editor, paint a small grid, save the battlefield editor, click "Start match", and reach the "match loaded" stub — without leaving the browser or seeing any error from the validator that wasn't explained inline.
## Release Boundary
Plan 3b is releasable when every in-scope capability and success criterion is met, the verification suite is green (PHPUnit, PHPStan, PHPCS), and no out-of-scope capability (the JS-driven UX, the battle interface, bundled scenarios) is required to exercise the local creation flow. The local dev server runs the full app on a single port with `php -S`.
The next plan (Plan 3c) will add the JavaScript surface (`storage.js`, `grid-editor.js`, placeholder images) and the `archetypes.json` asset. Plan 4 will replace the `match-stub.php` placeholder with the real battle interface, add the three bundled scenarios, and ship the E2E smoke test that this plan seeds.
@@ -0,0 +1,184 @@
# Plan 3c: JavaScript Frontend Design
## Purpose
Plan 3c of the four-plan BattleForge build. Closes the gap between the Plan 3a+3b PHP backend and the in-browser experience by adding the small JavaScript surface that Plan 3a's web layer was waiting for. Plan 3c is the third of four plans and the last one in the original "Plan 3" deliverable.
Plan 3a's web layer (the front controller, the editor handlers, the views) shipped without any JavaScript beyond a `<script type="module" src="...">` placeholder. Plan 3c delivers the JS that pre-fills the team editor from `localStorage`, runs the battlefield grid interactions, and wires the home page's "Recent scenarios" list. The web app is currently functional from the browser with HTML-only form submits; Plan 3c adds the JS-driven UX that the spec calls for.
## Success Criteria
Plan 3c succeeds when a new user, without developer assistance, can:
1. Visit `http://localhost:8000/` and see the home page with a "Recent scenarios" list populated from `localStorage`.
2. Click "New scenario", fill the team editor, save — the page reloads, the recent list shows the new scenario, and clicking the new scenario in the recent list opens the team editor pre-filled with the saved data.
3. Navigate to the battlefield editor, paint a small grid, place deployment zones, place an objective, save — the saved state is in `localStorage` under the same `scenario:{id}` key, and the home page's recent list shows the updated scenario.
4. Refresh the page or come back tomorrow and find their scenarios still in their browser.
5. Open the browser dev tools and see no console errors, no `eval`, no unhandled promise rejections, no mixed-content warnings.
## Out of Scope (deferred to Plan 4)
- The hot-seat battle interface (Plan 4 ships a real battle page that reads `match:current` from `localStorage` and renders the turn-based UI).
- Three bundled scenarios (Plan 4 ships them as JSON fixtures in `public/scenarios/`).
- An end-to-end smoke test against a real browser. The spec calls for it under Plan 4, not Plan 3c.
- Drag-and-drop on the grid (the spec says it's optional; the click-paint approach in Plan 3c is sufficient for the MVP).
- Service worker / offline support (the spec explicitly excludes this).
- Animation of combat, terrain, or transitions (Plan 4).
- The "Start match" button on the team editor navigates to the home page after a successful match start; Plan 4 adds the route that shows the match in progress.
## In Scope
### Two JavaScript files
**`public/js/storage.js`** — the localStorage helper. Exposes `window.bfStorage` with eight methods:
```js
window.bfStorage = {
// Read a scenario by id. Returns the parsed object or null if missing/corrupt.
load(id) -> object | null,
// Write a scenario to localStorage under `scenario:{id}`.
// The argument is the canonical Plan-3a scenario shape (the same shape
// ScenarioSerializer::scenarioToArray produces).
save(id, scenario) -> void,
// Remove a scenario from localStorage.
delete(id) -> void,
// List all stored scenarios, sorted by last-modified descending.
// Returns [{id, name, lastModified}].
listAll() -> [{id, name, lastModified}],
// Read/write the in-progress match.
currentMatch() -> object | null,
setCurrentMatch(match) -> void,
removeCurrentMatch() -> void,
};
```
The module handles corrupt JSON gracefully: `load(id)` returns `null` if `JSON.parse` throws, and `listAll()` skips keys that fail to parse (showing a "corrupt scenario" placeholder in the home page).
**`public/js/grid-editor.js`** — the battlefield editor grid helper. Runs on `DOMContentLoaded`. Reads `data-scenario-id` from the form element, calls `bfStorage.load(id)`, and populates the grid. Manages three modes selected by the existing `name="zoneMode"` radio buttons (alpha, bravo, objective, plus the default terrain-paint mode which is implicit when none of the zone radios are selected). On tile click, applies the current mode to that tile. Shift-click removes the paint or zone membership. The form's submit is intercepted; the JS builds the canonical scenario JSON, fetches `POST /scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token: <token>` (read from the layout's `<meta name="csrf-token">`), parses the response, and writes the returned `scenario` to `localStorage`. On 400 with `{ok: false, errors: [...]}`, displays the errors inline in the form.
### One JSON asset
**`public/assets/archetypes.json`** — the four `ArchetypeTemplate` definitions, served at `/assets/archetypes.json`. Format:
```json
{
"defender": { "minHealth": 10, "maxHealth": 14, "minAttack": 2, "maxAttack": 4, "minDefense": 3, "maxDefense": 5, "minSpeed": 1, "maxSpeed": 3, "allowedAbilities": ["buff"] },
"striker": { "minHealth": 6, "maxHealth": 10, "minAttack": 4, "maxAttack": 6, "minDefense": 1, "maxDefense": 2, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["area_damage"] },
"support": { "minHealth": 5, "maxHealth": 9, "minAttack": 1, "maxAttack": 3, "minDefense": 1, "maxDefense": 3, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["heal", "buff"] },
"scout": { "minHealth": 4, "maxHealth": 7, "minAttack": 2, "maxAttack": 4, "minDefense": 0, "maxDefense": 2, "minSpeed": 4, "maxSpeed": 6, "allowedAbilities": [] }
}
```
The `ArchetypeCatalog::templates()` from Plan 3a is the canonical source for these values. Plan 3c's JSON must stay in sync with Plan 3a's PHP enum values. A future task could generate this JSON from the PHP source, but Plan 3c ships a hand-maintained copy.
### Four placeholder images
**`public/assets/placeholders/{defender,striker,support,scout}.png`** — 64×64 (or 32×32) transparent PNGs with a simple silhouette representing each archetype. Served at `/assets/placeholders/{archetype}.png` (no `userToken` prefix). The team editor's `<img>` tags for unit art default to these URLs when no uploaded image is set. The exact pixel art is up to the implementer; the test just checks the file exists and is a valid PNG.
### Updated view templates
Three templates get new `<script type="module">` tags:
- **`src/Views/home.php`** — adds `<script type="module" src="/js/storage.js"></script>`. The `<div id="recent">` is populated by `storage.js` on `DOMContentLoaded`.
- **`src/Views/team-editor.php`** — adds `<script type="module" src="/js/storage.js"></script>`. The form fields are pre-filled by `storage.js` on `DOMContentLoaded`. Adds a "Start match" button at the bottom of the form that calls `bfStorage.load(id)`, fetches `POST /scenarios/{id}/start`, calls `bfStorage.setCurrentMatch(match)`, and navigates to `GET /`.
- **`src/Views/battlefield-editor.php`** — adds `<script type="module" src="/js/grid-editor.js"></script>`. The grid is populated by `grid-editor.js` on `DOMContentLoaded`.
### Node toolchain
- **`package.json`** — declares `eslint ^8.57.0`, `eslint-config-airbnb-base ^15.0.0`, `eslint-plugin-import ^2.29.0`, `prettier ^3.3.0`. Scripts: `lint` (`eslint public/js`), `format` (`prettier --check public/js`).
- **`.eslintrc.json`** — extends `airbnb-base`, env `browser + es2022`, sourceType `module`.
- **`.prettierrc`** — `{ "singleQuote": true, "trailingComma": "all", "printWidth": 100 }` (matches the existing PHPCS PSR-12 style).
### CI update
- **`.github/workflows/ci.yml`** — adds `npm ci`, `npm run lint`, `npm run format` steps after the existing `composer check` step.
- **`.gitignore`** — adds `var/secret.key` (Plan 3b's `FullFlowTest` creates it on first run; it shouldn't be committed) and `node_modules/`.
## System Boundaries
Plan 3c introduces no new boundaries. The Plan 3a+3b web layer's four boundaries still hold:
1. **Content library** (Domain, unchanged): `ArchetypeCatalog` is the canonical source; `archetypes.json` is a hand-maintained mirror used by the JS.
2. **Scenario editor** (web, now JS-driven): Plan 3c adds the JS layer that pre-fills forms and runs grid interactions. The form submit still goes to the same PHP handlers.
3. **Rules engine** (Domain, unchanged).
4. **Battle interface** (Plan 4).
The JS layer reads and writes the canonical scenario shape (Plan 3a's `ScenarioSerializer::scenarioToArray`). It does not introduce a separate "draft" shape or a UI-only data model. The home page's "Recent scenarios" list parses the same shape for display.
## Data and Action Flow
### Cold-start flow
1. Browser hits `GET /`. `GetHomePage` returns 200 with the home page (which now includes `<script type="module" src="/js/storage.js"></script>`).
2. `storage.js` runs. `bfStorage.listAll()` enumerates `localStorage` keys matching `scenario:*`, parses each value, returns an array sorted by `lastModified` descending. The home page's `<div id="recent">` is populated with one `<a>` per item, pointing to `/scenarios/{id}/edit/team`.
3. User clicks "New scenario" → `GET /scenarios/new/edit/team`.
### Team editor flow
1. Browser hits `GET /scenarios/{id}/edit/team`. `GetTeamEditor` returns 200 with the team editor template (which now includes `<script type="module" src="/js/storage.js"></script>`).
2. `storage.js` reads `window.location.pathname`, extracts `{id}` (or `'new'` for the new-scenario case), calls `bfStorage.load(id)`. If a scenario exists, the JS pre-fills each form field by name (e.g. `teamA[units][0][archetype]``document.querySelector('[name="teamA[units][0][archetype]"]').value = scenario.units[0].archetype`). If the id is `'new'`, the form stays empty.
3. The archetype dropdowns are populated from `/assets/archetypes.json` (fetched once on script load).
4. User edits, then presses Save. The form's submit triggers a normal `POST /scenarios/{id}/edit/team` (no JS intercept).
5. The server returns 200 with a `<script type="module">` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))`. The browser runs the script and writes.
6. On a validation error, the server returns 200 with the form re-rendered and the validator's error in `<div class="bf-errors">`. No JS write happens; the form keeps the user's in-progress values.
### Battlefield editor flow
1. Browser hits `GET /scenarios/{id}/edit/battlefield`. `GetBattlefieldEditor` returns 200 with the battlefield editor template (which now includes `<script type="module" src="/js/grid-editor.js"></script>`).
2. `grid-editor.js` reads `bfStorage.load(id)`, populates the `tileMap` from `scenario.battlefieldTerrain`, populates the deployment zones (extracted from `scenario.deploymentZones`), and populates the objective position (if `HoldObjective`).
3. The grid is repainted: each tile button's `data-paint` attribute is updated, the objective tile gets `data-objective="1"`, and the zone tiles get `data-zone="alpha"` or `data-zone="bravo"`.
4. User clicks a palette button to select terrain paint mode, then clicks tiles. Each click updates the in-memory `tileMap` and updates the button's `data-paint`. User clicks one of the `name="zoneMode"` radios (alpha / bravo / objective) and clicks tiles to add to that zone or place the objective. Shift-click in any mode removes the paint or zone membership.
5. User presses Save. The JS intercepts the form submit (`event.preventDefault()`), builds the canonical scenario JSON, POSTs to `/scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token: <token>` (read from the layout's `<meta name="csrf-token">`), parses the response, and writes the returned `scenario` to `localStorage`.
6. On 400 with `{ok: false, errors: [...]}`, the JS shows the errors inline in the form (e.g. "Deployment zone alpha includes impassable terrain at (3,4)").
### Start-match flow
1. From the team editor, user clicks "Start match" (a new button at the bottom of the form). The JS reads `bfStorage.load(id)`, fetches `POST /scenarios/{id}/start` with `X-CSRF-Token`, parses the response, calls `bfStorage.setCurrentMatch(match)`, and navigates to `GET /`.
2. The home page (when reloaded) shows a "Match in progress" banner at the top, driven by `bfStorage.currentMatch()`. Plan 4 adds a real battle page that this banner links to.
## Validation and Failure Handling
- `bfStorage.load(id)` returns `null` if the value is missing or fails to JSON-parse. The home page's `listAll()` skips keys that fail to parse, and the team editor's pre-fill gracefully no-ops on `null`.
- The team editor's form submit falls back to the server's re-render path (Plan 3a's `PostTeamEditor` re-renders the form on validation failure with the user's in-progress values and the validator's error message). The JS does not intercept the submit.
- The battlefield editor's fetch fallback path: on 400, the JS shows the validator's errors inline and keeps the user's in-progress state in the `tileMap` and zone maps. On network failure (e.g. fetch throws), the JS shows a "Network error" message and the form stays editable.
- The "Start match" button's failure path: on 400 (invalid scenario), the JS shows the validator's errors inline. On network failure, shows a "Network error" message and keeps the form editable.
## Security and Quality Requirements
Inherited from the Plan 3 spec:
- All rendered output is escaped for its output context. The PHP templates use `Escape::html` / `Escape::attr` / `Escape::url`. The JS uses `textContent` and `value` setters, never `innerHTML` (with one exception: building the home page's recent-scenarios list uses `appendChild` on `<a>` elements with `textContent`, never `innerHTML`).
- The single small JS file passes ESLint `airbnb/base` and Prettier checks. CI fails if either fails.
- `composer validate --strict` and the existing `composer check` continue to pass.
Plan 3c-specific:
- No third-party JS libraries. The two files are pure ESM, no dependencies, no `eval`, no `Function` constructor.
- The `archetypes.json` is served from the same origin. The home page's "Recent scenarios" list is read from `localStorage`, never from the network.
- `var/secret.key` is added to `.gitignore` so the Plan 3b full-flow test's generated secret file is not committed.
## Verification Strategy
### Lint and format (CI gate)
- `npm run lint` — ESLint `airbnb-base` over `public/js/*.js`. Fails on any rule violation, including unused variables, missing semicolons, and unhandled promise rejections.
- `npm run format` — Prettier `--check public/js`. Fails on any formatting drift.
### Smoke test (optional, recommended)
- One Playwright test that boots the dev server with `php -S`, loads the home page, asserts the empty "Recent scenarios" list, calls `bfStorage.save('demo', {id: 'demo', name: 'Demo'})` via `page.evaluate`, reloads, and asserts the list shows "Demo". This is the Plan 3c analog of `FullFlowTest` for Plan 3b.
- If Playwright is too heavy for Plan 3c, the spec's CI requirement (ESLint + Prettier) is sufficient, and the Playwright test lands in Plan 4 with the battle interface.
### Manual usability check
A new tester with no BattleForge context can: open the dev URL, see the home page, click "New scenario", fill the team editor, save, see the recent list show the new scenario, click it, see the team editor pre-filled, paint a small grid, save, see the battlefield state persist, and refresh to find their work still there — without leaving the browser or seeing any error from the validator that wasn't explained inline.
## Release Boundary
Plan 3c is releasable when every in-scope capability and success criterion is met, the verification suite is green (`composer check`, `npm run lint`, `npm run format`), and no out-of-scope capability (the battle interface, bundled scenarios, the end-to-end Playwright smoke test) is required to exercise the local creation flow. The local dev server runs the full app on a single port with `php -S`, and the JS layer loads from `/js/`.
The next plan (Plan 4) will replace the home page's "Match in progress" banner with the real battle interface, add the three bundled scenarios, and ship the end-to-end smoke test that this plan's spec leaves as a follow-up.
+3284
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "battleforge-battleforge",
"description": "BattleForge frontend toolchain.",
"private": true,
"license": "proprietary",
"scripts": {
"lint": "eslint public/js",
"format": "prettier --check public/js"
},
"devDependencies": {
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.29.0",
"prettier": "^3.3.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"defender": { "minHealth": 10, "maxHealth": 14, "minAttack": 2, "maxAttack": 4, "minDefense": 3, "maxDefense": 5, "minSpeed": 1, "maxSpeed": 3, "allowedAbilities": ["buff"] },
"striker": { "minHealth": 6, "maxHealth": 10, "minAttack": 4, "maxAttack": 6, "minDefense": 1, "maxDefense": 2, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["area_damage"] },
"support": { "minHealth": 5, "maxHealth": 9, "minAttack": 1, "maxAttack": 3, "minDefense": 1, "maxDefense": 3, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["heal", "buff"] },
"scout": { "minHealth": 4, "maxHealth": 7, "minAttack": 2, "maxAttack": 4, "minDefense": 0, "maxDefense": 2, "minSpeed": 4, "maxSpeed": 6, "allowedAbilities": [] }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

+133 -5
View File
@@ -2,8 +2,136 @@
declare(strict_types=1); declare(strict_types=1);
// Front controller. The router and handler dispatch land in Task 13. use BattleForge\Http\CsrfToken;
// For now this returns a 200 with a stub so the dev server is reachable. use BattleForge\Http\Handlers\GetAssets;
http_response_code(200); use BattleForge\Http\Handlers\GetBattlefieldEditor;
header('Content-Type: text/plain; charset=utf-8'); use BattleForge\Http\Handlers\GetHomePage;
echo "BattleForge dev server up.\n"; use BattleForge\Http\Handlers\GetTeamEditor;
use BattleForge\Http\Handlers\PostBattlefieldEditor;
use BattleForge\Http\Handlers\PostImageUpload;
use BattleForge\Http\Handlers\PostStartMatch;
use BattleForge\Http\Handlers\PostTeamEditor;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
use BattleForge\Http\Router;
require __DIR__ . '/../vendor/autoload.php';
// 1. Read the app secret.
$secret = getenv('BATTLEFORGE_SECRET');
if ($secret === false || $secret === '') {
$secretFile = __DIR__ . '/../var/secret.key';
if (!is_file($secretFile)) {
if (!is_dir(dirname($secretFile))) {
mkdir(dirname($secretFile), 0700, true);
}
file_put_contents($secretFile, random_bytes(32));
chmod($secretFile, 0600);
}
$secret = file_get_contents($secretFile);
if ($secret === false) {
http_response_code(500);
echo "Failed to read app secret.\n";
return;
}
}
// 2. Ensure the __csrf cookie is set. If absent, issue a fresh token.
$cookies = $_COOKIE;
$existingCookie = (string) ($cookies['__csrf'] ?? '');
if ($existingCookie === '') {
[$token, $cookie] = CsrfToken::issue($secret);
setcookie('__csrf', $cookie, [
'expires' => time() + 86400,
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
$existingCookie = $cookie;
$cookies['__csrf'] = $cookie;
}
// 3. Compute the upload-endpoint user token (derived from the same secret).
$uploadsToken = hash_hmac('sha256', $secret, 'bf-uploads');
// 4. Determine the request method, path, and content type.
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
$queryString = (string) (parse_url($uri, PHP_URL_QUERY) ?? '');
$contentType = isset($_SERVER['CONTENT_TYPE']) ? (string) $_SERVER['CONTENT_TYPE'] : null;
if ($contentType !== null) {
$semicolon = strpos($contentType, ';');
if ($semicolon !== false) {
$contentType = substr($contentType, 0, $semicolon);
}
$contentType = trim($contentType);
}
// 5. Read the raw body for fetch POSTs that submit JSON.
$rawBody = (string) ($_SERVER['__raw_body'] ?? file_get_contents('php://input'));
// 6. Build a Request with the request data, the CSRF secret, and the upload token.
$server = $_SERVER;
if (!isset($server['__csrf_secret'])) {
$server['__csrf_secret'] = $secret;
}
$server['__uploads_token'] = $uploadsToken;
$request = new Request(
get: $_GET,
post: $_POST,
files: $_FILES,
cookies: $cookies,
server: $server,
queryString: $queryString,
method: $method,
path: $path,
contentType: $contentType,
rawBody: $rawBody,
);
// 7. Configure the router with the eight routes.
$uploadsRoot = __DIR__ . '/../var/uploads';
$placeholderDir = __DIR__ . '/assets/placeholders';
$homePage = static fn (Request $r, array $p): Response => (new GetHomePage())->handle($r, $p);
$getTeam = static fn (Request $r, array $p): Response => (new GetTeamEditor())->handle($r, $p);
$postTeam = static fn (Request $r, array $p): Response => (new PostTeamEditor())->handle($r, $p);
$getBattlefield = static fn (Request $r, array $p): Response => (new GetBattlefieldEditor())->handle($r, $p);
$postBattlefield = static fn (Request $r, array $p): Response => (new PostBattlefieldEditor())->handle($r, $p);
$postStart = static fn (Request $r, array $p): Response => (new PostStartMatch())->handle($r, $p);
$postUpload = static fn (Request $r, array $p): Response => (new PostImageUpload($uploadsRoot))->handle($r, $p);
$getAssets = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response {
return (new GetAssets($placeholderDir, $uploadsRoot))->handle($r, $p);
};
$getUploadedAsset = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response {
$handler = new GetAssets($placeholderDir, $uploadsRoot);
return $handler->handle(
$r,
['kind' => 'uploads', 'userToken' => $p['userToken'] ?? '', 'filename' => $p['filename'] ?? ''],
);
};
$router = new Router();
$router->add('GET', '/', $homePage);
$router->add('GET', '/scenarios/{id}/edit/team', $getTeam);
$router->add('POST', '/scenarios/{id}/edit/team', $postTeam);
$router->add('GET', '/scenarios/{id}/edit/battlefield', $getBattlefield);
$router->add('POST', '/scenarios/{id}/edit/battlefield', $postBattlefield);
$router->add('POST', '/scenarios/{id}/start', $postStart);
$router->add('POST', '/assets/upload', $postUpload);
$router->add('GET', '/assets/uploads/{userToken}/{filename}', $getUploadedAsset);
$router->add('GET', '/assets/{kind}/{filename}', $getAssets);
// 8. Dispatch and emit.
$response = $router->dispatch($request);
http_response_code($response->status);
foreach ($response->headers as $name => $value) {
header($name . ': ' . $value);
}
echo $response->body;
return $response->status;
+266
View File
@@ -0,0 +1,266 @@
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function getCurrentMode() {
const checked = document.querySelector('input[name="zoneMode"]:checked');
return checked ? checked.value : 'terrain';
}
function getSelectedPaint() {
const active = document.querySelector('.bf-paint.active');
if (active) {
return active.getAttribute('data-paint');
}
return 'forest';
}
function loadScenario(id) {
if (window.bfStorage && typeof window.bfStorage.load === 'function') {
return window.bfStorage.load(id);
}
return null;
}
function saveScenario(id, scenario) {
if (window.bfStorage && typeof window.bfStorage.save === 'function') {
window.bfStorage.save(id, scenario);
}
}
function applyTile(button, paint, zone, isObjective) {
button.setAttribute('data-paint', paint);
if (zone) {
button.setAttribute('data-zone', zone);
} else {
button.removeAttribute('data-zone');
}
if (isObjective) {
button.setAttribute('data-objective', '1');
} else {
button.removeAttribute('data-objective');
}
}
function loadGrid(tileMap, deploymentZones, objective) {
document.querySelectorAll('.bf-tile').forEach((button) => {
const x = button.getAttribute('data-x');
const y = button.getAttribute('data-y');
const key = `${x}:${y}`;
if (tileMap[key]) {
button.setAttribute('data-paint', tileMap[key]);
}
button.removeAttribute('data-zone');
button.removeAttribute('data-objective');
});
Object.entries(deploymentZones).forEach(([team, positions]) => {
positions.forEach((pos) => {
const selector = `.bf-tile[data-x="${pos.x}"][data-y="${pos.y}"]`;
const button = document.querySelector(selector);
if (button) {
button.setAttribute('data-zone', team);
}
});
});
if (objective) {
const selector = `.bf-tile[data-x="${objective.x}"][data-y="${objective.y}"]`;
const button = document.querySelector(selector);
if (button) {
button.setAttribute('data-objective', '1');
}
}
}
function readGrid() {
const tileMap = {};
const deploymentZones = { alpha: [], bravo: [] };
let objective = null;
document.querySelectorAll('.bf-tile').forEach((button) => {
const x = Number(button.getAttribute('data-x'));
const y = Number(button.getAttribute('data-y'));
const paint = button.getAttribute('data-paint');
if (paint && paint !== 'open') {
tileMap[`${x}:${y}`] = paint;
}
const zone = button.getAttribute('data-zone');
if (zone === 'alpha' || zone === 'bravo') {
deploymentZones[zone].push({ x, y });
}
if (button.getAttribute('data-objective') === '1') {
objective = { x, y };
}
});
return { tileMap, deploymentZones, objective };
}
function buildScenarioJson(scenarioId, tileMap, deploymentZones, objective) {
const widthInput = document.querySelector('input[name="battlefieldWidth"]');
const heightInput = document.querySelector('input[name="battlefieldHeight"]');
const result = {
id: scenarioId,
name: scenarioId,
battlefieldWidth: widthInput ? Number(widthInput.value) : 8,
battlefieldHeight: heightInput ? Number(heightInput.value) : 8,
battlefieldTerrain: tileMap,
teamA: {
units: [],
},
teamB: {
units: [],
},
deploymentZones,
objectives: {},
victoryCondition: 'eliminate_all',
holdRoundsRequired: 1,
};
if (objective) {
result.victoryCondition = 'hold_objective';
result.holdRoundsRequired = 3;
result.objectiveId = 'objective-1';
result.objectiveX = objective.x;
result.objectiveY = objective.y;
}
return result;
}
function paintTile(button, ev) {
const mode = getCurrentMode();
if (ev.shiftKey) {
applyTile(button, 'open', null, false);
return;
}
if (mode === 'objective') {
document.querySelectorAll('.bf-tile[data-objective]').forEach((other) => {
other.removeAttribute('data-objective');
});
applyTile(button, 'open', null, true);
return;
}
if (mode === 'alpha' || mode === 'bravo') {
const current = button.getAttribute('data-zone');
if (current === mode) {
button.removeAttribute('data-zone');
} else {
button.setAttribute('data-zone', mode);
}
return;
}
applyTile(button, getSelectedPaint(), null, false);
}
function selectPalette(event) {
document.querySelectorAll('.bf-paint').forEach((b) => b.classList.remove('active'));
event.currentTarget.classList.add('active');
}
function showToast(message) {
const toast = document.createElement('div');
toast.className = 'bf-toast';
toast.appendChild(document.createTextNode(message));
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
function showErrors(errors) {
const existing = document.querySelector('.bf-errors');
if (existing) {
existing.remove();
}
const div = document.createElement('div');
div.className = 'bf-errors';
const ul = document.createElement('ul');
errors.forEach((msg) => {
const li = document.createElement('li');
li.appendChild(document.createTextNode(msg));
ul.appendChild(li);
});
div.appendChild(ul);
const form = document.getElementById('battlefield-form');
if (form) {
form.parentNode.insertBefore(div, form);
}
}
async function save(event) {
event.preventDefault();
const form = document.getElementById('battlefield-form');
if (!form) {
return;
}
const id = form.getAttribute('data-scenario-id');
if (!id) {
return;
}
const { tileMap, deploymentZones, objective } = readGrid();
const scenario = buildScenarioJson(id, tileMap, deploymentZones, objective);
const csrf = getCsrfToken();
let res;
try {
res = await fetch(`/scenarios/${encodeURIComponent(id)}/edit/battlefield`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify(scenario),
});
} catch (e) {
showErrors([`Network error: ${e.message}`]);
return;
}
if (res.ok) {
const data = await res.json();
if (data.scenario) {
saveScenario(id, data.scenario);
showToast('Saved.');
} else {
showErrors([(data.errors || ['Unknown error']).join(', ')]);
}
} else if (res.status === 400) {
let data = {};
try {
data = await res.json();
} catch (e) {
// body wasn't JSON
}
showErrors(data.errors || ['Validation failed (HTTP 400).']);
} else {
showErrors([`Save failed: HTTP ${res.status}`]);
}
}
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('battlefield-form');
if (!form) {
return;
}
const id = form.getAttribute('data-scenario-id');
if (id) {
const scenario = loadScenario(id);
if (scenario) {
const battlefield = scenario.battlefield || {};
const terrain = battlefield.terrain || {};
const rawZones = scenario.deploymentZones || {};
const deploymentZones = { alpha: [], bravo: [] };
['alpha', 'bravo'].forEach((team) => {
if (rawZones[team] && Array.isArray(rawZones[team].positions)) {
deploymentZones[team] = rawZones[team].positions;
}
});
const objectiveMarker = scenario.objectives ? scenario.objectives['objective-1'] : null;
const objective = objectiveMarker ? objectiveMarker.position : null;
loadGrid(terrain, deploymentZones, objective);
}
}
document.querySelectorAll('.bf-tile').forEach((button) => {
button.addEventListener('click', (ev) => paintTile(button, ev));
});
document.querySelectorAll('.bf-paint').forEach((button) => {
button.addEventListener('click', selectPalette);
});
if (document.querySelector('.bf-paint')) {
document.querySelector('.bf-paint').classList.add('active');
}
form.addEventListener('submit', save);
});
+219
View File
@@ -0,0 +1,219 @@
const STORAGE_PREFIX = 'scenario:';
const MATCH_KEY = 'match:current';
function load(id) {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + id);
if (raw === null) {
return null;
}
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function save(id, scenario) {
const stamped = { ...scenario, lastModified: Date.now() };
localStorage.setItem(STORAGE_PREFIX + id, JSON.stringify(stamped));
}
function remove(id) {
localStorage.removeItem(STORAGE_PREFIX + id);
}
function listAll() {
const items = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (key !== null && key.startsWith(STORAGE_PREFIX)) {
const id = key.slice(STORAGE_PREFIX.length);
const scenario = load(id);
if (scenario !== null) {
items.push({
id,
name: typeof scenario.name === 'string' ? scenario.name : id,
lastModified: typeof scenario.lastModified === 'number' ? scenario.lastModified : 0,
});
}
}
}
items.sort((a, b) => b.lastModified - a.lastModified);
return items;
}
function currentMatch() {
try {
const raw = localStorage.getItem(MATCH_KEY);
if (raw === null) {
return null;
}
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function setCurrentMatch(match) {
localStorage.setItem(MATCH_KEY, JSON.stringify(match));
}
function removeCurrentMatch() {
localStorage.removeItem(MATCH_KEY);
}
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function parsePath(name) {
const match = name.match(/^(teamA|teamB)\[units\]\[(\d+)\]\[(\w+)\]$/);
if (match) {
const [, team, indexStr, key] = match;
const teamKey = team === 'teamA' ? 'teamA' : 'teamB';
return [teamKey, 'units', Number(indexStr), key];
}
const simple = [
'id',
'name',
'battlefieldWidth',
'battlefieldHeight',
'victoryCondition',
'holdRoundsRequired',
];
if (simple.includes(name)) {
return [name];
}
return null;
}
function resolveField(scenario, path) {
const parts = parsePath(path);
if (parts === null) {
return undefined;
}
let cursor = scenario;
for (let i = 0; i < parts.length; i += 1) {
if (cursor === null || cursor === undefined) {
return undefined;
}
cursor = cursor[parts[i]];
}
return cursor;
}
function populateRecentScenarios() {
const root = document.getElementById('recent');
if (!root) {
return;
}
while (root.firstChild) {
root.removeChild(root.firstChild);
}
const items = listAll();
if (items.length === 0) {
const p = document.createElement('p');
p.className = 'bf-empty';
p.appendChild(document.createTextNode('No saved scenarios yet.'));
root.appendChild(p);
return;
}
const ul = document.createElement('ul');
items.forEach((item) => {
const li = document.createElement('li');
const a = document.createElement('a');
a.setAttribute('href', `/scenarios/${encodeURIComponent(item.id)}/edit/team`);
a.appendChild(document.createTextNode(`${item.name} (${item.id})`));
li.appendChild(a);
ul.appendChild(li);
});
root.appendChild(ul);
}
// eslint-disable-next-line no-unused-vars
async function fetchArchetypes() {
const res = await fetch('/assets/archetypes.json');
return res.json();
}
function prefillTeamEditor() {
const form = document.querySelector('form[action*="/edit/team"]');
if (!form) {
return;
}
const id = decodeURIComponent(window.location.pathname.split('/').slice(-3, -2)[0] || 'new');
if (id === 'new') {
return;
}
const scenario = load(id);
if (scenario === null) {
return;
}
const fields = form.querySelectorAll('input[name], select[name]');
fields.forEach((field) => {
const name = field.getAttribute('name');
if (!name) {
return;
}
const value = resolveField(scenario, name);
if (value !== undefined) {
Object.assign(field, { value: String(value) });
}
});
}
async function startMatch() {
const id = decodeURIComponent(window.location.pathname.split('/').slice(-3, -2)[0] || 'new');
if (id === 'new') {
return;
}
const scenario = load(id);
if (scenario === null) {
return;
}
const csrf = getCsrfToken();
const res = await fetch(`/scenarios/${encodeURIComponent(id)}/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify(scenario),
});
if (!res.ok) {
// eslint-disable-next-line no-alert
alert(`Start match failed: HTTP ${res.status}`);
return;
}
const data = await res.json();
if (data.match) {
setCurrentMatch(data.match);
window.location.href = '/';
} else {
// eslint-disable-next-line no-alert
alert(`Start match failed: ${data.errors ? data.errors.join(', ') : 'unknown'}`);
}
}
window.bfStorage = {
load,
save,
delete: remove,
listAll,
currentMatch,
setCurrentMatch,
removeCurrentMatch,
};
document.addEventListener('DOMContentLoaded', () => {
populateRecentScenarios();
prefillTeamEditor();
const startBtn = document.getElementById('bf-start-match');
if (startBtn) {
startBtn.addEventListener('click', (event) => {
event.preventDefault();
startMatch();
});
}
});
+1 -1
View File
@@ -33,6 +33,6 @@ final class ImageUploadService
// file itself to 0600 in case the server's umask is permissive. // file itself to 0600 in case the server's umask is permissive.
chmod($destination, 0600); chmod($destination, 0600);
return '/assets/' . $this->userToken . '/' . $filename; return '/assets/uploads/' . $this->userToken . '/' . $filename;
} }
} }
+6 -2
View File
@@ -27,9 +27,13 @@ final class GetAssets
if ($kind === 'uploads') { if ($kind === 'uploads') {
$userToken = $params['userToken'] ?? ''; $userToken = $params['userToken'] ?? '';
$requestToken = $request->cookies['__uploads_token'] ?? ''; if (!preg_match('/^[a-f0-9]{32,}$/', $userToken)) {
return Response::html(404, '<h1>Not found</h1>');
}
if ($userToken === '' || $userToken !== $requestToken) { $requestToken = (string) ($request->server['__uploads_token'] ?? '');
if ($userToken !== $requestToken) {
return Response::html(404, '<h1>Not found</h1>'); return Response::html(404, '<h1>Not found</h1>');
} }
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetBattlefieldEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$csrf = $request->cookies['__csrf'] ?? '';
$scenarioId = $params['id'] ?? 'new';
$width = 8;
$height = 8;
ob_start();
require_once __DIR__ . '/../../Views/layout.php';
require __DIR__ . '/../../Views/battlefield-editor.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
}
+5 -23
View File
@@ -12,30 +12,12 @@ final class GetHomePage
/** @param array<string, string> $params */ /** @param array<string, string> $params */
public function handle(Request $request, array $params): Response public function handle(Request $request, array $params): Response
{ {
$body = <<<'HTML' $csrf = $request->cookies['__csrf'] ?? '';
<!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) ob_start();
// before the body is sent. The handler does not see the cookie or the require_once __DIR__ . '/../../Views/layout.php';
// secret; it just receives a pre-issued token from the front controller. require __DIR__ . '/../../Views/home.php';
$token = $request->cookies['__csrf'] ?? ''; $body = (string) ob_get_clean();
$body = str_replace('{{ csrf }}', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $body);
return Response::html(200, $body); return Response::html(200, $body);
} }
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetTeamEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$csrf = $request->cookies['__csrf'] ?? '';
$scenarioId = $params['id'] ?? 'new';
$error = null;
$old = [];
$post = [];
ob_start();
require_once __DIR__ . '/../../Views/layout.php';
require __DIR__ . '/../../Views/team-editor.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioDraft;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostBattlefieldEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return Response::json(403, ['error' => 'csrf']);
}
try {
$payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
return Response::json(400, ['ok' => false, 'errors' => ['Malformed JSON.']]);
}
try {
$draft = ScenarioDraft::fromPost($payload);
$scenario = $draft->toScenario();
ScenarioValidator::validate($scenario);
} catch (\InvalidArgumentException $exception) {
return Response::json(400, ['ok' => false, 'errors' => [$exception->getMessage()]]);
}
return Response::json(200, ['ok' => true, 'scenario' => ScenarioSerializer::scenarioToArray($scenario)]);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ImageUploadService;
use BattleForge\Application\InvalidImageException;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostImageUpload
{
public function __construct(private readonly string $uploadsRoot)
{
}
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->post['_csrf'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return Response::json(403, ['error' => 'csrf']);
}
$file = $request->files['image'] ?? null;
if ($file === null) {
return Response::json(400, ['error' => 'No image uploaded.']);
}
$tempPath = (string) ($file['tmp_name'] ?? '');
$declaredMime = (string) ($file['type'] ?? '');
if ($tempPath === '' || !is_file($tempPath)) {
// Production: $request->files['image']['tmp_name'] is PHP-set from the multipart body,
// so is_file() confirms the temp file exists. is_uploaded_file() is stricter but
// unsuitable for unit tests that synthesize tmp files via tempnam().
return Response::json(400, ['error' => 'Upload is not a file.']);
}
$userToken = hash_hmac('sha256', $secret, 'bf-uploads');
try {
$service = new ImageUploadService($userToken, $this->uploadsRoot);
$url = $service->store($tempPath, $declaredMime);
} catch (InvalidImageException $exception) {
return Response::json(400, ['error' => $exception->getMessage()]);
}
return Response::json(200, ['url' => $url]);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioDraft;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostStartMatch
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return Response::json(403, ['error' => 'csrf']);
}
try {
$payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR);
$draft = ScenarioDraft::fromPost($payload);
$scenario = $draft->toScenario();
ScenarioValidator::validate($scenario);
$match = $scenario->startMatch('alpha');
} catch (\JsonException $exception) {
return Response::json(400, ['errors' => ['Malformed JSON.']]);
} catch (\InvalidArgumentException $exception) {
return Response::json(400, ['errors' => [$exception->getMessage()]]);
}
return Response::json(200, ['match' => ScenarioSerializer::matchToArray($match)]);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioDraft;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Escape;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostTeamEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->post['_csrf'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return Response::html(403, '<h1>Forbidden</h1>');
}
try {
$draft = ScenarioDraft::fromPost($request->post);
$scenario = $draft->toScenario();
ScenarioValidator::validate($scenario);
} catch (\InvalidArgumentException $exception) {
return $this->renderForm($request, $params['id'] ?? 'new', $request->cookies['__csrf'] ?? '', $exception->getMessage(), $request->post);
}
$json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR);
$url = (string) ($params['id'] ?? $scenario->id);
$safeUrl = json_encode($url, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$body = <<<HTML
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Saved</title></head>
<body>
<p>Scenario saved.</p>
<script type="module">
localStorage.setItem('scenario:{$safeUrl}', $json);
</script>
</body>
</html>
HTML;
return Response::html(200, $body);
}
/** @param array<string, mixed> $post */
private function renderForm(Request $request, string $scenarioId, string $csrf, string $error, array $post): Response
{
$error = Escape::html($error);
$csrf = Escape::html($csrf);
$scenarioId = Escape::html($scenarioId);
$old = array_intersect_key($post, array_flip([
'id',
'name',
'battlefieldWidth',
'battlefieldHeight',
'victoryCondition',
'holdRoundsRequired',
]));
$old = array_map(static fn ($v): string => is_scalar($v) ? (string) $v : '', $old);
$teamA = $post['teamA']['units'] ?? [];
$teamB = $post['teamB']['units'] ?? [];
ob_start();
require_once __DIR__ . '/../../Views/layout.php';
require __DIR__ . '/../../Views/team-editor.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
/** @return array<string, mixed> */
private function scenarioToArray(\BattleForge\Domain\Scenario $scenario): array
{
return \BattleForge\Application\ScenarioSerializer::scenarioToArray($scenario);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/**
* @var string $csrf Provided by the front controller.
* @var string $scenarioId Provided by the front controller from the URL.
* @var int $width Battlefield width (8-16).
* @var int $height Battlefield height (8-16).
*/
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
$terrain = ['open', 'forest', 'rough', 'water', 'blocking'];
?><?php
render_layout(static function () use ($csrf, $e, $a, $scenarioId, $width, $height, $terrain): void {
?>
<h1>Battlefield editor</h1>
<p>Scenario: <strong><?= $e($scenarioId) ?></strong></p>
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded" data-scenario-id="<?= $a($scenarioId) ?>">
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
<fieldset>
<legend>Battlefield</legend>
<label>Width: <input type="number" name="battlefieldWidth" min="8" max="16" value="<?= $e((string) $width) ?>" required></label>
<label>Height: <input type="number" name="battlefieldHeight" min="8" max="16" value="<?= $e((string) $height) ?>" required></label>
</fieldset>
<fieldset>
<legend>Terrain palette</legend>
<?php foreach ($terrain as $t) : ?>
<button type="button" data-paint="<?= $a($t) ?>" class="bf-paint"><?= $e($t) ?></button>
<?php endforeach; ?>
</fieldset>
<fieldset>
<legend>Grid (<?= $e((string) $width) ?> × <?= $e((string) $height) ?>)</legend>
<table class="bf-grid" id="bf-grid">
<?php for ($y = 0; $y < $height; $y++) : ?>
<tr>
<?php for ($x = 0; $x < $width; $x++) : ?>
<td><button type="button" class="bf-tile" data-x="<?= $e((string) $x) ?>" data-y="<?= $e((string) $y) ?>" data-paint="open">.</button></td>
<?php endfor; ?>
</tr>
<?php endfor; ?>
</table>
</fieldset>
<fieldset>
<legend>Deployment zones</legend>
<label><input type="radio" name="zoneMode" value="alpha" checked> Place team A zones</label>
<label><input type="radio" name="zoneMode" value="bravo"> Place team B zones</label>
<label><input type="radio" name="zoneMode" value="objective"> Place objective</label>
</fieldset>
<button type="submit">Save</button>
</form>
<script type="module" src="<?= $a('/js/grid-editor.js') ?>"></script>
<?php
}, $csrf, 'Battlefield editor');
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/** @var string $csrf Provided by the front controller. */
?><?php
render_layout(static function () use ($csrf): void {
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
?>
<h1>BattleForge</h1>
<p><a href="<?= $a('/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="<?= $a('/js/storage.js') ?>"></script>
<?php
}, $csrf, 'BattleForge');
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/**
* Render the shared document chrome around a body closure.
*
* @param callable(): void $body Emits the template's body content.
* @param string $csrf The pre-issued CSRF token (HMAC-verified by the front controller).
* @param string $title Page title; HTML-escaped by the layout.
* @param string $extraHead Optional extra `<head>` content (e.g. a per-template script tag).
*/
function render_layout(callable $body, string $csrf, string $title, string $extraHead = ''): void
{
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $e($title) ?></title>
<link rel="stylesheet" href="<?= $a('/assets/styles.css') ?>">
<meta name="csrf-token" content="<?= $a($csrf) ?>">
<?= $extraHead /* trusted raw HTML for the optional extra-head block */ ?>
</head>
<body>
<?php $body(); ?>
</body>
</html>
<?php
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use BattleForge\Domain\Archetype;
use BattleForge\Domain\ArchetypeCatalog;
use BattleForge\Http\Escape;
/**
* @var string $csrf Provided by the front controller.
* @var ?string $error Optional validator error message; HTML-escaped on output.
* @var array<string, string> $old Optional old form values, keyed by field name; HTML-escaped on output.
* @var array<string, mixed> $post Raw form data (for repopulating per-unit fields).
*/
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
$archetypes = ArchetypeCatalog::templates();
?><?php
render_layout(static function () use ($csrf, $e, $a, $archetypes, $error, $old, $post): void {
?>
<h1>Team editor</h1>
<?php if ($error !== null) : ?>
<div class="bf-errors"><p><?= $e($error) ?></p></div>
<?php endif; ?>
<form method="post" action="<?= $a('/scenarios/' . $e($old['id'] ?? 'new') . '/edit/team') ?>" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
<fieldset>
<legend>Scenario</legend>
<label>Id: <input type="text" name="id" value="<?= $e($old['id'] ?? '') ?>" required></label>
<label>Name: <input type="text" name="name" value="<?= $e($old['name'] ?? '') ?>" required></label>
<label>Battlefield width: <input type="number" name="battlefieldWidth" min="8" max="16" value="<?= $e($old['battlefieldWidth'] ?? '8') ?>" required></label>
<label>Battlefield height: <input type="number" name="battlefieldHeight" min="8" max="16" value="<?= $e($old['battlefieldHeight'] ?? '8') ?>" required></label>
</fieldset>
<fieldset>
<legend>Team A</legend>
<?php for ($i = 0; $i < 3; $i++) :
$row = $post['teamA']['units'][$i] ?? null; ?>
<div class="bf-unit">
<input type="text" name="teamA[units][<?= $i ?>][id]" placeholder="a-<?= $i + 1 ?>" value="<?= $e($row['id'] ?? '') ?>" required>
<select name="teamA[units][<?= $i ?>][archetype]" required>
<?php foreach ($archetypes as $key => $template) : ?>
<option value="<?= $a($key) ?>"<?= (($row['archetype'] ?? '') === $key) ? ' selected' : '' ?>><?= $e($key) ?></option>
<?php endforeach; ?>
</select>
<input type="number" name="teamA[units][<?= $i ?>][maxHealth]" placeholder="maxHealth" required>
<input type="number" name="teamA[units][<?= $i ?>][attack]" placeholder="attack" required>
<input type="number" name="teamA[units][<?= $i ?>][defense]" placeholder="defense" required>
<input type="number" name="teamA[units][<?= $i ?>][speed]" placeholder="speed" required>
<input type="text" name="teamA[units][<?= $i ?>][x]" placeholder="x" value="<?= $e($row['x'] ?? '0') ?>" required>
<input type="text" name="teamA[units][<?= $i ?>][y]" placeholder="y" value="<?= $e($row['y'] ?? '0') ?>" required>
<input type="file" name="teamA[units][<?= $i ?>][image]" accept="image/*">
<input type="hidden" name="teamA[units][<?= $i ?>][imageUrl]" value="<?= $e($row['imageUrl'] ?? '') ?>">
</div>
<?php endfor; ?>
</fieldset>
<fieldset>
<legend>Team B</legend>
<?php for ($i = 0; $i < 3; $i++) :
$row = $post['teamB']['units'][$i] ?? null; ?>
<div class="bf-unit">
<input type="text" name="teamB[units][<?= $i ?>][id]" placeholder="b-<?= $i + 1 ?>" value="<?= $e($row['id'] ?? '') ?>" required>
<select name="teamB[units][<?= $i ?>][archetype]" required>
<?php foreach ($archetypes as $key => $template) : ?>
<option value="<?= $a($key) ?>"<?= (($row['archetype'] ?? '') === $key) ? ' selected' : '' ?>><?= $e($key) ?></option>
<?php endforeach; ?>
</select>
<input type="number" name="teamB[units][<?= $i ?>][maxHealth]" placeholder="maxHealth" required>
<input type="number" name="teamB[units][<?= $i ?>][attack]" placeholder="attack" required>
<input type="number" name="teamB[units][<?= $i ?>][defense]" placeholder="defense" required>
<input type="number" name="teamB[units][<?= $i ?>][speed]" placeholder="speed" required>
<input type="text" name="teamB[units][<?= $i ?>][x]" placeholder="x" value="<?= $e($row['x'] ?? '7') ?>" required>
<input type="text" name="teamB[units][<?= $i ?>][y]" placeholder="y" value="<?= $e($row['y'] ?? '7') ?>" required>
<input type="file" name="teamB[units][<?= $i ?>][image]" accept="image/*">
<input type="hidden" name="teamB[units][<?= $i ?>][imageUrl]" value="<?= $e($row['imageUrl'] ?? '') ?>">
</div>
<?php endfor; ?>
</fieldset>
<fieldset>
<legend>Victory</legend>
<label><input type="radio" name="victoryCondition" value="eliminate_all"<?= (($old['victoryCondition'] ?? 'eliminate_all') === 'eliminate_all') ? ' checked' : '' ?>> Eliminate all</label>
<label><input type="radio" name="victoryCondition" value="hold_objective"<?= (($old['victoryCondition'] ?? '') === 'hold_objective') ? ' checked' : '' ?>> Hold objective</label>
<label>Hold rounds: <input type="number" name="holdRoundsRequired" min="1" max="10" value="<?= $e($old['holdRoundsRequired'] ?? '1') ?>"></label>
</fieldset>
<button type="submit">Save</button>
<button type="button" id="bf-start-match">Start match</button>
</form>
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
<?php
}, $csrf, 'Team editor');
+226
View File
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use PHPUnit\Framework\TestCase;
final class FullFlowTest extends TestCase
{
private const SECRET = 'unit-test-secret';
protected function setUp(): void
{
// Reset superglobals between requests.
$_GET = [];
$_POST = [];
$_FILES = [];
$_COOKIE = [];
$_SERVER = [];
}
public function testTheFullCreateAndSaveFlow(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
// Use a deterministic secret so the upload token HMAC matches between upload and fetch.
putenv('BATTLEFORGE_SECRET=' . self::SECRET);
try {
// Step 1: GET home page.
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/';
$homeBody = $this->runFrontController();
self::assertStringContainsString('New scenario', $homeBody);
self::assertStringContainsString('name="csrf-token"', $homeBody);
// Step 2: POST team editor.
$_COOKIE['__csrf'] = $cookie;
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/team';
$_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
$_SERVER['__csrf_secret'] = self::SECRET;
$_POST = $this->validTeamPost($token);
$teamBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
self::assertStringContainsString('localStorage.setItem', $teamBody);
self::assertStringContainsString('scenario:"demo"', $teamBody);
// Step 3: POST battlefield editor.
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/battlefield';
$_SERVER['CONTENT_TYPE'] = 'application/json';
$_SERVER['HTTP_X_CSRF_TOKEN'] = $token;
$_POST = [];
$this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR);
$_SERVER['__raw_body'] = $this->rawBody;
$bfBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
$bfJson = json_decode($bfBody, true);
self::assertSame(true, $bfJson['ok'] ?? null);
// Step 4: POST start-match.
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/scenarios/demo/start';
$this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR);
$_SERVER['__raw_body'] = $this->rawBody;
$startBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
$startJson = json_decode($startBody, true);
self::assertSame('alpha', $startJson['match']['activeTeamId'] ?? null);
self::assertSame(1, $startJson['match']['round'] ?? null);
self::assertCount(6, $startJson['match']['units'] ?? []);
// Step 5: POST image upload, then GET the returned URL.
$this->uploadAndFetchAsset();
} finally {
// Always purge the per-install uploads dir after the test.
$uploadsRoot = __DIR__ . '/../../var/uploads';
if (is_dir($uploadsRoot)) {
foreach (glob($uploadsRoot . '/*/*') as $file) {
@unlink($file);
}
foreach (glob($uploadsRoot . '/*') as $dir) {
@rmdir($dir);
}
}
}
}
private function uploadAndFetchAsset(): void
{
$expectedToken = hash_hmac('sha256', self::SECRET, 'bf-uploads');
$expectedDir = realpath(__DIR__ . '/../../var/uploads') ?: (__DIR__ . '/../../var/uploads');
// Create the multipart body manually so we can pre-compute the file we expect to find.
$png = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
true,
);
$tmp = tempnam(sys_get_temp_dir(), 'bf-upload-');
file_put_contents($tmp, $png);
try {
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$_COOKIE['__csrf'] = $cookie;
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/assets/upload';
$_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=----test';
$_SERVER['__csrf_secret'] = self::SECRET;
$_POST = ['_csrf' => $token];
$_FILES = [
'image' => [
'name' => 'test.png',
'type' => 'image/png',
'tmp_name' => $tmp,
'error' => 0,
'size' => strlen($png),
],
];
$this->rawBody = '';
$_SERVER['__raw_body'] = '';
$body = $this->runFrontController();
self::assertSame(200, $this->lastStatus, 'upload response: ' . $body);
$payload = json_decode($body, true);
$url = is_array($payload) ? (string) ($payload['url'] ?? '') : '';
self::assertNotSame('', $url);
self::assertStringStartsWith('/assets/uploads/' . $expectedToken . '/', $url);
// Now fetch the asset back through the front controller.
$_COOKIE = [];
$_POST = [];
$_FILES = [];
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = $url;
unset($_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN'], $_SERVER['__raw_body']);
$this->rawBody = '';
$fetchBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus, 'fetch response: ' . $fetchBody);
self::assertSame($png, $fetchBody);
// Clean up the uploaded file so the outer finally does not loop on it.
$filename = substr($url, strlen('/assets/uploads/' . $expectedToken . '/'));
@unlink($expectedDir . '/' . $expectedToken . '/' . $filename);
} finally {
@unlink($tmp);
}
}
private string $rawBody = '';
private int $lastStatus = 0;
private function runFrontController(): string
{
$this->lastStatus = 0;
$body = $this->captureOutput(function (): void {
$this->lastStatus = (require __DIR__ . '/../../public/index.php') ?? http_response_code();
});
return $body;
}
/** @param callable(): void $fn */
private function captureOutput(callable $fn): string
{
ob_start();
try {
$fn();
} finally {
$output = (string) ob_get_clean();
}
return $output;
}
/** @return array<string, mixed> */
private function validTeamPost(string $token): array
{
return [
'_csrf' => $token,
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => '8',
'battlefieldHeight' => '8',
'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)],
'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => '1',
];
}
/** @return array<string, mixed> */
private function validBattlefieldPayload(): array
{
return [
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => 8,
'battlefieldHeight' => 8,
'battlefieldTerrain' => [],
'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)],
'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
}
/**
* @return list<array<string, string|int>>
*/
private function unitRows(string $teamPrefix, int $xStart, int $xEnd, int $y): array
{
$rows = [];
for ($i = 0; $i <= $xEnd - $xStart; $i++) {
$rows[] = [
'id' => "{$teamPrefix}{$i}",
'archetype' => 'defender',
'maxHealth' => 12,
'attack' => 3,
'defense' => 4,
'speed' => 2,
'x' => $xStart + $i,
'y' => $y,
];
}
return $rows;
}
}
+57
View File
@@ -67,4 +67,61 @@ final class GetAssetsTest extends TestCase
self::assertSame(404, $response->status); self::assertSame(404, $response->status);
} }
public function testItServesAnUploadedAssetWhenTheServerTokenMatches(): void
{
$userToken = str_repeat('a', 64);
$namespace = $this->uploadsDir . '/' . $userToken;
mkdir($namespace, 0700, true);
$payload = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
true,
);
file_put_contents($namespace . '/demo.png', $payload);
$handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
$request = new Request(
[],
[],
[],
[],
['__uploads_token' => $userToken],
'',
'GET',
'/assets/uploads/' . $userToken . '/demo.png',
null,
'',
);
$response = $handler->handle(
$request,
['kind' => 'uploads', 'userToken' => $userToken, 'filename' => 'demo.png'],
);
self::assertSame(200, $response->status);
self::assertSame('image/png', $response->headers['Content-Type'] ?? '');
self::assertSame($payload, $response->body);
}
public function testItRefusesAnUploadedAssetWhenTheServerTokenDiffers(): void
{
$handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
$request = new Request(
[],
[],
[],
[],
['__uploads_token' => str_repeat('a', 64)],
'',
'GET',
'/assets/uploads/abc/def.png',
null,
'',
);
$response = $handler->handle(
$request,
['kind' => 'uploads', 'userToken' => str_repeat('b', 64), 'filename' => 'def.png'],
);
self::assertSame(404, $response->status);
}
} }
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetBattlefieldEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetBattlefieldEditorTest extends TestCase
{
public function testItReturnsTheBattlefieldEditorPageWithAnEmptyGrid(): void
{
$handler = new GetBattlefieldEditor();
$request = new Request([], [], [], [], [], '', 'GET', '/scenarios/demo/edit/battlefield', 'text/html', '');
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
self::assertStringContainsString('class="bf-grid"', $response->body);
self::assertStringContainsString('name="_csrf"', $response->body);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetTeamEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetTeamEditorTest extends TestCase
{
public function testItReturnsTheTeamEditorPageWithSecurityHeaders(): void
{
$handler = new GetTeamEditor();
$request = new Request([], [], [], [], [], '', 'GET', '/scenarios/demo/edit/team', 'text/html', '');
$response = $handler->handle($request, ['id' => 'demo']);
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('name="_csrf"', $response->body);
self::assertStringContainsString('name="id"', $response->body);
self::assertStringContainsString('name="victoryCondition"', $response->body);
self::assertStringContainsString('value="defender"', $response->body);
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostBattlefieldEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostBattlefieldEditorTest extends TestCase
{
private const SECRET = 'unit-test-secret';
public function testItRejectsARequestWithAMissingCsrfHeader(): void
{
$handler = new PostBattlefieldEditor();
$request = $this->buildRequest(rawBody: '{}', csrfHeader: '');
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
self::assertStringContainsString('csrf', $response->body);
}
public function testItAcceptsAValidBattlefieldJsonAndReturnsOk(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostBattlefieldEditor();
$request = $this->buildRequest(
rawBody: json_encode($this->validBattlefield(), JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertSame(true, $body['ok'] ?? null);
self::assertSame('demo', $body['scenario']['id'] ?? null);
}
public function testItReturns400OnAnInvalidShape(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostBattlefieldEditor();
$request = $this->buildRequest(
rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(400, $response->status);
$body = json_decode($response->body, true);
self::assertSame(false, $body['ok'] ?? null);
self::assertNotEmpty($body['errors'] ?? []);
}
/**
* @param array<string, string> $cookies
* @param array<string, string> $server
*/
private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = [], array $server = []): Request
{
$server['HTTP_X_CSRF_TOKEN'] = $csrfHeader;
$server['CONTENT_TYPE'] = 'application/json';
return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/battlefield', 'application/json', $rawBody);
}
/** @return array<string, mixed> */
private function validBattlefield(): array
{
return [
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => 8,
'battlefieldHeight' => 8,
'battlefieldTerrain' => [],
'teamA' => [
'units' => [
['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0],
['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0],
['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0],
],
],
'teamB' => [
'units' => [
['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7],
['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7],
['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7],
],
],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostImageUpload;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostImageUploadTest extends TestCase
{
private const SECRET = 'unit-test-secret';
private string $uploadsRoot;
protected function setUp(): void
{
$this->uploadsRoot = sys_get_temp_dir() . '/bf-uploads-' . bin2hex(random_bytes(4));
mkdir($this->uploadsRoot, 0700, true);
}
protected function tearDown(): void
{
if (is_dir($this->uploadsRoot)) {
foreach (glob($this->uploadsRoot . '/*/*') as $file) {
unlink($file);
}
foreach (glob($this->uploadsRoot . '/*') as $dir) {
rmdir($dir);
}
rmdir($this->uploadsRoot);
}
}
public function testItRejectsARequestWithAMissingCsrfField(): void
{
$handler = new PostImageUpload($this->uploadsRoot);
$request = new Request([], [], [], [], [], '', 'POST', '/assets/upload', 'multipart/form-data', '');
$response = $handler->handle($request, []);
self::assertSame(403, $response->status);
}
public function testItAcceptsAValidImageAndReturnsAUrl(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostImageUpload($this->uploadsRoot);
$tmp = tempnam(sys_get_temp_dir(), 'bf-up');
file_put_contents($tmp, $this->validPng(8, 8));
$request = new Request(
get: [],
post: ['_csrf' => $token],
files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 70, 'type' => 'image/png']],
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
queryString: '',
method: 'POST',
path: '/assets/upload',
contentType: 'multipart/form-data',
rawBody: '',
);
$response = $handler->handle($request, []);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertStringStartsWith('/assets/', $body['url'] ?? '');
self::assertStringEndsWith('.png', $body['url'] ?? '');
}
public function testItReturns400OnAnInvalidImage(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostImageUpload($this->uploadsRoot);
$tmp = tempnam(sys_get_temp_dir(), 'bf-up');
file_put_contents($tmp, 'not an image');
$request = new Request(
get: [],
post: ['_csrf' => $token],
files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 12, 'type' => 'image/png']],
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
queryString: '',
method: 'POST',
path: '/assets/upload',
contentType: 'multipart/form-data',
rawBody: '',
);
$response = $handler->handle($request, []);
self::assertSame(400, $response->status);
}
private 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.');
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostStartMatch;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostStartMatchTest extends TestCase
{
private const SECRET = 'unit-test-secret';
public function testItRejectsARequestWithAMissingCsrfHeader(): void
{
$handler = new PostStartMatch();
$request = $this->buildRequest('{}', '');
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
}
public function testItAcceptsAValidScenarioAndReturnsTheInitialMatch(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostStartMatch();
$request = $this->buildRequest(
rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertSame('alpha', $body['match']['activeTeamId'] ?? null);
self::assertSame(1, $body['match']['round'] ?? null);
self::assertCount(6, $body['match']['units'] ?? []);
}
public function testItReturns400OnAnInvalidScenario(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostStartMatch();
$request = $this->buildRequest(
rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(400, $response->status);
}
/** @param array<string, string> $cookies */
private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = []): Request
{
$server = [
'HTTP_X_CSRF_TOKEN' => $csrfHeader,
'__csrf_secret' => self::SECRET,
'CONTENT_TYPE' => 'application/json',
];
return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/start', 'application/json', $rawBody);
}
/** @return array<string, mixed> */
private function validScenario(): array
{
return [
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => 8,
'battlefieldHeight' => 8,
'battlefieldTerrain' => [],
'teamA' => [
'units' => [
['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0],
['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0],
['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0],
],
],
'teamB' => [
'units' => [
['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7],
['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7],
['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7],
],
],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Domain\Archetype;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostTeamEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostTeamEditorTest extends TestCase
{
private const SECRET = 'unit-test-secret';
public function testItRejectsARequestWithAMissingCsrfToken(): void
{
$handler = new PostTeamEditor();
$request = $this->buildRequest(post: ['id' => 'demo']);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
self::assertStringContainsString('Forbidden', $response->body);
}
public function testItRejectsARequestWithAnInvalidCsrfToken(): void
{
$handler = new PostTeamEditor();
$request = $this->buildRequest(post: ['id' => 'demo', '_csrf' => 'tampered']);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
}
public function testItSavesAValidScenarioAndReturnsTheLocalStorageSnippet(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostTeamEditor();
$request = $this->buildRequest(
post: $this->validPost($token),
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
self::assertStringContainsString('localStorage.setItem', $response->body);
self::assertStringContainsString('scenario:"demo"', $response->body);
}
public function testItRejectsAnOutOfBoundsStatAndReRendersTheForm(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostTeamEditor();
$post = $this->validPost($token);
$post['teamA']['units'][0]['maxHealth'] = '9999';
$request = $this->buildRequest(
post: $post,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
self::assertStringContainsString('bf-errors', $response->body);
self::assertStringContainsString('outside archetype bounds', $response->body);
}
public function testItEscapesAMaliciousIdSoItCannotBreakOutOfTheScriptBlock(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostTeamEditor();
$post = $this->validPost($token);
$post['id'] = '</script><script>alert(1)</script>';
$request = $this->buildRequest(
post: $post,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => $post['id']]);
self::assertSame(200, $response->status);
// The literal payload that would execute must NOT appear verbatim in the body.
self::assertStringNotContainsString('<script>alert(1)</script>', $response->body);
// The JSON-encoded id is what we want to see, with the </ tag escaped to <\/.
self::assertStringContainsString('<\\/script><script>alert(1)<\\/script>', $response->body);
}
/**
* @param array<string, mixed> $post
* @param array<string, string> $cookies
* @param array<string, string> $server
*/
private function buildRequest(array $post, array $cookies = [], array $server = []): Request
{
return new Request([], $post, [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/team', 'application/x-www-form-urlencoded', '');
}
/** @return array<string, mixed> */
private function validPost(string $token): array
{
return [
'_csrf' => $token,
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => '8',
'battlefieldHeight' => '8',
'teamA' => [
'units' => [
['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '0', 'y' => '0'],
['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '1', 'y' => '0'],
['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '2', 'y' => '0'],
],
],
'teamB' => [
'units' => [
['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '7', 'y' => '7'],
['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '6', 'y' => '7'],
['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '5', 'y' => '7'],
],
],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => '1',
];
}
}
@@ -38,7 +38,7 @@ final class ImageUploadServiceTest extends TestCase
$url = $service->store($tmp, 'image/png'); $url = $service->store($tmp, 'image/png');
self::assertStringStartsWith('/assets/user-token-1/', $url); self::assertStringStartsWith('/assets/uploads/user-token-1/', $url);
self::assertStringEndsWith('.png', $url); self::assertStringEndsWith('.png', $url);
$stored = $this->tmpDir . '/user-token-1/' . basename($url); $stored = $this->tmpDir . '/user-token-1/' . basename($url);