diff --git a/docs/incident-2026-07-04-blank-site.md b/docs/incident-2026-07-04-blank-site.md new file mode 100644 index 0000000..2b8f5f0 --- /dev/null +++ b/docs/incident-2026-07-04-blank-site.md @@ -0,0 +1,109 @@ +# Incident: dwroller.alw.dk blank page (2026-07-04) + +## Symptom +`https://dwroller.alw.dk/` loaded a valid HTML shell (correct ``, +correct hashed JS/CSS asset links, all served 200) but the page body was +blank — `<div id="root">` never got populated by React. + +## Root causes + +Two independent problems, both needed fixing. + +### 1. `/nas` autofs idle-unmount (infrastructure) + +`/etc/fstab` mounted `/nas` via autofs with `x-systemd.idle-timeout=10min`. +The dwroller app's code, data files, and cwd all live under +`/nas/git/dwroller`, so every ~10 minutes of inactivity autofs silently +unmounted the share out from under the running Node process. PM2 detected +the process as unhealthy ("Process 0 in a stopped status") and force +stop/restarted it every 6–47 minutes (119+ restarts logged in +`pm2.log` / `deathwatch-server-out.log`). Any request during one of these +restart windows got nothing back. + +**Fix:** changed the `/nas` line in `/etc/fstab` from an autofs mount with +an idle timeout to a plain persistent NFS mount (kept `_netdev,nofail` so +boot still won't hang if the NAS is down): + +```diff +-192.168.1.113:/volume1/data /nas nfs vers=4,noauto,x-systemd.automount,x-systemd.idle-timeout=10min,_netdev,nofail,retry=15,timeo=600,rsize=32768,wsize=32768 0 0 ++192.168.1.113:/volume1/data /nas nfs vers=4,_netdev,nofail,retry=15,timeo=600,rsize=32768,wsize=32768 0 0 +``` + +Original backed up to `/etc/fstab.bak.<timestamp>`. Applied live with +`systemctl daemon-reload` + `umount`/`mount /nas` (no reboot needed). + +### 2. Frontend JS crash on every page load (application bug) + +`src/components/BattlemapTab.jsx` (new component, not yet committed at the +time) did: + +```js +const { ... } = require('../shared/battlemapEngine'); +``` + +`../shared/battlemapEngine.js` is plain CommonJS (`module.exports = {...}`), +required directly by the Express backend (`database/routes/*.js`) — that +usage is fine, since backend code runs as real, unbundled Node. + +But `BattlemapTab.jsx` is itself an ES module (`import React from 'react'`, +`export default function BattlemapTab(...)`), so it's part of the frontend's +ESM dependency graph. Webpack 5's CommonJS↔ESM interop cannot safely +reconcile a **whole-object** `module.exports = {...}` reassignment with a +consumer inside an ES module graph. Depending on the exact +`require`/`import` style used, this surfaced as one of: + +- `Uncaught Error: ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: <id>` +- `Uncaught ReferenceError: exports is not defined` +- a build-time failure: `Attempted import error: 'X' is not exported from '../shared/battlemapEngine'` + +All are the same underlying conflict, just different corners of webpack's +static analysis / module concatenation / `hmd` (harmony module decorator) +guard tripping over each other. This is **not** a transient glitch or a +caching issue — every browser hitting the built bundle got a thrown error +before `ReactDOM.createRoot(...).render(...)` ever ran, leaving `#root` +permanently empty. + +Confirmed by rendering the production bundle in headless Chromium +(`chromium --headless=old --dump-dom` + `--enable-logging=stderr --v=1` to +capture `console` output) — `<div id="root"></div>` stayed empty and the +uncaught error was visible in the captured console log. + +**Fix:** `src/shared/battlemapEngine.js` was left untouched (still plain +CommonJS, still required directly by the backend and by +`src/tests/battlemapEngine.test.js` via Jest/Node). `BattlemapTab.jsx` no +longer requires/imports it at all. The ~10 small pure functions it actually +needed (`battlemapVisibleForPlayer`, `normalizeMovement`, +`normalizeTurnState`, `movementBudget`, `moveWithinRange`, `stepToward`, +`distance`, `enemyMoveTowardNearest`, `sceneBattlemapTheme`, and +`snapCoordinate`, which the component already had a duplicate of) are +defined locally inside the component, with a comment pointing at this +incident/reasoning. This sidesteps the webpack interop bug entirely rather +than fighting it further. + +**Known trade-off:** those functions are now duplicated between +`BattlemapTab.jsx` and `src/shared/battlemapEngine.js` (still used by +`database/routes/missionRoutes.js` and `simulationRoutes.js`). If +battlemap movement/theme logic changes, it needs updating in both places — +nothing currently guards against drift between the two copies. + +## Verification + +- `CI=true npx react-scripts build` compiles cleanly. +- Headless Chromium render of the live production bundle shows `#root` + populated with the full app shell (nav, login form, tab list including + "Battlemap") and zero uncaught console errors. +- `node -e "require('./src/shared/battlemapEngine')"` still resolves all + 41 exports for the backend/tests, unaffected by the frontend-side fix. +- `curl https://dwroller.alw.dk/` and `curl http://localhost:5000/` both + return 200 with the current build's asset hashes. +- PM2 restart count stable (no new restarts) after the `/nas` mount fix. + +## Follow-ups (not done as part of this fix) + +- Consider whether `src/shared/battlemapEngine.js` should be split into a + true dual CJS/ESM package (e.g. via a build step) if more frontend + components need to consume it, instead of duplicating functions again. +- Jest/RTL tests that spin up jsdom (`battlemapTab.test.js`, + `battlemapEngine.test.js` when run together with DOM tests) hang/segfault + on this ARM host — verify logic changes via plain `node -e` require checks + or the built bundle instead of relying on `npm test` here. diff --git a/src/components/BattlemapTab.jsx b/src/components/BattlemapTab.jsx new file mode 100644 index 0000000..c10b856 --- /dev/null +++ b/src/components/BattlemapTab.jsx @@ -0,0 +1,1649 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import axios from 'axios'; + +// Duplicated from src/shared/battlemapEngine.js (kept CommonJS there for the +// Node backend) rather than imported: webpack's CJS/ESM interop for that +// module breaks module.exports when it is pulled into an ES module graph +// like this component, producing a blank page in production. +const METERS_PER_CHECKER = 10; +const DEFAULT_DOOR_WIDTH = 3; + +function battlemapVisibleForPlayer(scene, authedPlayer) { + if (authedPlayer === 'gm') return true; + const reveal = scene?.reveal || {}; + if (typeof reveal.battlemap === 'boolean') return reveal.battlemap; + return scene?.revealed === true; +} + +function normalizeMovement(movement = {}) { + const half = Math.max(1, Number(movement.half || 3) || 3); + const full = Math.max(half, Number(movement.full || half * 2) || half * 2); + const charge = Math.max(half, Number(movement.charge || full) || full); + const run = Math.max(full, Number(movement.run || full * 2) || full * 2); + return { half, full, charge, run }; +} + +function normalizeTurnState(existing = {}, turnKey = '') { + if (existing?.turnKey === turnKey) { + return { + turnKey, + movedBy: existing.movedBy && typeof existing.movedBy === 'object' ? existing.movedBy : {}, + movementUsedBy: existing.movementUsedBy && typeof existing.movementUsedBy === 'object' ? existing.movementUsedBy : {}, + moveModeBy: existing.moveModeBy && typeof existing.moveModeBy === 'object' ? existing.moveModeBy : {}, + }; + } + return { turnKey, movedBy: {}, movementUsedBy: {}, moveModeBy: {} }; +} + +function movementBudget(movement, mode, isCarrier = false) { + const profile = normalizeMovement(movement); + if (isCarrier && (mode === 'charge' || mode === 'run')) return 0; + const checkers = (metres) => Math.max(1, Math.ceil((Number(metres || 0) || 0) / METERS_PER_CHECKER)); + if (mode === 'half') return checkers(profile.half); + if (mode === 'charge') return checkers(profile.charge); + if (mode === 'run') return checkers(profile.run); + return checkers(profile.full); +} + +function movementSpent(turnState, tokenId) { + return Math.max(0, Number(turnState?.movementUsedBy?.[tokenId] || 0) || 0); +} + +function movementModeLocked(turnState, tokenId) { + return String(turnState?.moveModeBy?.[tokenId] || ''); +} + +function distance(from, to) { + const dx = Number(to.x || 0) - Number(from.x || 0); + const dy = Number(to.y || 0) - Number(from.y || 0); + return Math.sqrt((dx * dx) + (dy * dy)); +} + +function pointInRect(point, rect) { + if (!point || !rect) return false; + const x = Number(point.x || 0); + const y = Number(point.y || 0); + return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; +} + +function pointInAnyZone(zones, point) { + return (zones || []).some((zone) => pointInRect(point, zone)); +} + +function doorwaySpan(start, end, width = DEFAULT_DOOR_WIDTH) { + const min = Math.min(start, end); + const max = Math.max(start, end); + const span = max - min; + if (span <= 0) return null; + const opening = Math.min(width, span); + const center = (min + max) / 2; + return { from: center - (opening / 2), to: center + (opening / 2) }; +} + +function computeZoneDoorways(zones) { + const doorways = []; + (zones || []).forEach((zone, zoneIndex) => { + (zones || []).slice(zoneIndex + 1).forEach((other) => { + if (other.y === zone.y + zone.h || zone.y === other.y + other.h) { + const overlapFrom = Math.max(zone.x, other.x); + const overlapTo = Math.min(zone.x + zone.w, other.x + other.w); + if (overlapTo > overlapFrom) { + const opening = doorwaySpan(overlapFrom, overlapTo); + if (opening) doorways.push({ axis: 'h', at: other.y === zone.y + zone.h ? zone.y + zone.h : other.y + other.h, from: opening.from, to: opening.to }); + } + } + if (other.x === zone.x + zone.w || zone.x === other.x + other.w) { + const overlapFrom = Math.max(zone.y, other.y); + const overlapTo = Math.min(zone.y + zone.h, other.y + other.h); + if (overlapTo > overlapFrom) { + const opening = doorwaySpan(overlapFrom, overlapTo); + if (opening) doorways.push({ axis: 'v', at: other.x === zone.x + zone.w ? zone.x + zone.w : other.x + other.w, from: opening.from, to: opening.to }); + } + } + }); + }); + return doorways; +} + +function segmentCrossesWall(from, to, wall) { + if (wall.axis === 'h') { + if (from.y === to.y) return false; + if ((from.y - wall.at) * (to.y - wall.at) > 0) return false; + const t = (wall.at - from.y) / (to.y - from.y); + if (t < 0 || t > 1) return false; + const x = from.x + (t * (to.x - from.x)); + return x > wall.from && x < wall.to; + } + if (from.x === to.x) return false; + if ((from.x - wall.at) * (to.x - wall.at) > 0) return false; + const t = (wall.at - from.x) / (to.x - from.x); + if (t < 0 || t > 1) return false; + const y = from.y + (t * (to.y - from.y)); + return y > wall.from && y < wall.to; +} + +function computeZoneWalls(zones, doors = []) { + const walls = []; + const doorways = doors.length ? doors : computeZoneDoorways(zones); + (zones || []).forEach((zone) => { + [zone.y, zone.y + zone.h].forEach((atY) => { + const doorway = doorways.find((entry) => entry.axis === 'h' && entry.at === atY && entry.from < zone.x + zone.w && entry.to > zone.x); + if (!doorway) walls.push({ axis: 'h', at: atY, from: zone.x, to: zone.x + zone.w }); + else { + if (zone.x < doorway.from) walls.push({ axis: 'h', at: atY, from: zone.x, to: doorway.from }); + if (doorway.to < zone.x + zone.w) walls.push({ axis: 'h', at: atY, from: doorway.to, to: zone.x + zone.w }); + } + }); + [zone.x, zone.x + zone.w].forEach((atX) => { + const doorway = doorways.find((entry) => entry.axis === 'v' && entry.at === atX && entry.from < zone.y + zone.h && entry.to > zone.y); + if (!doorway) walls.push({ axis: 'v', at: atX, from: zone.y, to: zone.y + zone.h }); + else { + if (zone.y < doorway.from) walls.push({ axis: 'v', at: atX, from: zone.y, to: doorway.from }); + if (doorway.to < zone.y + zone.h) walls.push({ axis: 'v', at: atX, from: doorway.to, to: zone.y + zone.h }); + } + }); + }); + return walls; +} + +function wallBetween(layout = {}, from, to) { + const walls = computeZoneWalls(layout.zones || [], layout.doors || []); + return walls.find((wall) => segmentCrossesWall(from, to, wall)) || null; +} + +function pointToCell(point) { + return { + x: Math.max(0, Math.round(Number(point?.x || 0) - 0.5)), + y: Math.max(0, Math.round(Number(point?.y || 0) - 0.5)), + }; +} + +function cellToPoint(cell) { + return { x: Number(cell.x) + 0.5, y: Number(cell.y) + 0.5 }; +} + +function cellKey(cell) { + return `${cell.x},${cell.y}`; +} + +function pointBlocked(layout = {}, point) { + if (!pointInAnyZone(layout.zones || [], point)) return true; + return (layout.blockers || []).some((item) => pointInRect(point, item)); +} + +function movementBlockedBetween(layout = {}, from, to) { + if (pointBlocked(layout, to)) return true; + if ((layout.blockers || []).some((item) => pointInRect(to, item))) return true; + if (wallBetween(layout, from, to)) return true; + return false; +} + +function pointTerrainCost(layout = {}, point) { + const hazard = (layout.hazards || []).find((item) => pointInRect(point, item)); + if (!hazard) return 1; + if (hazard.kind === 'no-charge') return 3; + if (hazard.kind === 'difficult' || hazard.kind === 'agility' || hazard.kind === 'vent') return 2; + return 1; +} + +function buildOccupiedSet(occupied = [], allow = []) { + const allowed = new Set((allow || []).map((point) => cellKey(pointToCell(point)))); + return new Set( + (occupied || []) + .map((point) => pointToCell(point)) + .map(cellKey) + .filter((key) => !allowed.has(key)) + ); +} + +function targetGoalCells(targetPoint, stopRange, bounds, layout, occupiedSet) { + const targetCell = pointToCell(targetPoint); + const goals = []; + const radius = Math.max(0, Number(stopRange || 0)); + for (let dx = -radius; dx <= radius; dx += 1) { + for (let dy = -radius; dy <= radius; dy += 1) { + const chebyshev = Math.max(Math.abs(dx), Math.abs(dy)); + if (radius === 0 && (dx !== 0 || dy !== 0)) continue; + if (radius > 0 && (chebyshev === 0 || chebyshev > radius)) continue; + const cell = { x: targetCell.x + dx, y: targetCell.y + dy }; + if (cell.x < 0 || cell.y < 0 || cell.x >= bounds.width || cell.y >= bounds.height) continue; + const point = cellToPoint(cell); + if (pointBlocked(layout, point)) continue; + if (occupiedSet.has(cellKey(cell))) continue; + goals.push(cell); + } + } + return goals.length ? goals : (radius === 0 ? [targetCell] : []); +} + +function moveWithinRange(from, to, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) { + const layout = options.layout || {}; + const startCell = pointToCell(from); + const occupiedSet = buildOccupiedSet(options.occupied || [], [from, ...(options.allowOccupied || [])]); + const goals = targetGoalCells(to, options.stopRange || 0, bounds, layout, occupiedSet); + if (!goals.length) return { x: from.x, y: from.y, travelled: 0 }; + + const goalKeys = new Set(goals.map(cellKey)); + const visited = new Map([[cellKey(startCell), { cost: 0, prev: null }]]); + const queue = [{ cell: startCell, cost: 0 }]; + let bestKey = cellKey(startCell); + let bestScore = Infinity; + + while (queue.length) { + queue.sort((a, b) => a.cost - b.cost); + const current = queue.shift(); + const currentKey = cellKey(current.cell); + const currentInfo = visited.get(currentKey); + if (!currentInfo || currentInfo.cost !== current.cost) continue; + + const distanceToGoal = Math.min(...goals.map((goal) => Math.max(Math.abs(goal.x - current.cell.x), Math.abs(goal.y - current.cell.y)))); + const score = distanceToGoal * 100 + current.cost; + if (score < bestScore) { + bestScore = score; + bestKey = currentKey; + } + if (goalKeys.has(currentKey)) { + bestKey = currentKey; + break; + } + + const directions = [ + [-1, -1], [0, -1], [1, -1], + [-1, 0], [1, 0], + [-1, 1], [0, 1], [1, 1], + ]; + for (const [dx, dy] of directions) { + const nextCell = { x: current.cell.x + dx, y: current.cell.y + dy }; + if (nextCell.x < 0 || nextCell.y < 0 || nextCell.x >= bounds.width || nextCell.y >= bounds.height) continue; + const nextKey = cellKey(nextCell); + if (occupiedSet.has(nextKey)) continue; + const nextPoint = cellToPoint(nextCell); + if (movementBlockedBetween(layout, cellToPoint(current.cell), nextPoint)) continue; + const nextCost = current.cost + pointTerrainCost(layout, nextPoint); + if (nextCost > maxDistance) continue; + const previous = visited.get(nextKey); + if (!previous || nextCost < previous.cost) { + visited.set(nextKey, { cost: nextCost, prev: currentKey }); + queue.push({ cell: nextCell, cost: nextCost }); + } + } + } + + const [x, y] = bestKey.split(',').map(Number); + const bestPoint = cellToPoint({ x, y }); + return { x: bestPoint.x, y: bestPoint.y, travelled: visited.get(bestKey)?.cost || 0 }; +} + +function stepToward(from, to, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) { + return moveWithinRange(from, to, maxDistance, bounds, options); +} + +function enemyMoveTowardNearest(enemyToken, playerTokens, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) { + const alivePlayers = Array.isArray(playerTokens) ? playerTokens : []; + if (!enemyToken || !alivePlayers.length) return null; + const target = alivePlayers + .map(token => ({ token, dist: distance(enemyToken, token) })) + .sort((a, b) => a.dist - b.dist)[0]?.token; + if (!target) return null; + const next = stepToward(enemyToken, target, maxDistance, bounds, { + ...options, + stopRange: Math.max(1, Number(options.stopRange || 1)), + }); + return { target, ...next }; +} + +function sceneBattlemapTheme(scene = {}) { + const title = String(scene.title || scene.name || '').toLowerCase(); + const checks = Array.isArray(scene.checks) ? scene.checks.map(check => String(check.name || '').toLowerCase()).join(' ') : ''; + const combined = `${title} ${checks}`; + if (/landing|dock|breach|hangar/.test(combined)) { + return { + layoutKey: 'landing', + boardTitle: 'Landing Zone Breach', + boardSummary: 'Landing pad, gantries, cargo lanes, and breach routes tuned to the current mission scene.', + }; + } + if (/vault|gene|lab|reactor|core/.test(combined)) { + return { + layoutKey: 'vault', + boardTitle: 'Vault Control Fight', + boardSummary: 'Objective rooms, crossfire lanes, and extraction pressure for the current mission scene.', + }; + } + if (/shrine|temple|cathedral|sanctum/.test(combined)) { + return { + layoutKey: 'sanctum', + boardTitle: 'Sanctum Assault', + boardSummary: 'Central shrine lanes, elevated firing points, and flanking routes fitted to this scene.', + }; + } + if (/corridor|tunnel|passage|service/.test(combined)) { + return { + layoutKey: 'corridor', + boardTitle: 'Corridor Kill Zone', + boardSummary: 'Long fire lanes, choke points, and service routes fitted to this scene.', + }; + } + return { + layoutKey: 'vault', + boardTitle: String(scene.title || scene.name || 'Battlemap'), + boardSummary: 'Dynamic tactical board generated from the active mission scene.', + }; +} + +const GRID_WIDTH = 40; +const GRID_HEIGHT = 30; + +// Kept in sync with src/shared/battlemapEngine.js's DEFAULT_ZONES/DEFAULT_* +// (duplicated here, not imported, because importing that CommonJS module +// from this ES module component breaks webpack's CJS/ESM interop — see the +// comment above the moveWithinRange/etc. duplicates further up this file). +// Every room touches the corridor along one full edge with zero rectangle +// overlap anywhere, so the floor plan is fully connected and every boundary +// is a real wall — see validateBattlemapLayout/computeZoneWalls in the +// shared engine for the rules this must keep satisfying. +const ZONES = [ + { id: 'extraction', name: 'Extraction Zone', x: 1, y: 11, w: 7, h: 8, tone: 'spawn', elevation: 0, lighting: 'bright', purpose: 'Start and extract here.', cover: 'Light cover from crates, barricades, and wreckage.' }, + { id: 'main-corridor', name: 'Main Kill-Corridor', x: 8, y: 13, w: 24, h: 4, tone: 'corridor', elevation: 0, lighting: 'normal', purpose: 'Long firing lane for bolters, heavy weapons, overwatch, and suppression.', cover: 'Alternating half-cover every 5-6 squares.' }, + { id: 'left-wing', name: 'Left Stabilization Wing', x: 12, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: 'bright', purpose: 'Tech-Use or Medicae station under heavy pressure.', cover: 'Heavy cover from cogitator banks and machinery.' }, + { id: 'right-wing', name: 'Right Stabilization Wing', x: 25, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: 'dim', purpose: 'Flank route objective room with mixed fire angles.', cover: 'Mixed light and heavy cover.' }, + { id: 'maintenance', name: 'Lower Maintenance Wing', x: 8, y: 17, w: 15, h: 8, tone: 'hazard', elevation: -1, lighting: 'dark', purpose: 'Ambush route and third stabilization station.', cover: 'Pipes, tanks, and broken wall sections.' }, + { id: 'vault', name: 'Central Gene-Vault', x: 23, y: 17, w: 12, h: 8, tone: 'vault', elevation: 0, lighting: 'dim', purpose: 'Secure the gene-core for three rounds.', cover: 'Heavy central containment pillars and shattered equipment.' }, +]; + +const STATIONS = [ + { id: 'left-station', name: 'Alpha Stabilization', zoneId: 'left-wing', x: 17, y: 7, action: 'Full Action / Tech-Use / Medicae', note: 'Heavy machinery blocks easy charges.' }, + { id: 'right-station', name: 'Beta Stabilization', zoneId: 'right-wing', x: 30, y: 7, action: 'Full Action / Tech-Use / Medicae', note: 'Side access makes this wing harder to lock down.' }, + { id: 'maintenance-station', name: 'Gamma Stabilization', zoneId: 'maintenance', x: 20, y: 22, action: 'Full Action / Tech-Use / Medicae', note: 'Maintenance sludge and pipework slow support.' }, +]; + +const SPAWN_POINTS = [ + { id: 'north-hatch', name: 'North maintenance hatch', x: 19, y: 1, lane: 'Melee swarm pressure into the left wing.' }, + { id: 'east-corridor', name: 'East corrupted corridor', x: 38, y: 12, lane: 'Ranged traitors push the right side and corridor.' }, + { id: 'south-tunnel', name: 'South service tunnel', x: 21, y: 29, lane: 'Ambush route through maintenance.' }, + { id: 'vault-breach', name: 'Vault breach point', x: 35, y: 20, lane: 'Elite contest at the core chamber.' }, +]; + +const COVER_OBJECTS = [ + { id: 'ext-crates-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 3, y: 12, w: 2, h: 2, label: 'Crates' }, + { id: 'ext-wreck-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 5, y: 16, w: 2, h: 2, label: 'Wreckage' }, + { id: 'corr-cover-1', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 11, y: 13, w: 1, h: 4, label: 'Bulkhead' }, + { id: 'corr-cover-2', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 17, y: 13, w: 1, h: 4, label: 'Bulkhead' }, + { id: 'corr-cover-3', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 23, y: 13, w: 1, h: 4, label: 'Bulkhead' }, + { id: 'corr-cover-4', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 29, y: 13, w: 1, h: 4, label: 'Bulkhead' }, + { id: 'left-cog-1', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 13, y: 4, w: 3, h: 2, label: 'Cogitators' }, + { id: 'left-cog-2', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 18, y: 9, w: 3, h: 2, label: 'Machinery' }, + { id: 'right-cog-1', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 26, y: 4, w: 3, h: 2, label: 'Cogitators' }, + { id: 'right-cog-2', kind: 'light', material: 'Armour-glas', elevation: 1, x: 31, y: 9, w: 2, h: 2, label: 'Consoles' }, + { id: 'maint-pipe-1', kind: 'light', material: 'Light Metal', elevation: -1, x: 11, y: 18, w: 3, h: 1, label: 'Pipes' }, + { id: 'maint-tank-1', kind: 'heavy', material: 'Thick Iron', elevation: -1, x: 19, y: 21, w: 2, h: 3, label: 'Tank' }, + { id: 'vault-pillar-1', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 26, y: 19, w: 2, h: 2, label: 'Pillar' }, + { id: 'vault-pillar-2', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 32, y: 19, w: 2, h: 2, label: 'Pillar' }, + { id: 'vault-pillar-3', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 26, y: 22, w: 2, h: 2, label: 'Pillar' }, + { id: 'vault-pillar-4', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 32, y: 22, w: 2, h: 2, label: 'Pillar' }, + { id: 'vault-shatter-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 29, y: 20, w: 2, h: 1, label: 'Shattered gear' }, +]; + +const BLOCKERS = [ + { id: 'blast-door-west', material: 'Plasteel', x: 16, y: 5, w: 2, h: 1, label: 'Blast Door W' }, + { id: 'blast-door-east', material: 'Plasteel', x: 29, y: 5, w: 2, h: 1, label: 'Blast Door E' }, + { id: 'cylinder-1', material: 'Thick Iron', x: 33, y: 19, w: 2, h: 4, label: 'Containment' }, + { id: 'collapsed-slab', material: 'Rockcrete', x: 11, y: 20, w: 4, h: 2, label: 'Collapsed slab' }, +]; + +const HAZARDS = [ + { id: 'sludge', kind: 'difficult', x: 11, y: 21, w: 5, h: 2, label: 'Toxic sludge' }, + { id: 'decking', kind: 'agility', x: 12, y: 20, w: 4, h: 2, label: 'Collapsed decking' }, + { id: 'corpses', kind: 'no-charge', x: 24, y: 22, w: 4, h: 2, label: 'Corpse pile' }, + { id: 'vent', kind: 'vent', x: 29, y: 22, w: 2, h: 2, label: 'Toxic vent' }, +]; + +const DYNAMIC_FEATURES = [ + { id: 'doors', name: 'Blast doors', rule: 'GM can open, seal, or lock them to change line of sight and movement.' }, + { id: 'vents', name: 'Toxic vents', rule: 'Temporary hazardous zones that deny easy movement.' }, + { id: 'alarm', name: 'Alarm lights', rule: 'Low visibility sectors can apply ranged penalties.' }, + { id: 'turrets', name: 'Automated turrets', rule: 'Hack, destroy, or use cover to avoid them.' }, + { id: 'walkways', name: 'Collapsing walkways', rule: 'Force Agility tests and open or close routes mid-fight.' }, +]; + +const MOVEMENT_NOTES = { + corridorWidths: [ + 'Main corridor is 3-4 squares wide for Space Marines, enemies, and crossfire.', + 'Side lanes pinch to 2 squares to create chokepoints.', + 'Maintenance approach drops to 1-2 squares for ambush pressure.', + ], + chargeLanes: [ + 'Main kill-corridor gives a clean 8-12 square lane into the vault approach.', + 'Each stabilization room has one straight approach lane, partially broken by cover.', + ], + difficultTerrain: [ + 'Toxic sludge doubles movement cost.', + 'Collapsed decking can call for Agility or stalled movement.', + 'Corpse piles and cable nests block running and charging.', + ], + verticality: [ + 'Raised gantry gives 3m high ground and partial cover onto the vault approach.', + 'Broken stairwell creates an alternate Assault Marine route.', + ], +}; + +const ENEMY_ROLES = [ + 'Melee swarm enemies pressure movement lanes and tie up the objective actor.', + 'Ranged cultists or traitor guardsmen exploit corridor cover.', + 'Elite corrupted marine or Nurgle lieutenant contests the vault.', + 'Objective saboteurs try to undo stabilization progress.', +]; + +function layoutPresetForKey(key = 'vault') { + if (key === 'landing') { + return { + zones: [ + { id: 'extraction', name: 'Dropship LZ', x: 1, y: 18, w: 8, h: 8, tone: 'spawn', elevation: 0, lighting: 'bright' }, + { id: 'main-corridor', name: 'Cargo Tarmac', x: 8, y: 18, w: 20, h: 5, tone: 'corridor', elevation: 0, lighting: 'bright' }, + { id: 'left-wing', name: 'West Gantry', x: 10, y: 4, w: 10, h: 8, tone: 'objective', elevation: 1, lighting: 'normal' }, + { id: 'right-wing', name: 'East Gantry', x: 24, y: 3, w: 10, h: 7, tone: 'objective', elevation: 1, lighting: 'normal' }, + { id: 'maintenance', name: 'Fuel Line', x: 28, y: 19, w: 8, h: 8, tone: 'hazard', elevation: 0, lighting: 'dim' }, + { id: 'vault', name: 'Breach Crater', x: 20, y: 11, w: 10, h: 6, tone: 'vault', elevation: 0, lighting: 'dark' }, + ], + stations: [ + { id: 'left-station', name: 'West Vox Relay', zoneId: 'left-wing', x: 15, y: 8 }, + { id: 'right-station', name: 'East Loader Controls', zoneId: 'right-wing', x: 29, y: 8 }, + { id: 'maintenance-station', name: 'Fuel Cutoff', zoneId: 'maintenance', x: 32, y: 23 }, + ], + spawnPoints: [ + { id: 'north-hatch', name: 'North maglift', x: 19, y: 1 }, + { id: 'east-corridor', name: 'East dock tunnel', x: 38, y: 20 }, + { id: 'south-tunnel', name: 'South rubble path', x: 14, y: 29 }, + { id: 'vault-breach', name: 'Central breach', x: 25, y: 14 }, + ], + cover: [ + { id: 'lz-crates', kind: 'light', material: 'Light Metal', x: 3, y: 20, w: 2, h: 2, label: 'Crates' }, + { id: 'lz-wreck', kind: 'heavy', material: 'Rockcrete', x: 6, y: 23, w: 2, h: 2, label: 'Wreckage' }, + { id: 'cargo-1', kind: 'light', material: 'Heavy Wood', x: 12, y: 18, w: 1, h: 5, label: 'Cargo stack' }, + { id: 'cargo-2', kind: 'light', material: 'Heavy Wood', x: 18, y: 18, w: 1, h: 5, label: 'Cargo stack' }, + { id: 'cargo-3', kind: 'light', material: 'Heavy Wood', x: 24, y: 18, w: 1, h: 5, label: 'Cargo stack' }, + { id: 'west-gunrail', kind: 'heavy', material: 'Plasteel', x: 12, y: 5, w: 3, h: 2, label: 'Gunrail' }, + { id: 'east-gunrail', kind: 'heavy', material: 'Plasteel', x: 26, y: 5, w: 3, h: 2, label: 'Gunrail' }, + { id: 'breach-wreck', kind: 'heavy', material: 'Rockcrete', x: 22, y: 12, w: 2, h: 2, label: 'Breach wreck' }, + { id: 'fuel-pipe', kind: 'heavy', material: 'Thick Iron', x: 30, y: 21, w: 2, h: 3, label: 'Fuel pipe' }, + ], + blockers: [ + { id: 'shuttle-tail', material: 'Plasteel', x: 8, y: 23, w: 3, h: 2, label: 'Shuttle tail' }, + { id: 'collapsed-bus', material: 'Rockcrete', x: 20, y: 16, w: 4, h: 2, label: 'Collapsed bus' }, + ], + hazards: [ + { id: 'burning-fuel', kind: 'difficult', x: 28, y: 24, w: 4, h: 2, label: 'Burning fuel' }, + { id: 'open-breach', kind: 'agility', x: 23, y: 13, w: 3, h: 2, label: 'Open breach' }, + ], + }; + } + if (key === 'corridor') { + return { + zones: [ + { id: 'extraction', name: 'Rear Bulkhead', x: 1, y: 12, w: 6, h: 6, tone: 'spawn', elevation: 0, lighting: 'bright' }, + { id: 'main-corridor', name: 'Main Corridor', x: 7, y: 12, w: 26, h: 6, tone: 'corridor', elevation: 0, lighting: 'normal' }, + { id: 'left-wing', name: 'West Junction', x: 14, y: 3, w: 8, h: 7, tone: 'objective', elevation: 0, lighting: 'normal' }, + { id: 'right-wing', name: 'East Junction', x: 25, y: 20, w: 8, h: 7, tone: 'objective', elevation: 0, lighting: 'dim' }, + { id: 'maintenance', name: 'Service Ducts', x: 33, y: 11, w: 5, h: 8, tone: 'hazard', elevation: 0, lighting: 'dark' }, + { id: 'vault', name: 'Lockdown Node', x: 22, y: 12, w: 8, h: 6, tone: 'vault', elevation: 0, lighting: 'dark' }, + ], + stations: [ + { id: 'left-station', name: 'Junction Console', zoneId: 'left-wing', x: 18, y: 6 }, + { id: 'right-station', name: 'Blast Door Relay', zoneId: 'right-wing', x: 29, y: 24 }, + { id: 'maintenance-station', name: 'Vent Purge Controls', zoneId: 'maintenance', x: 35, y: 15 }, + ], + spawnPoints: [ + { id: 'north-hatch', name: 'Ceiling hatch', x: 18, y: 1 }, + { id: 'east-corridor', name: 'Forward seal', x: 38, y: 15 }, + { id: 'south-tunnel', name: 'Lower conduit', x: 28, y: 29 }, + { id: 'vault-breach', name: 'Lockdown iris', x: 26, y: 15 }, + ], + cover: [ + { id: 'rear-crates', kind: 'light', material: 'Light Metal', x: 3, y: 13, w: 2, h: 2, label: 'Crates' }, + { id: 'corridor-1', kind: 'light', material: 'Heavy Wood', x: 11, y: 12, w: 1, h: 6, label: 'Bulkhead' }, + { id: 'corridor-2', kind: 'light', material: 'Heavy Wood', x: 17, y: 12, w: 1, h: 6, label: 'Bulkhead' }, + { id: 'corridor-3', kind: 'light', material: 'Heavy Wood', x: 31, y: 12, w: 1, h: 6, label: 'Bulkhead' }, + { id: 'west-junction', kind: 'heavy', material: 'Plasteel', x: 15, y: 4, w: 2, h: 2, label: 'Junction cover' }, + { id: 'east-junction', kind: 'heavy', material: 'Plasteel', x: 27, y: 22, w: 2, h: 2, label: 'Junction cover' }, + { id: 'node-cover', kind: 'heavy', material: 'Rockcrete', x: 24, y: 13, w: 2, h: 2, label: 'Node cover' }, + ], + blockers: [ + { id: 'sealed-door-west', material: 'Plasteel', x: 13, y: 10, w: 2, h: 2, label: 'Sealed door' }, + { id: 'sealed-door-east', material: 'Plasteel', x: 25, y: 18, w: 2, h: 2, label: 'Sealed door' }, + ], + hazards: [ + { id: 'steam', kind: 'vent', x: 34, y: 13, w: 2, h: 2, label: 'Steam vent' }, + { id: 'cables', kind: 'difficult', x: 22, y: 17, w: 3, h: 2, label: 'Cable snare' }, + ], + }; + } + if (key === 'sanctum') { + return { + zones: [ + { id: 'extraction', name: 'Nave Entry', x: 1, y: 12, w: 7, h: 7, tone: 'spawn', elevation: 0, lighting: 'normal' }, + { id: 'main-corridor', name: 'Central Nave', x: 8, y: 12, w: 24, h: 6, tone: 'corridor', elevation: 0, lighting: 'dim' }, + { id: 'left-wing', name: 'Choir Gallery', x: 11, y: 3, w: 8, h: 7, tone: 'objective', elevation: 1, lighting: 'bright' }, + { id: 'right-wing', name: 'Reliquary', x: 23, y: 3, w: 8, h: 7, tone: 'objective', elevation: 1, lighting: 'dim' }, + { id: 'maintenance', name: 'Crypt Passage', x: 16, y: 22, w: 10, h: 6, tone: 'hazard', elevation: -1, lighting: 'dark' }, + { id: 'vault', name: 'Sanctum Dais', x: 21, y: 11, w: 10, h: 8, tone: 'vault', elevation: 1, lighting: 'glow' }, + ], + stations: [ + { id: 'left-station', name: 'Choir Vox Pulpit', zoneId: 'left-wing', x: 15, y: 6 }, + { id: 'right-station', name: 'Reliquary Seal', zoneId: 'right-wing', x: 27, y: 6 }, + { id: 'maintenance-station', name: 'Crypt Winch', zoneId: 'maintenance', x: 21, y: 25 }, + ], + spawnPoints: [ + { id: 'north-hatch', name: 'Rose window breach', x: 21, y: 1 }, + { id: 'east-corridor', name: 'Reliquary side door', x: 38, y: 11 }, + { id: 'south-tunnel', name: 'Crypt stair', x: 21, y: 29 }, + { id: 'vault-breach', name: 'Dais warp-rift', x: 27, y: 14 }, + ], + cover: [ + { id: 'nave-statue-1', kind: 'heavy', material: 'Stone', x: 12, y: 13, w: 2, h: 2, label: 'Statue' }, + { id: 'nave-statue-2', kind: 'heavy', material: 'Stone', x: 18, y: 13, w: 2, h: 2, label: 'Statue' }, + { id: 'nave-statue-3', kind: 'heavy', material: 'Stone', x: 24, y: 13, w: 2, h: 2, label: 'Statue' }, + { id: 'nave-statue-4', kind: 'heavy', material: 'Stone', x: 30, y: 13, w: 2, h: 2, label: 'Statue' }, + { id: 'choir-rail', kind: 'light', material: 'Heavy Wood', x: 13, y: 7, w: 4, h: 1, label: 'Rail' }, + { id: 'reliquary-screen', kind: 'light', material: 'Armour-glas', x: 25, y: 7, w: 4, h: 1, label: 'Screen' }, + { id: 'dais-pillar-1', kind: 'heavy', material: 'Rockcrete', x: 23, y: 12, w: 2, h: 2, label: 'Pillar' }, + { id: 'dais-pillar-2', kind: 'heavy', material: 'Rockcrete', x: 27, y: 16, w: 2, h: 2, label: 'Pillar' }, + ], + blockers: [ + { id: 'fallen-bell', material: 'Thick Iron', x: 19, y: 20, w: 3, h: 2, label: 'Fallen bell' }, + ], + hazards: [ + { id: 'warp-flame', kind: 'vent', x: 25, y: 14, w: 2, h: 2, label: 'Warp flame' }, + { id: 'broken-tiles', kind: 'agility', x: 17, y: 23, w: 4, h: 2, label: 'Broken tiles' }, + ], + }; + } + return { + zones: ZONES, + stations: STATIONS, + spawnPoints: SPAWN_POINTS, + cover: COVER_OBJECTS, + blockers: BLOCKERS, + hazards: HAZARDS, + }; +} + +function titleForScene(scene, index) { + return scene?.title || scene?.name || `Scene ${index + 1}`; +} + +function sceneEnemyObjects(scene) { + if (!Array.isArray(scene?.enemies)) return []; + return scene.enemies + .map(enemy => (typeof enemy === 'string' ? { name: enemy } : enemy)) + .filter(enemy => enemy?.name || enemy?.type) + .map((enemy, index) => ({ ...enemy, name: enemy.name || enemy.type || `Enemy ${index + 1}` })); +} + +function playerEntriesForScene(scene, authedPlayer) { + const initiatives = Array.isArray(scene?.combatState?.initiatives) ? scene.combatState.initiatives : []; + const names = initiatives + .filter(entry => String(entry?.type || 'player').toLowerCase() !== 'npc') + .map(entry => String(entry.player || entry.name || '').trim()) + .filter(Boolean); + if (!names.length && authedPlayer && authedPlayer !== 'gm') return [authedPlayer]; + return names.filter((name, index, list) => list.indexOf(name) === index); +} + +function currentTurnName(scene) { + const turnKey = scene?.combatState?.turnKey || ''; + const initiatives = Array.isArray(scene?.combatState?.initiatives) ? scene.combatState.initiatives : []; + const current = initiatives.find((entry) => { + const name = String(entry.player || entry.name || '').trim().toLowerCase(); + const type = String(entry.type || 'player').toLowerCase(); + const key = type === 'npc' + ? `npc:${name}:${entry.ts ?? entry.createdAt ?? entry.created_at ?? entry.dieRoll ?? entry.total ?? ''}` + : `player:${name}`; + return key === turnKey; + }); + return String(current?.player || current?.name || '').trim(); +} + +function defaultObjectiveState(existing) { + const prior = existing && typeof existing === 'object' ? existing : {}; + return { + stations: STATIONS.map((station) => ({ + id: station.id, + active: Boolean(prior.stations?.find(item => item.id === station.id)?.active), + })), + geneCoreSecuredRounds: Math.max(0, Number(prior.geneCoreSecuredRounds || 0) || 0), + extractionProgress: Math.max(0, Number(prior.extractionProgress || 0) || 0), + carrier: String(prior.carrier || ''), + activeSpawnId: prior.activeSpawnId || SPAWN_POINTS[0].id, + }; +} + +function normalizeToken(token, index) { + return { + id: token.id || `token-${index + 1}`, + name: token.name || `Token ${index + 1}`, + side: token.side === 'enemy' ? 'enemy' : 'player', + x: Number.isFinite(Number(token.x)) ? Number(token.x) : 1.5, + y: Number.isFinite(Number(token.y)) ? Number(token.y) : 1.5, + hp: token.hp ?? null, + maxHp: token.maxHp ?? null, + color: token.color || (token.side === 'enemy' ? '#ef4444' : '#38bdf8'), + }; +} + +function normalizeTokens(tokens) { + return Array.isArray(tokens) ? tokens.map(normalizeToken) : []; +} + +function zoneById(layout, id) { + return (layout?.zones || ZONES).find(zone => zone.id === id) || null; +} + +function tokenAnchorPositions(players, enemies, layout = null) { + const extraction = zoneById(layout, 'extraction'); + const vault = zoneById(layout, 'vault'); + const maintenance = zoneById(layout, 'maintenance'); + const rightWing = zoneById(layout, 'right-wing'); + // A couple of the default anchors are placed on top of COVER_OBJECTS + // (crates/wreckage/pillars/tank) so cover bonuses actually get exercised + // by default, instead of every token always starting in the open. + const playerAnchors = [ + [extraction.x + 3, extraction.y + 2], // on ext-crates-1 + [extraction.x + 4.5, extraction.y + 1.5], + [extraction.x + 1.5, extraction.y + 4.5], + [extraction.x + 6, extraction.y + 6], // on ext-wreck-1 + [extraction.x + 6.5, extraction.y + 3.5], + ]; + const enemyAnchors = [ + [vault.x + 8.5, vault.y + 5.5], + [rightWing.x + 6.5, rightWing.y + 2.5], + [maintenance.x + 8.5, maintenance.y + 2.5], + [vault.x + 3.5, vault.y + 6], // on vault-pillar-3 + [maintenance.x + 2.5, maintenance.y + 1.5], // on maint-pipe-1 + ]; + return { + players: players.map((_, index) => playerAnchors[index % playerAnchors.length]), + enemies: enemies.map((_, index) => enemyAnchors[index % enemyAnchors.length]), + }; +} + +function buildTokensForScene(scene, authedPlayer, existingTokens = []) { + const previousByName = new Map(existingTokens.map(token => [String(token.name).toLowerCase(), token])); + const players = playerEntriesForScene(scene, authedPlayer); + const enemies = sceneEnemyObjects(scene); + const layout = createLayoutSpec(scene); + const anchors = tokenAnchorPositions(players, enemies, layout); + + const nextTokens = players.map((name, index) => { + const previous = previousByName.get(name.toLowerCase()); + const [x, y] = anchors.players[index]; + return normalizeToken({ + id: `player:${name.toLowerCase()}`, + name, + side: 'player', + x: previous ? previous.x : x, + y: previous ? previous.y : y, + color: '#38bdf8', + }, index); + }); + + enemies.forEach((enemy, index) => { + const previous = previousByName.get(enemy.name.toLowerCase()); + const [x, y] = anchors.enemies[index]; + nextTokens.push(normalizeToken({ + id: `enemy:${enemy.name.toLowerCase()}:${index + 1}`, + name: enemy.name, + side: 'enemy', + x: previous ? previous.x : x, + y: previous ? previous.y : y, + hp: enemy.wounds ?? enemy.magnitude ?? null, + maxHp: enemy.wounds ?? enemy.magnitude ?? null, + color: '#ef4444', + }, players.length + index)); + }); + + return nextTokens; +} + +function createLayoutSpec(scene) { + const theme = sceneBattlemapTheme(scene); + const preset = layoutPresetForKey(theme.layoutKey); + return { + title: theme.boardTitle, + summary: theme.boardSummary, + gridWidth: GRID_WIDTH, + gridHeight: GRID_HEIGHT, + zones: preset.zones, + stations: preset.stations, + spawnPoints: preset.spawnPoints, + cover: preset.cover, + blockers: preset.blockers, + hazards: preset.hazards, + dynamicFeatures: DYNAMIC_FEATURES, + viewBox: `0 0 ${GRID_WIDTH} ${GRID_HEIGHT}`, + }; +} + +function normalizeLayoutSpec(scene, existingLayout) { + const base = createLayoutSpec(scene); + const incoming = existingLayout && typeof existingLayout === 'object' ? existingLayout : {}; + return { + ...base, + ...incoming, + gridWidth: Number(incoming.gridWidth || base.gridWidth) || base.gridWidth, + gridHeight: Number(incoming.gridHeight || base.gridHeight) || base.gridHeight, + title: incoming.title || base.title, + summary: incoming.summary || base.summary, + zones: Array.isArray(incoming.zones) && incoming.zones.length ? incoming.zones : base.zones, + stations: Array.isArray(incoming.stations) && incoming.stations.length ? incoming.stations : base.stations, + spawnPoints: Array.isArray(incoming.spawnPoints) && incoming.spawnPoints.length ? incoming.spawnPoints : base.spawnPoints, + cover: Array.isArray(incoming.cover) ? incoming.cover : base.cover, + blockers: Array.isArray(incoming.blockers) ? incoming.blockers : base.blockers, + hazards: Array.isArray(incoming.hazards) ? incoming.hazards : base.hazards, + dynamicFeatures: Array.isArray(incoming.dynamicFeatures) ? incoming.dynamicFeatures : base.dynamicFeatures, + viewBox: incoming.viewBox || base.viewBox, + }; +} + +function buildBattlemapState(scene, authedPlayer, existingState) { + const prior = existingState && typeof existingState === 'object' ? existingState : {}; + const turnKey = scene?.combatState?.turnKey || ''; + return { + layout: normalizeLayoutSpec(scene, prior.layout), + tokens: buildTokensForScene(scene, authedPlayer, normalizeTokens(prior.tokens)), + objectives: defaultObjectiveState(prior.objectives), + turnState: normalizeTurnState(prior.turnState, turnKey), + updatedAt: Date.now(), + }; +} + +function initialBoardFromMission(mission, authedPlayer) { + const sceneIndex = mission?.active_scene_index ?? mission?.current_scene ?? 0; + const scene = Array.isArray(mission?.scenes) ? mission.scenes[sceneIndex] || mission.scenes[0] || null : null; + if (!scene) return null; + const visible = battlemapVisibleForPlayer(scene, authedPlayer); + return { + missionId: mission.id, + missionName: mission.name || 'Active mission', + sceneIndex, + sceneTitle: titleForScene(scene, sceneIndex), + scene, + battlemapVisible: visible, + battlemapState: visible || authedPlayer === 'gm' ? buildBattlemapState(scene, authedPlayer, scene.battlemapState) : null, + }; +} + +function snapCoordinate(value, max) { + const snapped = Math.round(value - 0.5) + 0.5; + return Math.max(0.5, Math.min(max - 0.5, snapped)); +} + +function findZoneForPoint(layout, x, y) { + const zones = Array.isArray(layout?.zones) ? layout.zones : []; + return zones.find(zone => x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) || null; +} + +function fillForZoneTone(tone) { + if (tone === 'spawn') return '#7c2d12'; + if (tone === 'corridor') return '#1d4ed8'; + if (tone === 'objective') return '#991b1b'; + if (tone === 'hazard') return '#0f766e'; + if (tone === 'vault') return '#4c1d95'; + return '#334155'; +} + +function lightingOverlay(lighting) { + if (lighting === 'dark') return { fill: 'url(#bm-darkness)', opacity: 0.5, label: 'Darkness' }; + if (lighting === 'dim') return { fill: 'url(#bm-dimness)', opacity: 0.35, label: 'Dim light' }; + if (lighting === 'glow') return { fill: 'url(#bm-glow)', opacity: 0.32, label: 'Warp glow' }; + return null; +} + +function elevationStroke(elevation) { + if (Number(elevation) > 0) return '#facc15'; + if (Number(elevation) < 0) return '#22d3ee'; + return 'rgba(226,232,240,0.8)'; +} + +function elevationBadge(elevation) { + if (Number(elevation) > 0) return `▲${Number(elevation)}`; + if (Number(elevation) < 0) return `▼${Math.abs(Number(elevation))}`; + return ''; +} + +function fillForCover(kind) { + if (kind === 'heavy') return '#b45309'; + return '#cbd5e1'; +} + +function fillForHazard(kind) { + if (kind === 'difficult') return '#0f766e'; + if (kind === 'agility') return '#f59e0b'; + if (kind === 'no-charge') return '#b91c1c'; + return '#14b8a6'; +} + +function stationMeta(layout, id) { + const stations = Array.isArray(layout?.stations) ? layout.stations : STATIONS; + return stations.find(station => station.id === id) || null; +} + +function coverPatternId(kind) { + return kind === 'heavy' ? 'bm-cover-heavy' : 'bm-cover-light'; +} + +function hazardPatternId(kind) { + if (kind === 'vent') return 'bm-hazard-vent'; + if (kind === 'agility') return 'bm-hazard-agility'; + return 'bm-hazard-default'; +} + +// Exported for the layout-validation regression test (battlemapTabLayout.test.js) +// only — not part of the component's public API. +export { ZONES as __ZONES, COVER_OBJECTS as __COVER_OBJECTS, tokenAnchorPositions as __tokenAnchorPositions }; + +export default function BattlemapTab({ authedPlayer, sessionId }) { + const isGM = authedPlayer === 'gm'; + const svgRef = useRef(null); + const [board, setBoard] = useState(null); + const [selectedTokenId, setSelectedTokenId] = useState(''); + const [status, setStatus] = useState('Loading active mission battlemap...'); + const [saving, setSaving] = useState(false); + const [playerMovementByName, setPlayerMovementByName] = useState({}); + const [armedMoveMode, setArmedMoveMode] = useState(''); + + const loadBoard = useCallback(async (silent = false) => { + if (!silent) setStatus('Loading active mission battlemap...'); + try { + const endpoint = isGM ? '/api/missions/active/current' : '/api/missions/active/player'; + const headers = sessionId ? { 'x-session-id': sessionId } : {}; + const [response, playersResponse] = await Promise.all([ + axios.get(endpoint), + sessionId ? axios.get('/api/players', { headers }) : Promise.resolve({ data: [] }), + ]); + const nextBoard = initialBoardFromMission(response.data, authedPlayer); + const playerRows = Array.isArray(playersResponse?.data) ? playersResponse.data : []; + const movementMap = playerRows.reduce((acc, player) => { + acc[player.name] = normalizeMovement(player?.tabInfo?.movement || {}); + return acc; + }, {}); + setPlayerMovementByName(movementMap); + if (!nextBoard) { + setBoard(null); + setStatus('No active mission loaded.'); + return; + } + setBoard(nextBoard); + setSelectedTokenId((current) => { + if (!nextBoard.battlemapState?.tokens?.length) return ''; + const ownToken = !isGM ? nextBoard.battlemapState.tokens.find(token => token.side === 'player' && token.name === authedPlayer) : null; + if (ownToken) return ownToken.id; + return current && nextBoard.battlemapState.tokens.some(token => token.id === current) ? current : ''; + }); + if (!silent) { + setStatus( + nextBoard.battlemapVisible || isGM + ? (isGM ? 'Battlemap synced to active mission. GM controls token positions and objective state.' : 'Battlemap synced to active mission. Player movement follows turn and move limits.') + : 'Battlemap is still hidden by the GM.' + ); + } + } catch (error) { + setStatus(`Failed to load battlemap: ${error.response?.data?.error || error.message}`); + } + }, [authedPlayer, isGM, sessionId]); + + useEffect(() => { + loadBoard(); + const timer = setInterval(() => loadBoard(true), 5000); + return () => clearInterval(timer); + }, [loadBoard]); + + const persistState = useCallback(async (nextBattlemapState, nextStatus) => { + if (!isGM) return; + setSaving(true); + try { + await axios.put('/api/missions/active/scene', { + patch: { battlemapState: { ...nextBattlemapState, updatedAt: Date.now() } }, + }); + setStatus(nextStatus); + } catch (error) { + setStatus(`Failed to save battlemap: ${error.response?.data?.error || error.message}`); + } finally { + setSaving(false); + } + }, [isGM]); + + const applyBattlemapState = useCallback((nextBattlemapState, nextStatus, persist = true) => { + setBoard((current) => { + if (!current) return current; + return { + ...current, + battlemapState: { + ...nextBattlemapState, + updatedAt: Date.now(), + }, + }; + }); + if (persist) persistState(nextBattlemapState, nextStatus); + else setStatus(nextStatus); + }, [persistState]); + + const selectedToken = useMemo( + () => board?.battlemapState?.tokens?.find(token => token.id === selectedTokenId) || null, + [board, selectedTokenId] + ); + + const currentActor = currentTurnName(board?.scene); + const currentTurnKey = board?.scene?.combatState?.turnKey || ''; + const currentBattleTurnState = useMemo( + () => normalizeTurnState(board?.battlemapState?.turnState, currentTurnKey), + [board?.battlemapState?.turnState, currentTurnKey] + ); + const tokenCounts = useMemo(() => { + const tokens = board?.battlemapState?.tokens || []; + return { + players: tokens.filter(token => token.side === 'player').length, + enemies: tokens.filter(token => token.side === 'enemy').length, + }; + }, [board]); + + const activeStations = useMemo(() => { + const stations = board?.battlemapState?.objectives?.stations || []; + return stations.filter(station => station.active).length; + }, [board]); + const extractionComplete = Number(board?.battlemapState?.objectives?.extractionProgress || 0) >= 2; + + const selectedZone = selectedToken ? findZoneForPoint(board?.battlemapState?.layout, selectedToken.x, selectedToken.y) : null; + const selectedMoveMode = selectedToken ? movementModeLocked(currentBattleTurnState, selectedToken.id) : ''; + const selectedMoveSpent = selectedToken ? movementSpent(currentBattleTurnState, selectedToken.id) : 0; + + useEffect(() => { + setArmedMoveMode(''); + }, [currentTurnKey, selectedTokenId]); + + const syncCombatants = () => { + if (!board || !isGM) return; + const nextState = buildBattlemapState(board.scene, authedPlayer, board.battlemapState); + applyBattlemapState(nextState, 'Tokens resynced from the active mission scene.'); + }; + + const toggleBattlemapVisibility = () => { + if (!board || !isGM) return; + const currentReveal = board.scene?.reveal || {}; + axios.put('/api/missions/active/scene', { + patch: { reveal: { ...currentReveal, battlemap: !board.battlemapVisible } }, + }).then(() => loadBoard()).catch((error) => { + setStatus(`Failed to update battlemap visibility: ${error.response?.data?.error || error.message}`); + }); + }; + + const moveSelectedToken = (x, y, moveMeta = null) => { + if (!board || !selectedToken) return; + const nextTokens = board.battlemapState.tokens.map((token) => ( + token.id === selectedToken.id + ? { ...token, x: snapCoordinate(x, GRID_WIDTH), y: snapCoordinate(y, GRID_HEIGHT) } + : token + )); + const priorTurnState = normalizeTurnState(board.battlemapState.turnState, currentTurnKey); + const nextTurnState = moveMeta?.consumeTurn + ? { + ...priorTurnState, + movedBy: { + ...priorTurnState.movedBy, + [selectedToken.id]: currentTurnKey || 'free', + }, + movementUsedBy: { + ...priorTurnState.movementUsedBy, + [selectedToken.id]: movementSpent(priorTurnState, selectedToken.id) + Math.max(0, Number(moveMeta?.cost || 0) || 0), + }, + moveModeBy: { + ...priorTurnState.moveModeBy, + [selectedToken.id]: String(moveMeta?.mode || ''), + }, + } + : priorTurnState; + applyBattlemapState({ ...board.battlemapState, tokens: nextTokens, turnState: nextTurnState }, `${selectedToken.name} moved${moveMeta?.label ? ` (${moveMeta.label})` : ''}.`); + }; + + const handleCanvasClick = (event) => { + if (!selectedToken || !svgRef.current || (!isGM && selectedToken.name !== authedPlayer)) return; + const bounds = svgRef.current.getBoundingClientRect(); + if (!bounds.width || !bounds.height) return; + const x = ((event.clientX - bounds.left) / bounds.width) * GRID_WIDTH; + const y = ((event.clientY - bounds.top) / bounds.height) * GRID_HEIGHT; + if (armedMoveMode && selectedToken.side === 'player') { + const moveProfile = playerMovementByName[selectedToken.name] || normalizeMovement(); + const isCarrier = board?.battlemapState?.objectives?.carrier === selectedToken.name; + const budget = movementBudget(moveProfile, armedMoveMode, isCarrier); + if (budget <= 0) { + setStatus('Carrier cannot use that movement mode.'); + return; + } + const turnState = currentBattleTurnState; + const lockedMode = movementModeLocked(turnState, selectedToken.id); + const spent = movementSpent(turnState, selectedToken.id); + const remaining = Math.max(0, budget - spent); + const actorCanMove = isGM || selectedToken.name === currentActor; + if (!actorCanMove) { + setStatus('Only the active combatant can take a movement action.'); + return; + } + if (lockedMode && lockedMode !== armedMoveMode) { + setStatus(`${selectedToken.name} is already using ${lockedMode} movement this turn.`); + return; + } + if (remaining <= 0) { + setStatus(`${selectedToken.name} has used all ${budget} checker(s) of ${armedMoveMode} movement this turn.`); + return; + } + const fromCell = pointToCell(selectedToken); + const targetCell = pointToCell({ x, y }); + const stepDistance = Math.max(Math.abs(targetCell.x - fromCell.x), Math.abs(targetCell.y - fromCell.y)); + if (stepDistance !== 1) { + setStatus('Movement is checker-by-checker: click one adjacent square at a time.'); + return; + } + const occupied = (board?.battlemapState?.tokens || []) + .filter((token) => token.id !== selectedToken.id) + .map((token) => ({ x: token.x, y: token.y })); + const next = moveWithinRange(selectedToken, { x, y }, remaining, { width: GRID_WIDTH, height: GRID_HEIGHT }, { + layout: board?.battlemapState?.layout, + occupied, + }); + if (pointToCell(next).x === fromCell.x && pointToCell(next).y === fromCell.y) { + setStatus('That square is blocked by a wall, door line, blocker, unit, or terrain cost beyond your remaining movement.'); + return; + } + const spentNow = Math.max(1, Number(next.travelled || 0) || 0); + moveSelectedToken(next.x, next.y, { consumeTurn: true, label: armedMoveMode, mode: armedMoveMode, cost: spentNow }); + if ((spent + spentNow) >= budget) setArmedMoveMode(''); + return; + } + if (isGM) moveSelectedToken(x, y); + }; + + const nudgeSelectedToken = (dx, dy) => { + if (!selectedToken || !isGM) return; + moveSelectedToken(selectedToken.x + dx, selectedToken.y + dy); + }; + + const scriptEnemyTurn = () => { + if (!board || !board.battlemapState || !isGM) return; + const enemyToken = board.battlemapState.tokens.find(token => token.side === 'enemy' && token.name === currentActor) + || board.battlemapState.tokens.find(token => token.side === 'enemy'); + if (!enemyToken) { + setStatus('No enemy token available for scripted movement.'); + return; + } + const playerTokens = board.battlemapState.tokens.filter(token => token.side === 'player'); + const scripted = enemyMoveTowardNearest(enemyToken, playerTokens, 1, { width: GRID_WIDTH, height: GRID_HEIGHT }, { + layout: board?.battlemapState?.layout, + occupied: board.battlemapState.tokens + .filter((token) => token.id !== enemyToken.id) + .map((token) => ({ x: token.x, y: token.y })), + }); + if (!scripted) { + setStatus('No valid player target for enemy script.'); + return; + } + const nextTokens = board.battlemapState.tokens.map((token) => ( + token.id === enemyToken.id ? { ...token, x: scripted.x, y: scripted.y } : token + )); + applyBattlemapState({ ...board.battlemapState, tokens: nextTokens, turnState: normalizeTurnState(board.battlemapState.turnState, currentTurnKey) }, `${enemyToken.name} advances toward ${scripted.target.name}.`); + }; + + const toggleStation = (stationId) => { + if (!board || !isGM) return; + const nextObjectives = { + ...board.battlemapState.objectives, + stations: board.battlemapState.objectives.stations.map((station) => ( + station.id === stationId ? { ...station, active: !station.active } : station + )), + }; + const meta = stationMeta(board?.battlemapState?.layout, stationId); + applyBattlemapState({ ...board.battlemapState, objectives: nextObjectives }, `${meta?.name || 'Station'} updated.`); + }; + + const adjustCoreRounds = (delta) => { + if (!board || !isGM) return; + const nextObjectives = { + ...board.battlemapState.objectives, + geneCoreSecuredRounds: Math.max(0, Math.min(9, Number(board.battlemapState.objectives.geneCoreSecuredRounds || 0) + delta)), + }; + applyBattlemapState({ ...board.battlemapState, objectives: nextObjectives }, 'Gene-core hold tracker updated.'); + }; + + const adjustExtractionProgress = (delta) => { + if (!board || !isGM) return; + const nextObjectives = { + ...board.battlemapState.objectives, + extractionProgress: Math.max(0, Math.min(2, Number(board.battlemapState.objectives.extractionProgress || 0) + delta)), + }; + applyBattlemapState({ ...board.battlemapState, objectives: nextObjectives }, 'Extraction tracker updated.'); + }; + + const assignCarrier = () => { + if (!board || !isGM || !selectedToken || selectedToken.side !== 'player') return; + const nextObjectives = { + ...board.battlemapState.objectives, + carrier: selectedToken.name, + }; + applyBattlemapState({ ...board.battlemapState, objectives: nextObjectives }, `${selectedToken.name} is now carrying the gene-core.`); + }; + + const cycleSpawn = () => { + if (!board || !isGM) return; + const currentId = board.battlemapState.objectives.activeSpawnId; + const currentIndex = Math.max(0, SPAWN_POINTS.findIndex(point => point.id === currentId)); + const next = SPAWN_POINTS[(currentIndex + 1) % SPAWN_POINTS.length]; + const nextObjectives = { + ...board.battlemapState.objectives, + activeSpawnId: next.id, + }; + applyBattlemapState({ ...board.battlemapState, objectives: nextObjectives }, `Active reinforcement point: ${next.name}.`); + }; + + if (!board) { + return ( + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-6 shadow-lg"> + <h2 className="text-2xl font-bold text-slate-100">Battlemap</h2> + <p className="mt-3 text-sm text-slate-300">{status}</p> + </div> + ); + } + + if (!board.battlemapVisible && !isGM) { + return ( + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-6 shadow-lg"> + <h2 className="text-2xl font-bold text-slate-100">Battlemap</h2> + <p className="mt-3 text-sm text-slate-300">The GM has not revealed the battlemap for this scene yet.</p> + </div> + ); + } + + const layout = board.battlemapState.layout; + const tokens = board.battlemapState.tokens; + const objectives = board.battlemapState.objectives; + const activeSpawn = (layout.spawnPoints || SPAWN_POINTS).find(point => point.id === objectives.activeSpawnId) || (layout.spawnPoints || SPAWN_POINTS)[0]; + + return ( + <div className="space-y-4"> + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg"> + <div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between"> + <div> + <h2 className="text-2xl font-bold text-slate-100">{layout.title || board.sceneTitle}</h2> + <p className="mt-1 text-sm text-slate-300"> + Mission: <span className="font-semibold text-slate-100">{board.missionName}</span> · Scene: <span className="font-semibold text-slate-100">{board.sceneTitle}</span> + </p> + <p className="mt-2 max-w-3xl text-sm text-slate-400"> + {layout.summary || 'Fixed 40 x 30 tactical board.'} 1 checker = 10 metres. Built for movement, cover, overwatch, suppression, charge lanes, chokepoints, doorway control, objective pressure, and reinforcement entry. + </p> + </div> + <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3"> + <button + onClick={syncCombatants} + disabled={!isGM || saving} + className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-emerald-500 disabled:opacity-50" + > + Sync Combatants + </button> + <button + onClick={toggleBattlemapVisibility} + disabled={!isGM || saving} + className="rounded-lg bg-indigo-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-600 disabled:opacity-50" + > + {board.battlemapVisible ? 'Hide From Players' : 'Reveal To Players'} + </button> + <button + onClick={cycleSpawn} + disabled={!isGM || saving} + className="rounded-lg bg-rose-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-rose-600 disabled:opacity-50" + > + Cycle Reinforcement + </button> + <button + onClick={scriptEnemyTurn} + disabled={!isGM || saving} + className="rounded-lg bg-amber-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-amber-600 disabled:opacity-50" + > + Script Enemy Move + </button> + <button + onClick={() => loadBoard()} + disabled={saving} + className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-semibold text-slate-100 transition-colors hover:bg-slate-600 disabled:opacity-50" + > + Refresh + </button> + </div> + </div> + <div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-slate-300"> + <span className="rounded bg-slate-800 px-2 py-1">40 x 30 grid</span> + <span className="rounded bg-slate-800 px-2 py-1">{tokenCounts.players} players</span> + <span className="rounded bg-slate-800 px-2 py-1">{tokenCounts.enemies} enemies</span> + <span className="rounded bg-slate-800 px-2 py-1">{activeStations}/3 stations online</span> + <span className="rounded bg-slate-800 px-2 py-1">Gene-Core Held {objectives.geneCoreSecuredRounds}/3</span> + <span className={`rounded px-2 py-1 ${extractionComplete ? 'bg-emerald-800 text-emerald-100' : 'bg-slate-800 text-slate-200'}`}>Extraction {objectives.extractionProgress || 0}/2</span> + {objectives.carrier && <span className="rounded bg-violet-900/80 px-2 py-1 text-violet-100">Carrier {objectives.carrier}</span>} + {currentActor && <span className="rounded bg-amber-700 px-2 py-1 text-amber-100">Turn {currentActor}</span>} + <span className="rounded bg-rose-900/80 px-2 py-1 text-rose-100">Active Spawn {activeSpawn.name}</span> + <span className={`rounded px-2 py-1 ${saving ? 'bg-amber-700 text-amber-100' : 'bg-slate-800 text-slate-200'}`}>{saving ? 'Saving...' : status}</span> + </div> + </div> + + <div className="grid gap-4 xl:grid-cols-[minmax(0,1.75fr)_minmax(340px,1fr)]"> + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-3 shadow-lg"> + <div className="mb-3 flex flex-wrap gap-2 text-xs text-slate-300"> + <span className="rounded bg-slate-800 px-2 py-1">Win: Activate 2 of 3 stabilization stations</span> + <span className="rounded bg-slate-800 px-2 py-1">Hold gene-core 3 rounds</span> + <span className="rounded bg-slate-800 px-2 py-1">Carry the core back to extraction</span> + </div> + <div className="mb-3 flex flex-wrap gap-2 text-[11px] text-slate-300"> + <span className="rounded border border-yellow-500/60 bg-yellow-950/30 px-2 py-1 text-yellow-100">Elevation: amber border + ▲/▼ badge</span> + <span className="rounded border border-emerald-500/60 bg-emerald-950/30 px-2 py-1 text-emerald-100">Doors: white bars between rooms</span> + <span className="rounded border border-slate-500/60 bg-slate-800 px-2 py-1 text-slate-100">Light cover: pale steel + diagonal slashes</span> + <span className="rounded border border-amber-700/60 bg-amber-950/40 px-2 py-1 text-amber-100">Heavy cover: bronze + crosshatch</span> + <span className="rounded border border-indigo-500/60 bg-indigo-950/30 px-2 py-1 text-indigo-100">Darkness: blue overlay dots</span> + <span className="rounded border border-cyan-500/60 bg-cyan-950/30 px-2 py-1 text-cyan-100">Hazards: cyan/orange dashed zones</span> + </div> + <div className="overflow-hidden rounded-lg border border-slate-700 bg-slate-950"> + <svg + ref={svgRef} + data-testid="battlemap-canvas" + viewBox={layout.viewBox} + className={`h-[70vh] min-h-[420px] w-full ${(isGM && selectedToken) || armedMoveMode ? 'cursor-crosshair' : ''}`} + role="img" + aria-label="Gene-vault battlemap" + onClick={handleCanvasClick} + > + <rect x="0" y="0" width={GRID_WIDTH} height={GRID_HEIGHT} fill="#020617" /> + <defs> + <pattern id="bm-darkness" width="1.6" height="1.6" patternUnits="userSpaceOnUse"> + <rect width="1.6" height="1.6" fill="rgba(15,23,42,0.6)" /> + <circle cx="0.45" cy="0.45" r="0.12" fill="rgba(129,140,248,0.6)" /> + <circle cx="1.2" cy="1.1" r="0.12" fill="rgba(129,140,248,0.45)" /> + </pattern> + <pattern id="bm-dimness" width="2" height="2" patternUnits="userSpaceOnUse"> + <rect width="2" height="2" fill="rgba(30,41,59,0.18)" /> + <line x1="0" y1="2" x2="2" y2="0" stroke="rgba(191,219,254,0.18)" strokeWidth="0.08" /> + </pattern> + <radialGradient id="bm-glow" cx="50%" cy="50%" r="60%"> + <stop offset="0%" stopColor="rgba(196,181,253,0.48)" /> + <stop offset="55%" stopColor="rgba(168,85,247,0.22)" /> + <stop offset="100%" stopColor="rgba(30,41,59,0)" /> + </radialGradient> + <pattern id="bm-cover-light" width="0.8" height="0.8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"> + <line x1="0" y1="0" x2="0" y2="0.8" stroke="rgba(15,23,42,0.38)" strokeWidth="0.12" /> + </pattern> + <pattern id="bm-cover-heavy" width="1.1" height="1.1" patternUnits="userSpaceOnUse"> + <line x1="0" y1="0" x2="1.1" y2="1.1" stroke="rgba(15,23,42,0.45)" strokeWidth="0.12" /> + <line x1="1.1" y1="0" x2="0" y2="1.1" stroke="rgba(15,23,42,0.45)" strokeWidth="0.12" /> + </pattern> + <pattern id="bm-hazard-default" width="0.9" height="0.9" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"> + <line x1="0" y1="0" x2="0" y2="0.9" stroke="rgba(240,253,250,0.55)" strokeWidth="0.08" /> + </pattern> + <pattern id="bm-hazard-agility" width="1.2" height="1.2" patternUnits="userSpaceOnUse"> + <path d="M0.15 1 L0.6 0.2 L1.05 1 Z" fill="rgba(255,251,235,0.45)" /> + </pattern> + <pattern id="bm-hazard-vent" width="1.2" height="1.2" patternUnits="userSpaceOnUse"> + <circle cx="0.3" cy="0.35" r="0.12" fill="rgba(240,253,250,0.5)" /> + <circle cx="0.7" cy="0.75" r="0.14" fill="rgba(240,253,250,0.36)" /> + </pattern> + </defs> + {Array.from({ length: GRID_WIDTH + 1 }, (_, index) => ( + <line key={`vx-${index}`} x1={index} y1="0" x2={index} y2={GRID_HEIGHT} stroke="rgba(148,163,184,0.18)" strokeWidth="0.04" /> + ))} + {Array.from({ length: GRID_HEIGHT + 1 }, (_, index) => ( + <line key={`hy-${index}`} x1="0" y1={index} x2={GRID_WIDTH} y2={index} stroke="rgba(148,163,184,0.18)" strokeWidth="0.04" /> + ))} + + {layout.zones.map((zone) => ( + <g key={zone.id}> + <rect x={zone.x} y={zone.y} width={zone.w} height={zone.h} fill={fillForZoneTone(zone.tone)} fillOpacity="0.35" stroke={elevationStroke(zone.elevation)} strokeWidth="0.18" /> + {lightingOverlay(zone.lighting) && ( + <rect + x={zone.x} + y={zone.y} + width={zone.w} + height={zone.h} + fill={lightingOverlay(zone.lighting).fill} + fillOpacity={lightingOverlay(zone.lighting).opacity} + stroke="none" + /> + )} + <text x={zone.x + zone.w / 2} y={zone.y + 0.9} textAnchor="middle" fontSize="0.75" fill="#f8fafc" style={{ fontWeight: 700 }}> + {zone.name} + </text> + {elevationBadge(zone.elevation) && ( + <text x={zone.x + zone.w - 0.5} y={zone.y + zone.h - 0.4} textAnchor="end" fontSize="0.72" fill={elevationStroke(zone.elevation)} style={{ fontWeight: 700 }}> + {elevationBadge(zone.elevation)} + </text> + )} + {lightingOverlay(zone.lighting)?.label && ( + <text x={zone.x + 0.45} y={zone.y + zone.h - 0.4} fontSize="0.55" fill="#cbd5e1"> + {lightingOverlay(zone.lighting).label} + </text> + )} + </g> + ))} + + <rect x="27.5" y="13.5" width="3" height="3" fill="#f8fafc" fillOpacity="0.08" stroke="#f8fafc" strokeWidth="0.12" /> + <text x="29" y="15.3" textAnchor="middle" fontSize="0.7" fill="#e2e8f0" style={{ fontWeight: 700 }}>CORE</text> + + <rect x="18" y="1" width="4" height="1.2" fill="#475569" stroke="#cbd5e1" strokeWidth="0.12" /> + <text x="20" y="1.8" textAnchor="middle" fontSize="0.52" fill="#f8fafc">Raised Gantry</text> + + <rect x="11.5" y="19.2" width="2" height="4" fill="#475569" stroke="#cbd5e1" strokeWidth="0.12" /> + <text x="12.5" y="21.5" textAnchor="middle" fontSize="0.46" fill="#f8fafc">Stairs</text> + + {layout.cover.map((item) => ( + <g key={item.id}> + <rect x={item.x} y={item.y} width={item.w} height={item.h} fill={fillForCover(item.kind)} fillOpacity={item.kind === 'heavy' ? 0.9 : 0.68} stroke="#0f172a" strokeWidth="0.12" /> + <rect x={item.x} y={item.y} width={item.w} height={item.h} fill={`url(#${coverPatternId(item.kind)})`} fillOpacity="0.9" stroke="none" /> + </g> + ))} + + {(layout.doors || []).map((item) => ( + <g key={item.id}> + <rect x={item.x} y={item.y} width={item.w} height={item.h} rx="0.08" fill="#f8fafc" stroke="#0f172a" strokeWidth="0.08" /> + </g> + ))} + + {layout.blockers.map((item) => ( + <g key={item.id}> + <rect x={item.x} y={item.y} width={item.w} height={item.h} fill="#0f172a" stroke="#f8fafc" strokeWidth="0.15" /> + </g> + ))} + + {layout.hazards.map((item) => ( + <g key={item.id}> + <rect x={item.x} y={item.y} width={item.w} height={item.h} fill={fillForHazard(item.kind)} fillOpacity="0.55" stroke="#f8fafc" strokeDasharray="0.25 0.25" strokeWidth="0.12" /> + <rect x={item.x} y={item.y} width={item.w} height={item.h} fill={`url(#${hazardPatternId(item.kind)})`} fillOpacity="0.9" stroke="none" /> + </g> + ))} + + {layout.stations.map((station) => { + const active = objectives.stations.find(item => item.id === station.id)?.active; + return ( + <g key={station.id}> + <rect x={station.x - 0.7} y={station.y - 0.7} width="1.4" height="1.4" fill={active ? '#22c55e' : '#eab308'} stroke="#0f172a" strokeWidth="0.12" /> + <text x={station.x} y={station.y + 0.2} textAnchor="middle" fontSize="0.55" fill="#020617" style={{ fontWeight: 700 }}> + S + </text> + </g> + ); + })} + + {layout.spawnPoints.map((point) => { + const isActiveSpawn = point.id === objectives.activeSpawnId; + return ( + <g key={point.id}> + <circle cx={point.x + 0.5} cy={point.y + 0.5} r="0.55" fill={isActiveSpawn ? '#fb7185' : '#7f1d1d'} stroke="#f8fafc" strokeWidth={isActiveSpawn ? '0.14' : '0.1'} /> + {isActiveSpawn && <circle cx={point.x + 0.5} cy={point.y + 0.5} r="0.95" fill="none" stroke="#fb7185" strokeWidth="0.1" strokeDasharray="0.2 0.2" />} + </g> + ); + })} + + {tokens.map((token) => { + const isSelected = token.id === selectedTokenId; + const isCurrent = currentActor && currentActor.toLowerCase() === token.name.toLowerCase(); + return ( + <g + key={token.id} + data-testid={`battlemap-token-${token.id}`} + onClick={(event) => { + event.stopPropagation(); + if (isGM || (token.side === 'player' && token.name === authedPlayer)) setSelectedTokenId(token.id); + }} + > + <circle + cx={token.x} + cy={token.y} + r="0.72" + fill={token.color} + stroke={isSelected ? '#f8fafc' : isCurrent ? '#fde68a' : '#0f172a'} + strokeWidth={isSelected || isCurrent ? '0.18' : '0.12'} + /> + <text x={token.x} y={token.y + 0.2} textAnchor="middle" fontSize="0.44" fill="#f8fafc" style={{ fontWeight: 700 }}> + {token.name.slice(0, 2).toUpperCase()} + </text> + </g> + ); + })} + </svg> + </div> + </div> + + <div className="space-y-4"> + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg"> + <h3 className="text-lg font-semibold text-slate-100">Objectives</h3> + <div className="mt-3 space-y-2"> + {(layout.stations || STATIONS).map((station) => { + const active = objectives.stations.find(item => item.id === station.id)?.active; + const zone = zoneById(station.zoneId); + return ( + <div key={station.id} className="rounded-lg border border-slate-700 bg-slate-950/60 p-3"> + <div className="flex items-center justify-between gap-3"> + <div className="min-w-0"> + <div className="truncate text-sm font-semibold text-slate-100">{station.name}</div> + <div className="text-xs text-slate-400">{zone?.name}</div> + </div> + <button + type="button" + onClick={() => toggleStation(station.id)} + disabled={!isGM || saving} + className={`rounded px-3 py-1 text-xs font-semibold ${active ? 'bg-emerald-700 text-emerald-100' : 'bg-amber-700 text-amber-100'} disabled:opacity-50`} + > + {active ? 'Online' : 'Offline'} + </button> + </div> + <div className="mt-2 text-xs text-slate-300">{station.action}</div> + <div className="mt-1 text-xs text-slate-400">{station.note}</div> + </div> + ); + })} + <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3"> + <div className="flex items-center justify-between gap-3"> + <div> + <div className="text-sm font-semibold text-slate-100">Gene-Core Hold</div> + <div className="text-xs text-slate-400">Apothecary or Techmarine secures the core while the squad holds the vault.</div> + </div> + <div className="flex items-center gap-2"> + <button onClick={() => adjustCoreRounds(-1)} disabled={!isGM || saving} className="rounded bg-slate-700 px-2 py-1 text-sm text-white disabled:opacity-50">-</button> + <span className="rounded bg-slate-800 px-3 py-1 text-sm font-bold text-slate-100">{objectives.geneCoreSecuredRounds}/3</span> + <button onClick={() => adjustCoreRounds(1)} disabled={!isGM || saving} className="rounded bg-slate-700 px-2 py-1 text-sm text-white disabled:opacity-50">+</button> + </div> + </div> + </div> + <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3"> + <div className="flex items-center justify-between gap-3"> + <div> + <div className="text-sm font-semibold text-slate-100">Extraction Withdrawal</div> + <div className="text-xs text-slate-400">The carrier cannot run or charge and must fight back to the extraction zone.</div> + </div> + <div className="flex items-center gap-2"> + <button onClick={() => adjustExtractionProgress(-1)} disabled={!isGM || saving} className="rounded bg-slate-700 px-2 py-1 text-sm text-white disabled:opacity-50">-</button> + <span className="rounded bg-slate-800 px-3 py-1 text-sm font-bold text-slate-100">{objectives.extractionProgress || 0}/2</span> + <button onClick={() => adjustExtractionProgress(1)} disabled={!isGM || saving} className="rounded bg-slate-700 px-2 py-1 text-sm text-white disabled:opacity-50">+</button> + </div> + </div> + <div className="mt-2 flex flex-wrap items-center gap-2"> + <button onClick={assignCarrier} disabled={!isGM || saving || !selectedToken || selectedToken.side !== 'player'} className="rounded bg-violet-700 px-3 py-1 text-xs font-semibold text-violet-100 disabled:opacity-50"> + Set Selected Player As Carrier + </button> + {objectives.carrier && <span className="text-xs text-violet-200">Current carrier: {objectives.carrier}</span>} + </div> + </div> + </div> + </div> + + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg"> + <h3 className="text-lg font-semibold text-slate-100">Selected Token</h3> + {!selectedToken ? ( + <p className="mt-3 text-sm text-slate-400">{isGM ? 'Select a token, then click the board or use the nudge controls.' : 'GM controls movement. Your view updates live.'}</p> + ) : ( + <div className="mt-3 space-y-3"> + <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3"> + <div className="flex items-center justify-between gap-3"> + <div> + <div className="text-sm font-semibold text-slate-100">{selectedToken.name}</div> + <div className="text-xs text-slate-400">{selectedToken.side}</div> + </div> + {currentActor && currentActor.toLowerCase() === selectedToken.name.toLowerCase() && ( + <span className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-amber-100">Turn</span> + )} + </div> + <div className="mt-2 grid grid-cols-2 gap-2 text-xs text-slate-300"> + <div className="rounded bg-slate-800/80 px-2 py-1">X {selectedToken.x}</div> + <div className="rounded bg-slate-800/80 px-2 py-1">Y {selectedToken.y}</div> + <div className="col-span-2 rounded bg-slate-800/80 px-2 py-1">{selectedZone ? selectedZone.name : 'Outside named zone'}</div> + </div> + {objectives.carrier === selectedToken.name && ( + <div className="mt-2 rounded bg-violet-950/50 px-2 py-1 text-xs text-violet-200">Carrying gene-core: reduced movement, no run or charge.</div> + )} + </div> + {selectedToken.side === 'player' && (isGM || selectedToken.name === authedPlayer) && ( + <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3"> + <div className="text-xs font-semibold uppercase tracking-wide text-slate-400">Move Action</div> + <div className="mt-2 grid grid-cols-2 gap-2"> + {['half', 'full', 'charge', 'run'].map((mode) => { + const budget = movementBudget(playerMovementByName[selectedToken.name] || normalizeMovement(), mode, objectives.carrier === selectedToken.name); + const lockedToOtherMode = Boolean(selectedMoveMode) && selectedMoveMode !== mode; + const spent = selectedMoveMode === mode ? selectedMoveSpent : 0; + const remaining = Math.max(0, budget - spent); + const active = armedMoveMode === mode || selectedMoveMode === mode; + return ( + <button + key={mode} + type="button" + onClick={() => setArmedMoveMode(armedMoveMode === mode ? '' : mode)} + disabled={budget <= 0 || lockedToOtherMode || remaining <= 0} + className={`rounded px-3 py-2 text-xs font-semibold ${active ? 'bg-sky-700 text-sky-100' : 'bg-slate-800 text-slate-200'} disabled:opacity-40`} + > + {mode} {budget > 0 ? `(${remaining}/${budget})` : '(blocked)'} + </button> + ); + })} + </div> + <div className="mt-2 text-xs text-slate-400"> + Click one adjacent checker at a time. Movement is spent across the whole round for the chosen mode, and walls/doors/terrain are checked on every single step. + </div> + {selectedMoveMode && ( + <div className="mt-2 rounded bg-sky-950/40 px-2 py-1 text-xs text-sky-200"> + Current turn: {selectedMoveMode} movement, {Math.max(0, movementBudget(playerMovementByName[selectedToken.name] || normalizeMovement(), selectedMoveMode, objectives.carrier === selectedToken.name) - selectedMoveSpent)} checker(s) left. + </div> + )} + </div> + )} + {isGM && ( + <div className="grid grid-cols-3 gap-2"> + <div /> + <button onClick={() => nudgeSelectedToken(0, -1)} disabled={saving} className="rounded-lg bg-slate-700 px-3 py-2 text-sm font-semibold text-white hover:bg-slate-600 disabled:opacity-50">Up</button> + <div /> + <button onClick={() => nudgeSelectedToken(-1, 0)} disabled={saving} className="rounded-lg bg-slate-700 px-3 py-2 text-sm font-semibold text-white hover:bg-slate-600 disabled:opacity-50">Left</button> + <button onClick={syncCombatants} disabled={saving} className="rounded-lg bg-emerald-700 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-600 disabled:opacity-50">Reset</button> + <button onClick={() => nudgeSelectedToken(1, 0)} disabled={saving} className="rounded-lg bg-slate-700 px-3 py-2 text-sm font-semibold text-white hover:bg-slate-600 disabled:opacity-50">Right</button> + <div /> + <button onClick={() => nudgeSelectedToken(0, 1)} disabled={saving} className="rounded-lg bg-slate-700 px-3 py-2 text-sm font-semibold text-white hover:bg-slate-600 disabled:opacity-50">Down</button> + <div /> + </div> + )} + </div> + )} + </div> + + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg"> + <h3 className="text-lg font-semibold text-slate-100">Movement And LOS</h3> + <div className="mt-3 space-y-3"> + <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3"> + <div className="text-sm font-semibold text-slate-100">Corridor Widths</div> + <div className="mt-2 space-y-1 text-xs text-slate-300"> + {MOVEMENT_NOTES.corridorWidths.map((item) => <div key={item}>• {item}</div>)} + </div> + </div> + <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3"> + <div className="text-sm font-semibold text-slate-100">Charge And Fire Lanes</div> + <div className="mt-2 space-y-1 text-xs text-slate-300"> + {MOVEMENT_NOTES.chargeLanes.map((item) => <div key={item}>• {item}</div>)} + </div> + </div> + <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3"> + <div className="text-sm font-semibold text-slate-100">Difficult Terrain And Verticality</div> + <div className="mt-2 space-y-1 text-xs text-slate-300"> + {MOVEMENT_NOTES.difficultTerrain.map((item) => <div key={item}>• {item}</div>)} + {MOVEMENT_NOTES.verticality.map((item) => <div key={item}>• {item}</div>)} + </div> + </div> + </div> + </div> + + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg"> + <h3 className="text-lg font-semibold text-slate-100">Reinforcement Pressure</h3> + <div className="mt-3 space-y-2"> + {(layout.spawnPoints || SPAWN_POINTS).map((point) => { + const active = point.id === objectives.activeSpawnId; + return ( + <div key={point.id} className={`rounded-lg border p-3 ${active ? 'border-rose-500 bg-rose-950/30' : 'border-slate-700 bg-slate-950/60'}`}> + <div className="flex items-center justify-between gap-3"> + <div className="text-sm font-semibold text-slate-100">{point.name}</div> + {active && <span className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-rose-100">Active</span>} + </div> + <div className="mt-1 text-xs text-slate-400">{point.lane}</div> + </div> + ); + })} + </div> + </div> + + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg"> + <h3 className="text-lg font-semibold text-slate-100">Terrain Notes</h3> + <div className="mt-3 grid gap-2"> + {(layout.zones || ZONES).map((zone) => ( + <div key={zone.id} className="rounded-lg border border-slate-700 bg-slate-950/60 p-3"> + <div className="text-sm font-semibold text-slate-100">{zone.name}</div> + <div className="mt-1 text-xs text-slate-300">{zone.purpose}</div> + <div className="mt-1 text-xs text-slate-400">{zone.cover}</div> + <div className="mt-1 text-xs text-slate-500">Elevation {zone.elevation || 0}</div> + <div className="mt-1 text-xs text-slate-500">Lighting {zone.lighting || 'normal'}</div> + </div> + ))} + </div> + </div> + + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg"> + <h3 className="text-lg font-semibold text-slate-100">Cover Materials</h3> + <div className="mt-3 grid gap-2"> + {(layout.cover || []).slice(0, 8).map((item) => ( + <div key={item.id} className="rounded-lg border border-slate-700 bg-slate-950/60 p-3 text-xs text-slate-300"> + <div className="font-semibold text-slate-100">{item.label}</div> + <div className="mt-1">{item.kind} cover · {item.material || 'Unknown material'}</div> + <div className="mt-1 text-slate-500">Elevation {item.elevation || 0}</div> + </div> + ))} + </div> + </div> + + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg"> + <h3 className="text-lg font-semibold text-slate-100">Dynamic Features</h3> + <div className="mt-3 space-y-2"> + {DYNAMIC_FEATURES.map((feature) => ( + <div key={feature.id} className="rounded-lg border border-slate-700 bg-slate-950/60 p-3"> + <div className="text-sm font-semibold text-slate-100">{feature.name}</div> + <div className="mt-1 text-xs text-slate-400">{feature.rule}</div> + </div> + ))} + </div> + </div> + + <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg"> + <h3 className="text-lg font-semibold text-slate-100">Enemy Roles</h3> + <div className="mt-3 space-y-2 text-xs text-slate-300"> + {ENEMY_ROLES.map((role) => ( + <div key={role} className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">• {role}</div> + ))} + </div> + </div> + </div> + </div> + </div> + ); +} diff --git a/src/shared/battlemapEngine.js b/src/shared/battlemapEngine.js new file mode 100644 index 0000000..9fb66fe --- /dev/null +++ b/src/shared/battlemapEngine.js @@ -0,0 +1,1331 @@ +const GRID_WIDTH = 40; +const GRID_HEIGHT = 30; +const METERS_PER_CHECKER = 10; +const DEFAULT_DOOR_WIDTH = 3; +const MATERIAL_AP = { + 'Light Wood': 4, + 'Armour-glas': 4, + 'Light Metal': 4, + 'Heavy Wood': 8, + Flakboard: 8, + Sandbags: 8, + Ice: 8, + Rockcrete: 16, + 'Thick Iron': 16, + Stone: 16, + Plasteel: 32, + Armaplas: 32, +}; +// Rooms + the connecting corridor form the structural "wall base": every +// room shares a touching edge with the corridor (zero rectangle overlap +// anywhere — see validateBattlemapLayout), so the floor plan is fully +// connected and every boundary line is a real wall. Cover, elevation, +// hazards, and blockers are placed on top of this base afterward as +// decoration/tactical detail — they never change room connectivity. +const DEFAULT_ZONES = [ + { id: 'extraction', name: 'Extraction Zone', x: 1, y: 11, w: 7, h: 8, tone: 'spawn', elevation: 0, lighting: 'bright', purpose: 'Start and extract here.', cover: 'Light cover from crates, barricades, and wreckage.' }, + { id: 'main-corridor', name: 'Main Kill-Corridor', x: 8, y: 13, w: 24, h: 4, tone: 'corridor', elevation: 0, lighting: 'normal', purpose: 'Long firing lane for bolters, heavy weapons, overwatch, and suppression.', cover: 'Alternating half-cover every 5-6 squares.' }, + { id: 'left-wing', name: 'Left Stabilization Wing', x: 12, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: 'bright', purpose: 'Tech-Use or Medicae station under heavy pressure.', cover: 'Heavy cover from cogitator banks and machinery.' }, + { id: 'right-wing', name: 'Right Stabilization Wing', x: 25, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: 'dim', purpose: 'Flank route objective room with mixed fire angles.', cover: 'Mixed light and heavy cover.' }, + { id: 'maintenance', name: 'Lower Maintenance Wing', x: 8, y: 17, w: 15, h: 8, tone: 'hazard', elevation: -1, lighting: 'dark', purpose: 'Ambush route and third stabilization station.', cover: 'Pipes, tanks, and broken wall sections.' }, + { id: 'vault', name: 'Central Gene-Vault', x: 23, y: 17, w: 12, h: 8, tone: 'vault', elevation: 0, lighting: 'dim', purpose: 'Secure the gene-core for three rounds.', cover: 'Heavy central containment pillars and shattered equipment.' }, +]; +const DEFAULT_STATIONS = [ + { id: 'left-station', name: 'Alpha Stabilization', zoneId: 'left-wing', x: 17, y: 7, action: 'Full Action / Tech-Use / Medicae', note: 'Heavy machinery blocks easy charges.' }, + { id: 'right-station', name: 'Beta Stabilization', zoneId: 'right-wing', x: 30, y: 7, action: 'Full Action / Tech-Use / Medicae', note: 'Side access makes this wing harder to lock down.' }, + { id: 'maintenance-station', name: 'Gamma Stabilization', zoneId: 'maintenance', x: 20, y: 22, action: 'Full Action / Tech-Use / Medicae', note: 'Maintenance sludge and pipework slow support.' }, +]; +const DEFAULT_SPAWN_POINTS = [ + { id: 'north-hatch', name: 'North maintenance hatch', x: 17, y: 4, lane: 'Melee swarm pressure into the left wing.' }, + { id: 'east-corridor', name: 'East corrupted corridor', x: 33, y: 15, lane: 'Ranged traitors push the right side and corridor.' }, + { id: 'south-tunnel', name: 'South service tunnel', x: 16, y: 22, lane: 'Ambush route through maintenance.' }, + { id: 'vault-breach', name: 'Vault breach point', x: 31, y: 20, lane: 'Elite contest at the core chamber.' }, +]; +const DEFAULT_COVER = [ + { id: 'ext-crates-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 3, y: 12, w: 2, h: 2, label: 'Crates' }, + { id: 'ext-wreck-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 5, y: 16, w: 2, h: 2, label: 'Wreckage' }, + { id: 'corr-cover-1', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 11, y: 13, w: 1, h: 4, label: 'Bulkhead' }, + { id: 'corr-cover-2', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 17, y: 13, w: 1, h: 4, label: 'Bulkhead' }, + { id: 'corr-cover-3', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 23, y: 13, w: 1, h: 4, label: 'Bulkhead' }, + { id: 'corr-cover-4', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 29, y: 13, w: 1, h: 4, label: 'Bulkhead' }, + { id: 'left-cog-1', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 13, y: 4, w: 3, h: 2, label: 'Cogitators' }, + { id: 'left-cog-2', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 18, y: 9, w: 3, h: 2, label: 'Machinery' }, + { id: 'right-cog-1', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 26, y: 4, w: 3, h: 2, label: 'Cogitators' }, + { id: 'right-cog-2', kind: 'light', material: 'Armour-glas', elevation: 1, x: 31, y: 9, w: 2, h: 2, label: 'Consoles' }, + { id: 'maint-pipe-1', kind: 'light', material: 'Light Metal', elevation: -1, x: 11, y: 18, w: 3, h: 1, label: 'Pipes' }, + { id: 'maint-tank-1', kind: 'heavy', material: 'Thick Iron', elevation: -1, x: 19, y: 21, w: 2, h: 3, label: 'Tank' }, + { id: 'vault-pillar-1', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 26, y: 19, w: 2, h: 2, label: 'Pillar' }, + { id: 'vault-pillar-2', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 32, y: 19, w: 2, h: 2, label: 'Pillar' }, + { id: 'vault-pillar-3', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 26, y: 22, w: 2, h: 2, label: 'Pillar' }, + { id: 'vault-pillar-4', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 32, y: 22, w: 2, h: 2, label: 'Pillar' }, + { id: 'vault-shatter-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 29, y: 20, w: 2, h: 1, label: 'Shattered gear' }, +]; +const DEFAULT_BLOCKERS = [ + { id: 'blast-door-west', material: 'Plasteel', x: 16, y: 5, w: 2, h: 1, label: 'Blast Door W' }, + { id: 'blast-door-east', material: 'Plasteel', x: 29, y: 5, w: 2, h: 1, label: 'Blast Door E' }, + { id: 'cylinder-1', material: 'Thick Iron', x: 33, y: 19, w: 2, h: 4, label: 'Containment' }, + { id: 'collapsed-slab', material: 'Rockcrete', x: 11, y: 20, w: 4, h: 2, label: 'Collapsed slab' }, +]; +const DEFAULT_HAZARDS = [ + { id: 'sludge', kind: 'difficult', x: 11, y: 21, w: 5, h: 2, label: 'Toxic sludge' }, + { id: 'decking', kind: 'agility', x: 12, y: 20, w: 4, h: 2, label: 'Collapsed decking' }, + { id: 'corpses', kind: 'no-charge', x: 24, y: 22, w: 4, h: 2, label: 'Corpse pile' }, + { id: 'vent', kind: 'vent', x: 29, y: 22, w: 2, h: 2, label: 'Toxic vent' }, +]; +const DEFAULT_DYNAMIC_FEATURES = [ + { id: 'doors', name: 'Blast doors', rule: 'GM can open, seal, or lock them to change line of sight and movement.' }, + { id: 'vents', name: 'Toxic vents', rule: 'Temporary hazardous zones that deny easy movement.' }, + { id: 'alarm', name: 'Alarm lights', rule: 'Low visibility sectors can apply ranged penalties.' }, + { id: 'turrets', name: 'Automated turrets', rule: 'Hack, destroy, or use cover to avoid them.' }, + { id: 'walkways', name: 'Collapsing walkways', rule: 'Force Agility tests and open or close routes mid-fight.' }, +]; +const DEFAULT_LAYOUT_KEY = 'vault'; + +function zonesOverlap(a, b) { + return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; +} + +function pointInZone(zone, x, y) { + return x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h; +} + +function pointInAnyZone(zones, x, y) { + return (Array.isArray(zones) ? zones : []).some(zone => pointInZone(zone, x, y)); +} + +function zonesShareEdge(a, b) { + if (!a || !b) return false; + const horizontalTouch = (a.y + a.h === b.y || b.y + b.h === a.y) + && Math.min(a.x + a.w, b.x + b.w) > Math.max(a.x, b.x); + const verticalTouch = (a.x + a.w === b.x || b.x + b.w === a.x) + && Math.min(a.y + a.h, b.y + b.h) > Math.max(a.y, b.y); + return horizontalTouch || verticalTouch; +} + +// Rooms and the corridor are the structural "wall base": each room's +// boundary is a real wall, and rooms only connect where they share a +// touching edge with the corridor (or another room) — never by overlapping. +// Cover/elevation/hazards/blockers are decoration placed on top and never +// affect this base. validateBattlemapLayout guards against the three +// battlemap-layout bugs that have shipped already: (1) room rectangles +// overlapping each other or the corridor (drawn borders visually +// cross/collide — corridors included, no exemptions), (2) hardcoded anchor +// points landing outside every room, and (3) rooms that are geometrically +// present but unreachable from the spawn zone (movement pathfinding treats +// gaps between zones as walls, so a room that doesn't touch the connected +// structure is permanently unreachable even though it "exists"). +function zoneCellIsWalkable(zones, x, y) { + return pointInAnyZone(zones, x, y); +} + +function reachableZoneIds(zones, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }) { + const zoneList = Array.isArray(zones) ? zones : []; + const start = zoneList.find(zone => zone.tone === 'spawn') || zoneList[0]; + if (!start) return new Set(); + const startCell = [Math.floor(start.x), Math.floor(start.y)]; + const visited = new Set([startCell.join(',')]); + const queue = [startCell]; + const dirs = [[0, -1], [-1, 0], [1, 0], [0, 1]]; + while (queue.length) { + const [cx, cy] = queue.shift(); + for (const [dx, dy] of dirs) { + const nx = cx + dx; + const ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= bounds.width || ny >= bounds.height) continue; + const key = `${nx},${ny}`; + if (visited.has(key)) continue; + if (!zoneCellIsWalkable(zoneList, nx + 0.5, ny + 0.5)) continue; + visited.add(key); + queue.push([nx, ny]); + } + } + const reached = new Set(); + visited.forEach((key) => { + const [x, y] = key.split(',').map(Number); + zoneList.forEach((zone) => { if (pointInZone(zone, x + 0.5, y + 0.5)) reached.add(zone.id); }); + }); + return reached; +} + +function validateBattlemapLayout(zones, points = [], bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }) { + const issues = []; + const zoneList = Array.isArray(zones) ? zones : []; + + zoneList.forEach((zone) => { + if (zone.x < 0 || zone.y < 0 || zone.x + zone.w > bounds.width || zone.y + zone.h > bounds.height) { + issues.push(`Zone "${zone.id}" (x:${zone.x}, y:${zone.y}, w:${zone.w}, h:${zone.h}) extends outside the ${bounds.width}x${bounds.height} map.`); + } + }); + + for (let i = 0; i < zoneList.length; i += 1) { + for (let j = i + 1; j < zoneList.length; j += 1) { + const a = zoneList[i]; + const b = zoneList[j]; + if (zonesOverlap(a, b)) { + issues.push(`Zones "${a.id}" and "${b.id}" overlap — room borders would cross/collide.`); + } + } + } + + zoneList.forEach((zone) => { + if (zoneList.length <= 1) return; + const hasConnection = zoneList.some((other) => other !== zone && zonesShareEdge(zone, other)); + if (!hasConnection) { + issues.push(`Zone "${zone.id}" does not share an edge with any room or hallway.`); + } + }); + + if (zoneList.length > 1) { + const reached = reachableZoneIds(zoneList, bounds); + zoneList.forEach((zone) => { + if (!reached.has(zone.id)) { + issues.push(`Zone "${zone.id}" is not reachable from the spawn zone — it doesn't touch the connected structure.`); + } + }); + } + + points.forEach(([x, y], index) => { + if (!pointInAnyZone(zoneList, x, y)) { + issues.push(`Point ${index} (${x}, ${y}) does not fall inside any zone.`); + } + }); + + return issues; +} + +function battlemapVisibleForPlayer(scene, authedPlayer) { + if (authedPlayer === 'gm') return true; + const reveal = scene?.reveal || {}; + if (typeof reveal.battlemap === 'boolean') return reveal.battlemap; + return scene?.revealed === true; +} + +function battlemapActorKey(side, name) { + return `${side}:${String(name || '').trim()}`; +} + +function parseRangeNumber(value) { + const match = String(value || '').match(/(\d+)\s*m?/i); + return match ? Math.max(1, Number(match[1]) || 0) : 0; +} + +function checkersForMeters(metres, minimum = 1) { + const numeric = Math.max(0, Number(metres || 0) || 0); + return Math.max(minimum, Math.ceil(numeric / METERS_PER_CHECKER)); +} + +function defaultRangeForWeaponName(name = '') { + const lower = String(name).toLowerCase(); + if (/lascannon|missile launcher|cyclone/.test(lower)) return 300; + if (/heavy bolter|plasma cannon|assault cannon/.test(lower)) return 150; + if (/bolter|boltgun|storm bolter|lasgun|plasma gun/.test(lower)) return 100; + if (/shotgun|autogun/.test(lower)) return 30; + if (/bolt pistol|plasma pistol|autopistol|laspistol/.test(lower)) return 30; + if (/melta/.test(lower)) return 20; + if (/flamer/.test(lower)) return 20; + if (/grenade/.test(lower)) return 20; + if (/melee|sword|axe|knife|hammer|fist|claw|staff|chain/.test(lower)) return 2; + return 30; +} + +function normalizeWeaponRange(weapon = {}) { + const explicit = parseRangeNumber(weapon.range); + return explicit || defaultRangeForWeaponName(weapon.name || weapon.weaponName || weapon.type || ''); +} + +function normalizeMovement(movement = {}) { + const half = Math.max(1, Number(movement.half || 3) || 3); + const full = Math.max(half, Number(movement.full || half * 2) || half * 2); + const charge = Math.max(half, Number(movement.charge || full) || full); + const run = Math.max(full, Number(movement.run || full * 2) || full * 2); + return { half, full, charge, run }; +} + +function normalizeTurnState(existing = {}, turnKey = '') { + if (existing?.turnKey === turnKey) { + return { + turnKey, + movedBy: existing.movedBy && typeof existing.movedBy === 'object' ? existing.movedBy : {}, + }; + } + return { turnKey, movedBy: {} }; +} + +function movementBudget(movement, mode, isCarrier = false) { + const profile = normalizeMovement(movement); + if (isCarrier && (mode === 'charge' || mode === 'run')) return 0; + if (mode === 'half') return checkersForMeters(profile.half); + if (mode === 'charge') return checkersForMeters(profile.charge); + if (mode === 'run') return checkersForMeters(profile.run); + return checkersForMeters(profile.full); +} + +function distanceCells(from, to) { + const dx = Number(to.x || 0) - Number(from.x || 0); + const dy = Number(to.y || 0) - Number(from.y || 0); + return Math.sqrt((dx * dx) + (dy * dy)); +} + +function distance(from, to) { + return distanceCells(from, to) * METERS_PER_CHECKER; +} + +function pointInRect(point, rect) { + if (!point || !rect) return false; + const x = Number(point.x || 0); + const y = Number(point.y || 0); + const rx = Number(rect.x || 0); + const ry = Number(rect.y || 0); + const rw = Number(rect.w || rect.width || 0); + const rh = Number(rect.h || rect.height || 0); + return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh; +} + +function segmentIntersectsRect(from, to, rect) { + if (!from || !to || !rect) return false; + const steps = Math.max(1, Math.ceil(distanceCells(from, to) * 2)); + for (let index = 0; index <= steps; index += 1) { + const t = index / steps; + const point = { + x: Number(from.x || 0) + ((Number(to.x || 0) - Number(from.x || 0)) * t), + y: Number(from.y || 0) + ((Number(to.y || 0) - Number(from.y || 0)) * t), + }; + if (pointInRect(point, rect)) return true; + } + return false; +} + +function materialAp(material, fallbackKind = '') { + if (MATERIAL_AP[material]) return MATERIAL_AP[material]; + if (fallbackKind === 'heavy') return 16; + if (fallbackKind === 'light') return 8; + return 4; +} + +function coverModifier(cover) { + if (!cover) return 0; + if (cover.kind === 'heavy') return -20; + if (cover.kind === 'light') return -10; + return -10; +} + +function coverForPoint(layout = {}, point) { + const items = Array.isArray(layout.cover) ? layout.cover : []; + return items.find(item => pointInRect(point, item)) || null; +} + +function blockerBetween(layout = {}, from, to) { + const items = Array.isArray(layout.blockers) ? layout.blockers : []; + return items.find(item => segmentIntersectsRect(from, to, item)) || null; +} + +function rangesOverlap(a1, a2, b1, b2) { + return Math.max(Math.min(a1, a2), Math.min(b1, b2)) < Math.min(Math.max(a1, a2), Math.max(b1, b2)); +} + +function doorwaySpan(start, end, width = 3) { + const min = Math.min(start, end); + const max = Math.max(start, end); + const span = max - min; + if (span <= 0) return null; + const opening = Math.min(width, span); + const center = (min + max) / 2; + return { + from: center - (opening / 2), + to: center + (opening / 2), + }; +} + +function computeZoneDoorways(zones, width = DEFAULT_DOOR_WIDTH) { + const doorways = []; + const zoneList = Array.isArray(zones) ? zones : []; + zoneList.forEach((zone, zoneIndex) => { + zoneList.slice(zoneIndex + 1).forEach((other) => { + if (other.y === zone.y + zone.h || zone.y === other.y + other.h) { + const overlapFrom = Math.max(zone.x, other.x); + const overlapTo = Math.min(zone.x + zone.w, other.x + other.w); + if (overlapTo > overlapFrom) { + const opening = doorwaySpan(overlapFrom, overlapTo, width); + if (opening) { + doorways.push({ + id: `door:${zone.id}:${other.id}:h`, + axis: 'h', + at: other.y === zone.y + zone.h ? zone.y + zone.h : other.y + other.h, + from: opening.from, + to: opening.to, + zones: [zone.id, other.id], + x: opening.from, + y: (other.y === zone.y + zone.h ? zone.y + zone.h : other.y + other.h) - 0.25, + w: opening.to - opening.from, + h: 0.5, + label: `${zone.name || zone.id} / ${other.name || other.id} door`, + }); + } + } + } + if (other.x === zone.x + zone.w || zone.x === other.x + other.w) { + const overlapFrom = Math.max(zone.y, other.y); + const overlapTo = Math.min(zone.y + zone.h, other.y + other.h); + if (overlapTo > overlapFrom) { + const opening = doorwaySpan(overlapFrom, overlapTo, width); + if (opening) { + doorways.push({ + id: `door:${zone.id}:${other.id}:v`, + axis: 'v', + at: other.x === zone.x + zone.w ? zone.x + zone.w : other.x + other.w, + from: opening.from, + to: opening.to, + zones: [zone.id, other.id], + x: (other.x === zone.x + zone.w ? zone.x + zone.w : other.x + other.w) - 0.25, + y: opening.from, + w: 0.5, + h: opening.to - opening.from, + label: `${zone.name || zone.id} / ${other.name || other.id} door`, + }); + } + } + } + }); + }); + return doorways; +} + +// Room walls are derived from the zone rectangles themselves: every edge of +// every room is a solid wall EXCEPT a short centered doorway on the stretch +// that coincides with another zone's touching edge. This prevents entire +// shared edges from acting like open-air seams, so combatants need to shoot +// or move through a distinct doorway rather than "through the whole wall". +function computeZoneWalls(zones, doors = []) { + const walls = []; + const doorways = Array.isArray(doors) && doors.length ? doors : computeZoneDoorways(zones); + zones.forEach((zone) => { + const horizontalEdges = [zone.y, zone.y + zone.h]; + const verticalEdges = [zone.x, zone.x + zone.w]; + horizontalEdges.forEach((atY) => { + const doorway = doorways.find((entry) => entry.axis === 'h' && entry.at === atY && entry.from < zone.x + zone.w && entry.to > zone.x); + if (!doorway) { + walls.push({ axis: 'h', at: atY, from: zone.x, to: zone.x + zone.w }); + } else { + if (zone.x < doorway.from) walls.push({ axis: 'h', at: atY, from: zone.x, to: doorway.from }); + if (doorway.to < zone.x + zone.w) walls.push({ axis: 'h', at: atY, from: doorway.to, to: zone.x + zone.w }); + } + }); + verticalEdges.forEach((atX) => { + const doorway = doorways.find((entry) => entry.axis === 'v' && entry.at === atX && entry.from < zone.y + zone.h && entry.to > zone.y); + if (!doorway) { + walls.push({ axis: 'v', at: atX, from: zone.y, to: zone.y + zone.h }); + } else { + if (zone.y < doorway.from) walls.push({ axis: 'v', at: atX, from: zone.y, to: doorway.from }); + if (doorway.to < zone.y + zone.h) walls.push({ axis: 'v', at: atX, from: doorway.to, to: zone.y + zone.h }); + } + }); + }); + return walls; +} + +function segmentCrossesWall(from, to, wall) { + if (wall.axis === 'h') { + if (from.y === to.y) return false; + if ((from.y - wall.at) * (to.y - wall.at) > 0) return false; + const t = (wall.at - from.y) / (to.y - from.y); + if (t < 0 || t > 1) return false; + const x = from.x + (t * (to.x - from.x)); + return x > wall.from && x < wall.to; + } + if (from.x === to.x) return false; + if ((from.x - wall.at) * (to.x - wall.at) > 0) return false; + const t = (wall.at - from.x) / (to.x - from.x); + if (t < 0 || t > 1) return false; + const y = from.y + (t * (to.y - from.y)); + return y > wall.from && y < wall.to; +} + +function wallBetween(layout = {}, from, to) { + const zones = Array.isArray(layout.zones) ? layout.zones : []; + if (zones.length < 2 || !from || !to) return null; + const walls = computeZoneWalls(zones, layout.doors); + return walls.find(wall => segmentCrossesWall(from, to, wall)) || null; +} + +function zoneForPoint(layout = {}, point) { + const zones = Array.isArray(layout.zones) ? layout.zones : []; + return zones.find(zone => pointInRect(point, zone)) || null; +} + +function elevationForPoint(layout = {}, point) { + const cover = coverForPoint(layout, point); + if (cover && Number.isFinite(Number(cover.elevation))) return Number(cover.elevation); + const zone = zoneForPoint(layout, point); + if (zone && Number.isFinite(Number(zone.elevation))) return Number(zone.elevation); + return 0; +} + +function lightingForPoint(layout = {}, point) { + const zone = zoneForPoint(layout, point); + return String(zone?.lighting || 'normal').toLowerCase(); +} + +function detectionRangeForLighting(lighting) { + if (lighting === 'dark') return 10; + if (lighting === 'dim') return 18; + if (lighting === 'bright') return 40; + if (lighting === 'glow') return 28; + return 30; +} + +function visibilityBetween(layout = {}, from, to) { + const dist = distance(from, to); + const blocking = blockerBetween(layout, from, to); + if (blocking) { + return { + visible: false, + blocked: true, + distance: dist, + reasons: [`Blocked by ${blocking.label || 'terrain'}`], + sourceLighting: lightingForPoint(layout, from), + targetLighting: lightingForPoint(layout, to), + maxSight: 0, + }; + } + const wall = wallBetween(layout, from, to); + if (wall) { + return { + visible: false, + blocked: true, + distance: dist, + reasons: ['Blocked by a wall — no line of sight'], + sourceLighting: lightingForPoint(layout, from), + targetLighting: lightingForPoint(layout, to), + maxSight: 0, + }; + } + const sourceLighting = lightingForPoint(layout, from); + const targetLighting = lightingForPoint(layout, to); + const maxSight = Math.min( + detectionRangeForLighting(sourceLighting), + detectionRangeForLighting(targetLighting) + ); + if (dist > maxSight) { + return { + visible: false, + blocked: false, + distance: dist, + reasons: [`Visibility limit ${maxSight}m (${sourceLighting}/${targetLighting})`], + sourceLighting, + targetLighting, + maxSight, + }; + } + return { + visible: true, + blocked: false, + distance: dist, + reasons: [], + sourceLighting, + targetLighting, + maxSight, + }; +} + +function rangeBand(distanceMetres, rangeMetres) { + const range = Math.max(1, Number(rangeMetres || 0) || 1); + if (distanceMetres <= METERS_PER_CHECKER * 2) return { band: 'point_blank', modifier: 30 }; + if (distanceMetres <= range / 2) return { band: 'short', modifier: 10 }; + if (distanceMetres <= range) return { band: 'standard', modifier: 0 }; + if (distanceMetres <= range * 2) return { band: 'long', modifier: -10 }; + if (distanceMetres <= range * 4) return { band: 'extreme', modifier: -30 }; + return { band: 'out_of_range', modifier: -60 }; +} + +// A ranged shot can't be taken clean through an ally standing between the +// shooter and the target — this checks whether any ally position lies close +// enough to the attacker-defender line (excluding the very ends, so allies +// standing right next to the attacker or target don't falsely block it). +function pointNearSegment(point, from, to, tolerance = 0.45) { + const totalLenSq = Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2); + if (totalLenSq < 0.0001) return distanceCells(point, from) <= tolerance; + const t = Math.max(0, Math.min(1, ( + ((point.x - from.x) * (to.x - from.x)) + ((point.y - from.y) * (to.y - from.y)) + ) / totalLenSq)); + if (t <= 0.08 || t >= 0.92) return false; + const projX = from.x + (t * (to.x - from.x)); + const projY = from.y + (t * (to.y - from.y)); + return distanceCells(point, { x: projX, y: projY }) <= tolerance; +} + +function allyBetween(allies = [], from, to, tolerance = 0.45) { + return (Array.isArray(allies) ? allies : []).find(point => point && pointNearSegment(point, from, to, tolerance)) || null; +} + +function attackPositioning(layout = {}, attacker, defender, weapon = {}, attackMode = 'ranged', allies = []) { + const dist = distance(attacker, defender); + const melee = attackMode === 'melee'; + const reasons = []; + const blocking = blockerBetween(layout, attacker, defender); + if (blocking) { + return { + distance: dist, + blocked: true, + totalModifier: -60, + coverAp: 0, + reasons: [`Blocked by ${blocking.label || 'terrain'}`], + range: { band: melee ? 'melee' : 'blocked', modifier: -60 }, + cover: null, + elevation: 0, + }; + } + const wall = wallBetween(layout, attacker, defender); + if (wall) { + return { + distance: dist, + blocked: true, + totalModifier: -60, + coverAp: 0, + reasons: ['Blocked by a wall — no line of sight'], + range: { band: melee ? 'melee' : 'blocked', modifier: -60 }, + cover: null, + elevation: 0, + }; + } + if (!melee && allyBetween(allies, attacker, defender)) { + return { + distance: dist, + blocked: true, + totalModifier: -60, + coverAp: 0, + reasons: ['Blocked by an ally in the line of fire'], + range: { band: 'blocked', modifier: -60 }, + cover: null, + elevation: 0, + }; + } + if (melee) { + if (dist > 2.5 * METERS_PER_CHECKER) { + return { + distance: dist, + blocked: false, + totalModifier: -60, + coverAp: 0, + reasons: ['Not in melee range'], + range: { band: 'melee_out_of_range', modifier: -60 }, + cover: null, + elevation: 0, + }; + } + let total = 0; + const attackerElevation = elevationForPoint(layout, attacker); + const defenderElevation = elevationForPoint(layout, defender); + const elevationMod = attackerElevation > defenderElevation ? 10 : 0; + if (elevationMod) reasons.push('Higher ground +10'); + total += elevationMod; + return { + distance: dist, + blocked: false, + totalModifier: total, + coverAp: 0, + reasons, + range: { band: 'melee', modifier: 0 }, + cover: null, + elevation: elevationMod, + }; + } + + const weaponRange = normalizeWeaponRange(weapon); + const rangeInfo = rangeBand(dist, weaponRange); + if (rangeInfo.modifier) reasons.push(`Range ${rangeInfo.band} ${rangeInfo.modifier > 0 ? '+' : ''}${rangeInfo.modifier}`); + const defenderCover = coverForPoint(layout, defender); + const coverMod = coverModifier(defenderCover); + const coverApValue = defenderCover ? Math.max(0, materialAp(defenderCover.material, defenderCover.kind) - Math.max(0, Number(weapon.pen || 0))) : 0; + if (defenderCover && coverMod) reasons.push(`${defenderCover.material || defenderCover.kind || 'Cover'} ${coverMod}`); + const attackerElevation = elevationForPoint(layout, attacker); + const defenderElevation = elevationForPoint(layout, defender); + const elevationMod = attackerElevation > defenderElevation ? 10 : 0; + if (elevationMod) reasons.push('Higher ground +10'); + return { + distance: dist, + blocked: false, + totalModifier: rangeInfo.modifier + coverMod + elevationMod, + coverAp: coverApValue, + reasons, + range: rangeInfo, + cover: defenderCover, + elevation: elevationMod, + }; +} + +function snapCoordinate(value, max) { + const snapped = Math.round(value - 0.5) + 0.5; + return Math.max(0.5, Math.min(max - 0.5, snapped)); +} + +function pointToCell(point) { + return { + x: Math.max(0, Math.round(Number(point?.x || 0) - 0.5)), + y: Math.max(0, Math.round(Number(point?.y || 0) - 0.5)), + }; +} + +function cellToPoint(cell) { + return { x: Number(cell.x) + 0.5, y: Number(cell.y) + 0.5 }; +} + +function cellKey(cell) { + return `${cell.x},${cell.y}`; +} + +function pointBlocked(layout = {}, point) { + if (!pointInAnyZone(layout.zones || [], point.x, point.y)) return true; + return Boolean(blockerBetween({ blockers: layout.blockers || [] }, point, point)); +} + +function movementBlockedBetween(layout = {}, from, to) { + if (!from || !to) return true; + if (pointBlocked(layout, to)) return true; + if (blockerBetween(layout, from, to)) return true; + if (wallBetween(layout, from, to)) return true; + return false; +} + +function pointTerrainCost(layout = {}, point) { + const hazards = Array.isArray(layout.hazards) ? layout.hazards : []; + const hazard = hazards.find((item) => pointInRect(point, item)); + if (!hazard) return 1; + if (hazard.kind === 'difficult') return 2; + if (hazard.kind === 'agility') return 2; + if (hazard.kind === 'vent') return 2; + if (hazard.kind === 'no-charge') return 3; + return 1; +} + +function buildOccupiedSet(occupied = [], allow = []) { + const allowed = new Set((allow || []).map((point) => cellKey(pointToCell(point)))); + return new Set( + (occupied || []) + .map((point) => pointToCell(point)) + .map(cellKey) + .filter((key) => !allowed.has(key)) + ); +} + +function targetGoalCells(targetPoint, stopRange, bounds, layout, occupiedSet) { + const targetCell = pointToCell(targetPoint); + const goals = []; + const maxRadius = Math.max(0, Number(stopRange || 0)); + for (let dx = -maxRadius; dx <= maxRadius; dx += 1) { + for (let dy = -maxRadius; dy <= maxRadius; dy += 1) { + const chebyshev = Math.max(Math.abs(dx), Math.abs(dy)); + if (maxRadius === 0 && (dx !== 0 || dy !== 0)) continue; + if (maxRadius > 0 && (chebyshev === 0 || chebyshev > maxRadius)) continue; + const cell = { x: targetCell.x + dx, y: targetCell.y + dy }; + if (cell.x < 0 || cell.y < 0 || cell.x >= bounds.width || cell.y >= bounds.height) continue; + const point = cellToPoint(cell); + if (pointBlocked(layout, point)) continue; + if (occupiedSet.has(cellKey(cell))) continue; + goals.push(cell); + } + } + if (goals.length) return goals; + return maxRadius === 0 ? [targetCell] : []; +} + +function moveWithinRange(from, to, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) { + const startCell = pointToCell(from); + const layout = options.layout || {}; + const occupiedSet = buildOccupiedSet(options.occupied || [], [from, ...(options.allowOccupied || [])]); + const goalCells = targetGoalCells(to, options.stopRange || 0, bounds, layout, occupiedSet); + if (!goalCells.length) { + return { x: snapCoordinate(Number(from.x || 0), bounds.width), y: snapCoordinate(Number(from.y || 0), bounds.height), travelled: 0 }; + } + + const goalKeys = new Set(goalCells.map(cellKey)); + const startKey = cellKey(startCell); + const queue = [{ cell: startCell, cost: 0 }]; + const visited = new Map([[startKey, { cost: 0, prev: null }]]); + let bestKey = startKey; + let bestScore = Infinity; + + while (queue.length) { + queue.sort((a, b) => a.cost - b.cost); + const current = queue.shift(); + const currentKey = cellKey(current.cell); + const currentInfo = visited.get(currentKey); + if (!currentInfo || current.cost !== currentInfo.cost) continue; + + const distanceToGoal = Math.min(...goalCells.map((goal) => Math.max(Math.abs(goal.x - current.cell.x), Math.abs(goal.y - current.cell.y)))); + const score = distanceToGoal * 100 + current.cost; + if (score < bestScore) { + bestScore = score; + bestKey = currentKey; + } + if (goalKeys.has(currentKey)) { + bestKey = currentKey; + break; + } + + const directions = [ + [-1, -1], [0, -1], [1, -1], + [-1, 0], [1, 0], + [-1, 1], [0, 1], [1, 1], + ]; + for (const [dx, dy] of directions) { + const nextCell = { x: current.cell.x + dx, y: current.cell.y + dy }; + if (nextCell.x < 0 || nextCell.y < 0 || nextCell.x >= bounds.width || nextCell.y >= bounds.height) continue; + const nextKey = cellKey(nextCell); + if (occupiedSet.has(nextKey)) continue; + const nextPoint = cellToPoint(nextCell); + if (movementBlockedBetween(layout, cellToPoint(current.cell), nextPoint)) continue; + const stepCost = pointTerrainCost(layout, nextPoint); + const nextCost = current.cost + stepCost; + if (nextCost > maxDistance) continue; + const previous = visited.get(nextKey); + if (!previous || nextCost < previous.cost) { + visited.set(nextKey, { cost: nextCost, prev: currentKey }); + queue.push({ cell: nextCell, cost: nextCost }); + } + } + } + + const best = visited.get(bestKey) || { cost: 0, prev: null }; + const bestPoint = cellToPoint(bestKey.split(',').reduce((acc, part, index) => { + acc[index === 0 ? 'x' : 'y'] = Number(part); + return acc; + }, {})); + return { + x: snapCoordinate(bestPoint.x, bounds.width), + y: snapCoordinate(bestPoint.y, bounds.height), + travelled: best.cost, + }; +} + +function stepToward(from, to, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) { + return moveWithinRange(from, to, maxDistance, bounds, options); +} + +function enemyMoveTowardNearest(enemyToken, playerTokens, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) { + const alivePlayers = Array.isArray(playerTokens) ? playerTokens : []; + if (!enemyToken || !alivePlayers.length) return null; + const target = alivePlayers + .map(token => ({ token, dist: distance(enemyToken, token) })) + .sort((a, b) => a.dist - b.dist)[0]?.token; + if (!target) return null; + const next = stepToward(enemyToken, target, maxDistance, bounds, { + ...options, + stopRange: Math.max(1, Number(options.stopRange || 1)), + }); + return { target, ...next }; +} + +function fallbackPoint(id) { + const map = { + extraction: { x: 5, y: 15 }, + 'left-station': { x: 17, y: 7 }, + 'right-station': { x: 30, y: 7 }, + 'maintenance-station': { x: 20, y: 25 }, + vault: { x: 29, y: 15 }, + core: { x: 29, y: 15 }, + 'north-hatch': { x: 17, y: 4 }, + 'east-corridor': { x: 33, y: 15 }, + 'south-tunnel': { x: 16, y: 22 }, + 'vault-breach': { x: 31, y: 20 }, + }; + return map[id] || { x: 20, y: 15 }; +} + +function pointFromLayout(layout = {}, id, fallbackId = id) { + const zones = Array.isArray(layout.zones) ? layout.zones : []; + const stations = Array.isArray(layout.stations) ? layout.stations : []; + const spawns = Array.isArray(layout.spawnPoints) ? layout.spawnPoints : []; + const zone = zones.find(entry => entry.id === id); + if (zone && Number.isFinite(Number(zone.x)) && Number.isFinite(Number(zone.y))) { + return { x: Number(zone.x) + Number(zone.w || 1) / 2, y: Number(zone.y) + Number(zone.h || 1) / 2 }; + } + const station = stations.find(entry => entry.id === id); + if (station && Number.isFinite(Number(station.x)) && Number.isFinite(Number(station.y))) { + return { x: Number(station.x), y: Number(station.y) }; + } + const spawn = spawns.find(entry => entry.id === id); + if (spawn && Number.isFinite(Number(spawn.x)) && Number.isFinite(Number(spawn.y))) { + return { x: Number(spawn.x), y: Number(spawn.y) }; + } + return fallbackPoint(fallbackId); +} + +function createBattlemapPositions(layout = {}, players = [], enemies = []) { + const extraction = pointFromLayout(layout, 'extraction'); + const vault = pointFromLayout(layout, 'vault'); + const rightStation = pointFromLayout(layout, 'right-station'); + const maintenanceStation = pointFromLayout(layout, 'maintenance-station'); + const playerOffsets = [[-1.5, -1.5], [1.5, -1.5], [-1.5, 1.5], [1.5, 1.5], [0, 3]]; + const enemyAnchors = [ + { x: vault.x, y: vault.y }, + { x: rightStation.x, y: rightStation.y }, + { x: maintenanceStation.x, y: maintenanceStation.y }, + { x: Math.max(24.5, vault.x - 3), y: Math.min(23.5, vault.y + 3) }, + ]; + const positions = {}; + players.forEach((player, index) => { + const offset = playerOffsets[index % playerOffsets.length]; + positions[battlemapActorKey('player', player.name)] = { x: extraction.x + offset[0], y: extraction.y + offset[1] }; + }); + enemies.forEach((enemy, index) => { + const anchor = enemyAnchors[index % enemyAnchors.length]; + positions[battlemapActorKey('enemy', enemy.name)] = { x: anchor.x, y: anchor.y }; + }); + return positions; +} + +function sceneEnemyObjects(scene = {}) { + if (!Array.isArray(scene.enemies)) return []; + return scene.enemies + .map(enemy => (typeof enemy === 'string' ? { name: enemy } : enemy)) + .filter(enemy => enemy && (enemy.name || enemy.type)) + .map((enemy, index) => ({ ...enemy, name: enemy.name || enemy.type || `Enemy ${index + 1}` })); +} + +function scenePlayerEntries(scene = {}) { + const initiatives = Array.isArray(scene?.combatState?.initiatives) ? scene.combatState.initiatives : []; + const names = initiatives + .filter(entry => String(entry?.type || 'player').toLowerCase() !== 'npc') + .map(entry => String(entry.player || entry.name || '').trim()) + .filter(Boolean); + return names.filter((name, index, list) => list.indexOf(name) === index); +} + +function normalizeToken(token, index) { + return { + id: token.id || `token-${index + 1}`, + name: token.name || `Token ${index + 1}`, + side: token.side === 'enemy' ? 'enemy' : 'player', + x: Number.isFinite(Number(token.x)) ? Number(token.x) : 1.5, + y: Number.isFinite(Number(token.y)) ? Number(token.y) : 1.5, + hp: token.hp ?? null, + maxHp: token.maxHp ?? null, + color: token.color || (token.side === 'enemy' ? '#ef4444' : '#38bdf8'), + }; +} + +function normalizeTokens(tokens) { + return Array.isArray(tokens) ? tokens.map(normalizeToken) : []; +} + +function defaultBattlemapObjectives(existing = {}) { + return { + stations: DEFAULT_STATIONS.map((station) => ({ + id: station.id, + active: Boolean(existing.stations?.find(item => item.id === station.id)?.active), + })), + geneCoreSecuredRounds: Math.max(0, Number(existing.geneCoreSecuredRounds || 0) || 0), + extractionProgress: Math.max(0, Number(existing.extractionProgress || 0) || 0), + carrier: String(existing.carrier || ''), + activeSpawnId: existing.activeSpawnId || DEFAULT_SPAWN_POINTS[0].id, + }; +} + +function defaultBattlemapLayout(scene = {}) { + const theme = sceneBattlemapTheme(scene); + const preset = layoutPresetForKey(theme.layoutKey); + return { + title: theme.boardTitle, + summary: theme.boardSummary, + gridWidth: GRID_WIDTH, + gridHeight: GRID_HEIGHT, + metersPerChecker: METERS_PER_CHECKER, + zones: preset.zones, + doors: preset.doors, + stations: preset.stations, + spawnPoints: preset.spawnPoints, + cover: preset.cover, + blockers: preset.blockers, + hazards: preset.hazards, + dynamicFeatures: DEFAULT_DYNAMIC_FEATURES, + viewBox: `0 0 ${GRID_WIDTH} ${GRID_HEIGHT}`, + }; +} + +function normalizeBattlemapLayout(scene = {}, existingLayout = {}) { + const base = defaultBattlemapLayout(scene); + const incoming = existingLayout && typeof existingLayout === 'object' ? existingLayout : {}; + return { + ...base, + ...incoming, + title: incoming.title || base.title, + summary: incoming.summary || base.summary, + gridWidth: Number(incoming.gridWidth || base.gridWidth) || base.gridWidth, + gridHeight: Number(incoming.gridHeight || base.gridHeight) || base.gridHeight, + metersPerChecker: Number(incoming.metersPerChecker || base.metersPerChecker) || base.metersPerChecker, + zones: Array.isArray(incoming.zones) && incoming.zones.length ? incoming.zones : base.zones, + doors: Array.isArray(incoming.doors) && incoming.doors.length ? incoming.doors : base.doors, + stations: Array.isArray(incoming.stations) && incoming.stations.length ? incoming.stations : base.stations, + spawnPoints: Array.isArray(incoming.spawnPoints) && incoming.spawnPoints.length ? incoming.spawnPoints : base.spawnPoints, + cover: Array.isArray(incoming.cover) ? incoming.cover : base.cover, + blockers: Array.isArray(incoming.blockers) ? incoming.blockers : base.blockers, + hazards: Array.isArray(incoming.hazards) ? incoming.hazards : base.hazards, + dynamicFeatures: Array.isArray(incoming.dynamicFeatures) ? incoming.dynamicFeatures : base.dynamicFeatures, + viewBox: incoming.viewBox || base.viewBox, + }; +} + +function defaultBattlemapTokens(scene = {}, existingTokens = []) { + const priorByName = new Map(normalizeTokens(existingTokens).map(token => [String(token.name).toLowerCase(), token])); + const players = scenePlayerEntries(scene); + const enemies = sceneEnemyObjects(scene); + const layout = defaultBattlemapLayout(scene); + const anchors = createBattlemapPositions(layout, players.map(name => ({ name })), enemies); + const tokens = []; + players.forEach((name, index) => { + const previous = priorByName.get(name.toLowerCase()); + const anchor = anchors[battlemapActorKey('player', name)] || { x: 1.5 + index, y: 1.5 }; + tokens.push(normalizeToken({ + id: `player:${name.toLowerCase()}`, + name, + side: 'player', + x: previous ? previous.x : anchor.x, + y: previous ? previous.y : anchor.y, + color: '#38bdf8', + }, index)); + }); + enemies.forEach((enemy, index) => { + const previous = priorByName.get(enemy.name.toLowerCase()); + const anchor = anchors[battlemapActorKey('enemy', enemy.name)] || { x: 30.5, y: 14.5 + index }; + tokens.push(normalizeToken({ + id: `enemy:${enemy.name.toLowerCase()}:${index + 1}`, + name: enemy.name, + side: 'enemy', + x: previous ? previous.x : anchor.x, + y: previous ? previous.y : anchor.y, + hp: enemy.wounds ?? enemy.magnitude ?? null, + maxHp: enemy.wounds ?? enemy.magnitude ?? null, + color: '#ef4444', + }, players.length + index)); + }); + return tokens; +} + +function buildBattlemapStateForScene(scene = {}, existingState = {}) { + const turnKey = scene?.combatState?.turnKey || ''; + return { + layout: normalizeBattlemapLayout(scene, existingState.layout), + tokens: defaultBattlemapTokens(scene, existingState.tokens), + objectives: defaultBattlemapObjectives(existingState.objectives), + turnState: normalizeTurnState(existingState.turnState, turnKey), + updatedAt: Number(existingState.updatedAt || Date.now()) || Date.now(), + }; +} + +function sceneBattlemapTheme(scene = {}) { + const title = String(scene.title || scene.name || '').toLowerCase(); + const checks = Array.isArray(scene.checks) ? scene.checks.map(check => String(check.name || '').toLowerCase()).join(' ') : ''; + const combined = `${title} ${checks}`; + // Checked before the more generic landing/vault/sanctum/corridor keywords + // below, since words like "core" or "reactor" also appear in these themes. + if (/starship|voidship|frigate|cruiser|bridge|boarding torpedo|void|bulkhead|airlock/.test(combined)) { + return { + layoutKey: 'spaceship', + boardTitle: 'Boarding Action', + boardSummary: 'Companionways, void-sealed bulkheads, and a bridge objective tuned to the current mission scene.', + }; + } + if (/factory|foundry|manufactorum|forge|assembly line|industrial|mill/.test(combined)) { + return { + layoutKey: 'factory', + boardTitle: 'Manufactorum Floor Fight', + boardSummary: 'Assembly lines, foundry control rooms, and a reactor core objective tuned to the current mission scene.', + }; + } + if (/city|hive|street|habblock|urban|precinct|spire|boulevard/.test(combined)) { + return { + layoutKey: 'city', + boardTitle: 'Hive Streets Skirmish', + boardSummary: 'Rubble checkpoints, collapsed habblocks, and a governor’s vault objective tuned to the current mission scene.', + }; + } + if (/jungle|feral|overgrown|canopy|swamp|bog|xenos ruin/.test(combined)) { + return { + layoutKey: 'jungle', + boardTitle: 'Feral Canopy Ambush', + boardSummary: 'Game trails, overgrown shrines, and a xenos ruin objective tuned to the current mission scene.', + }; + } + if (/landing|dock|breach|hangar/.test(combined)) { + return { + layoutKey: 'landing', + boardTitle: 'Landing Zone Breach', + boardSummary: 'Landing pad, gantries, cargo lanes, and breach routes tuned to the current mission scene.', + }; + } + if (/vault|gene|lab|reactor|core/.test(combined)) { + return { + layoutKey: 'vault', + boardTitle: 'Vault Control Fight', + boardSummary: 'Objective rooms, crossfire lanes, and extraction pressure for the current mission scene.', + }; + } + if (/shrine|temple|cathedral|sanctum/.test(combined)) { + return { + layoutKey: 'sanctum', + boardTitle: 'Sanctum Assault', + boardSummary: 'Central shrine lanes, elevated firing points, and flanking routes fitted to this scene.', + }; + } + if (/corridor|tunnel|passage|service/.test(combined)) { + return { + layoutKey: 'corridor', + boardTitle: 'Corridor Kill Zone', + boardSummary: 'Long fire lanes, choke points, and service routes fitted to this scene.', + }; + } + return { + layoutKey: DEFAULT_LAYOUT_KEY, + boardTitle: String(scene.title || scene.name || 'Battlemap'), + boardSummary: 'Dynamic tactical board generated from the active mission scene.', + }; +} + +// Every preset below shares one proven floor plan (see DEFAULT_ZONES): a +// spawn room, a corridor spine, two flanking rooms, a hazard wing, and a +// vault/core room, laid out so every room touches the corridor with zero +// rectangle overlap anywhere. Reusing this exact geometry per theme means +// every new environment is guaranteed connected and non-overlapping by +// construction — only names, materials, and hazard flavor change per theme. +// Cover/blockers/hazards are added on top and never affect this base. +function standardZones(labels) { + return [ + { id: 'extraction', name: labels.spawn, x: 1, y: 11, w: 7, h: 8, tone: 'spawn', elevation: 0, lighting: labels.spawnLighting || 'bright', purpose: labels.spawnPurpose }, + { id: 'main-corridor', name: labels.corridor, x: 8, y: 13, w: 24, h: 4, tone: 'corridor', elevation: 0, lighting: labels.corridorLighting || 'normal', purpose: labels.corridorPurpose }, + { id: 'left-wing', name: labels.left, x: 12, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: labels.leftLighting || 'bright', purpose: labels.leftPurpose }, + { id: 'right-wing', name: labels.right, x: 25, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: labels.rightLighting || 'dim', purpose: labels.rightPurpose }, + { id: 'maintenance', name: labels.hazardRoom, x: 8, y: 17, w: 15, h: 8, tone: 'hazard', elevation: -1, lighting: labels.hazardLighting || 'dark', purpose: labels.hazardPurpose }, + { id: 'vault', name: labels.core, x: 23, y: 17, w: 12, h: 8, tone: 'vault', elevation: 0, lighting: labels.coreLighting || 'dim', purpose: labels.corePurpose }, + ]; +} + +function standardStations(labels) { + return [ + { id: 'left-station', name: labels.leftStation, zoneId: 'left-wing', x: 17, y: 7, action: 'Full Action / Tech-Use / Medicae' }, + { id: 'right-station', name: labels.rightStation, zoneId: 'right-wing', x: 30, y: 7, action: 'Full Action / Tech-Use / Medicae' }, + { id: 'maintenance-station', name: labels.hazardStation, zoneId: 'maintenance', x: 20, y: 22, action: 'Full Action / Tech-Use / Medicae' }, + ]; +} + +function standardSpawnPoints(labels) { + return [ + { id: 'north-hatch', name: labels.spawnNorth, x: 17, y: 4 }, + { id: 'east-corridor', name: labels.spawnEast, x: 33, y: 15 }, + { id: 'south-tunnel', name: labels.spawnSouth, x: 16, y: 22 }, + { id: 'vault-breach', name: labels.spawnCore, x: 31, y: 20 }, + ]; +} + +function standardCover(materials) { + return [ + { id: 'ext-cover-1', kind: 'light', material: materials.spawn, elevation: 0, x: 3, y: 12, w: 2, h: 2 }, + { id: 'ext-cover-2', kind: 'light', material: materials.spawn, elevation: 0, x: 5, y: 16, w: 2, h: 2 }, + { id: 'corr-cover-1', kind: 'light', material: materials.corridor, elevation: 0, x: 11, y: 13, w: 1, h: 4 }, + { id: 'corr-cover-2', kind: 'light', material: materials.corridor, elevation: 0, x: 17, y: 13, w: 1, h: 4 }, + { id: 'corr-cover-3', kind: 'light', material: materials.corridor, elevation: 0, x: 23, y: 13, w: 1, h: 4 }, + { id: 'corr-cover-4', kind: 'light', material: materials.corridor, elevation: 0, x: 29, y: 13, w: 1, h: 4 }, + { id: 'left-cover-1', kind: 'heavy', material: materials.room, elevation: 1, x: 13, y: 4, w: 3, h: 2 }, + { id: 'left-cover-2', kind: 'heavy', material: materials.room, elevation: 1, x: 18, y: 9, w: 3, h: 2 }, + { id: 'right-cover-1', kind: 'heavy', material: materials.room, elevation: 1, x: 26, y: 4, w: 3, h: 2 }, + { id: 'right-cover-2', kind: 'light', material: materials.roomLight, elevation: 1, x: 31, y: 9, w: 2, h: 2 }, + { id: 'hazard-cover-1', kind: 'light', material: materials.spawn, elevation: -1, x: 11, y: 18, w: 3, h: 1 }, + { id: 'hazard-cover-2', kind: 'heavy', material: materials.hazard, elevation: -1, x: 19, y: 21, w: 2, h: 3 }, + { id: 'core-pillar-1', kind: 'heavy', material: materials.core, elevation: 0, x: 26, y: 19, w: 2, h: 2 }, + { id: 'core-pillar-2', kind: 'heavy', material: materials.core, elevation: 0, x: 32, y: 19, w: 2, h: 2 }, + { id: 'core-pillar-3', kind: 'heavy', material: materials.core, elevation: 0, x: 26, y: 22, w: 2, h: 2 }, + { id: 'core-pillar-4', kind: 'heavy', material: materials.core, elevation: 0, x: 32, y: 22, w: 2, h: 2 }, + { id: 'core-cover', kind: 'light', material: materials.spawn, elevation: 0, x: 29, y: 20, w: 2, h: 1 }, + ]; +} + +function standardBlockers(material) { + return [ + { id: 'blocker-left', material, x: 16, y: 5, w: 2, h: 1 }, + { id: 'blocker-right', material, x: 29, y: 5, w: 2, h: 1 }, + { id: 'blocker-core', material, x: 33, y: 19, w: 2, h: 4 }, + { id: 'blocker-hazard', material, x: 11, y: 20, w: 4, h: 2 }, + ]; +} + +function standardHazards(kinds) { + return [ + { id: 'hazard-1', kind: kinds.difficult, x: 11, y: 21, w: 5, h: 2 }, + { id: 'hazard-2', kind: kinds.agility, x: 12, y: 20, w: 4, h: 2 }, + { id: 'hazard-3', kind: kinds.noCharge, x: 24, y: 22, w: 4, h: 2 }, + { id: 'hazard-4', kind: kinds.vent, x: 29, y: 22, w: 2, h: 2 }, + ]; +} + +function layoutPresetForKey(key = DEFAULT_LAYOUT_KEY) { + if (key === 'landing') { + const zones = standardZones({ + spawn: 'Dropship LZ', corridor: 'Cargo Tarmac', left: 'West Gantry', right: 'East Gantry', + hazardRoom: 'Fuel Line', core: 'Breach Crater', + }); + return { + zones, + doors: computeZoneDoorways(zones), + stations: standardStations({ leftStation: 'West Vox Relay', rightStation: 'East Loader Controls', hazardStation: 'Fuel Cutoff' }), + spawnPoints: standardSpawnPoints({ spawnNorth: 'North maglift', spawnEast: 'East dock tunnel', spawnSouth: 'South rubble path', spawnCore: 'Central breach' }), + cover: standardCover({ spawn: 'Light Metal', corridor: 'Heavy Wood', room: 'Plasteel', roomLight: 'Armour-glas', hazard: 'Thick Iron', core: 'Rockcrete' }), + blockers: standardBlockers('Plasteel'), + hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'vent' }), + }; + } + if (key === 'corridor') { + const zones = standardZones({ + spawn: 'Rear Bulkhead', corridor: 'Main Corridor', left: 'West Junction', right: 'East Junction', + hazardRoom: 'Service Ducts', core: 'Lockdown Node', + }); + return { + zones, + doors: computeZoneDoorways(zones), + stations: standardStations({ leftStation: 'Junction Console', rightStation: 'Blast Door Relay', hazardStation: 'Vent Purge Controls' }), + spawnPoints: standardSpawnPoints({ spawnNorth: 'Ceiling hatch', spawnEast: 'Forward seal', spawnSouth: 'Lower conduit', spawnCore: 'Lockdown iris' }), + cover: standardCover({ spawn: 'Light Metal', corridor: 'Heavy Wood', room: 'Plasteel', roomLight: 'Armour-glas', hazard: 'Thick Iron', core: 'Rockcrete' }), + blockers: standardBlockers('Plasteel'), + hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'vent' }), + }; + } + if (key === 'sanctum') { + const zones = standardZones({ + spawn: 'Nave Entry', corridor: 'Central Nave', left: 'Choir Gallery', right: 'Reliquary', + hazardRoom: 'Crypt Passage', core: 'Sanctum Dais', coreLighting: 'glow', + }); + return { + zones, + doors: computeZoneDoorways(zones), + stations: standardStations({ leftStation: 'Choir Vox Pulpit', rightStation: 'Reliquary Seal', hazardStation: 'Crypt Winch' }), + spawnPoints: standardSpawnPoints({ spawnNorth: 'Rose window breach', spawnEast: 'Reliquary side door', spawnSouth: 'Crypt stair', spawnCore: 'Dais warp-rift' }), + cover: standardCover({ spawn: 'Light Metal', corridor: 'Heavy Wood', room: 'Stone', roomLight: 'Armour-glas', hazard: 'Thick Iron', core: 'Rockcrete' }), + blockers: standardBlockers('Thick Iron'), + hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'vent' }), + }; + } + if (key === 'spaceship') { + const zones = standardZones({ + spawn: 'Boarding Torpedo Bay', corridor: 'Central Companionway', left: 'Port Reactor Control', right: 'Starboard Weapons Battery', + hazardRoom: 'Lower Cargo Hold', core: 'Bridge Command Sanctum', spawnLighting: 'dim', hazardLighting: 'dark', + }); + return { + zones, + doors: computeZoneDoorways(zones), + stations: standardStations({ leftStation: 'Reactor Vox Relay', rightStation: 'Battery Fire Control', hazardStation: 'Cargo Bay Winch' }), + spawnPoints: standardSpawnPoints({ spawnNorth: 'Dorsal airlock', spawnEast: 'Starboard breach', spawnSouth: 'Bilge access', spawnCore: 'Bridge blast door' }), + cover: standardCover({ spawn: 'Light Metal', corridor: 'Plasteel', room: 'Plasteel', roomLight: 'Armour-glas', hazard: 'Thick Iron', core: 'Rockcrete' }), + blockers: standardBlockers('Plasteel'), + hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'vent' }), + }; + } + if (key === 'factory') { + const zones = standardZones({ + spawn: 'Loading Dock', corridor: 'Assembly Line Floor', left: 'Foundry Control Room', right: 'Smelter Overwatch', + hazardRoom: 'Waste Processing Pit', core: 'Reactor Core Chamber', + }); + return { + zones, + doors: computeZoneDoorways(zones), + stations: standardStations({ leftStation: 'Foundry Console', rightStation: 'Smelter Relay', hazardStation: 'Waste Purge Controls' }), + spawnPoints: standardSpawnPoints({ spawnNorth: 'Gantry maglift', spawnEast: 'Freight tunnel', spawnSouth: 'Drainage conduit', spawnCore: 'Reactor blast door' }), + cover: standardCover({ spawn: 'Light Metal', corridor: 'Heavy Wood', room: 'Plasteel', roomLight: 'Armour-glas', hazard: 'Thick Iron', core: 'Rockcrete' }), + blockers: standardBlockers('Thick Iron'), + hazards: standardHazards({ difficult: 'no-charge', agility: 'agility', noCharge: 'difficult', vent: 'vent' }), + }; + } + if (key === 'city') { + const zones = standardZones({ + spawn: 'Rubble Checkpoint', corridor: 'Main Boulevard', left: 'Collapsed Habblock', right: 'Comms Spire', + hazardRoom: 'Sewer Access', core: "Governor's Vault", + }); + return { + zones, + doors: computeZoneDoorways(zones), + stations: standardStations({ leftStation: 'Habblock Vox Relay', rightStation: 'Spire Uplink', hazardStation: 'Sewer Floodgate' }), + spawnPoints: standardSpawnPoints({ spawnNorth: 'Rooftop maglift', spawnEast: 'Boulevard barricade', spawnSouth: 'Sewer grate', spawnCore: 'Vault blast door' }), + cover: standardCover({ spawn: 'Rockcrete', corridor: 'Heavy Wood', room: 'Rockcrete', roomLight: 'Light Metal', hazard: 'Thick Iron', core: 'Plasteel' }), + blockers: standardBlockers('Rockcrete'), + hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'vent' }), + }; + } + if (key === 'jungle') { + const zones = standardZones({ + spawn: 'Landing Clearing', corridor: 'Game Trail', left: 'Overgrown Shrine', right: 'Canopy Watch Post', + hazardRoom: 'Bog Crossing', core: 'Xenos Ruin Core', spawnLighting: 'normal', corridorLighting: 'dim', hazardLighting: 'dark', + }); + return { + zones, + doors: computeZoneDoorways(zones), + stations: standardStations({ leftStation: 'Shrine Vox Relay', rightStation: 'Watch Post Signal', hazardStation: 'Bog Winch' }), + spawnPoints: standardSpawnPoints({ spawnNorth: 'Canopy drop zone', spawnEast: 'Overgrown trailhead', spawnSouth: 'Bog crossing path', spawnCore: 'Ruin breach' }), + cover: standardCover({ spawn: 'Light Wood', corridor: 'Heavy Wood', room: 'Stone', roomLight: 'Light Wood', hazard: 'Light Wood', core: 'Stone' }), + blockers: standardBlockers('Light Wood'), + hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'agility' }), + }; + } + return { + zones: DEFAULT_ZONES, + doors: computeZoneDoorways(DEFAULT_ZONES), + stations: DEFAULT_STATIONS, + spawnPoints: DEFAULT_SPAWN_POINTS, + cover: DEFAULT_COVER, + blockers: DEFAULT_BLOCKERS, + hazards: DEFAULT_HAZARDS, + }; +} + +module.exports = { + GRID_WIDTH, + GRID_HEIGHT, + METERS_PER_CHECKER, + MATERIAL_AP, + DEFAULT_ZONES, + DEFAULT_STATIONS, + DEFAULT_SPAWN_POINTS, + DEFAULT_COVER, + DEFAULT_BLOCKERS, + DEFAULT_HAZARDS, + DEFAULT_DYNAMIC_FEATURES, + battlemapVisibleForPlayer, + battlemapActorKey, + parseRangeNumber, + normalizeWeaponRange, + normalizeMovement, + normalizeTurnState, + movementBudget, + distance, + pointInRect, + segmentIntersectsRect, + materialAp, + coverForPoint, + blockerBetween, + elevationForPoint, + lightingForPoint, + visibilityBetween, + rangeBand, + attackPositioning, + snapCoordinate, + moveWithinRange, + stepToward, + enemyMoveTowardNearest, + pointFromLayout, + createBattlemapPositions, + sceneEnemyObjects, + scenePlayerEntries, + normalizeTokens, + defaultBattlemapObjectives, + defaultBattlemapLayout, + normalizeBattlemapLayout, + defaultBattlemapTokens, + buildBattlemapStateForScene, + sceneBattlemapTheme, + zonesOverlap, + pointInZone, + pointInAnyZone, + validateBattlemapLayout, + pointNearSegment, + allyBetween, + computeZoneDoorways, + computeZoneWalls, + wallBetween, + movementBlockedBetween, +}; diff --git a/src/tests/battlemapEngine.test.js b/src/tests/battlemapEngine.test.js new file mode 100644 index 0000000..440907f --- /dev/null +++ b/src/tests/battlemapEngine.test.js @@ -0,0 +1,314 @@ +const { + attackPositioning, + rangeBand, + sceneBattlemapTheme, + validateBattlemapLayout, + createBattlemapPositions, + coverForPoint, + moveWithinRange, + defaultBattlemapLayout, + wallBetween, + visibilityBetween, + DEFAULT_ZONES, + DEFAULT_STATIONS, + DEFAULT_SPAWN_POINTS, + DEFAULT_COVER, +} = require('../shared/battlemapEngine'); + +describe('battlemapEngine', () => { + test('applies short range, cover, and elevation modifiers', () => { + const layout = { + // 'low' touches 'high' at x=4. The shared edge only opens as a narrow + // centered doorway, so this shot intentionally passes through that + // doorway rather than through the whole wall seam. + zones: [ + { id: 'high', x: 0, y: 0, w: 4, h: 4, elevation: 1 }, + { id: 'low', x: 4, y: 0, w: 8, h: 4, elevation: 0 }, + ], + cover: [ + { id: 'c1', x: 9, y: 0, w: 2, h: 2, kind: 'light', material: 'Light Metal' }, + ], + blockers: [], + }; + const result = attackPositioning(layout, { x: 1, y: 2 }, { x: 10, y: 2 }, { name: 'Godwyn Bolter', range: '100m', pen: 0 }, 'ranged'); + expect(result.range.band).toBe('standard'); + expect(result.coverAp).toBe(4); + expect(result.totalModifier).toBe(0); + expect(result.reasons.join(' ')).toMatch(/Higher ground/); + }); + + test('marks melee attacks out of range', () => { + const result = attackPositioning({ zones: [], cover: [], blockers: [] }, { x: 1, y: 1 }, { x: 6, y: 1 }, { name: 'Chainsword' }, 'melee'); + expect(result.range.band).toBe('melee_out_of_range'); + expect(result.totalModifier).toBe(-60); + }); + + test('blocks a ranged shot with an ally standing in the line of fire', () => { + const layout = { zones: [], cover: [], blockers: [] }; + const attacker = { x: 1, y: 1 }; + const defender = { x: 10, y: 1 }; + const allyInTheWay = { x: 5, y: 1 }; + const result = attackPositioning(layout, attacker, defender, { name: 'Bolter', range: '100m' }, 'ranged', [allyInTheWay]); + expect(result.blocked).toBe(true); + expect(result.totalModifier).toBe(-60); + expect(result.reasons.join(' ')).toMatch(/ally/i); + }); + + test('does not block a ranged shot when the ally is off the firing line', () => { + const layout = { zones: [], cover: [], blockers: [] }; + const attacker = { x: 1, y: 1 }; + const defender = { x: 10, y: 1 }; + const allyOffLine = { x: 5, y: 6 }; + const result = attackPositioning(layout, attacker, defender, { name: 'Bolter', range: '100m' }, 'ranged', [allyOffLine]); + expect(result.blocked).toBe(false); + }); + + test('does not block adjacent melee attacks even with an ally listed', () => { + const layout = { zones: [], cover: [], blockers: [] }; + const attacker = { x: 1, y: 1 }; + const defender = { x: 2, y: 1 }; + const result = attackPositioning(layout, attacker, defender, { name: 'Chainsword' }, 'melee', [{ x: 1.5, y: 1 }]); + expect(result.blocked).toBe(false); + expect(result.range.band).toBe('melee'); + }); + + test('does not treat the attacker or target themselves as blocking allies', () => { + const layout = { zones: [], cover: [], blockers: [] }; + const attacker = { x: 1, y: 1 }; + const defender = { x: 10, y: 1 }; + const result = attackPositioning(layout, attacker, defender, { name: 'Bolter', range: '100m' }, 'ranged', [attacker, defender]); + expect(result.blocked).toBe(false); + }); + + // Regression: attacks previously only checked the small decorative + // `blockers` list (blast doors, containment cylinders) for line of sight, + // never the room walls themselves — so combatants in different, + // non-adjacent rooms could shoot straight through a solid wall. + test('blocks a ranged shot between two rooms that do not share a doorway', () => { + const layout = { zones: DEFAULT_ZONES, cover: [], blockers: [] }; + const leftWingPoint = { x: 15, y: 6 }; + const rightWingPoint = { x: 30, y: 6 }; + const result = attackPositioning(layout, leftWingPoint, rightWingPoint, { name: 'Bolter', range: '100m' }, 'ranged'); + expect(result.blocked).toBe(true); + expect(result.reasons.join(' ')).toMatch(/wall/i); + }); + + test('blocks a melee swing through a wall even at close range', () => { + const layout = { zones: DEFAULT_ZONES, cover: [], blockers: [] }; + // Just inside left-wing vs just inside the corridor's non-doorway top + // wall region isn't adjacent here; use two points close together but on + // opposite sides of left-wing's east wall (x=22). + const insideLeftWing = { x: 21.8, y: 6 }; + const insideRightWing = { x: 25.2, y: 6 }; + const result = attackPositioning(layout, insideLeftWing, insideRightWing, { name: 'Chainsword' }, 'melee'); + expect(result.blocked).toBe(true); + }); + + test('does not block a shot within the same open room or through an actual doorway', () => { + const layout = { zones: DEFAULT_ZONES, cover: [], blockers: [] }; + const corridorA = { x: 10, y: 15 }; + const corridorB = { x: 28, y: 15 }; + expect(wallBetween(layout, corridorA, corridorB)).toBeNull(); + + const leftWingDoor = { x: 17, y: 12 }; + const corridorPoint = { x: 17, y: 15 }; + expect(wallBetween(layout, leftWingDoor, corridorPoint)).toBeNull(); + }); + + test('blocks a shot that tries to cross a shared edge outside the doorway opening', () => { + const layout = { zones: DEFAULT_ZONES, cover: [], blockers: [] }; + const leftWingWall = { x: 13.5, y: 12 }; + const corridorPoint = { x: 13.5, y: 15 }; + expect(wallBetween(layout, leftWingWall, corridorPoint)).not.toBeNull(); + }); + + test('visibility is fully denied through walls even before attack logic runs', () => { + const layout = { zones: DEFAULT_ZONES, cover: [], blockers: [] }; + const leftWingPoint = { x: 15, y: 6 }; + const rightWingPoint = { x: 30, y: 6 }; + const result = visibilityBetween(layout, leftWingPoint, rightWingPoint); + expect(result.visible).toBe(false); + expect(result.reasons.join(' ')).toMatch(/wall/i); + }); + + test('darkness reduces detection range even without a wall', () => { + const layout = { + zones: [ + { id: 'dark-lane', x: 0, y: 0, w: 30, h: 4, lighting: 'dark' }, + ], + cover: [], + blockers: [], + }; + const result = visibilityBetween(layout, { x: 1, y: 2 }, { x: 18, y: 2 }); + expect(result.visible).toBe(false); + expect(result.reasons.join(' ')).toMatch(/Visibility limit/i); + }); + + test('rejects corner-only room contact as a valid connection', () => { + const zones = [ + { id: 'spawn', tone: 'spawn', x: 0, y: 0, w: 4, h: 4 }, + { id: 'corner-touch', tone: 'objective', x: 4, y: 4, w: 4, h: 4 }, + ]; + const issues = validateBattlemapLayout(zones); + expect(issues.some(issue => issue.includes('corner-touch') && issue.includes('share an edge'))).toBe(true); + }); + + test('uses scene theme to fit landing scenes', () => { + const theme = sceneBattlemapTheme({ title: 'Landing Goes Wrong' }); + expect(theme.boardTitle).toBe('Landing Zone Breach'); + }); + + test('range band matches local modifier table', () => { + expect(rangeBand(2, 100)).toEqual({ band: 'point_blank', modifier: 30 }); + expect(rangeBand(40, 100)).toEqual({ band: 'short', modifier: 10 }); + expect(rangeBand(150, 100)).toEqual({ band: 'long', modifier: -10 }); + expect(rangeBand(350, 100)).toEqual({ band: 'extreme', modifier: -30 }); + }); +}); + +describe('validateBattlemapLayout', () => { + test('the shipped default layout has no overlapping rooms', () => { + // Spawn points (DEFAULT_SPAWN_POINTS) intentionally sit outside every room — + // they're off-map entry hatches enemies/players arrive through, not anchors + // that must resolve to a room. Only room-vs-room overlap is checked here. + expect(validateBattlemapLayout(DEFAULT_ZONES)).toEqual([]); + }); + + test('flags two non-corridor rooms that overlap (regression: right-wing/vault crossed borders)', () => { + const zones = [ + { id: 'right-wing', tone: 'objective', x: 25, y: 3, w: 10, h: 8 }, + { id: 'vault', tone: 'vault', x: 23, y: 9, w: 12, h: 12 }, + ]; + const issues = validateBattlemapLayout(zones); + expect(issues.some(issue => issue.includes('right-wing') && issue.includes('vault'))).toBe(true); + }); + + // Rooms and the corridor are the wall base: they connect by sharing a + // touching edge (zero overlap), never by overlapping — a corridor drawn + // through a room's rectangle is just as much a crossed-border bug as two + // rooms overlapping each other. No zone is exempt from this check anymore. + test('flags a corridor overlapping a room (no exemptions)', () => { + const zones = [ + { id: 'corridor', tone: 'corridor', x: 0, y: 0, w: 10, h: 4 }, + { id: 'room', tone: 'objective', x: 5, y: 0, w: 10, h: 10 }, + ]; + const issues = validateBattlemapLayout(zones); + expect(issues.some(issue => issue.includes('corridor') && issue.includes('room'))).toBe(true); + }); + + test('accepts a corridor and a room that only share a touching edge (a doorway, not an overlap)', () => { + const zones = [ + { id: 'corridor', tone: 'corridor', x: 0, y: 0, w: 10, h: 4 }, + { id: 'room', tone: 'objective', x: 5, y: 4, w: 10, h: 10 }, + ]; + expect(validateBattlemapLayout(zones)).toEqual([]); + }); + + test('flags a room that exists but is not reachable from the spawn zone', () => { + const zones = [ + { id: 'extraction', tone: 'spawn', x: 0, y: 0, w: 4, h: 4 }, + { id: 'corridor', tone: 'corridor', x: 4, y: 0, w: 10, h: 4 }, + { id: 'isolated-room', tone: 'objective', x: 20, y: 20, w: 6, h: 6 }, + ]; + const issues = validateBattlemapLayout(zones); + expect(issues.some(issue => issue.includes('isolated-room') && issue.includes('not reachable'))).toBe(true); + }); + + test('flags a point that falls outside every room (regression: enemy anchor outside all rooms)', () => { + const zones = [ + { id: 'vault', tone: 'vault', x: 23, y: 9, w: 12, h: 12 }, + { id: 'maintenance', tone: 'hazard', x: 14, y: 22, w: 12, h: 6 }, + ]; + const issues = validateBattlemapLayout(zones, [[31.5, 24.5]]); + expect(issues.some(issue => issue.includes('does not fall inside any zone'))).toBe(true); + }); + + test('flags a zone that extends past the map bounds', () => { + const zones = [{ id: 'oob', tone: 'objective', x: 35, y: 0, w: 10, h: 5 }]; + const issues = validateBattlemapLayout(zones); + expect(issues.some(issue => issue.includes('extends outside'))).toBe(true); + }); +}); + +describe('createBattlemapPositions cover usage', () => { + test('some default player and enemy anchors sit on cover, but not all of them', () => { + const layout = { + zones: DEFAULT_ZONES, + stations: DEFAULT_STATIONS, + spawnPoints: DEFAULT_SPAWN_POINTS, + cover: DEFAULT_COVER, + }; + const players = ['p1', 'p2', 'p3', 'p4'].map(name => ({ name })); + const enemies = ['e1', 'e2', 'e3', 'e4'].map(name => ({ name })); + const positions = createBattlemapPositions(layout, players, enemies); + + const playerPoints = players.map(p => positions[`player:${p.name}`]); + const enemyPoints = enemies.map(e => positions[`enemy:${e.name}`]); + const coveredPlayers = playerPoints.filter(point => coverForPoint(layout, point)); + const coveredEnemies = enemyPoints.filter(point => coverForPoint(layout, point)); + + expect(coveredPlayers.length).toBeGreaterThan(0); + expect(coveredPlayers.length).toBeLessThan(playerPoints.length); + expect(coveredEnemies.length).toBeGreaterThan(0); + expect(coveredEnemies.length).toBeLessThan(enemyPoints.length); + }); +}); + +describe('moveWithinRange pathing', () => { + test('routes around blockers and occupied cells on square-by-square movement', () => { + const layout = { + zones: [{ id: 'room', x: 0, y: 0, w: 12, h: 12 }], + blockers: [{ id: 'wall', x: 3, y: 0, w: 1, h: 8 }], + hazards: [], + }; + const next = moveWithinRange( + { x: 1.5, y: 1.5 }, + { x: 8.5, y: 1.5 }, + 8, + { width: 12, height: 12 }, + { + layout, + occupied: [{ x: 2.5, y: 2.5 }], + } + ); + + expect(next.travelled).toBeLessThanOrEqual(8); + expect(next.x).not.toBe(8.5); + expect(next.x).not.toBe(3.5); + expect(next.y).not.toBe(1.5); + }); +}); + +describe('defaultBattlemapLayout presets', () => { + test.each([ + [{ title: 'Landing Goes Wrong' }, 'landing'], + [{ title: 'Silent Service Corridor' }, 'corridor'], + [{ title: 'Shrine of Black Glass' }, 'sanctum'], + [{ title: 'Gene Vault Collapse' }, 'vault'], + [{ title: 'Boarding Torpedo Assault' }, 'spaceship'], + [{ title: 'Manufactorum Uprising' }, 'factory'], + [{ title: 'Hive Street Siege' }, 'city'], + [{ title: 'Feral Canopy Ambush' }, 'jungle'], + ])('selects a clean, fully-connected preset for %p', (scene, expectedKey) => { + const theme = sceneBattlemapTheme(scene); + const layout = defaultBattlemapLayout(scene); + + expect(theme.layoutKey).toBe(expectedKey); + expect(validateBattlemapLayout(layout.zones)).toEqual([]); + expect(layout.zones.length).toBeGreaterThan(0); + expect(layout.spawnPoints.length).toBeGreaterThan(0); + + // Every station and cover piece should land inside the room it's meant for. + layout.stations.forEach((station) => { + const zone = layout.zones.find((z) => z.id === station.zoneId); + expect(zone && zone.x <= station.x && station.x <= zone.x + zone.w && zone.y <= station.y && station.y <= zone.y + zone.h).toBe(true); + }); + layout.cover.forEach((item) => { + const fits = layout.zones.some((zone) => ( + zone.x <= item.x && item.x + item.w <= zone.x + zone.w + && zone.y <= item.y && item.y + item.h <= zone.y + zone.h + )); + expect(fits).toBe(true); + }); + }); +}); diff --git a/src/tests/battlemapTab.test.js b/src/tests/battlemapTab.test.js new file mode 100644 index 0000000..9b9de62 --- /dev/null +++ b/src/tests/battlemapTab.test.js @@ -0,0 +1,172 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import axios from 'axios'; +import BattlemapTab from '../components/BattlemapTab'; + +jest.mock('axios'); + +const gmMission = { + id: 7, + name: 'Landing Goes Wrong', + current_scene: 0, + scenes: [ + { + title: 'Dock Breach', + type: 'combat', + description: 'Boarding torpedoes punch into a void dock while cultists hold the gantries.', + enemies: [ + { name: 'Cultist Cell', wounds: 8 }, + { name: 'Heavy Stubber Team', wounds: 10 }, + ], + combatState: { + initiatives: [ + { player: 'Chris', total: 12, type: 'player' }, + { player: 'Rafen', total: 10, type: 'player' }, + { name: 'Cultist Cell', total: 9, type: 'npc', ts: 1 }, + ], + turnKey: 'player:chris', + }, + }, + ], +}; + +const gmMissionWithPartialLayout = { + ...gmMission, + scenes: [ + { + ...gmMission.scenes[0], + battlemapState: { + layout: { + gridWidth: 40, + gridHeight: 30, + zones: [{ id: 'extraction', name: 'Extraction', x: 1, y: 11, w: 8, h: 8, tone: 'spawn' }], + }, + }, + }, + ], +}; + +describe('BattlemapTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(global, 'setInterval').mockImplementation(() => 1); + jest.spyOn(global, 'clearInterval').mockImplementation(() => {}); + }); + + afterEach(() => { + global.setInterval.mockRestore(); + global.clearInterval.mockRestore(); + }); + + test('loads the gene-vault mission board, shows objectives, and persists GM token moves', async () => { + axios.get.mockImplementation((url) => { + if (url === '/api/players') { + return Promise.resolve({ + data: [ + { name: 'Chris', tabInfo: { movement: { half: 3, full: 6, charge: 6 } } }, + { name: 'Rafen', tabInfo: { movement: { half: 3, full: 6, charge: 6 } } }, + ], + }); + } + return Promise.resolve({ data: gmMission }); + }); + axios.put.mockResolvedValue({ data: { success: true } }); + + const { unmount } = render(<BattlemapTab authedPlayer="gm" sessionId="session-test" />); + + expect(await screen.findByText('Landing Zone Breach')).toBeInTheDocument(); + expect(await screen.findByText('Landing Goes Wrong')).toBeInTheDocument(); + expect(screen.getByText(/Dock Breach/)).toBeInTheDocument(); + expect(screen.getByText(/1 checker = 10 metres/i)).toBeInTheDocument(); + expect(screen.getByText(/Activate 2 of 3 stabilization stations/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sync Combatants' })).toBeEnabled(); + + await waitFor(() => { + expect(screen.getByText(/Turn Chris/)).toBeInTheDocument(); + expect(screen.getByText('North maglift')).toBeInTheDocument(); + expect(screen.getByTestId('battlemap-token-player:chris')).toBeInTheDocument(); + expect(screen.getByTestId('battlemap-token-enemy:cultist cell:1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('battlemap-token-player:chris')); + fireEvent.click(screen.getByRole('button', { name: 'Right' })); + + await waitFor(() => expect(axios.put).toHaveBeenCalled()); + unmount(); + }); + + test('does not crash on older partial battlemap layout data', async () => { + axios.get.mockImplementation((url) => { + if (url === '/api/players') { + return Promise.resolve({ data: [{ name: 'Chris', tabInfo: { movement: { half: 3, full: 6 } } }] }); + } + return Promise.resolve({ data: gmMissionWithPartialLayout }); + }); + axios.put.mockResolvedValue({ data: { success: true } }); + + const { unmount } = render(<BattlemapTab authedPlayer="gm" sessionId="session-test" />); + expect(await screen.findByText('Landing Zone Breach')).toBeInTheDocument(); + expect(screen.getByText(/Cover Materials/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sync Combatants' })).toBeEnabled(); + unmount(); + }); + + test('tracks checker-by-checker movement across the turn budget', async () => { + axios.get.mockImplementation((url) => { + if (url === '/api/players') { + return Promise.resolve({ + data: [ + { name: 'Chris', tabInfo: { movement: { half: 10, full: 20, charge: 20, run: 30 } } }, + { name: 'Rafen', tabInfo: { movement: { half: 10, full: 20, charge: 20, run: 30 } } }, + ], + }); + } + return Promise.resolve({ data: gmMission }); + }); + axios.put.mockResolvedValue({ data: { success: true } }); + + const { unmount } = render(<BattlemapTab authedPlayer="gm" sessionId="session-test" />); + + await waitFor(() => expect(screen.getByTestId('battlemap-token-player:chris')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('battlemap-token-player:chris')); + fireEvent.click(screen.getByRole('button', { name: /full/i })); + + const canvas = screen.getByTestId('battlemap-canvas'); + canvas.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 400, + height: 300, + right: 400, + bottom: 300, + }); + + const firstCircle = screen.getByTestId('battlemap-token-player:chris').querySelector('circle'); + const firstX = Number(firstCircle.getAttribute('cx')); + const firstY = Number(firstCircle.getAttribute('cy')); + fireEvent.click(canvas, { + clientX: ((firstX + 1) / 40) * 400, + clientY: (firstY / 30) * 300, + }); + + await waitFor(() => expect(screen.getByText(/Current turn: full movement, 1 checker\(s\) left\./i)).toBeInTheDocument()); + + const secondCircle = screen.getByTestId('battlemap-token-player:chris').querySelector('circle'); + const secondX = Number(secondCircle.getAttribute('cx')); + const secondY = Number(secondCircle.getAttribute('cy')); + fireEvent.click(canvas, { + clientX: ((secondX + 1) / 40) * 400, + clientY: (secondY / 30) * 300, + }); + + await waitFor(() => { + expect(axios.put).toHaveBeenCalledTimes(2); + const payload = axios.put.mock.calls[1][1]; + expect(payload.patch.battlemapState.turnState.movementUsedBy['player:chris']).toBe(2); + expect(payload.patch.battlemapState.turnState.moveModeBy['player:chris']).toBe('full'); + }); + + unmount(); + }); +}); diff --git a/src/tests/battlemapTabLayout.test.js b/src/tests/battlemapTabLayout.test.js new file mode 100644 index 0000000..40ed679 --- /dev/null +++ b/src/tests/battlemapTabLayout.test.js @@ -0,0 +1,38 @@ +const { validateBattlemapLayout, coverForPoint } = require('../shared/battlemapEngine'); +const { + __ZONES: ZONES, + __COVER_OBJECTS: COVER_OBJECTS, + __tokenAnchorPositions: tokenAnchorPositions, +} = require('../components/BattlemapTab'); + +describe('BattlemapTab layout (frontend copy)', () => { + test('rooms do not overlap', () => { + expect(validateBattlemapLayout(ZONES)).toEqual([]); + }); + + test('player and enemy anchors always land inside a room, for any roster size', () => { + for (let count = 0; count <= 6; count += 1) { + const players = Array.from({ length: count }, (_, i) => `player-${i}`); + const enemies = Array.from({ length: count }, (_, i) => `enemy-${i}`); + const anchors = tokenAnchorPositions(players, enemies); + const points = [...anchors.players, ...anchors.enemies]; + const issues = validateBattlemapLayout(ZONES, points); + expect(issues).toEqual([]); + } + }); + + test('at least one default player anchor and one enemy anchor sit on cover, but not all of them', () => { + const layout = { cover: COVER_OBJECTS }; + const anchors = tokenAnchorPositions( + ['p1', 'p2', 'p3', 'p4', 'p5'], + ['e1', 'e2', 'e3', 'e4', 'e5'], + ); + const coveredPlayers = anchors.players.filter(([x, y]) => coverForPoint(layout, { x, y })); + const coveredEnemies = anchors.enemies.filter(([x, y]) => coverForPoint(layout, { x, y })); + + expect(coveredPlayers.length).toBeGreaterThan(0); + expect(coveredPlayers.length).toBeLessThan(anchors.players.length); + expect(coveredEnemies.length).toBeGreaterThan(0); + expect(coveredEnemies.length).toBeLessThan(anchors.enemies.length); + }); +}); diff --git a/src/tests/simulationBattlemap.test.js b/src/tests/simulationBattlemap.test.js new file mode 100644 index 0000000..03130dd --- /dev/null +++ b/src/tests/simulationBattlemap.test.js @@ -0,0 +1,270 @@ +jest.mock('../../database/mariadb', () => ({ + missionHelpers: {}, + playerHelpers: {}, + simulationHelpers: {}, + logToFile: jest.fn(), +})); + +const simulationRouter = require('../../database/routes/simulationRoutes'); + +const { runSimulation, DIFFICULTY, chooseEnemyAttackProfile, combatRound } = simulationRouter.__testables; + +function makePlayer(name, overrides = {}) { + return { + name, + chapter: 'Ultramarines', + spec: 'Kill-Marine', + WS: 48, + BS: 52, + S: 45, + T: 45, + Ag: 42, + Int: 46, + Per: 44, + Wp: 46, + Fel: 38, + wounds: 20, + maxWounds: 20, + fate: 1, + maxFate: 1, + weapons: [ + { name: 'Godwyn Bolter', damage: '2d10+5', rof: 'S/3/6', clipSize: 28, ammoRemaining: 28, ammoType: 'Standard', specialAmmo: false, shotsFired: 0, class: 'Basic' }, + { name: 'Astartes Chainsword', damage: '1d10+6', class: 'Melee' }, + ], + conditions: [], + fateUsed: false, + rolls: [], + successCount: 0, + failCount: 0, + fateSpends: 0, + heroMoments: [], + shameList: [], + combatProfile: 'balanced', + ...overrides, + }; +} + +function clonePlayer(player) { + return { + ...player, + weapons: player.weapons.map(weapon => ({ ...weapon })), + conditions: [...player.conditions], + rolls: [...player.rolls], + heroMoments: [...player.heroMoments], + shameList: [...player.shameList], + }; +} + +const mission = { + id: 991, + name: 'Secure the Gene-Vault Under Fire', + scenes: [ + { + title: 'Gene-Vault Push', + type: 'combat', + enemies: [ + { name: 'Cultist Fire Team', wounds: 8, bs: 38, ws: 35, damage: '1d10+3', ap: 1 }, + { name: 'Plague Marine', wounds: 18, bs: 42, ws: 48, damage: '1d10+6', ap: 4 }, + ], + checks: [ + { name: 'Hold firing lane', characteristic: 'BS', modifier: 0 }, + ], + battlemapState: { + layout: { + gridWidth: 40, + gridHeight: 30, + zones: [ + { id: 'extraction', tone: 'spawn' }, + { id: 'left-wing', tone: 'objective' }, + { id: 'right-wing', tone: 'objective' }, + { id: 'maintenance', tone: 'hazard' }, + { id: 'vault', tone: 'vault' }, + ], + cover: Array.from({ length: 10 }, (_, index) => ({ id: `cover-${index}` })), + hazards: [{ id: 'sludge' }, { id: 'vent' }], + blockers: [{ id: 'door-1' }], + stations: [ + { id: 'left-station', name: 'Alpha Stabilization' }, + { id: 'right-station', name: 'Beta Stabilization' }, + { id: 'maintenance-station', name: 'Gamma Stabilization' }, + ], + spawnPoints: [ + { id: 'north-hatch', name: 'North maintenance hatch' }, + { id: 'east-corridor', name: 'East corrupted corridor' }, + { id: 'south-tunnel', name: 'South service tunnel' }, + { id: 'vault-breach', name: 'Vault breach point' }, + ], + }, + objectives: { + stations: [ + { id: 'left-station', active: false }, + { id: 'right-station', active: false }, + { id: 'maintenance-station', active: false }, + ], + geneCoreSecuredRounds: 0, + activeSpawnId: 'north-hatch', + }, + }, + }, + ], +}; + +describe('battlemap-aware simulation', () => { + test('runs 20 battlemap simulations with objective and reinforcement activity', () => { + const basePlayers = [ + makePlayer('Chris', { Int: 48 }), + makePlayer('Rafen', { WS: 54, BS: 45, combatProfile: 'melee' }), + makePlayer('Leon', { BS: 56, Int: 44, combatProfile: 'ranged' }), + makePlayer('Mikael', { Int: 52, Per: 48 }), + ]; + + const summaries = []; + for (let index = 0; index < 20; index += 1) { + const result = runSimulation( + mission, + basePlayers.map(clonePlayer), + DIFFICULTY[2], + { enemyProfile: 'balanced' } + ); + summaries.push(result.scene_results[0]?.battlemap || null); + } + + expect(summaries).toHaveLength(20); + expect(summaries.every(summary => summary && summary.active)).toBe(true); + expect(summaries.some(summary => summary.reinforcementWaves >= 1)).toBe(true); + expect(summaries.some(summary => summary.positions && Object.keys(summary.positions).length >= 4)).toBe(true); + }); + + test('emits battlemap movement events for players and enemies while keeping combat on-map', () => { + const result = runSimulation( + mission, + [ + makePlayer('Chris', { Int: 48 }), + makePlayer('Rafen', { WS: 54, BS: 45, combatProfile: 'melee' }), + makePlayer('Leon', { BS: 56, Int: 44, combatProfile: 'ranged' }), + makePlayer('Mikael', { Int: 52, Per: 48 }), + ].map(clonePlayer), + DIFFICULTY[2], + { enemyProfile: 'balanced' } + ); + + const events = result.scene_results[0]?.events || []; + expect(events.some(event => event.type === 'battlemapMove' && event.side === 'enemy')).toBe(true); + expect(events.some(event => event.type === 'battlemapMove' && event.side === 'player')).toBe(true); + expect(events.some(event => event.type === 'reinforcement' || event.type === 'battlemapAttack' || event.type === 'enemyAttack')).toBe(true); + }); + + test('melee enemy profiles commit to melee even before contact', () => { + const profile = chooseEnemyAttackProfile({ combatProfile: 'melee', ws: 55, bs: 35, range: 30 }, 12); + expect(profile.mode).toBe('melee'); + expect(profile.stat).toBe('WS'); + expect(profile.range).toBe(2); + }); + + test('authored battlemaps eventually produce horde combat beats', () => { + const basePlayers = [ + makePlayer('Chris', { Int: 48 }), + makePlayer('Rafen', { WS: 60, BS: 40, combatProfile: 'melee', weapons: [{ name: 'Astartes Chainsword', damage: '1d10+6', class: 'Melee' }] }), + makePlayer('Leon', { BS: 56, Int: 44, combatProfile: 'ranged' }), + makePlayer('Mikael', { Int: 52, Per: 48 }), + ]; + + let sawHordeBeat = false; + for (let index = 0; index < 25; index += 1) { + const result = runSimulation( + mission, + basePlayers.map(clonePlayer), + DIFFICULTY[2], + { enemyProfile: 'melee' } + ); + const events = result.scene_results[0]?.events || []; + if (events.some((event) => event.type === 'horde_damage' || (event.type === 'reinforcement' && /horde/i.test(String(event.enemy || ''))))) { + sawHordeBeat = true; + } + if (sawHordeBeat) break; + } + + expect(sawHordeBeat).toBe(true); + }); + + test('combat round does not acquire or attack targets through a wall', () => { + const players = [ + clonePlayer(makePlayer('Rafen', { combatProfile: 'melee', weapons: [{ name: 'Astartes Chainsword', damage: '1d10+6', class: 'Melee' }] })), + ]; + const enemies = [ + { name: 'Plague Marine', wounds: 18, bs: 42, ws: 48, damage: '1d10+6', ap: 4, agBonus: 4, combatProfile: 'melee' }, + ]; + const rollFeed = []; + const events = []; + const battlemapContext = { + hasAuthoredLayout: false, + gridWidth: 20, + gridHeight: 10, + zones: [ + { id: 'left-room', x: 0, y: 0, w: 8, h: 10, lighting: 'bright', tone: 'objective' }, + { id: 'right-room', x: 12, y: 0, w: 8, h: 10, lighting: 'bright', tone: 'objective' }, + ], + cover: [], + blockers: [], + hazards: [], + positions: { + 'player:Rafen': { x: 4.5, y: 5.5 }, + 'enemy:Plague Marine': { x: 15.5, y: 5.5 }, + }, + }; + + combatRound(1, players, enemies, [], 'Sealed Bulkhead', rollFeed, events, DIFFICULTY[2], { + battlemapContext, + objectiveActors: new Set(), + }); + + expect(events.some((event) => event.type === 'enemyAttack')).toBe(false); + expect(events.some((event) => event.type === 'battlemapAttack')).toBe(false); + }); + + // Regression: battlemap.positions is mutated in place throughout combat, so + // by report time it holds end-of-fight positions, not where the fight + // started. The replay UI must seed from a separate pre-round-1 snapshot + // (startingPositions) or every token renders frozen at its final spot. + test('reports a startingPositions snapshot distinct from the end-of-combat positions', () => { + const result = runSimulation( + mission, + [ + makePlayer('Chris', { Int: 48 }), + makePlayer('Rafen', { WS: 54, BS: 45, combatProfile: 'melee' }), + makePlayer('Leon', { BS: 56, Int: 44, combatProfile: 'ranged' }), + makePlayer('Mikael', { Int: 52, Per: 48 }), + ].map(clonePlayer), + DIFFICULTY[2], + { enemyProfile: 'balanced' } + ); + + const battlemap = result.scene_results[0]?.battlemap; + const events = result.scene_results[0]?.events || []; + expect(battlemap.startingPositions).toBeTruthy(); + expect(Object.keys(battlemap.startingPositions).length).toBeGreaterThanOrEqual(4); + + // Every actor's very first logged battlemapMove should start from the + // snapshot position, not from wherever they ended up. Reinforcements + // spawned mid-fight legitimately have no pre-round-1 snapshot entry, so + // those are skipped rather than asserted on. + const seen = new Set(); + let checked = 0; + for (const ev of events) { + if (ev.type !== 'battlemapMove') continue; + const name = ev.actor || ev.player; + const key = `${ev.side === 'enemy' ? 'enemy' : 'player'}:${name}`; + if (seen.has(key)) continue; + seen.add(key); + const start = battlemap.startingPositions[key]; + if (!start) continue; + checked += 1; + const travelled = Number(ev.travelled || 0); + const moved = Math.hypot(Number(ev.x) - start.x, Number(ev.y) - start.y); + // The logged destination should be reachable from the snapshot start + // within the reported travel distance (small slack for path rounding). + expect(moved).toBeLessThanOrEqual(travelled + 1.5); + } + expect(checked).toBeGreaterThan(0); + }); +});