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

34 KiB
Raw Blame History

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

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:

{
    "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 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:

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:

    <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):
    <script type="module" src="<?= $a('/js/storage.js') ?>"></script>
  1. 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 (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:

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:
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded" data-scenario-id="<?= $a($scenarioId) ?>">
  1. Add the script import at the end of the template body (before the render_layout closure):
    <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: 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:

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.