Merge feature/frontend-js into develop
CI / php (push) Failing after 1m12s

Plan 3c: JavaScript frontend (storage.js, grid-editor.js, toolchain).

Adds the Node toolchain (ESLint airbnb-base + Prettier) with a CI step,
public/js/storage.js (bfStorage module for localStorage pre-fill and the home
page's recent-scenarios list), public/js/grid-editor.js (battlefield grid
interactions and JSON fetch save), public/assets/archetypes.json (the four
ArchetypeTemplate definitions), four placeholder PNGs at
public/assets/placeholders/, and updated <script> tags in home.php and
team-editor.php and a data-scenario-id attribute + script tag in
battlefield-editor.php. Also adds *.js and *.json to .gitattributes to
enforce LF line endings on those file types.

196/196 tests pass; PHPStan level 6 clean; ESLint 0 errors; Prettier clean.
Two fix commits were made during the per-task review loop: brief's URL
parsing slice index in storage.js was off-by-one (slice(-2,-1) returned
'edit' instead of the id), and brief's buildScenarioJson emitted a nested
objective shape that didn't match the backend's flat objectiveId/X/Y
fields. Both were corrected.
This commit is contained in:
Keith Solomon
2026-07-12 21:36:54 -05:00
16 changed files with 3817 additions and 2 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"env": {
"browser": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"extends": "airbnb-base"
}
+3 -1
View File
@@ -1 +1,3 @@
*.php text eol=lf
*.php text eol=lf
*.js text eol=lf
*.json text eol=lf
+3
View File
@@ -25,3 +25,6 @@ jobs:
- 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
+1
View File
@@ -7,6 +7,7 @@
/var/uploads/*
!/var/uploads/.htaccess
!/var/uploads/index.php
/var/secret.key
/node_modules/
/public/js/*.map
.phpunit.result.cache
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
+3284
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"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"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"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": [] }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

+266
View File
@@ -0,0 +1,266 @@
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');
});
Object.entries(deploymentZones).forEach(([team, positions]) => {
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"]');
const result = {
id: scenarioId,
name: scenarioId,
battlefieldWidth: widthInput ? Number(widthInput.value) : 8,
battlefieldHeight: heightInput ? Number(heightInput.value) : 8,
battlefieldTerrain: tileMap,
teamA: {
units: [],
},
teamB: {
units: [],
},
deploymentZones,
objectives: {},
victoryCondition: 'eliminate_all',
holdRoundsRequired: 1,
};
if (objective) {
result.victoryCondition = 'hold_objective';
result.holdRoundsRequired = 3;
result.objectiveId = 'objective-1';
result.objectiveX = objective.x;
result.objectiveY = objective.y;
}
return result;
}
function paintTile(button, ev) {
const mode = getCurrentMode();
if (ev.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) {
const battlefield = scenario.battlefield || {};
const terrain = battlefield.terrain || {};
const rawZones = scenario.deploymentZones || {};
const deploymentZones = { alpha: [], bravo: [] };
['alpha', 'bravo'].forEach((team) => {
if (rawZones[team] && Array.isArray(rawZones[team].positions)) {
deploymentZones[team] = rawZones[team].positions;
}
});
const objectiveMarker = scenario.objectives ? scenario.objectives['objective-1'] : null;
const objective = objectiveMarker ? objectiveMarker.position : null;
loadGrid(terrain, deploymentZones, objective);
}
}
document.querySelectorAll('.bf-tile').forEach((button) => {
button.addEventListener('click', (ev) => paintTile(button, ev));
});
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);
});
+219
View File
@@ -0,0 +1,219 @@
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 = { ...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)) {
const id = key.slice(STORAGE_PREFIX.length);
const scenario = load(id);
if (scenario !== null) {
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 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;
}
function resolveField(scenario, path) {
const parts = parsePath(path);
if (parts === null) {
return undefined;
}
let cursor = scenario;
for (let i = 0; i < parts.length; i += 1) {
if (cursor === null || cursor === undefined) {
return undefined;
}
cursor = cursor[parts[i]];
}
return cursor;
}
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');
items.forEach((item) => {
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);
}
// eslint-disable-next-line no-unused-vars
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(-3, -2)[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) {
Object.assign(field, { value: String(value) });
}
});
}
async function startMatch() {
const id = decodeURIComponent(window.location.pathname.split('/').slice(-3, -2)[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) {
// eslint-disable-next-line no-alert
alert(`Start match failed: HTTP ${res.status}`);
return;
}
const data = await res.json();
if (data.match) {
setCurrentMatch(data.match);
window.location.href = '/';
} else {
// eslint-disable-next-line no-alert
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();
});
}
});
+1 -1
View File
@@ -18,7 +18,7 @@ render_layout(static function () use ($csrf, $e, $a, $scenarioId, $width, $heigh
?>
<h1>Battlefield editor</h1>
<p>Scenario: <strong><?= $e($scenarioId) ?></strong></p>
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded">
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded" data-scenario-id="<?= $a($scenarioId) ?>">
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
<fieldset>
<legend>Battlefield</legend>
+2
View File
@@ -82,6 +82,8 @@ render_layout(static function () use ($csrf, $e, $a, $archetypes, $error, $old,
<label>Hold rounds: <input type="number" name="holdRoundsRequired" min="1" max="10" value="<?= $e($old['holdRoundsRequired'] ?? '1') ?>"></label>
</fieldset>
<button type="submit">Save</button>
<button type="button" id="bf-start-match">Start match</button>
</form>
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
<?php
}, $csrf, 'Team editor');