docs: add Plan 3c implementation plan (JavaScript frontend)

This commit is contained in:
Keith Solomon
2026-07-12 19:24:12 -05:00
parent 7acda302e8
commit 78565cfae9
@@ -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.