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:
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user