const CSRF_META = 'meta[name="csrf-token"]'; const ACTIVE_TEAM_LABEL = { alpha: 'Alpha', bravo: 'Bravo' }; function getCsrfToken() { const meta = document.querySelector(CSRF_META); return meta ? meta.getAttribute('content') : ''; } function getEnvelope() { const raw = localStorage.getItem('match:current'); if (raw === null) return null; try { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || !parsed.match || !parsed.matchId) return null; return parsed; } catch (e) { return null; } } function setEnvelope(envelope) { localStorage.setItem('match:current', JSON.stringify(envelope)); } function removeEnvelope() { localStorage.removeItem('match:current'); } function el(tag, attrs = {}, text = null) { const node = document.createElement(tag); Object.entries(attrs).forEach(([k, v]) => { if (v !== null && v !== undefined) { node.setAttribute(k, String(v)); } }); if (text !== null) { node.appendChild(document.createTextNode(String(text))); } return node; } function renderNoMatch(root) { while (root.firstChild) root.removeChild(root.firstChild); const empty = el('p', { class: 'bf-empty' }, 'No match in progress.'); const link = el('a', { href: '/' }, 'Return to home'); root.appendChild(empty); root.appendChild(link); } function renderHeader(match) { const header = document.getElementById('bf-match-header'); const team = document.getElementById('bf-active-team'); const round = document.getElementById('bf-round'); header.setAttribute('data-bf-active-team', match.activeTeamId); header.setAttribute('data-bf-round', String(match.round)); while (team.firstChild) team.removeChild(team.firstChild); team.appendChild( document.createTextNode(ACTIVE_TEAM_LABEL[match.activeTeamId] || match.activeTeamId), ); while (round.firstChild) round.removeChild(round.firstChild); round.appendChild(document.createTextNode(String(match.round))); } function renderGrid(root, match) { while (root.firstChild) root.removeChild(root.firstChild); const { width, height, terrain } = match.battlefield; root.setAttribute('data-bf-width', String(width)); root.setAttribute('data-bf-height', String(height)); const unitsByKey = {}; match.units.forEach((unit) => { if (unit.health > 0) { unitsByKey[`${unit.position.x}:${unit.position.y}`] = unit; } }); for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const key = `${x}:${y}`; const terrainType = (terrain && terrain[key]) || 'open'; const tile = el('button', { type: 'button', class: 'bf-tile', 'data-bf-x': x, 'data-bf-y': y, 'data-bf-terrain': terrainType, }); const unit = unitsByKey[key]; if (unit) { const isActive = unit.teamId === match.activeTeamId; const isWinner = match.winnerTeamId && unit.teamId === match.winnerTeamId; const cls = ['bf-unit', `bf-unit--${unit.teamId}`]; if (isActive) cls.push('bf-unit--active'); else cls.push('bf-unit--inactive'); if (isWinner) cls.push('bf-unit--winner'); const unitBtn = el( 'button', { type: 'button', class: cls.join(' '), 'data-bf-unit-id': unit.id, 'data-bf-team': unit.teamId, }, `${unit.id.split('-')[1] || unit.id} (${unit.health})`, ); tile.appendChild(unitBtn); } root.appendChild(tile); } } } function renderLog(root, match) { while (root.firstChild) root.removeChild(root.firstChild); const log = Array.isArray(match.actionLog) ? match.actionLog.slice(-10) : []; log.forEach((entry) => { root.appendChild(el('p', { class: 'bf-log-entry' }, entry)); }); } function renderResult(match) { const result = document.getElementById('bf-result'); const winner = document.getElementById('bf-winner'); while (winner.firstChild) winner.removeChild(winner.firstChild); winner.appendChild( document.createTextNode(ACTIVE_TEAM_LABEL[match.winnerTeamId] || match.winnerTeamId), ); result.removeAttribute('hidden'); const endBtn = document.getElementById('bf-end-turn'); if (endBtn) endBtn.setAttribute('disabled', 'disabled'); ['bf-action-move', 'bf-action-attack', 'bf-action-ability'].forEach((id) => { const btn = document.getElementById(id); if (btn) btn.setAttribute('disabled', 'disabled'); }); } function renderState() { const envelope = getEnvelope(); const grid = document.getElementById('bf-grid'); if (!envelope) { renderNoMatch(grid); return; } const { match } = envelope; renderHeader(match); renderGrid(grid, match); renderLog(document.getElementById('bf-log'), match); if (match.winnerTeamId) { renderResult(match); } else { document.getElementById('bf-result').setAttribute('hidden', 'hidden'); } } function toast(message) { const host = document.getElementById('bf-toast-host'); if (!host) return; const node = el('div', { class: 'bf-toast' }, message); host.appendChild(node); setTimeout(() => node.remove(), 3000); } async function dispatch(verb, params) { const envelope = getEnvelope(); if (!envelope) { toast('No match in progress.'); return; } const body = { matchId: envelope.matchId, match: envelope.match, ...params }; try { const response = await fetch(`/matches/current/${verb}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken(), 'X-Turn-Token': envelope.turnToken || '', }, body: JSON.stringify(body), }); const data = await response.json().catch(() => ({})); if (response.status === 200) { setEnvelope({ matchId: envelope.matchId, match: data.match, turnToken: data.turnToken, }); renderState(); return; } if (response.status === 409) { if (data.error === 'turn') { const fresh = getEnvelope(); if (fresh) { setEnvelope(fresh); renderState(); toast('Turn has changed; resynced.'); } else { renderNoMatch(document.getElementById('bf-grid')); } return; } if (data.match) { setEnvelope({ matchId: envelope.matchId, match: data.match, turnToken: data.turnToken || envelope.turnToken, }); renderState(); } if (data.error) toast(data.error); return; } if (data.error) { toast(data.error); } else { toast(`Action failed: HTTP ${response.status}`); } } catch (e) { toast(`Network error: ${e.message}`); } } let activeMode = 'move'; let selectedUnitId = null; function updateSelectedUnitIndicator() { const node = document.getElementById('bf-selected-unit'); if (!node) return; while (node.firstChild) node.removeChild(node.firstChild); node.appendChild(document.createTextNode(selectedUnitId || 'none')); } function renderAbilityOptions() { const host = document.getElementById('bf-ability-options'); if (!host) return; while (host.firstChild) host.removeChild(host.firstChild); if (activeMode !== 'ability' || !selectedUnitId) { return; } const envelope = getEnvelope(); if (!envelope) return; const unit = envelope.match.units.find((u) => u.id === selectedUnitId); if (!unit || !Array.isArray(unit.abilities) || unit.abilities.length === 0) { host.appendChild(el('p', { class: 'bf-empty' }, 'No abilities.')); return; } unit.abilities.forEach((abilityId) => { const radio = el('input', { type: 'radio', name: 'bf-ability', value: abilityId, id: `bf-ability-${abilityId}`, }); const label = el('label', { for: `bf-ability-${abilityId}` }, abilityId); const wrap = el('span', {}); wrap.appendChild(radio); wrap.appendChild(label); host.appendChild(wrap); }); } function setMode(mode) { activeMode = mode; ['move', 'attack', 'ability'].forEach((m) => { const btn = document.getElementById(`bf-action-${m}`); if (btn) { if (m === mode) btn.classList.add('bf-action--active'); else btn.classList.remove('bf-action--active'); } }); renderAbilityOptions(); } function onTileClick(event) { const tile = event.target.closest('.bf-tile'); if (!tile) return; const x = Number(tile.getAttribute('data-bf-x')); const y = Number(tile.getAttribute('data-bf-y')); const unitBtn = tile.querySelector('.bf-unit'); if (unitBtn) { const unitId = unitBtn.getAttribute('data-bf-unit-id'); const team = unitBtn.getAttribute('data-bf-team'); const envelope = getEnvelope(); if (!envelope) return; if (team !== envelope.match.activeTeamId) { toast('That unit is not on the active team.'); return; } if (activeMode === 'attack') { toast('Pick a friendly unit to attack with, then click an enemy.'); selectedUnitId = unitId; updateSelectedUnitIndicator(); return; } selectedUnitId = unitId; updateSelectedUnitIndicator(); return; } if (activeMode === 'move' && selectedUnitId) { dispatch('move', { unitId: selectedUnitId, x, y }); return; } if (activeMode === 'attack' && selectedUnitId) { const envelope = getEnvelope(); if (!envelope) return; const attacker = envelope.match.units.find((u) => u.id === selectedUnitId); if (!attacker) return; const target = envelope.match.units.find( (u) => u.position.x === x && u.position.y === y && u.teamId !== attacker.teamId, ); if (!target) { toast('No enemy on that tile.'); return; } dispatch('attack', { attackerId: selectedUnitId, targetId: target.id }); return; } if (activeMode === 'ability' && selectedUnitId) { const radio = document.querySelector('input[name="bf-ability"]:checked'); if (!radio) { toast('Pick an ability first.'); return; } dispatch('ability', { unitId: selectedUnitId, abilityId: radio.value, x, y, }); } } function onUnitClick(event) { const unitBtn = event.target.closest('.bf-unit'); if (!unitBtn) return; const unitId = unitBtn.getAttribute('data-bf-unit-id'); const team = unitBtn.getAttribute('data-bf-team'); const envelope = getEnvelope(); if (!envelope) return; if (activeMode === 'attack' && team !== envelope.match.activeTeamId) { if (!selectedUnitId) { toast('Pick a friendly attacker first.'); return; } dispatch('attack', { attackerId: selectedUnitId, targetId: unitId }); return; } if (team === envelope.match.activeTeamId) { selectedUnitId = unitId; updateSelectedUnitIndicator(); return; } toast('That unit is not on the active team.'); } function wireEvents() { document.getElementById('bf-grid').addEventListener('click', (event) => { const unitBtn = event.target.closest('.bf-unit'); if (unitBtn) { onUnitClick(event); } else { onTileClick(event); } }); ['move', 'attack', 'ability'].forEach((mode) => { const btn = document.getElementById(`bf-action-${mode}`); if (btn) btn.addEventListener('click', () => setMode(mode)); }); const endBtn = document.getElementById('bf-end-turn'); if (endBtn) { endBtn.addEventListener('click', () => dispatch('end-turn', {})); } const returnHome = document.getElementById('bf-return-home'); if (returnHome) { returnHome.addEventListener('click', (event) => { event.preventDefault(); removeEnvelope(); window.location.href = '/'; }); } } document.addEventListener('DOMContentLoaded', () => { wireEvents(); setMode('move'); renderState(); });