34 KiB
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}andmatch: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_csrffield; fetch uses anX-CSRF-Tokenheader. - All rendered output is escaped for its output context via the
Escape::html,Escape::attr, andEscape::urlhelpers. Templates do not interpolate user input without one of these helpers. The JS usestextContentandvaluesetters, neverinnerHTML(with one exception: building the home page's recent-scenarios list usesappendChildon<a>elements withtextContent, neverinnerHTML). - Bundled placeholder images are committed under
public/assets/placeholders/; user uploads live invar/uploads/(git-ignored). - The single small JS file (
public/js/grid-editor.jsandpublic/js/storage.js) passes ESLintairbnb/baseand 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, noFunctionconstructor. - The data shape for scenarios in
localStorageis the same canonical shape thatBattleForge\Application\ScenarioSerializer::scenarioToArrayproduces. var/secret.keyis added to.gitignore(the Plan 3bFullFlowTestcreates it on first run).
File Structure
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).
- Toolchain + assets (Task 1)
storage.jsand template<script>tags (Task 2)grid-editor.jsand CI update (Task 3)- 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.jsonmatchesBattleForge\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.gitignoreexcludesvar/secret.keyandnode_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:
{
"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:
{
"env": {
"browser": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"extends": "airbnb-base"
}
- Step 4: Create
.prettierrc
Create .prettierrc:
{
"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:
{
"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.
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 attacheswindow.bfStoragewith eight methods:load(id),save(id, scenario),delete(id),listAll(),currentMatch(),setCurrentMatch(match),removeCurrentMatch(). The module's top-level code runspopulateRecentScenarios()onDOMContentLoaded(for the home page) andprefillTeamEditor()onDOMContentLoaded(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}/startand stores the response inlocalStorage. -
Step 1: Implement
storage.js
Create public/js/storage.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.bfStoragewith 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}/startwith the loaded scenario as JSON andX-CSRF-Tokenfrom the layout's<meta name="csrf-token">. On success, stores the match inlocalStorageand navigates to/. -
Uses
textContentandvaluesetters throughout. NeverinnerHTML(the recent-scenarios list usesappendChildon<a>elements withtextContent). -
Handles corrupt JSON gracefully:
load()returnsnull,listAll()skips keys that fail to parse. -
Step 2: Update
src/Views/home.phpto 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:
<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.phpto import the JS and add the Start match button
Two changes to src/Views/team-editor.php:
- Add the script import at the end of the template body (before the
render_layoutclosure):
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
- Add a "Start match" button at the bottom of the form, just after the existing "Save" submit button:
<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).
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(addnpm ci,npm run lint,npm run formatsteps)
Interfaces:
-
Produces
public/js/grid-editor.js, an ESM module that runs onDOMContentLoadedfor the battlefield editor. The module:- Reads the
data-scenario-idattribute from the<form id="battlefield-form">element. - Loads the scenario from
bfStorage.load(id)and populates the in-memorytileMap,deploymentZones, andobjectivestate. - Paints the grid: each tile button's
data-paintattribute is updated, the objective tile getsdata-objective="1", and the zone tiles getdata-zone="alpha"ordata-zone="bravo". - Listens for clicks on
.bf-tilebuttons: in the default terrain-paint mode, updatestileMap; 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
Savebutton (event.preventDefault()), builds the canonical scenario JSON, POSTs to/scenarios/{id}/edit/battlefieldwithContent-Type: application/jsonandX-CSRF-Token. On success, writes the returnedscenariotolocalStorageand shows a "Saved" toast. On 400 with errors, shows the errors inline in the form.
- Reads the
-
Step 1: Implement
grid-editor.js
Create public/js/grid-editor.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-idfrom 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
PostBattlefieldEditorendpoint, parses the response, and writes the result back tolocalStorage. -
Handles all four response cases: 200 with
scenario, 400 witherrors, network failure, and other HTTP failures. -
Step 2: Update
src/Views/battlefield-editor.php
Three changes:
- Add
data-scenario-id="<?= $a($scenarioId) ?>"to the form element:
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded" data-scenario-id="<?= $a($scenarioId) ?>">
- Add the script import at the end of the template body (before the
render_layoutclosure):
<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:
- run: npm ci --no-audit --no-fund
- run: npm run lint
- run: npm run format
The full workflow should now be:
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.
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:
bfStoragereads and writeslocalStorageonly, neverevalorFunctionconstructor; the home page's recent-scenarios list usestextContent, neverinnerHTML.
After the review, address Critical and Important findings. Minor findings can be deferred to Plan 4.
Completion Check
Run:
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.