Back to Squad Table
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.

tests/game.test.mjs
// 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/);
});