diff --git a/public/js/storage.js b/public/js/storage.js
new file mode 100644
index 0000000..43f2fc2
--- /dev/null
+++ b/public/js/storage.js
@@ -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();
+ });
+ }
+});
diff --git a/src/Views/team-editor.php b/src/Views/team-editor.php
index f5bb1e6..bb27222 100644
--- a/src/Views/team-editor.php
+++ b/src/Views/team-editor.php
@@ -82,6 +82,8 @@ render_layout(static function () use ($csrf, $e, $a, $archetypes, $error, $old,
+
+