Files
BattleForge/docs/superpowers/plans/2026-07-07-frontend-js.md

913 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.