docs: add Plan 3c implementation plan (JavaScript frontend)
This commit is contained in:
@@ -0,0 +1,912 @@
|
||||
# Plan 3c: JavaScript Frontend Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add the small JavaScript surface that lets the home page list recent scenarios from `localStorage`, the team editor pre-fill from `localStorage`, and the battlefield editor run click-paint grid interactions with JSON fetch save.
|
||||
|
||||
**Architecture:** Two pure-ESM JavaScript files (`public/js/storage.js` and `public/js/grid-editor.js`) attach `window.bfStorage` and run `DOMContentLoaded` on their respective pages. The team editor's form continues to POST to the existing Plan 3b `PostTeamEditor` handler; the battlefield editor's form submit is intercepted by the JS to send a JSON fetch. The Node toolchain (ESLint `airbnb-base` + Prettier) is added with a new `npm run lint` and `npm run format` step in CI.
|
||||
|
||||
**Tech Stack:** PHP 8.3 (unchanged), vanilla ESM JavaScript (no bundler, no transpiler), Node 20+ with ESLint `^8.57.0`, `eslint-config-airbnb-base ^15.0.0`, `eslint-plugin-import ^2.29.0`, Prettier `^3.3.0`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
These apply to every task and are copied verbatim from the design spec.
|
||||
|
||||
- Anonymous browser persistence: scenarios and matches live in the browser's `localStorage` (`scenario:{id}` and `match:current`); the server never stores game state.
|
||||
- Every state-changing form or request uses and verifies a CSRF token before mutation. The token is per-session, signed with the app's secret using `hash_hmac('sha256', …)`. Forms use a hidden `_csrf` field; fetch uses an `X-CSRF-Token` header.
|
||||
- All rendered output is escaped for its output context via the `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Templates do not interpolate user input without one of these helpers. The JS uses `textContent` and `value` setters, never `innerHTML` (with one exception: building the home page's recent-scenarios list uses `appendChild` on `<a>` elements with `textContent`, never `innerHTML`).
|
||||
- Bundled placeholder images are committed under `public/assets/placeholders/`; user uploads live in `var/uploads/` (git-ignored).
|
||||
- The single small JS file (`public/js/grid-editor.js` and `public/js/storage.js`) passes ESLint `airbnb/base` and Prettier.
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- Composer configuration passes `composer validate --strict`.
|
||||
- Lint, static analysis, unit and integration tests, the end-to-end smoke test, and Composer validation are required CI checks.
|
||||
- No third-party JS libraries. The two JS files are pure ESM, no dependencies, no `eval`, no `Function` constructor.
|
||||
- The data shape for scenarios in `localStorage` is the same canonical shape that `BattleForge\Application\ScenarioSerializer::scenarioToArray` produces.
|
||||
- `var/secret.key` is added to `.gitignore` (the Plan 3b `FullFlowTest` creates it on first run).
|
||||
|
||||
## File Structure
|
||||
|
||||
```text
|
||||
public/
|
||||
├── assets/
|
||||
│ ├── archetypes.json (NEW) — 4 ArchetypeTemplate definitions
|
||||
│ ├── placeholders/ (NEW)
|
||||
│ │ ├── defender.png
|
||||
│ │ ├── striker.png
|
||||
│ │ ├── support.png
|
||||
│ │ └── scout.png
|
||||
│ ├── styles.css (unchanged)
|
||||
│ └── js/ (NEW)
|
||||
│ ├── storage.js (NEW) — bfStorage module
|
||||
│ └── grid-editor.js (NEW) — battlefield grid interactions
|
||||
|
||||
src/Views/ (updated — add <script> tags only)
|
||||
├── home.php (add <script type="module" src="/js/storage.js">)
|
||||
├── team-editor.php (add <script type="module" src="/js/storage.js">)
|
||||
└── battlefield-editor.php (add <script type="module" src="/js/grid-editor.js"></script>)
|
||||
|
||||
package.json (NEW)
|
||||
.eslintrc.json (NEW)
|
||||
.prettierrc (NEW)
|
||||
.github/workflows/ci.yml (updated — add npm ci, lint, format steps)
|
||||
.gitignore (updated — add var/secret.key, node_modules/)
|
||||
```
|
||||
|
||||
## Delivery Sequence
|
||||
|
||||
Plan 3c is the third of four plans. It is the last plan in the original "Plan 3" deliverable. It depends on Plan 3a+3b's PHP web layer being merged (which it is on `develop`).
|
||||
|
||||
1. Toolchain + assets (Task 1)
|
||||
2. `storage.js` and template `<script>` tags (Task 2)
|
||||
3. `grid-editor.js` and CI update (Task 3)
|
||||
4. Final whole-branch code review (Task 4)
|
||||
|
||||
## Execution Preflight
|
||||
|
||||
Execute this plan in an isolated worktree on `feature/frontend-js`, branched from `develop`. The implementation branch will be submitted as a pull request into `develop` only after every completion check is green.
|
||||
|
||||
### Task 1: Node toolchain + `archetypes.json` + placeholder images
|
||||
|
||||
**Files:**
|
||||
- Create: `package.json`
|
||||
- Create: `.eslintrc.json`
|
||||
- Create: `.prettierrc`
|
||||
- Create: `public/assets/archetypes.json`
|
||||
- Create: `public/assets/placeholders/defender.png`
|
||||
- Create: `public/assets/placeholders/striker.png`
|
||||
- Create: `public/assets/placeholders/support.png`
|
||||
- Create: `public/assets/placeholders/scout.png`
|
||||
- Modify: `.gitignore`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a Node toolchain that lints and formats the JS files (Tasks 2 and 3 land in this toolchain's lint gate). The `archetypes.json` matches `BattleForge\Application\ArchetypeCatalog::templates()` exactly (defender, striker, support, scout). The four placeholder PNGs are valid 32×32 transparent PNGs with simple monochrome silhouettes (a circle for defender, a triangle for striker, a square for support, a diamond for scout — the exact pixel art is up to the implementer; the test only checks that the file is a valid PNG and has the right dimensions). The `.gitignore` excludes `var/secret.key` and `node_modules/`.
|
||||
|
||||
- [ ] **Step 1: Update `.gitignore`**
|
||||
|
||||
Append `var/secret.key` and `node_modules/` to the existing `.gitignore`. Replace the file with:
|
||||
|
||||
```
|
||||
.worktrees/
|
||||
/vendor/
|
||||
/var/cache/
|
||||
/var/phpunit/
|
||||
/var/phpstan/
|
||||
/var/logs/
|
||||
/var/uploads/*
|
||||
!/var/uploads/.htaccess
|
||||
!/var/uploads/index.php
|
||||
/var/secret.key
|
||||
/node_modules/
|
||||
/public/js/*.map
|
||||
.phpunit.result.cache
|
||||
.phpunit.cache
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `package.json`**
|
||||
|
||||
Create `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "battleforge-battleforge",
|
||||
"description": "BattleForge frontend toolchain.",
|
||||
"private": true,
|
||||
"license": "proprietary",
|
||||
"scripts": {
|
||||
"lint": "eslint public/js",
|
||||
"format": "prettier --check public/js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"prettier": "^3.3.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create `.eslintrc.json`**
|
||||
|
||||
Create `.eslintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"extends": "airbnb-base"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `.prettierrc`**
|
||||
|
||||
Create `.prettierrc`:
|
||||
|
||||
```json
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create `public/assets/archetypes.json`**
|
||||
|
||||
The `ArchetypeTemplate` values must match `BattleForge\Domain\ArchetypeCatalog::templates()` exactly. The Plan 3a catalog:
|
||||
|
||||
- defender: minHealth 10, maxHealth 14, minAttack 2, maxAttack 4, minDefense 3, maxDefense 5, minSpeed 1, maxSpeed 3, allowedAbilities ["buff"]
|
||||
- striker: minHealth 6, maxHealth 10, minAttack 4, maxAttack 6, minDefense 1, maxDefense 2, minSpeed 2, maxSpeed 4, allowedAbilities ["area_damage"]
|
||||
- support: minHealth 5, maxHealth 9, minAttack 1, maxAttack 3, minDefense 1, maxDefense 3, minSpeed 2, maxSpeed 4, allowedAbilities ["heal", "buff"]
|
||||
- scout: minHealth 4, maxHealth 7, minAttack 2, maxAttack 4, minDefense 0, maxDefense 2, minSpeed 4, maxSpeed 6, allowedAbilities []
|
||||
|
||||
Create `public/assets/archetypes.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"defender": { "minHealth": 10, "maxHealth": 14, "minAttack": 2, "maxAttack": 4, "minDefense": 3, "maxDefense": 5, "minSpeed": 1, "maxSpeed": 3, "allowedAbilities": ["buff"] },
|
||||
"striker": { "minHealth": 6, "maxHealth": 10, "minAttack": 4, "maxAttack": 6, "minDefense": 1, "maxDefense": 2, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["area_damage"] },
|
||||
"support": { "minHealth": 5, "maxHealth": 9, "minAttack": 1, "maxAttack": 3, "minDefense": 1, "maxDefense": 3, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["heal", "buff"] },
|
||||
"scout": { "minHealth": 4, "maxHealth": 7, "minAttack": 2, "maxAttack": 4, "minDefense": 0, "maxDefense": 2, "minSpeed": 4, "maxSpeed": 6, "allowedAbilities": [] }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Create the four placeholder PNGs**
|
||||
|
||||
Each placeholder is a 32×32 transparent PNG with a simple silhouette. Generate them programmatically (the implementer can run a one-off PHP or Node script, or use ImageMagick if installed). The exact pixel art is up to the implementer; the test (added in Step 7) only checks that:
|
||||
|
||||
- The file is a valid PNG (starts with the PNG magic bytes `\x89PNG\r\n\x1a\n`).
|
||||
- The file decodes to a 32×32 image via `getimagesize()`.
|
||||
|
||||
Suggested silhouettes (a 32×32 grid of black-on-transparent pixels; the implementer can use any simple shape that distinguishes the four archetypes):
|
||||
|
||||
- defender (circle): pixels at (16, 8), (12, 10), (20, 10), (8, 14), (24, 14), (8, 18), (24, 18), (12, 22), (20, 22), (16, 24).
|
||||
- striker (triangle): pixels at (16, 6), (10, 14), (22, 14), (12, 16), (20, 16), (14, 18), (18, 18), (16, 22).
|
||||
- support (square): pixels at (8, 8), (24, 8), (8, 24), (24, 24), (10, 10), (22, 10), (10, 22), (22, 22).
|
||||
- scout (diamond): pixels at (16, 6), (12, 10), (20, 10), (8, 16), (24, 16), (12, 22), (20, 22), (16, 26).
|
||||
|
||||
The implementer can create these by hand (e.g. via a small PHP script that calls `imagecreatetruecolor` + `imagesetpixel` + `imagepng`, or via a Node script using `sharp` — but per the global constraints, no third-party JS libraries; the implementer can use a hand-rolled PNG generator or just copy a 32×32 transparent PNG from an existing source and write the silhouette bytes by hand).
|
||||
|
||||
- [ ] **Step 7: Verify the assets are valid**
|
||||
|
||||
Run: `php -r 'foreach (["defender", "striker", "support", "scout"] as $a) { $p = __DIR__ . "/public/assets/placeholders/$a.png"; $i = getimagesize($p); echo "$a: {$i[0]}x{$i[1]} type=$i[2]\n"; }'`
|
||||
Expected: four lines, each `32x32 type=3` (3 = PNG). If any line shows different dimensions, regenerate that file.
|
||||
|
||||
Run: `php -r 'var_dump(json_decode(file_get_contents(__DIR__ . "/public/assets/archetypes.json"), true));' | head -5`
|
||||
Expected: the four archetypes as objects.
|
||||
|
||||
- [ ] **Step 8: Install Node dependencies**
|
||||
|
||||
Run: `npm install`
|
||||
Expected: `node_modules/` is created; `package-lock.json` is generated. `npm run lint` will fail at this point because `public/js/*.js` doesn't exist yet; that's expected and will be fixed in Tasks 2 and 3.
|
||||
|
||||
- [ ] **Step 9: Run the full quality suite and commit**
|
||||
|
||||
Run: `composer check`
|
||||
Expected: 196/196 tests pass, PHPStan clean, PHPCS clean on the changed files. The new `package.json`, `.eslintrc.json`, `.prettierrc`, and the four PNGs are not PHP, so PHPCS doesn't check them. PHPStan excludes `src/Views/*` and the JS files.
|
||||
|
||||
```powershell
|
||||
git add .gitignore package.json package-lock.json .eslintrc.json .prettierrc public/assets/archetypes.json public/assets/placeholders/
|
||||
git commit -m "chore: add Node toolchain, archetypes.json, and placeholder images"
|
||||
```
|
||||
|
||||
### Task 2: `storage.js` and template `<script>` tags
|
||||
|
||||
**Files:**
|
||||
- Create: `public/js/storage.js`
|
||||
- Modify: `src/Views/home.php` (add `<script type="module" src="/js/storage.js"></script>`)
|
||||
- Modify: `src/Views/team-editor.php` (add `<script type="module" src="/js/storage.js"></script>` and a "Start match" button)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces `public/js/storage.js`, an ESM module that attaches `window.bfStorage` with eight methods: `load(id)`, `save(id, scenario)`, `delete(id)`, `listAll()`, `currentMatch()`, `setCurrentMatch(match)`, `removeCurrentMatch()`. The module's top-level code runs `populateRecentScenarios()` on `DOMContentLoaded` (for the home page) and `prefillTeamEditor()` on `DOMContentLoaded` (for the team editor), based on which root element is present in the DOM. The "Start match" button on the team editor POSTs to `/scenarios/{id}/start` and stores the response in `localStorage`.
|
||||
|
||||
- [ ] **Step 1: Implement `storage.js`**
|
||||
|
||||
Create `public/js/storage.js`:
|
||||
|
||||
```js
|
||||
const STORAGE_PREFIX = 'scenario:';
|
||||
const MATCH_KEY = 'match:current';
|
||||
|
||||
function load(id) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + id);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function save(id, scenario) {
|
||||
const stamped = Object.assign({}, scenario, { lastModified: Date.now() });
|
||||
localStorage.setItem(STORAGE_PREFIX + id, JSON.stringify(stamped));
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
localStorage.removeItem(STORAGE_PREFIX + id);
|
||||
}
|
||||
|
||||
function listAll() {
|
||||
const items = [];
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (key === null || !key.startsWith(STORAGE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
const id = key.slice(STORAGE_PREFIX.length);
|
||||
const scenario = load(id);
|
||||
if (scenario === null) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
id,
|
||||
name: typeof scenario.name === 'string' ? scenario.name : id,
|
||||
lastModified: typeof scenario.lastModified === 'number' ? scenario.lastModified : 0,
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => b.lastModified - a.lastModified);
|
||||
return items;
|
||||
}
|
||||
|
||||
function currentMatch() {
|
||||
try {
|
||||
const raw = localStorage.getItem(MATCH_KEY);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentMatch(match) {
|
||||
localStorage.setItem(MATCH_KEY, JSON.stringify(match));
|
||||
}
|
||||
|
||||
function removeCurrentMatch() {
|
||||
localStorage.removeItem(MATCH_KEY);
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
function populateRecentScenarios() {
|
||||
const root = document.getElementById('recent');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
while (root.firstChild) {
|
||||
root.removeChild(root.firstChild);
|
||||
}
|
||||
const items = listAll();
|
||||
if (items.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'bf-empty';
|
||||
p.appendChild(document.createTextNode('No saved scenarios yet.'));
|
||||
root.appendChild(p);
|
||||
return;
|
||||
}
|
||||
const ul = document.createElement('ul');
|
||||
for (const item of items) {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute('href', '/scenarios/' + encodeURIComponent(item.id) + '/edit/team');
|
||||
a.appendChild(document.createTextNode(item.name + ' (' + item.id + ')'));
|
||||
li.appendChild(a);
|
||||
ul.appendChild(li);
|
||||
}
|
||||
root.appendChild(ul);
|
||||
}
|
||||
|
||||
async function fetchArchetypes() {
|
||||
const res = await fetch('/assets/archetypes.json');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function prefillTeamEditor() {
|
||||
const form = document.querySelector('form[action*="/edit/team"]');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const id = decodeURIComponent(window.location.pathname.split('/').slice(-2, -1)[0] || 'new');
|
||||
if (id === 'new') {
|
||||
return;
|
||||
}
|
||||
const scenario = load(id);
|
||||
if (scenario === null) {
|
||||
return;
|
||||
}
|
||||
const fields = form.querySelectorAll('input[name], select[name]');
|
||||
fields.forEach((field) => {
|
||||
const name = field.getAttribute('name');
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const value = resolveField(scenario, name);
|
||||
if (value !== undefined) {
|
||||
field.value = String(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resolveField(scenario, path) {
|
||||
const parts = parsePath(path);
|
||||
if (parts === null) {
|
||||
return undefined;
|
||||
}
|
||||
let cursor = scenario;
|
||||
for (const part of parts) {
|
||||
if (cursor === null || cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
cursor = cursor[part];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function parsePath(name) {
|
||||
const match = name.match(/^(teamA|teamB)\[units\]\[(\d+)\]\[(\w+)\]$/);
|
||||
if (match) {
|
||||
const [, team, indexStr, key] = match;
|
||||
const teamKey = team === 'teamA' ? 'teamA' : 'teamB';
|
||||
return [teamKey, 'units', Number(indexStr), key];
|
||||
}
|
||||
const simple = [
|
||||
'id',
|
||||
'name',
|
||||
'battlefieldWidth',
|
||||
'battlefieldHeight',
|
||||
'victoryCondition',
|
||||
'holdRoundsRequired',
|
||||
];
|
||||
if (simple.includes(name)) {
|
||||
return [name];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function startMatch() {
|
||||
const id = decodeURIComponent(window.location.pathname.split('/').slice(-2, -1)[0] || 'new');
|
||||
if (id === 'new') {
|
||||
return;
|
||||
}
|
||||
const scenario = load(id);
|
||||
if (scenario === null) {
|
||||
return;
|
||||
}
|
||||
const csrf = getCsrfToken();
|
||||
const res = await fetch('/scenarios/' + encodeURIComponent(id) + '/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify(scenario),
|
||||
});
|
||||
if (!res.ok) {
|
||||
alert('Start match failed: HTTP ' + res.status);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.match) {
|
||||
setCurrentMatch(data.match);
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
alert('Start match failed: ' + (data.errors ? data.errors.join(', ') : 'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
window.bfStorage = {
|
||||
load,
|
||||
save,
|
||||
delete: remove,
|
||||
listAll,
|
||||
currentMatch,
|
||||
setCurrentMatch,
|
||||
removeCurrentMatch,
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
populateRecentScenarios();
|
||||
prefillTeamEditor();
|
||||
const startBtn = document.getElementById('bf-start-match');
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
startMatch();
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The module:
|
||||
- Attaches `window.bfStorage` with the 8 methods.
|
||||
- On `DOMContentLoaded`, populates the home page's `<div id="recent">`, pre-fills the team editor's form fields, and wires the "Start match" button.
|
||||
- The "Start match" button calls `POST /scenarios/{id}/start` with the loaded scenario as JSON and `X-CSRF-Token` from the layout's `<meta name="csrf-token">`. On success, stores the match in `localStorage` and navigates to `/`.
|
||||
- Uses `textContent` and `value` setters throughout. Never `innerHTML` (the recent-scenarios list uses `appendChild` on `<a>` elements with `textContent`).
|
||||
- Handles corrupt JSON gracefully: `load()` returns `null`, `listAll()` skips keys that fail to parse.
|
||||
|
||||
- [ ] **Step 2: Update `src/Views/home.php` to import the JS**
|
||||
|
||||
Edit `src/Views/home.php`. After the existing `<div id="recent">` block, add the script import. The exact location depends on the existing template; locate the closing `</body>` tag and add the script before it (or add it after `<div id="recent">` if the template is structured that way).
|
||||
|
||||
Add this line at the end of the template body, before the closure that calls `render_layout`:
|
||||
|
||||
```php
|
||||
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
|
||||
```
|
||||
|
||||
The exact context-dependent insertion point will be clear from the file. Use `Escape::attr` to escape the URL attribute.
|
||||
|
||||
- [ ] **Step 3: Update `src/Views/team-editor.php` to import the JS and add the Start match button**
|
||||
|
||||
Two changes to `src/Views/team-editor.php`:
|
||||
|
||||
1. Add the script import at the end of the template body (before the `render_layout` closure):
|
||||
|
||||
```php
|
||||
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
|
||||
```
|
||||
|
||||
2. Add a "Start match" button at the bottom of the form, just after the existing "Save" submit button:
|
||||
|
||||
```php
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" id="bf-start-match">Start match</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
The "Start match" button is a `<button type="button">` (not `type="submit"`) so the form's submit handler is not invoked. The JS wires the click event in `storage.js`.
|
||||
|
||||
- [ ] **Step 4: Run the lint and format checks**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 0 errors. ESLint may flag a few unused parameters or stylistic issues; if so, fix them.
|
||||
|
||||
Run: `npm run format`
|
||||
Expected: 0 errors. Prettier may flag formatting drift; if so, run `npx prettier --write public/js/storage.js` and commit the result.
|
||||
|
||||
- [ ] **Step 5: Run the full quality suite and commit**
|
||||
|
||||
Run: `composer check`
|
||||
Expected: 196/196 tests pass, PHPStan clean, PHPCS clean on the changed files. The new JS file is checked by ESLint and Prettier (not by the PHP suite).
|
||||
|
||||
```powershell
|
||||
git add public/js/storage.js src/Views/home.php src/Views/team-editor.php
|
||||
git commit -m "feat: add localStorage helpers and wire team editor pre-fill"
|
||||
```
|
||||
|
||||
### Task 3: `grid-editor.js` and CI update
|
||||
|
||||
**Files:**
|
||||
- Create: `public/js/grid-editor.js`
|
||||
- Modify: `src/Views/battlefield-editor.php` (add `<script type="module" src="/js/grid-editor.js"></script>`)
|
||||
- Modify: `.github/workflows/ci.yml` (add `npm ci`, `npm run lint`, `npm run format` steps)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces `public/js/grid-editor.js`, an ESM module that runs on `DOMContentLoaded` for the battlefield editor. The module:
|
||||
- Reads the `data-scenario-id` attribute from the `<form id="battlefield-form">` element.
|
||||
- Loads the scenario from `bfStorage.load(id)` and populates the in-memory `tileMap`, `deploymentZones`, and `objective` state.
|
||||
- Paints the grid: each tile button's `data-paint` attribute is updated, the objective tile gets `data-objective="1"`, and the zone tiles get `data-zone="alpha"` or `data-zone="bravo"`.
|
||||
- Listens for clicks on `.bf-tile` buttons: in the default terrain-paint mode, updates `tileMap`; in zone mode, toggles zone membership; in objective mode, places the objective. Shift-click in any mode removes the paint or zone membership.
|
||||
- Listens for clicks on the `Save` button (`event.preventDefault()`), builds the canonical scenario JSON, POSTs to `/scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token`. On success, writes the returned `scenario` to `localStorage` and shows a "Saved" toast. On 400 with errors, shows the errors inline in the form.
|
||||
|
||||
- [ ] **Step 1: Implement `grid-editor.js`**
|
||||
|
||||
Create `public/js/grid-editor.js`:
|
||||
|
||||
```js
|
||||
const TERRAINS = ['open', 'forest', 'rough', 'water', 'blocking'];
|
||||
|
||||
function getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
function getCurrentMode() {
|
||||
const checked = document.querySelector('input[name="zoneMode"]:checked');
|
||||
return checked ? checked.value : 'terrain';
|
||||
}
|
||||
|
||||
function getSelectedPaint() {
|
||||
const active = document.querySelector('.bf-paint.active');
|
||||
if (active) {
|
||||
return active.getAttribute('data-paint');
|
||||
}
|
||||
return 'forest';
|
||||
}
|
||||
|
||||
function loadScenario(id) {
|
||||
if (window.bfStorage && typeof window.bfStorage.load === 'function') {
|
||||
return window.bfStorage.load(id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveScenario(id, scenario) {
|
||||
if (window.bfStorage && typeof window.bfStorage.save === 'function') {
|
||||
window.bfStorage.save(id, scenario);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTile(button, paint, zone, isObjective) {
|
||||
button.setAttribute('data-paint', paint);
|
||||
if (zone) {
|
||||
button.setAttribute('data-zone', zone);
|
||||
} else {
|
||||
button.removeAttribute('data-zone');
|
||||
}
|
||||
if (isObjective) {
|
||||
button.setAttribute('data-objective', '1');
|
||||
} else {
|
||||
button.removeAttribute('data-objective');
|
||||
}
|
||||
}
|
||||
|
||||
function loadGrid(tileMap, deploymentZones, objective) {
|
||||
document.querySelectorAll('.bf-tile').forEach((button) => {
|
||||
const x = button.getAttribute('data-x');
|
||||
const y = button.getAttribute('data-y');
|
||||
const key = x + ':' + y;
|
||||
if (tileMap[key]) {
|
||||
button.setAttribute('data-paint', tileMap[key]);
|
||||
}
|
||||
button.removeAttribute('data-zone');
|
||||
button.removeAttribute('data-objective');
|
||||
});
|
||||
for (const [team, positions] of Object.entries(deploymentZones)) {
|
||||
positions.forEach((pos) => {
|
||||
const selector = `.bf-tile[data-x="${pos.x}"][data-y="${pos.y}"]`;
|
||||
const button = document.querySelector(selector);
|
||||
if (button) {
|
||||
button.setAttribute('data-zone', team);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (objective) {
|
||||
const selector = `.bf-tile[data-x="${objective.x}"][data-y="${objective.y}"]`;
|
||||
const button = document.querySelector(selector);
|
||||
if (button) {
|
||||
button.setAttribute('data-objective', '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readGrid() {
|
||||
const tileMap = {};
|
||||
const deploymentZones = { alpha: [], bravo: [] };
|
||||
let objective = null;
|
||||
document.querySelectorAll('.bf-tile').forEach((button) => {
|
||||
const x = Number(button.getAttribute('data-x'));
|
||||
const y = Number(button.getAttribute('data-y'));
|
||||
const paint = button.getAttribute('data-paint');
|
||||
if (paint && paint !== 'open') {
|
||||
tileMap[x + ':' + y] = paint;
|
||||
}
|
||||
const zone = button.getAttribute('data-zone');
|
||||
if (zone === 'alpha' || zone === 'bravo') {
|
||||
deploymentZones[zone].push({ x, y });
|
||||
}
|
||||
if (button.getAttribute('data-objective') === '1') {
|
||||
objective = { x, y };
|
||||
}
|
||||
});
|
||||
return { tileMap, deploymentZones, objective };
|
||||
}
|
||||
|
||||
function buildScenarioJson(scenarioId, tileMap, deploymentZones, objective) {
|
||||
const widthInput = document.querySelector('input[name="battlefieldWidth"]');
|
||||
const heightInput = document.querySelector('input[name="battlefieldHeight"]');
|
||||
return {
|
||||
id: scenarioId,
|
||||
name: scenarioId,
|
||||
battlefieldWidth: widthInput ? Number(widthInput.value) : 8,
|
||||
battlefieldHeight: heightInput ? Number(heightInput.value) : 8,
|
||||
battlefieldTerrain: tileMap,
|
||||
teamA: {
|
||||
units: [],
|
||||
},
|
||||
teamB: {
|
||||
units: [],
|
||||
},
|
||||
deploymentZones,
|
||||
objectives: objective
|
||||
? { 'objective-1': { id: 'objective-1', position: objective } }
|
||||
: {},
|
||||
victoryCondition: objective ? 'hold_objective' : 'eliminate_all',
|
||||
holdRoundsRequired: objective ? 3 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function paintTile(button) {
|
||||
const mode = getCurrentMode();
|
||||
if (event.shiftKey) {
|
||||
applyTile(button, 'open', null, false);
|
||||
return;
|
||||
}
|
||||
if (mode === 'objective') {
|
||||
document.querySelectorAll('.bf-tile[data-objective]').forEach((other) => {
|
||||
other.removeAttribute('data-objective');
|
||||
});
|
||||
applyTile(button, 'open', null, true);
|
||||
return;
|
||||
}
|
||||
if (mode === 'alpha' || mode === 'bravo') {
|
||||
const current = button.getAttribute('data-zone');
|
||||
if (current === mode) {
|
||||
button.removeAttribute('data-zone');
|
||||
} else {
|
||||
button.setAttribute('data-zone', mode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
applyTile(button, getSelectedPaint(), null, false);
|
||||
}
|
||||
|
||||
function selectPalette(event) {
|
||||
document.querySelectorAll('.bf-paint').forEach((b) => b.classList.remove('active'));
|
||||
event.currentTarget.classList.add('active');
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'bf-toast';
|
||||
toast.appendChild(document.createTextNode(message));
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
function showErrors(errors) {
|
||||
const existing = document.querySelector('.bf-errors');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.className = 'bf-errors';
|
||||
const ul = document.createElement('ul');
|
||||
errors.forEach((msg) => {
|
||||
const li = document.createElement('li');
|
||||
li.appendChild(document.createTextNode(msg));
|
||||
ul.appendChild(li);
|
||||
});
|
||||
div.appendChild(ul);
|
||||
const form = document.getElementById('battlefield-form');
|
||||
if (form) {
|
||||
form.parentNode.insertBefore(div, form);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(event) {
|
||||
event.preventDefault();
|
||||
const form = document.getElementById('battlefield-form');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const id = form.getAttribute('data-scenario-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const { tileMap, deploymentZones, objective } = readGrid();
|
||||
const scenario = buildScenarioJson(id, tileMap, deploymentZones, objective);
|
||||
const csrf = getCsrfToken();
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('/scenarios/' + encodeURIComponent(id) + '/edit/battlefield', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify(scenario),
|
||||
});
|
||||
} catch (e) {
|
||||
showErrors(['Network error: ' + e.message]);
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.scenario) {
|
||||
saveScenario(id, data.scenario);
|
||||
showToast('Saved.');
|
||||
} else {
|
||||
showErrors([(data.errors || ['Unknown error']).join(', ')]);
|
||||
}
|
||||
} else if (res.status === 400) {
|
||||
let data = {};
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
// body wasn't JSON
|
||||
}
|
||||
showErrors(data.errors || ['Validation failed (HTTP 400).']);
|
||||
} else {
|
||||
showErrors(['Save failed: HTTP ' + res.status]);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('battlefield-form');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const id = form.getAttribute('data-scenario-id');
|
||||
if (id) {
|
||||
const scenario = loadScenario(id);
|
||||
if (scenario) {
|
||||
loadGrid(
|
||||
scenario.battlefieldTerrain || {},
|
||||
scenario.deploymentZones || { alpha: [], bravo: [] },
|
||||
scenario.objectives && scenario.objectives['objective-1']
|
||||
? scenario.objectives['objective-1'].position
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('.bf-tile').forEach((button) => {
|
||||
button.addEventListener('click', () => paintTile(button));
|
||||
});
|
||||
document.querySelectorAll('.bf-paint').forEach((button) => {
|
||||
button.addEventListener('click', selectPalette);
|
||||
});
|
||||
if (document.querySelector('.bf-paint')) {
|
||||
document.querySelector('.bf-paint').classList.add('active');
|
||||
}
|
||||
form.addEventListener('submit', save);
|
||||
});
|
||||
```
|
||||
|
||||
The module:
|
||||
- Reads `data-scenario-id` from the form.
|
||||
- Loads the scenario from `bfStorage.load(id)` and repaints the grid.
|
||||
- Handles click-paint on tiles, including shift-click to erase.
|
||||
- Handles palette selection (clicking a paint button sets the active mode).
|
||||
- On Save, intercepts the form submit, builds the canonical scenario JSON, fetches the existing Plan 3b `PostBattlefieldEditor` endpoint, parses the response, and writes the result back to `localStorage`.
|
||||
- Handles all four response cases: 200 with `scenario`, 400 with `errors`, network failure, and other HTTP failures.
|
||||
|
||||
- [ ] **Step 2: Update `src/Views/battlefield-editor.php`**
|
||||
|
||||
Three changes:
|
||||
|
||||
1. Add `data-scenario-id="<?= $a($scenarioId) ?>"` to the form element:
|
||||
|
||||
```php
|
||||
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded" data-scenario-id="<?= $a($scenarioId) ?>">
|
||||
```
|
||||
|
||||
2. Add the script import at the end of the template body (before the `render_layout` closure):
|
||||
|
||||
```php
|
||||
<script type="module" src="<?= $a('/js/grid-editor.js') ?>"></script>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `.github/workflows/ci.yml`**
|
||||
|
||||
Add `npm ci`, `npm run lint`, and `npm run format` steps after the existing `composer check` step. The workflow uses pinned action SHAs (per Plan 1's baseline).
|
||||
|
||||
Append the following steps to the existing `php` job in `.github/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run lint
|
||||
- run: npm run format
|
||||
```
|
||||
|
||||
The full workflow should now be:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
php:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
- run: composer validate --strict
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer check
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run lint
|
||||
- run: npm run format
|
||||
```
|
||||
|
||||
(Use the actual pinned SHAs from the existing workflow; the SHAs above are placeholders showing the shape.)
|
||||
|
||||
- [ ] **Step 4: Run the lint and format checks**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 0 errors. ESLint may flag a few unused parameters or stylistic issues; if so, fix them.
|
||||
|
||||
Run: `npm run format`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 5: Run the full quality suite and commit**
|
||||
|
||||
Run: `composer check`
|
||||
Expected: 196/196 tests pass, PHPStan clean, PHPCS clean on the changed files.
|
||||
|
||||
```powershell
|
||||
git add public/js/grid-editor.js src/Views/battlefield-editor.php .github/workflows/ci.yml
|
||||
git commit -m "feat: add battlefield grid editor and CI lint step"
|
||||
```
|
||||
|
||||
### Task 4: Final whole-branch code review
|
||||
|
||||
Dispatch a fresh subagent with the merge-base diff for the entire feature branch. The reviewer checks for:
|
||||
|
||||
- Spec compliance (every in-scope capability from the Plan 3c spec is implemented).
|
||||
- Code quality (clean separation of concerns, proper error handling, type safety, ESLint/Prettier clean).
|
||||
- Production readiness (backward compatibility, no obvious bugs).
|
||||
- Security: `bfStorage` reads and writes `localStorage` only, never `eval` or `Function` constructor; the home page's recent-scenarios list uses `textContent`, never `innerHTML`.
|
||||
|
||||
After the review, address Critical and Important findings. Minor findings can be deferred to Plan 4.
|
||||
|
||||
## Completion Check
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
composer validate --strict
|
||||
composer check
|
||||
npm run lint
|
||||
npm run format
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Composer reports a valid manifest.
|
||||
- PHPCS reports no coding-standard violations on the changed files.
|
||||
- PHPStan reports no errors at level 6.
|
||||
- PHPUnit passes 196 tests.
|
||||
- ESLint reports 0 errors on `public/js/*.js`.
|
||||
- Prettier reports 0 formatting drift.
|
||||
- Git reports no untracked files except `.worktrees/` (git-ignored).
|
||||
|
||||
The web app is fully functional from the browser: `php -S 0.0.0.0:8000 -t public` serves the home page with the recent-scenarios list, the team editor with `localStorage` pre-fill, and the battlefield editor with click-paint grid interactions. The battle interface and bundled scenarios land in Plan 4.
|
||||
Reference in New Issue
Block a user