SOURCE / PINNED RELEASE
Made of little things.
Squad Table
- Release
- d36201f8ed4f…
- Author-recorded commit
- 7b9bb681d6ba…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-c043b0c0f1d
Archive hash verified: 8c0604b31f00cc5a…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Game rules regression tests: hex geometry, the action-log reducer and the
// backend handler (run against an in-memory context). Uses Node's built-in
// TypeScript type stripping; a tiny resolve hook maps ".js" imports to ".ts".
import assert from 'node:assert/strict';
import { register } from 'node:module';
import test from 'node:test';
register(
'data:text/javascript,' +
encodeURIComponent(`export async function resolve(specifier, context, next) {
try { return await next(specifier, context); }
catch (error) {
if (specifier.startsWith('.') && specifier.endsWith('.js')) return next(specifier.slice(0, -3) + '.ts', context);
throw error;
}
}`),
);
const hex = await import('../src/hex.ts');
const model = await import('../src/model.ts');
const { handle } = await import('../backend/handler.ts');
const entry = (i, a, by = 'nostr:aa', side = 'a', d) => ({ i, by, side, at: 1000 + i, a, ...(d ? { d } : {}) });
test('hex ids round-trip, including double-letter columns', () => {
for (const id of ['A1', 'K6', 'V10', 'Z3', 'AA1', 'AB12']) assert.equal(hex.hexId(hex.parseHex(id)), id);
assert.equal(hex.parseHex('k6'), null);
assert.equal(hex.parseHex('A'), null);
});
test('neighbours follow the odd-q layout and are symmetric', () => {
for (const id of ['B4', 'C4', 'K6', 'L5']) {
const h = hex.parseHex(id);
const ns = hex.neighbors(h);
assert.equal(ns.length, 6);
for (const n of ns) {
assert.equal(hex.distance(h, n), 1, `${id} → ${hex.hexId(n)}`);
assert.ok(hex.adjacent(n, h), `${hex.hexId(n)} should neighbour ${id}`);
}
}
assert.equal(hex.distance(hex.parseHex('A1'), hex.parseHex('A5')), 4);
});
test('pixelToHex inverts hex centres', () => {
for (const id of ['A1', 'B1', 'K6', 'V10']) {
const c = hex.center(hex.parseHex(id));
assert.equal(hex.hexId(hex.pixelToHex(c.x, c.y)), id);
assert.equal(hex.hexId(hex.pixelToHex(c.x + 10, c.y - 10)), id);
}
});
test('LOS thread lists hexes in order from firer to target', () => {
const ids = hex.thread(hex.parseHex('A1'), hex.parseHex('A5')).map((h) => h.id);
assert.deepEqual(ids, ['A1', 'A2', 'A3', 'A4', 'A5']);
const diag = hex.thread(hex.parseHex('B2'), hex.parseHex('H5'));
assert.equal(diag[0].id, 'B2');
assert.equal(diag[diag.length - 1].id, 'H5');
// A straight hex row between two hexes that share a column-pair spine grazes hexsides.
const graze = hex.thread(hex.parseHex('C3'), hex.parseHex('E3'));
assert.ok(graze.some((h) => h.edge), 'expected a hexside graze to be flagged');
});
test('scenario setup places counters on the map and in off-board boxes', () => {
const s = model.replay('millbrook-dawn', []);
assert.equal(s.stacks.get('pool-a').length, 15);
assert.equal(s.stacks.get('K6').length, 3);
assert.equal(s.turn, 0, 'tables start in setup');
assert.equal(s.active, 'a');
// Every placed hex exists on the map.
const map = model.mapOf(s.scenario);
for (const at of s.stacks.keys()) assert.ok(at.includes('-') || map.cells.has(at), at);
});
test('move, flip, rotate, label and top update the table', () => {
const s = model.replay('millbrook-dawn', [
entry(0, { t: 'move', ids: ['s0', 's1'], to: 'A5' }),
entry(1, { t: 'flip', id: 's0' }),
entry(2, { t: 'add', id: 'cx1', k: 'us-tank', side: 'a', to: 'B5' }),
entry(3, { t: 'rot', id: 'cx1', r: 3 }),
entry(4, { t: 'label', id: 'cx1', text: 'M4A1' }),
entry(5, { t: 'top', id: 's0' }),
]);
assert.deepEqual(s.stacks.get('A5'), ['s1', 's0']);
assert.equal(s.counters.get('s0').face, 1);
assert.equal(s.counters.get('cx1').rot, 3);
assert.equal(model.counterName(s.counters.get('cx1')), 'M4A1');
assert.equal(s.stacks.get('pool-a').length, 13);
assert.equal(s.lastMove.to, 'A5');
});
test('invalid actions are ignored without breaking replay', () => {
const s = model.replay('millbrook-dawn', [
entry(0, { t: 'move', ids: ['s0'], to: 'ZZ99' }),
entry(1, { t: 'move', ids: ['nope'], to: 'A5' }),
entry(2, { t: 'add', id: 's1', k: 'us-666', side: 'a', to: 'A5' }), // duplicate id
entry(3, { t: 'add', id: 'x', k: 'not-a-unit', side: 'a', to: 'A5' }),
entry(4, { t: 'move', ids: ['s0'], to: 'B5' }),
]);
assert.equal(s.counters.get('s0').at, 'B5');
assert.equal(s.stacks.get('A5'), undefined);
});
test('undo reverts only the author’s own state changes', () => {
const entries = [
entry(0, { t: 'move', ids: ['s0'], to: 'A5' }),
entry(1, { t: 'move', ids: ['s1'], to: 'A6' }, 'nostr:bb', 'b'),
entry(2, { t: 'undo', ref: 1 }), // aa cannot undo bb's move
entry(3, { t: 'undo', ref: 0 }),
];
const s = model.replay('millbrook-dawn', entries);
assert.equal(s.counters.get('s0').at, 'pool-a');
assert.equal(s.counters.get('s1').at, 'A6');
assert.ok(s.lines.find((l) => l.i === 0).undone);
assert.ok(!s.lines.find((l) => l.i === 1).undone);
});
test('undo candidate is the newest change and only if it is yours', () => {
const entries = [entry(0, { t: 'move', ids: ['s0'], to: 'A5' }), entry(1, { t: 'roll', n: 2, why: '' }, 'nostr:aa', 'a', [3, 4])];
let s = model.replay('millbrook-dawn', entries);
assert.equal(model.undoCandidate(s, 'nostr:aa'), null, 'a dice roll makes earlier moves final');
s = model.replay('millbrook-dawn', [...entries, entry(2, { t: 'move', ids: ['s1'], to: 'A6' })]);
assert.equal(model.undoCandidate(s, 'nostr:aa').i, 2);
s = model.replay('millbrook-dawn', [...entries, entry(2, { t: 'undo', ref: 0 })]);
assert.equal(s.counters.get('s0').at, 'A5', 'undo across a roll is ignored');
s = model.replay('millbrook-dawn', [...entries, entry(2, { t: 'move', ids: ['s1'], to: 'A6' }), entry(3, { t: 'flip', id: 's20' }, 'nostr:bb', 'b')]);
assert.equal(model.undoCandidate(s, 'nostr:aa'), null);
assert.equal(model.undoCandidate(s, 'nostr:bb').i, 3);
});
test('phases advance only by End Phase, in order, and never backwards', () => {
const log = [];
const push = (a, side) => {
log.push(entry(log.length, a, side === 'a' ? 'nostr:aa' : 'nostr:bb', side));
return model.replay('millbrook-dawn', log);
};
let s = push({ t: 'end' }, 'a');
assert.equal(s.turn, 0, 'setup needs both sides');
s = push({ t: 'end' }, 'b');
assert.deepEqual([s.turn, s.phase, s.active], [1, 0, 'a']);
s = push({ t: 'end' }, 'a');
s = push({ t: 'end' }, 'b'); // Rally needs both
assert.equal(s.phase, 1, 'Prep Fire');
s = push({ t: 'end' }, 'b');
assert.equal(s.phase, 1, 'the defender cannot end Prep Fire');
assert.equal(s.lines.at(-1).kind, 'refused');
s = push({ t: 'phase', turn: 1, phase: 0, side: 'a' }, 'a');
assert.equal(s.phase, 1, 'phase jumps are refused');
s = push({ t: 'end' }, 'a'); // → Movement
s = push({ t: 'end' }, 'a'); // → Defensive Fire
assert.equal(s.phase, 3);
s = push({ t: 'end' }, 'a');
assert.equal(s.phase, 3, 'Defensive Fire belongs to the defender');
s = push({ t: 'end' }, 'b');
assert.equal(s.phase, 4);
const undo = push({ t: 'undo', ref: log.length - 1 }, 'b');
assert.equal(undo.phase, 4, 'phase changes cannot be undone');
});
test('the game ends after the last turn', () => {
const t = table('hill-stand', [], { turn: 5, phase: 7, side: 'b' });
let s = t.state();
assert.ok(!s.over);
for (const side of model.phaseActors(s)) s = t.push({ t: 'end' }, side);
assert.ok(s.over);
assert.equal(t.push({ t: 'end' }, 'a').lines.at(-1).kind, 'refused');
});
test('dice entries are logged with their totals', () => {
const s = model.replay('millbrook-dawn', [entry(0, { t: 'roll', n: 2, why: 'IFT attack' }, 'nostr:aa', 'a', [5, 6])]);
assert.deepEqual(s.lastRoll.dice, [5, 6]);
assert.match(s.lines[0].text, /5\+6 = 11 for IFT attack/);
});
test('validAction rejects malformed peer or stored actions', () => {
assert.ok(model.validAction({ t: 'move', ids: ['a'], to: 'A1' }));
assert.ok(!model.validAction({ t: 'move', ids: [], to: 'A1' }));
assert.ok(!model.validAction({ t: 'roll', n: 3, why: '' }));
assert.ok(!model.validAction({ t: 'chat', text: 'x'.repeat(281) }));
assert.ok(!model.validAction({ t: 'eval', code: '1' }));
assert.ok(!model.validAction(null));
});
// ---------- backend handler ----------
function fakeProvider() {
const data = new Map();
let access = null;
let n = 0;
const ctx = (operation, principal, extra = {}) => ({
actor: 'actor',
account: principal.startsWith('nostr:') ? principal.slice(6) : null,
principal,
owner: 'nostr:owner',
instance: 'inst1',
release: 'rel1',
operation,
requestId: `req${(n += 1)}`,
now: 1700000000 + n,
state: {
async get(c, k) {
const v = data.get(`${c}/${k}`);
return v === undefined ? null : structuredClone(v);
},
async set(c, k, v) {
data.set(`${c}/${k}`, structuredClone(v));
return null;
},
async remove(c, k) {
data.delete(`${c}/${k}`);
return null;
},
async access() {
return access;
},
async setAccess(v) {
access = v;
return null;
},
async setMember() {
return null;
},
},
...extra,
});
return { ctx, data, access: () => access };
}
test('backend: create, seats and permissions', async () => {
const p = fakeProvider();
const created = await handle(p.ctx('createTable', 'nostr:owner'), { scenario: 'millbrook-dawn', title: 'Test', side: 'b' });
assert.equal(created.meta.seatB, 'nostr:owner');
assert.equal(created.meta.count, 1);
assert.equal(p.access().visibility, 'public');
await assert.rejects(handle(p.ctx('act', 'nostr:guest2'), { actions: [{ t: 'move', ids: ['s0'], to: 'A1' }] }), /FORBIDDEN/);
// Observers may chat.
const chat = await handle(p.ctx('act', 'nostr:guest2'), { actions: [{ t: 'chat', text: 'good luck' }] });
assert.equal(chat.entries[0].side, 'n');
await assert.rejects(handle(p.ctx('joinTable', 'nostr:guest2'), { side: 'b' }), /taken/);
const joined = await handle(p.ctx('joinTable', 'nostr:guest2'), { side: 'a' });
assert.equal(joined.meta.seatA, 'nostr:guest2');
await assert.rejects(handle(p.ctx('joinTable', 'nostr:guest2'), { side: 'b' }), /FORBIDDEN/);
const moved = await handle(p.ctx('act', 'nostr:guest2'), { actions: [{ t: 'move', ids: ['s0'], to: 'A1', text: 'smuggled' }] });
assert.deepEqual(moved.entries[0].a, { t: 'move', ids: ['s0'], to: 'A1' }, 'unused fields are stripped');
assert.equal(moved.entries[0].side, 'a');
await assert.rejects(handle(p.ctx('act', 'nostr:guest2'), { actions: [{ t: 'join', side: 'b' }] }), /BAD_INPUT/);
await assert.rejects(handle(p.ctx('act', 'nostr:guest2'), { actions: [{ t: 'move', ids: ['s0'], to: '<b>' }] }), /BAD_INPUT/);
});
test('backend: dice are rolled server-side and stay in range', async () => {
const p = fakeProvider();
await handle(p.ctx('createTable', 'nostr:owner'), { scenario: 'open-table', title: '', side: 'a' });
const counts = [0, 0, 0, 0, 0, 0];
for (let i = 0; i < 300; i += 1) {
const r = await handle(p.ctx('act', 'nostr:owner'), { actions: [{ t: 'roll', n: 2, why: 'x', d: [6, 6] }] });
const d = r.entries[0].d;
assert.equal(d.length, 2);
for (const v of d) {
assert.ok(v >= 1 && v <= 6);
counts[v - 1] += 1;
}
assert.equal(r.entries[0].a.d, undefined, 'client-supplied dice are discarded');
}
for (const c of counts) assert.ok(c > 50, `each face should appear; got ${counts}`);
});
test('backend: log pages across records and reads resume from any index', async () => {
const p = fakeProvider();
await handle(p.ctx('createTable', 'nostr:owner'), { scenario: 'open-table', title: '', side: 'a' });
for (let i = 0; i < 12; i += 1) {
await handle(p.ctx('act', 'nostr:owner'), {
actions: Array.from({ length: 8 }, (_, k) => ({ t: 'chat', text: `m${i * 8 + k}` })),
});
}
const all = [];
for (let after = 0; ; ) {
const r = await handle(p.ctx('read', 'guest:x'), { after, limit: 40 });
all.push(...r.entries);
if (r.entries.length < 40) break;
after = r.entries[r.entries.length - 1].i + 1;
}
assert.equal(all.length, 97);
assert.deepEqual(
all.map((e) => e.i),
Array.from({ length: 97 }, (_, i) => i),
);
const mid = await handle(p.ctx('read', 'guest:x'), { after: 39, limit: 5 });
assert.deepEqual(
mid.entries.map((e) => e.i),
[39, 40, 41, 42, 43],
);
for (const [key, value] of p.data) if (key.startsWith('pages/')) assert.ok(value.entries.length <= 40, key);
});
test('backend output replays cleanly in the client reducer', async () => {
const p = fakeProvider();
await handle(p.ctx('createTable', 'nostr:owner'), { scenario: 'millbrook-dawn', title: '', side: 'a' });
await handle(p.ctx('joinTable', 'nostr:two'), { side: 'b' });
await handle(p.ctx('act', 'nostr:owner'), { actions: [{ t: 'move', ids: ['s0'], to: 'A5' }, { t: 'roll', n: 1 }] });
await handle(p.ctx('act', 'nostr:two'), { actions: [{ t: 'end' }] });
await handle(p.ctx('act', 'nostr:owner'), { actions: [{ t: 'end' }] });
const { entries } = await handle(p.ctx('read', 'guest:x'), { after: 0, limit: 40 });
assert.ok(entries.every((e) => model.validAction(e.a)));
const s = model.replay('millbrook-dawn', entries);
assert.equal(s.counters.get('s0').at, 'A5');
assert.deepEqual([s.turn, s.phase], [1, 0]);
assert.equal(s.lines.filter((l) => /took their seat/.test(l.text)).length, 2);
});
// ---------- movement rules ----------
const movement = await import('../src/rules/movement.ts');
const MPH = 2; // Movement Phase index
const ROUT = 5;
const ADV = 6;
/** Build a table: setup placements, then End Phase until the target phase. */
function table(scenario, placements, phase = { turn: 1, phase: MPH, side: 'a' }) {
const log = [];
const push = (a, side = 'a', by = side === 'a' ? 'nostr:aa' : 'nostr:bb') => {
log.push(entry(log.length, a, by, side));
return model.replay(scenario, log);
};
const state = () => model.replay(scenario, log);
const goto = (target) => {
for (let guard = 0; guard < 400; guard += 1) {
const s = state();
if (s.turn === target.turn && s.phase === target.phase && s.active === target.side) return s;
if (s.over) throw new Error('game ended before reaching the phase');
for (const side of model.phaseActors(s)) push({ t: 'end' }, side);
}
throw new Error('phase not reached');
};
for (const [id, k, side, to] of placements) push({ t: 'add', id, k, side, to }, side);
goto(phase);
return { log, push, state, goto };
}
const last = (s) => s.lines[s.lines.length - 1];
test('movement: squads have 4 MF and cannot overspend', () => {
const t2 = table('open-table', [['sq', 'us-666', 'a', 'H3']]);
// H3 → I3 → J3 → K3: all open, 3 MF.
let s = t2.push({ t: 'mv', ids: ['sq'], path: ['I3', 'J3', 'K3'] });
assert.equal(s.counters.get('sq').at, 'K3');
assert.match(last(s).text, /3 MF, 1 left/);
s = t2.push({ t: 'mv', ids: ['sq'], path: ['K2', 'K1'] }); // 2 more open hexes: over budget
assert.equal(last(s).kind, 'refused');
assert.equal(s.counters.get('sq').at, 'K3');
});
test('movement: a leader moving with a squad gives both 6 MF', () => {
const t = table('open-table', [['sq', 'us-666', 'a', 'H3'], ['ld', 'us-ldr91', 'a', 'H3']]);
const s = t.push({ t: 'mv', ids: ['sq', 'ld'], path: ['I3', 'J3', 'K3', 'L3', 'M3', 'N3'] });
assert.equal(s.counters.get('sq').at, 'N3', last(s).text);
const alone = table('open-table', [['sq', 'us-666', 'a', 'H3']]);
assert.equal(last(alone.push({ t: 'mv', ids: ['sq'], path: ['I3', 'J3', 'K3', 'L3', 'M3'] })).kind, 'refused');
});
test('movement: terrain, hedge and upslope costs', () => {
const s0 = table('open-table', [['sq', 'us-666', 'a', 'C6']]).state();
const step = (from, to) => movement.enterCost(s0, from, to, 'a', 'move').cost;
assert.equal(step('C6', 'D6'), 1.5 + 1, 'grain across a hedge');
assert.equal(step('A1', 'A2'), 2, 'woods');
assert.equal(step('Q2', 'R2'), 4, 'upslope into woods doubles');
assert.equal(step('R2', 'Q2'), 1, 'downslope is normal');
assert.equal(step('H9', 'H10'), null, 'water is prohibited');
});
test('movement: the road bonus and road movement through terrain', () => {
// Row 5 is a road: a squad moving only along it gets 5 MF.
const t = table('open-table', [['sq', 'us-666', 'a', 'B5']]);
const s = t.push({ t: 'mv', ids: ['sq'], path: ['C5', 'D5', 'E5', 'F5', 'G5'] });
assert.equal(s.counters.get('sq').at, 'G5', last(s).text);
const off = table('open-table', [['sq', 'us-666', 'a', 'B4']]);
assert.equal(last(off.push({ t: 'mv', ids: ['sq'], path: ['C5', 'D5', 'E5', 'F5', 'G5'] })).kind, 'refused');
});
test('movement: minimum move lets a fresh unit enter one costly hex', () => {
const t = table('open-table', [['sq', 'us-666', 'a', 'Q2'], ['mg', 'us-mmg', 'a', 'Q2']]);
// Carrying an MMG (4 PP) leaves 3 MF; uphill woods costs 4.
let s = t.push({ t: 'mv', ids: ['sq', 'mg'], path: ['R2'] });
assert.equal(s.counters.get('sq').at, 'R2', last(s).text);
assert.match(last(s).text, /minimum move/);
assert.ok(s.move.done.has('sq'));
s = t.push({ t: 'mv', ids: ['sq', 'mg'], path: ['S2'] });
assert.equal(last(s).kind, 'refused');
});
test('movement: only the phasing side moves, and only in the right phases', () => {
const t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['ge', 'ge-467', 'b', 'P3']]);
assert.equal(last(t.push({ t: 'mv', ids: ['ge'], path: ['O3'] }, 'b')).kind, 'refused');
assert.equal(last(t.push({ t: 'mv', ids: ['us'], path: ['I3'] }, 'b')).kind, 'refused', 'cannot move the enemy’s units');
const prep = table('open-table', [['us', 'us-666', 'a', 'H3']], { turn: 1, phase: 1, side: 'a' });
assert.match(last(prep.push({ t: 'mv', ids: ['us'], path: ['I3'] })).text, /don’t move during the Prep Fire/);
});
test('movement: enemy hexes, stacking and support weapons', () => {
const t = table('open-table', [
['a1', 'us-666', 'a', 'H3'], ['a2', 'us-666', 'a', 'I3'], ['a3', 'us-666', 'a', 'I3'], ['a4', 'us-666', 'a', 'I3'],
['ge', 'ge-467', 'b', 'H4'], ['mg', 'us-lmg', 'a', 'H2'],
]);
assert.match(last(t.push({ t: 'mv', ids: ['a1'], path: ['H4'] })).text, /enemy units/);
assert.match(last(t.push({ t: 'mv', ids: ['a1'], path: ['I3'] })).text, /squad-equivalents/);
assert.match(last(t.push({ t: 'mv', ids: ['mg'], path: ['H1'] })).text, /need infantry/);
});
test('movement: a stack that stops cannot continue after another unit moves', () => {
const t = table('open-table', [['a1', 'us-666', 'a', 'H3'], ['a2', 'us-666', 'a', 'H7']]);
t.push({ t: 'mv', ids: ['a1'], path: ['I3'] });
t.push({ t: 'mv', ids: ['a1'], path: ['J3'] }); // same group may continue
assert.equal(t.state().counters.get('a1').at, 'J3');
t.push({ t: 'mv', ids: ['a2'], path: ['H6'] });
const s = t.push({ t: 'mv', ids: ['a1'], path: ['K3'] });
assert.equal(last(s).kind, 'refused');
// The next Movement Phase starts fresh.
t.goto({ turn: 2, phase: MPH, side: 'a' });
assert.equal(t.push({ t: 'mv', ids: ['a1'], path: ['K3'] }).counters.get('a1').at, 'K3');
});
test('movement: Double Time adds 1 MF and makes the unit CX until its next turn', () => {
const t = table('open-table', [['sq', 'us-666', 'a', 'H3']]);
let s = t.push({ t: 'mv', ids: ['sq'], path: ['I3', 'J3', 'K3', 'L3', 'M3'], dt: true });
assert.equal(s.counters.get('sq').at, 'M3', last(s).text);
assert.ok(s.counters.get('sq').cx);
s = t.goto({ turn: 1, phase: 0, side: 'b' });
assert.ok(s.counters.get('sq').cx, 'still CX during the enemy turn');
s = t.goto({ turn: 2, phase: 0, side: 'a' });
assert.ok(!s.counters.get('sq').cx, 'CX clears when its side’s turn begins');
});
test('movement: broken units only move in the Rout Phase', () => {
const t = table('open-table', [['sq', 'us-666', 'a', 'H3']]);
t.push({ t: 'flip', id: 'sq', ovr: 'test: broken' });
assert.match(last(t.push({ t: 'mv', ids: ['sq'], path: ['I3'] })).text, /Broken units/);
t.goto({ turn: 1, phase: ROUT, side: 'a' });
assert.equal(t.push({ t: 'mv', ids: ['sq'], path: ['I3', 'J3'] }).counters.get('sq').at, 'J3');
});
test('movement: advancing moves exactly one hex, even into enemy units', () => {
const t = table('open-table', [['sq', 'us-666', 'a', 'H3'], ['ge', 'ge-467', 'b', 'H4']], { turn: 1, phase: ADV, side: 'a' });
assert.equal(last(t.push({ t: 'mv', ids: ['sq'], path: ['I3', 'J3'] })).kind, 'refused');
const s = t.push({ t: 'mv', ids: ['sq'], path: ['H4'] });
assert.equal(s.counters.get('sq').at, 'H4');
assert.equal(last(t.push({ t: 'mv', ids: ['sq'], path: ['H5'] })).kind, 'refused', 'one advance per phase');
});
test('movement: entering from off-board uses the scenario edge', () => {
const t = table('millbrook-dawn', []);
assert.match(last(t.push({ t: 'mv', ids: ['s0'], path: ['B5'] })).text, /west edge/);
const s = t.push({ t: 'mv', ids: ['s0'], path: ['A5', 'B5'] });
assert.equal(s.counters.get('s0').at, 'B5', last(s).text);
});
test('movement: free map moves need an override outside setup', () => {
const t = table('open-table', [['sq', 'us-666', 'a', 'H3']]);
assert.equal(last(t.push({ t: 'move', ids: ['sq'], to: 'P8' })).kind, 'refused');
const s = t.push({ t: 'move', ids: ['sq'], to: 'P8', ovr: 'agreed: hidden tunnel' });
assert.equal(s.counters.get('sq').at, 'P8');
assert.equal(last(s).kind, 'override');
assert.equal(last(t.push({ t: 'move', ids: ['sq'], to: 'elim-a' })).kind, 'refused', 'casualty-box moves need an override too');
assert.equal(last(t.push({ t: 'flip', id: 'sq' })).kind, 'refused', 'breaking by hand needs an override');
});
test('movement: reachable() agrees with checkPath for every highlighted hex', () => {
const s = table('millbrook-dawn', [['x1', 'us-666', 'a', 'E5'], ['x2', 'us-ldr91', 'a', 'E5']]).state();
const reach = movement.reachable(s, ['x1', 'x2'], 'a', false);
assert.ok(reach.ok && reach.hexes.size > 10);
for (const [hex, { path }] of reach.hexes) {
const v = movement.checkPath(s, ['x1', 'x2'], path, false, 'a');
assert.ok(v.ok, `${hex} via ${path.join('>')}: ${v.reasons?.[0]}`);
}
// And from off-board.
const pool = movement.reachable(s, ['s0'], 'a', false);
assert.ok([...pool.hexes.keys()].some((h) => h.startsWith('A')));
for (const [, { path }] of pool.hexes) assert.ok(movement.checkPath(s, ['s0'], path, false, 'a').ok);
});
// ---------- fire, morale, rally, close combat ----------
const fire = await import('../src/rules/fire.ts');
const cc = await import('../src/rules/cc.ts');
const losMod = await import('../src/rules/los.ts');
const PREP = 1;
const DFPH = 3;
const AFPH = 4;
const CCPH = 7;
/** Dice for one resolution, padded so later checks don't run dry. */
const dice = (...d) => [...d, ...Array(48 - d.length).fill(3)];
const withDice = (t, a, d, side = 'a') => {
t.log.push({ ...entry(t.log.length, a, side === 'a' ? 'nostr:aa' : 'nostr:bb', side), d: dice(...d) });
return t.state();
};
test('LOS: open ground is clear, woods block, grain hinders', () => {
const s = model.replay('open-table', []);
assert.ok(losMod.lineOfSight(s, 'A4', 'A7').clear);
const woods = losMod.lineOfSight(s, 'A7', 'A10');
assert.ok(!woods.clear && /woods/i.test(woods.reason));
const grain = losMod.lineOfSight(s, 'E1', 'I1');
assert.ok(grain.clear);
assert.equal(grain.hindrance, 3, 'three grain hexes');
assert.ok(!losMod.lineOfSight(s, 'P2', 'V2').clear, 'the wooded hill blocks');
});
test('fire plan: firepower, support weapons, leader and terrain', () => {
const t = table('open-table', [
['us', 'us-666', 'a', 'H3'], ['lmg', 'us-lmg', 'a', 'H3'], ['ldr', 'us-ldr91', 'a', 'H3'],
['ge', 'ge-467', 'b', 'J3'], ['ge2', 'ge-467', 'b', 'K4'], ['ge3', 'ge-467', 'b', 'I3'],
], { turn: 1, phase: PREP, side: 'a' });
const s = t.state();
let p = fire.planFire(s, ['us'], 'J3', 'a');
assert.ok(p.ok, p.reasons?.[0]);
assert.equal(p.totalFP, 6);
assert.equal(p.column, 6);
assert.equal(p.drm, 0);
p = fire.planFire(s, ['us', 'lmg', 'ldr'], 'J3', 'a');
assert.equal(p.totalFP, 8);
assert.equal(p.drm, -1, 'leader directs');
p = fire.planFire(s, ['us'], 'I3', 'a');
assert.equal(p.totalFP, 12, 'point blank doubles');
p = fire.planFire(s, ['us'], 'K4', 'a');
assert.ok(p.drms.some((d) => /Stone building/.test(d.label) && d.value === 3));
assert.ok(!fire.planFire(s, ['lmg'], 'J3', 'a').ok, 'a weapon needs a crew');
assert.ok(!fire.planFire(s, ['ge'], 'H3', 'b').ok, 'the defender does not prep fire');
});
test('fire plan: long range halves and advancing fire halves', () => {
const t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['ge', 'ge-467', 'b', 'O3']], { turn: 1, phase: PREP, side: 'a' });
const p = fire.planFire(t.state(), ['us'], 'O3', 'a');
assert.ok(p.ok, p.reasons?.[0]);
assert.equal(p.range, 7);
assert.equal(p.totalFP, 3);
assert.equal(p.column, 2);
t.goto({ turn: 1, phase: AFPH, side: 'a' });
assert.equal(fire.planFire(t.state(), ['us'], 'O3', 'a').totalFP, 1.5);
});
test('fire: a morale check result breaks the target and marks the firer', () => {
const t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['ge', 'ge-467', 'b', 'J3']], { turn: 1, phase: PREP, side: 'a' });
// 6 FP, DR 1+2 = 3 → 2MC; GE MC 5+4+2 = 11 > 7 → breaks.
const s = withDice(t, { t: 'fire', ids: ['us'], to: 'J3' }, [1, 2, 5, 4]);
assert.equal(last(s).kind, 'fire', last(s).text);
assert.match(last(s).text, /2MC/);
assert.equal(s.counters.get('ge').face, 1);
assert.equal(s.counters.get('us').fired, 'prep');
assert.match(last(withDice(t, { t: 'fire', ids: ['us'], to: 'J3' }, [1, 1])).text, /already fired/);
});
test('fire: cowering without a leader, none with one', () => {
const t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['ge', 'ge-467', 'b', 'J3']], { turn: 1, phase: PREP, side: 'a' });
const s = withDice(t, { t: 'fire', ids: ['us'], to: 'J3' }, [2, 2, 1, 1]);
assert.match(last(s).text, /cowered 6→4/);
assert.match(last(s).text, /NMC/);
const t2 = table('open-table', [['us', 'us-666', 'a', 'H3'], ['ldr', 'us-ldr91', 'a', 'H3'], ['ge', 'ge-467', 'b', 'J3']], { turn: 1, phase: PREP, side: 'a' });
const s2 = withDice(t2, { t: 'fire', ids: ['us', 'ldr'], to: 'J3' }, [2, 2, 1, 1]);
assert.doesNotMatch(last(s2).text, /cowered/);
assert.equal(s2.counters.get('ldr').fired, 'prep', 'the directing leader is marked too');
});
test('fire: KIA eliminates, K/# reduces a squad, PTC pins, MG breakdown', () => {
let t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['lmg', 'us-lmg', 'a', 'H3'], ['ldr', 'us-ldr91', 'a', 'H3'], ['ge', 'ge-467', 'b', 'J3']], { turn: 1, phase: PREP, side: 'a' });
let s = withDice(t, { t: 'fire', ids: ['us', 'lmg', 'ldr'], to: 'J3' }, [1, 1]); // 8 FP, DR 2 -1 = 1 → 1KIA
assert.match(last(s).text, /1KIA/);
assert.equal(s.counters.get('ge').at, 'elim-b');
t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['ldr', 'us-ldr91', 'a', 'H3'], ['ge', 'ge-467', 'b', 'J3']], { turn: 1, phase: PREP, side: 'a' });
s = withDice(t, { t: 'fire', ids: ['us', 'ldr'], to: 'J3' }, [1, 2, 1, 1]); // DR 3 -1 = 2 → K/2; then MC 1+1+2 = 4 passes
assert.match(last(s).text, /K\/2/);
const half = [...s.counters.values()].find((c) => c.k === 'ge-hs');
assert.ok(half, last(s).detail.join(' | '));
assert.equal(half.label, '2-4-7');
assert.equal(half.at, 'J3');
assert.equal(half.face, 0, 'passed its morale check');
t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['ge', 'ge-467', 'b', 'J3']], { turn: 1, phase: PREP, side: 'a' });
s = withDice(t, { t: 'fire', ids: ['us'], to: 'J3' }, [2, 4, 6, 5]); // DR 6 on 6 col → PTC; 11 > 7 → pinned
assert.match(last(s).text, /PTC/);
assert.ok(s.counters.get('ge').pin);
t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['lmg', 'us-lmg', 'a', 'H3'], ['ge', 'ge-467', 'b', 'J3']], { turn: 1, phase: PREP, side: 'a' });
s = withDice(t, { t: 'fire', ids: ['us', 'lmg'], to: 'J3' }, [5, 6]); // original 11 ≥ B11
assert.equal(s.counters.get('lmg').face, 1, 'the LMG malfunctions');
assert.match(last(s).detail.join(' '), /malfunctions/);
});
test('fire: a leader in the target hex checks first and then helps', () => {
const t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['ge', 'ge-467', 'b', 'J3'], ['gl', 'ge-ldr81', 'b', 'J3']], { turn: 1, phase: PREP, side: 'a' });
// DR 5 → NMC on the 6 column. Leader: 3+3 = 6 ≤ 8 passes. Squad: 4+4 -1 = 7 ≤ 7 passes.
const s = withDice(t, { t: 'fire', ids: ['us'], to: 'J3' }, [2, 3, 3, 3, 4, 4]);
const detail = last(s).detail.join(' | ');
assert.match(last(s).text, /NMC/);
assert.ok(detail.indexOf('8-1') < detail.indexOf('4-6-7'), detail);
assert.equal(s.counters.get('ge').face, 0, detail);
});
test('fire status: prep-fired and pinned units cannot move; statuses clear at turn end', () => {
const t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['ge', 'ge-467', 'b', 'J3']], { turn: 1, phase: PREP, side: 'a' });
withDice(t, { t: 'fire', ids: ['us'], to: 'J3' }, [2, 4, 6, 5]);
t.goto({ turn: 1, phase: MPH, side: 'a' });
assert.match(last(t.push({ t: 'mv', ids: ['us'], path: ['G3'] })).text, /Prep Fire/);
t.goto({ turn: 1, phase: 0, side: 'b' });
const s = t.state();
assert.equal(s.counters.get('us').fired, undefined);
assert.equal(s.counters.get('ge').pin, undefined);
});
test('defensive fire belongs to the defender; advancing fire excludes prep-fired units', () => {
const t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['us2', 'us-666', 'a', 'H5'], ['ge', 'ge-467', 'b', 'J3']], { turn: 1, phase: PREP, side: 'a' });
withDice(t, { t: 'fire', ids: ['us'], to: 'J3' }, [6, 5]);
t.goto({ turn: 1, phase: DFPH, side: 'a' });
assert.ok(fire.planFire(t.state(), ['ge'], 'H3', 'b').ok);
assert.ok(!fire.planFire(t.state(), ['us2'], 'J3', 'a').ok);
t.goto({ turn: 1, phase: AFPH, side: 'a' });
assert.match(fire.planFire(t.state(), ['us'], 'J3', 'a').reasons[0], /already fired/);
assert.ok(fire.planFire(t.state(), ['us2'], 'J3', 'a').ok);
});
test('rally: needs a leader, uses his DRM and terrain, once per phase', () => {
const t = table('open-table', [['g1', 'ge-467', 'b', 'A1'], ['gl', 'ge-ldr81', 'b', 'A1'], ['g2', 'ge-467', 'b', 'H3']], { turn: 1, phase: 0, side: 'a' });
t.push({ t: 'flip', id: 'g1', ovr: 'test' }, 'b');
t.push({ t: 'flip', id: 'g2', ovr: 'test' }, 'b');
assert.match(last(t.push({ t: 'rally', id: 'g2' }, 'b')).text, /needs a good-order leader/);
// Woods -1, leader -1: 4+4 -2 = 6 ≤ 7.
const s = withDice(t, { t: 'rally', id: 'g1' }, [4, 4], 'b');
assert.equal(s.counters.get('g1').face, 0, last(s).text);
t.push({ t: 'flip', id: 'g1', ovr: 'test' }, 'b');
assert.match(last(t.push({ t: 'rally', id: 'g1' }, 'b')).text, /already tried/);
});
test('repair: a malfunctioned MG is fixed on 1 and lost on 6', () => {
const t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['m1', 'us-lmg', 'a', 'H3'], ['m2', 'us-mmg', 'a', 'H3']], { turn: 1, phase: 0, side: 'a' });
t.push({ t: 'flip', id: 'm1', ovr: 'test' });
t.push({ t: 'flip', id: 'm2', ovr: 'test' });
let s = withDice(t, { t: 'repair', id: 'm1' }, [1]);
assert.equal(s.counters.get('m1').face, 0);
s = withDice(t, { t: 'repair', id: 'm2' }, [6]);
assert.equal(s.counters.get('m2').at, 'elim-a');
});
test('close combat: both sides attack at once on the odds table', () => {
const t = table('open-table', [['us', 'us-666', 'a', 'H3'], ['ge', 'ge-467', 'b', 'H4']], { turn: 1, phase: 6, side: 'a' });
t.push({ t: 'mv', ids: ['us'], path: ['H4'] }); // advance into the enemy hex
t.goto({ turn: 1, phase: CCPH, side: 'a' });
const s0 = t.state();
const { planCloseCombat } = cc;
const plan = planCloseCombat(s0, 'H4', 'a');
assert.ok(plan.ok, plan.reasons?.[0]);
const us = plan.attacks.find((x) => x.side === 'a');
const ge = plan.attacks.find((x) => x.side === 'b');
assert.equal(us.odds, '3:2');
assert.equal(us.kill, 7);
assert.equal(ge.odds, '1:2');
// US rolls 3 (< 7: eliminates); GE rolls 12 (no effect).
const s = withDice(t, { t: 'cc', to: 'H4' }, [1, 2, 6, 6]);
assert.equal(s.counters.get('ge').at, 'elim-b', [last(s).text, ...last(s).detail].join(' | '));
assert.equal(s.counters.get('us').at, 'H4');
assert.match(last(t.push({ t: 'cc', to: 'H4' })).text, /no close combat|already resolved/);
});
test('backend: rules actions get a batch of server dice; phase jumps are rejected', async () => {
const p = fakeProvider();
await handle(p.ctx('createTable', 'nostr:owner'), { scenario: 'open-table', title: '', side: 'a' });
const r = await handle(p.ctx('act', 'nostr:owner'), { actions: [{ t: 'fire', ids: ['x'], to: 'A1', d: [1, 1] }, { t: 'end' }] });
assert.equal(r.entries[0].d.length, model.DICE_PER_ACTION);
assert.ok(r.entries[0].d.every((v) => v >= 1 && v <= 6));
assert.equal(r.entries[1].d, undefined);
await assert.rejects(handle(p.ctx('act', 'nostr:owner'), { actions: [{ t: 'phase', turn: 3, phase: 0, side: 'a' }] }), /BAD_INPUT/);
});
