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.
// Movement rules: who may move when, what it costs, and where a group can go.
// Pure functions over TableState so every client (and the log replay) reaches
// the same verdict. Numbers come from ./data.ts.
import { TEMPLATE_BY_KEY } from '../catalog.js';
import { hexId, neighbors, parseHex } from '../hex.js';
import type { Counter, Owner, SideId, TableState } from '../model.js';
import { MAPS, PHASES, type GameMap, type MapCell } from '../scenarios.js';
import { RULES, type InfantryKind } from './data.js';
export type Role = InfantryKind | 'weapon' | 'gun' | 'vehicle' | 'marker';
export type MoveKind = 'setup' | 'move' | 'rout' | 'advance' | 'none';
export type MoveTracking = {
spent: Map<string, number>; // MF used this phase
done: Set<string>; // finished moving this phase
mover: string[]; // the group currently moving
offRoad: Set<string>; // moved off-road this phase (no road bonus)
doubled: Set<string>; // used Double Time this phase
};
export function emptyTracking(): MoveTracking {
return { spent: new Map(), done: new Set(), mover: [], offRoad: new Set(), doubled: new Set() };
}
export function roleOf(c: Counter): Role {
const type = TEMPLATE_BY_KEY.get(c.k)?.type ?? 'marker';
if (type === 'fort') return 'marker';
return type;
}
function weaponPP(c: Counter): number {
const suffix = c.k.split('-')[1] ?? '';
return RULES.portage.weapon[suffix] ?? 1;
}
export function phaseKind(state: TableState): MoveKind {
if (state.over) return 'none';
if (state.turn === 0) return 'setup';
const name = PHASES[state.phase];
if (name === 'Movement') return 'move';
if (name === 'Rout') return 'rout';
if (name === 'Advance') return 'advance';
return 'none';
}
export function phaseName(state: TableState): string {
return state.turn === 0 ? 'Setup' : PHASES[state.phase];
}
// ----- map helpers -----
const roadEdges = new WeakMap<GameMap, Set<string>>();
function roadEdge(map: GameMap, a: string, b: string): boolean {
let edges = roadEdges.get(map);
if (!edges) {
edges = new Set();
for (const road of map.roads) {
for (let i = 1; i < road.length; i += 1) {
edges.add(`${road[i - 1]}|${road[i]}`);
edges.add(`${road[i]}|${road[i - 1]}`);
}
}
roadEdges.set(map, edges);
}
return edges.has(`${a}|${b}`);
}
function hexsideFeature(map: GameMap, a: string, b: string) {
return map.hexsides.find((h) => (h.a === a && h.b === b) || (h.a === b && h.b === a));
}
function enemySide(side: Owner): SideId | null {
return side === 'a' ? 'b' : side === 'b' ? 'a' : null;
}
/** Hex holds units of the other side (markers and "?" counters don't count). */
function enemyIn(state: TableState, hex: string, side: Owner): boolean {
const enemy = enemySide(side);
if (!enemy) return false;
return (state.stacks.get(hex) ?? []).some((id) => {
const c = state.counters.get(id);
return c && c.side === enemy && roleOf(c) !== 'marker';
});
}
export function onEntryEdge(state: TableState, side: SideId, hex: string): boolean {
const edge = state.scenario.sides[side].entry;
const h = parseHex(hex);
const map = MAPS[state.scenario.map];
if (!edge || !h) return false;
if (edge === 'west') return h.c === 0;
if (edge === 'east') return h.c === map.cols - 1;
if (edge === 'north') return h.r === 1;
return h.r === map.rows;
}
type Step = { cost: number } | { cost: null; reason: string };
/** MF to enter `to` from `from` (adjacent hexes, or from off-board when `from` is null). */
export function enterCost(state: TableState, from: string | null, to: string, side: Owner, kind: MoveKind): Step {
const map = MAPS[state.scenario.map];
const cell = map.cells.get(to);
if (!cell) return { cost: null, reason: `${to} is not on the map.` };
if (kind !== 'advance' && enemyIn(state, to, side)) {
return { cost: null, reason: `${to} holds enemy units. Infantry can only enter them by advancing into Close Combat.` };
}
const prev: MapCell | undefined = from ? map.cells.get(from) : undefined;
const viaRoad = Boolean(prev && cell.road && prev.road && roadEdge(map, prev.id, cell.id));
const terrain = RULES.terrainMF[cell.terrain];
if (!viaRoad && terrain === null) return { cost: null, reason: `Infantry cannot enter ${cell.terrain} (${to}).` };
let cost = viaRoad ? RULES.roadMF : (terrain as number);
if (prev && cell.level > prev.level) cost *= RULES.movement.upslopeMultiplier;
const side2 = prev && !viaRoad ? hexsideFeature(map, prev.id, cell.id) : undefined;
if (side2) cost += RULES.hexsideMF[side2.kind];
return { cost };
}
// ----- groups -----
export type Group = {
ids: string[];
units: Counter[];
infantry: Counter[];
weapons: Counter[];
at: string;
side: Owner;
};
export type Verdict = { ok: true } | { ok: false; reasons: string[] };
function group(state: TableState, ids: string[]): Group | { reasons: string[] } {
const units = ids.map((id) => state.counters.get(id)).filter((c): c is Counter => Boolean(c));
if (!units.length || units.length !== ids.length) return { reasons: ['Some of those counters are no longer on the table.'] };
const at = units[0].at;
if (units.some((c) => c.at !== at)) return { reasons: ['Pick counters from a single hex to move them together.'] };
const real = units.filter((c) => roleOf(c) !== 'marker');
const sides = new Set(real.map((c) => c.side));
if (sides.size > 1) return { reasons: ['Move each side’s counters separately.'] };
const side: Owner = real[0]?.side ?? 'n';
const infantry = real.filter((c) => ['squad', 'half', 'crew', 'leader'].includes(roleOf(c)));
const weapons = real.filter((c) => roleOf(c) === 'weapon');
return { ids, units, infantry, weapons, at, side };
}
/** Phase, ownership and unit-state checks shared by every movement kind. */
function eligibility(state: TableState, g: Group, actor: Owner, kind: MoveKind): string[] {
const reasons: string[] = [];
const name = phaseName(state);
if (g.side !== 'n' && actor !== g.side) reasons.push('You can only move your own side’s counters.');
if (kind === 'setup') return reasons;
if (kind === 'none') {
reasons.push(`Units don’t move during the ${name} Phase.`);
return reasons;
}
if (g.at.startsWith('elim-')) reasons.push('Eliminated units cannot return to the map.');
if (g.units.some((c) => ['gun', 'vehicle'].includes(roleOf(c))))
reasons.push('Guns and vehicles are not handled by the movement engine yet.');
if (!g.infantry.length) reasons.push(g.weapons.length ? 'Support weapons need infantry to carry them.' : 'Nothing here can move.');
if ((kind === 'move' || kind === 'advance') && g.side !== state.active)
reasons.push(`Only the ${state.scenario.sides[state.active].name} side moves in the ${name} Phase.`);
const broken = g.infantry.filter((c) => c.face === 1);
if (kind === 'rout') {
if (g.infantry.some((c) => c.face === 0)) reasons.push('Only broken units rout in the Rout Phase.');
} else if (broken.length) {
reasons.push('Broken units can’t move now; they move in the Rout Phase.');
}
if ((kind === 'move' || kind === 'advance') && g.infantry.some((c) => c.pin)) reasons.push('Pinned units can’t move or advance.');
if (kind === 'move' && g.units.some((c) => c.fired === 'prep')) reasons.push('Units that fired in the Prep Fire Phase can’t move.');
if (state.over) reasons.push('The game is over.');
if (g.at.startsWith('pool-') && kind !== 'move') reasons.push(`Units enter the map in the Movement Phase, not the ${name} Phase.`);
const t = state.move;
const finished = g.ids.filter((id) => t.done.has(id));
if (finished.length) reasons.push('Some of these units already finished moving this phase.');
const startedElsewhere = g.ids.filter((id) => (t.spent.get(id) ?? 0) > 0 && !t.mover.includes(id));
if (startedElsewhere.length) reasons.push('These units stopped when another unit started moving.');
return reasons;
}
export type Budget = { allowance: number; left: number; minimumMove: boolean; roadBonus: boolean };
/** MF budget for the group: the most restricted unit sets the pace. */
export function budget(state: TableState, g: Group, kind: MoveKind, doubleTime: boolean): Budget {
const t = state.move;
const m = RULES.movement;
const goodLeader = g.infantry.some((c) => roleOf(c) === 'leader' && c.face === 0);
const mmc = g.infantry.filter((c) => roleOf(c) !== 'leader');
const capacity = g.infantry.reduce((s, c) => s + RULES.portage.capacity[roleOf(c) as InfantryKind], 0);
const load = g.weapons.reduce((s, c) => s + weaponPP(c), 0);
const penalty = Math.max(0, load - capacity) * RULES.portage.penaltyPerPP;
let left = Infinity;
let allowance = Infinity;
for (const c of g.infantry) {
const r = roleOf(c) as InfantryKind;
let mf = kind === 'rout' ? RULES.routMF[r] : RULES.movement.infantryMF[r];
if (kind === 'move') {
if (goodLeader && mmc.length) mf = Math.max(mf, RULES.movement.infantryMF.squad + m.leaderBonusMF);
if (doubleTime || t.doubled.has(c.id)) mf += m.doubleTimeMF;
}
mf = Math.max(0, mf - penalty);
allowance = Math.min(allowance, mf);
left = Math.min(left, mf - (t.spent.get(c.id) ?? 0));
}
if (!Number.isFinite(left)) left = 0;
const fresh = g.infantry.every((c) => (t.spent.get(c.id) ?? 0) === 0);
const roadBonus = kind === 'move' && g.infantry.every((c) => !t.offRoad.has(c.id));
return { allowance: Number.isFinite(allowance) ? allowance : 0, left: Math.max(0, left), minimumMove: m.minimumMove && fresh, roadBonus };
}
function stackingReasons(state: TableState, g: Group, to: string): string[] {
if (to.includes('-')) return [];
const moving = new Set(g.ids);
const there = (state.stacks.get(to) ?? [])
.map((id) => state.counters.get(id)!)
.filter((c) => c && !moving.has(c.id) && c.side === g.side);
const all = [...there, ...g.infantry];
const se = all.reduce((s, c) => {
const r = roleOf(c);
return s + (r in RULES.stacking.weight ? RULES.stacking.weight[r as InfantryKind] : 0);
}, 0);
const leaders = all.filter((c) => roleOf(c) === 'leader').length;
const out: string[] = [];
if (se > RULES.stacking.squadEquivalents)
out.push(`${to} would hold ${se} squad-equivalents; the limit is ${RULES.stacking.squadEquivalents}.`);
if (leaders > RULES.stacking.leaders) out.push(`${to} would hold ${leaders} leaders; the limit is ${RULES.stacking.leaders}.`);
return out;
}
export type PathCheck = { ok: true; cost: number; left: number; minimumMove: boolean; offRoad: boolean; kind: MoveKind } | { ok: false; reasons: string[] };
/** Validate a concrete path (hexes entered, in order). Used by the log replay. */
export function checkPath(state: TableState, ids: string[], path: string[], doubleTime: boolean, actor: Owner): PathCheck {
const g = group(state, ids);
if ('reasons' in g) return { ok: false, reasons: g.reasons };
const kind = phaseKind(state);
const reasons = eligibility(state, g, actor, kind);
if (!path.length) reasons.push('Choose a hex to move to.');
if (reasons.length) return { ok: false, reasons };
const dest = path[path.length - 1];
if (kind === 'setup') {
const map = MAPS[state.scenario.map];
if (!map.cells.has(dest) && !/^(pool|elim)-[ab]$/.test(dest)) return { ok: false, reasons: [`${dest} is not on the map.`] };
return { ok: true, cost: 0, left: 0, minimumMove: false, offRoad: false, kind };
}
if (doubleTime) {
if (kind !== 'move') return { ok: false, reasons: ['Double Time is only possible in the Movement Phase.'] };
if (g.infantry.some((c) => c.cx && !state.move.doubled.has(c.id))) return { ok: false, reasons: ['Units that are already CX cannot Double Time.'] };
}
if (kind === 'advance') {
if (path.length !== 1) return { ok: false, reasons: ['Advancing units move exactly one hex.'] };
const a = parseHex(g.at);
const b = parseHex(dest);
if (!a || !b || !neighbors(a).some((n) => hexId(n) === dest)) return { ok: false, reasons: ['Advance into an adjacent hex.'] };
const step = enterCost(state, g.at, dest, g.side, kind);
if (step.cost === null) return { ok: false, reasons: [step.reason] };
const stack = stackingReasons(state, g, dest);
if (stack.length && !enemyIn(state, dest, g.side)) return { ok: false, reasons: stack };
return { ok: true, cost: 0, left: 0, minimumMove: false, offRoad: false, kind };
}
const b = budget(state, g, kind, doubleTime);
let cost = 0;
let prev: string | null = g.at.startsWith('pool-') ? null : g.at;
let road = true;
if (prev === null && !onEntryEdge(state, g.side as SideId, path[0])) {
const edge = state.scenario.sides[g.side as SideId].entry;
return { ok: false, reasons: [edge ? `Enter along the ${edge} edge of the map.` : 'This side has no entry edge in this scenario; set it up on the map.'] };
}
const map = MAPS[state.scenario.map];
if (prev && !map.cells.get(prev)?.road) road = false;
for (const hex of path) {
if (prev) {
const a = parseHex(prev);
if (!a || !neighbors(a).some((n) => hexId(n) === hex)) return { ok: false, reasons: [`${hex} is not next to ${prev}.`] };
}
const step = enterCost(state, prev, hex, g.side, kind);
if (step.cost === null) return { ok: false, reasons: [step.reason] };
if (!prev || !roadEdge(map, prev, hex)) road = false;
cost += step.cost;
prev = hex;
}
const bonus = road && b.roadBonus ? RULES.movement.roadBonusMF : 0;
const left = b.left + bonus - cost;
const minimumMove = path.length === 1 && b.minimumMove && left < 0;
if (left < -1e-9 && !minimumMove) {
return { ok: false, reasons: [`That costs ${fmt(cost)} MF but only ${fmt(b.left + bonus)} MF ${b.left + bonus === b.allowance ? 'are available' : 'remain'}.`] };
}
const stack = stackingReasons(state, g, dest);
if (stack.length) return { ok: false, reasons: stack };
return { ok: true, cost: minimumMove ? b.left : cost, left: minimumMove ? 0 : left, minimumMove, offRoad: !road, kind };
}
export type Reach = {
ok: boolean;
reasons: string[];
kind: MoveKind;
left: number;
allowance: number;
canDoubleTime: boolean;
/** destination → cheapest legal path and its cost */
hexes: Map<string, { path: string[]; cost: number }>;
};
/** Every hex the group can legally reach from here, for highlighting and tap-to-move. */
export function reachable(state: TableState, ids: string[], actor: Owner, doubleTime: boolean): Reach {
const empty = (reasons: string[], kind: MoveKind = phaseKind(state)): Reach => ({
ok: false,
reasons,
kind,
left: 0,
allowance: 0,
canDoubleTime: false,
hexes: new Map(),
});
const g = group(state, ids);
if ('reasons' in g) return empty(g.reasons);
const kind = phaseKind(state);
const reasons = eligibility(state, g, actor, kind);
if (reasons.length) return empty(reasons, kind);
const map = MAPS[state.scenario.map];
const hexes = new Map<string, { path: string[]; cost: number }>();
const canDoubleTime =
kind === 'move' && g.infantry.every((c) => !c.cx || state.move.doubled.has(c.id)) && g.infantry.some((c) => !state.move.doubled.has(c.id));
if (kind === 'setup') {
for (const id of map.cells.keys()) hexes.set(id, { path: [id], cost: 0 });
return { ok: true, reasons: [], kind, left: 0, allowance: 0, canDoubleTime: false, hexes };
}
if (kind === 'advance') {
for (const n of neighbors(parseHex(g.at)!)) {
const id = hexId(n);
if (checkPath(state, ids, [id], false, actor).ok) hexes.set(id, { path: [id], cost: 0 });
}
return { ok: true, reasons: [], kind, left: 0, allowance: 0, canDoubleTime: false, hexes };
}
const b = budget(state, g, kind, doubleTime && canDoubleTime);
// Dijkstra twice: normal movement, and road-only movement with the road bonus.
const search = (roadOnly: boolean, limit: number) => {
const best = new Map<string, { path: string[]; cost: number }>();
const queue: { hex: string; cost: number; path: string[] }[] = [];
const startHexes: { hex: string; cost: number }[] = [];
if (g.at.startsWith('pool-')) {
for (const id of map.cells.keys()) {
if (!onEntryEdge(state, g.side as SideId, id) || roadOnly) continue;
const step = enterCost(state, null, id, g.side, kind);
if (step.cost !== null) startHexes.push({ hex: id, cost: step.cost });
}
for (const s of startHexes) if (s.cost <= limit + 1e-9) queue.push({ hex: s.hex, cost: s.cost, path: [s.hex] });
} else {
if (roadOnly && !map.cells.get(g.at)?.road) return best;
queue.push({ hex: g.at, cost: 0, path: [] });
}
while (queue.length) {
queue.sort((x, y) => x.cost - y.cost);
const cur = queue.shift()!;
if (cur.path.length) {
const seen = best.get(cur.hex);
if (seen && seen.cost <= cur.cost) continue;
best.set(cur.hex, { path: cur.path, cost: cur.cost });
}
for (const n of neighbors(parseHex(cur.hex)!)) {
const id = hexId(n);
if (!map.cells.has(id) || id === g.at) continue;
if (roadOnly && !roadEdge(map, cur.hex, id)) continue;
const step = enterCost(state, cur.hex, id, g.side, kind);
if (step.cost === null) continue;
const cost = cur.cost + step.cost;
if (cost > limit + 1e-9) continue;
const seen = best.get(id);
if (seen && seen.cost <= cost) continue;
queue.push({ hex: id, cost, path: [...cur.path, id] });
}
}
return best;
};
const normal = search(false, b.left);
for (const [id, v] of normal) hexes.set(id, v);
if (b.roadBonus) {
for (const [id, v] of search(true, b.left + RULES.movement.roadBonusMF)) if (!hexes.has(id)) hexes.set(id, v);
}
if (b.minimumMove && !g.at.startsWith('pool-')) {
for (const n of neighbors(parseHex(g.at)!)) {
const id = hexId(n);
if (!hexes.has(id) && map.cells.has(id) && enterCost(state, g.at, id, g.side, kind).cost !== null) hexes.set(id, { path: [id], cost: b.left });
}
}
// Drop destinations that would overstack (passing through is fine).
for (const id of [...hexes.keys()]) if (stackingReasons(state, g, id).length) hexes.delete(id);
return { ok: true, reasons: [], kind, left: b.left, allowance: b.allowance, canDoubleTime, hexes };
}
export function fmt(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1);
}
