feat: add Battlemap tab with grid-based tactical map

- src/components/BattlemapTab.jsx: full battlemap UI with hex grid,
  token placement, movement, and combat tracking
- src/shared/battlemapEngine.js: core engine (distance, movement,
  theme, coordinate utilities) used by backend routes and tests
- src/tests/battlemapEngine.test.js: unit tests for engine functions
- src/tests/battlemapTab.test.js: component rendering tests
- src/tests/battlemapTabLayout.test.js: layout/positioning tests
- src/tests/simulationBattlemap.test.js: integration tests for
  battlemap in simulation context
- docs/incident-2026-07-04-blank-site.md: post-mortem for the
  webpack CJS/ESM interop crash that caused blank pages;
  documents why BattlemapTab duplicates engine functions locally
This commit is contained in:
2026-07-15 19:36:00 +02:00
parent cc5aa88703
commit 2a6224ee6f
7 changed files with 3883 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
# Incident: dwroller.alw.dk blank page (2026-07-04)
## Symptom
`https://dwroller.alw.dk/` loaded a valid HTML shell (correct `<title>`,
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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+314
View File
@@ -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);
});
});
});
+172
View File
@@ -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();
});
});
+38
View File
@@ -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);
});
});
+270
View File
@@ -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);
});
});