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.
// Morale checks, pin checks, IFT result application, rally and repair.
import { TEMPLATE_BY_KEY } from '../catalog.js';
import { neighbors, parseHex, hexId } from '../hex.js';
import type { Counter, Owner, TableState } from '../model.js';
import { MAPS, PHASES } from '../scenarios.js';
import { FIRE, MORALE } from './data.js';
import { roleOf } from './movement.js';
import type { Dice } from './ops.js';
import { casualtyReduce, eliminate, statsOf } from './units.js';
function printed(c: Counter): string {
return TEMPLATE_BY_KEY.get(c.k)?.label ?? '';
}
const nameOf = (c: Counter) => c.label ?? printed(c);
const morale = (c: Counter) => statsOf(c, printed(c))?.morale ?? 7;
const leaderDRM = (c: Counter) => statsOf(c, printed(c))?.leaderDRM ?? 0;
function sign(n: number): string {
return n > 0 ? `+${n}` : n < 0 ? `${n}` : '';
}
/** Break a good-order unit; a unit that is already broken suffers casualty reduction. */
function breakUnit(state: TableState, c: Counter): string {
if (c.face === 1) return casualtyReduce(state, c, printed(c));
c.face = 1;
c.pin = undefined;
return `${nameOf(c)} breaks`;
}
/** One morale check. `mod` is the MC number; `help` the directing leader's DRM. */
export function moraleCheck(state: TableState, c: Counter, mod: number, help: number, dice: Dice): string {
const [a, b] = dice.dr();
const final = a + b + mod + help;
const target = morale(c);
const what = `${nameOf(c)} ${mod ? `${mod}MC` : 'NMC'}: DR ${a}+${b}${sign(mod + help)} = ${final} vs ${target}`;
if (final <= target && !(a + b === 12 && MORALE.twelveCasualty)) return `${what}: passed`;
if (a + b === 12 && MORALE.twelveCasualty) {
const broken = c.face === 0 ? breakUnit(state, c) : '';
const reduced = state.counters.has(c.id) ? casualtyReduce(state, c, printed(c)) : '';
return `${what}: original 12. ${[broken, reduced].filter(Boolean).join(', ')}`;
}
if (c.face === 1 && MORALE.brokenFailReduces) return `${what}: failed, ${casualtyReduce(state, c, printed(c))}`;
return `${what}: failed, ${breakUnit(state, c)}`;
}
export function pinCheck(c: Counter, help: number, dice: Dice): string {
if (c.face === 1) return `${nameOf(c)} is broken: unaffected by PTC`;
const [a, b] = dice.dr();
const final = a + b + help;
const passed = final <= morale(c);
if (!passed) c.pin = true;
return `${nameOf(c)} PTC: DR ${a}+${b}${sign(help)} = ${final} vs ${morale(c)}: ${passed ? 'passed' : 'pinned'}`;
}
/** Leaders check first; the best good-order leader then helps everyone else. */
function checkAll(state: TableState, units: Counter[], check: (c: Counter, help: number) => string): string[] {
const out: string[] = [];
const leaders = units.filter((c) => roleOf(c) === 'leader');
const others = units.filter((c) => roleOf(c) !== 'leader');
for (const l of leaders) out.push(check(l, 0));
const standing = leaders.filter((l) => state.counters.get(l.id)?.face === 0 && state.counters.get(l.id)?.at === l.at);
const help = standing.length ? Math.min(...standing.map(leaderDRM)) : 0;
for (const c of others) if (state.counters.has(c.id)) out.push(check(c, help));
return out;
}
export function applyIftResult(state: TableState, units: Counter[], result: string, dice: Dice): string[] {
const out: string[] = [];
let m: RegExpExecArray | null;
if ((m = /^(\d)KIA$/.exec(result))) {
const pool = [...units];
const n = Math.min(Number(m[1]), pool.length);
for (let k = 0; k < n; k += 1) {
const victim = pool.splice(dice.pick(pool.length), 1)[0];
eliminate(state, victim);
out.push(`${nameOf(victim)} eliminated`);
}
if (FIRE.kiaSurvivorsBreak) for (const c of pool) out.push(breakUnit(state, c));
return out;
}
if ((m = /^K\/(\d)$/.exec(result))) {
const n = Number(m[1]);
const hex = units[0].at;
const side = units[0].side;
const victim = units[dice.pick(units.length)];
out.push(`Casualty: ${casualtyReduce(state, victim, printed(victim))}`);
// Everyone left in the hex, including a new half-squad, takes the MC.
const remaining = (state.stacks.get(hex) ?? [])
.map((id) => state.counters.get(id)!)
.filter((c) => c && c.side === side && ['squad', 'half', 'crew', 'leader'].includes(roleOf(c)));
out.push(...checkAll(state, remaining, (c, help) => moraleCheck(state, c, n, help, dice)));
return out;
}
if ((m = /^(\d)MC$/.exec(result)) || result === 'NMC') {
const n = m ? Number(m[1]) : 0;
out.push(...checkAll(state, units, (c, help) => moraleCheck(state, c, n, help, dice)));
return out;
}
if (result === 'PTC') {
out.push(...checkAll(state, units, (c, help) => pinCheck(c, help, dice)));
return out;
}
return out;
}
// ----- Rally Phase -----
function enemyClose(state: TableState, c: Counter): boolean {
const h = parseHex(c.at);
if (!h) return false;
const near = [c.at, ...neighbors(h).map(hexId)];
return near.some((hex) =>
(state.stacks.get(hex) ?? []).some((id) => {
const o = state.counters.get(id);
return o && o.side !== c.side && o.side !== 'n' && ['squad', 'half', 'crew', 'leader'].includes(roleOf(o));
}),
);
}
export type RallyPlan = { ok: true; id: string; target: number; drms: { label: string; value: number }[]; drm: number } | { ok: false; reasons: string[] };
export function inRallyPhase(state: TableState): boolean {
return state.turn > 0 && !state.over && PHASES[state.phase] === 'Rally';
}
export function planRally(state: TableState, id: string, actor: Owner): RallyPlan {
const c = state.counters.get(id);
if (!c) return { ok: false, reasons: ['That unit is no longer on the table.'] };
if (!inRallyPhase(state)) return { ok: false, reasons: ['Units rally in the Rally Phase.'] };
if (c.side !== actor) return { ok: false, reasons: ['You can only rally your own units.'] };
if (!['squad', 'half', 'crew', 'leader'].includes(roleOf(c))) return { ok: false, reasons: ['Only infantry rallies.'] };
if (c.face !== 1) return { ok: false, reasons: [`${nameOf(c)} is not broken.`] };
if (!MAPS[state.scenario.map].cells.has(c.at)) return { ok: false, reasons: ['Only units on the map can rally.'] };
if (state.used.has(`rally:${id}`)) return { ok: false, reasons: [`${nameOf(c)} already tried to rally this phase.`] };
const drms: { label: string; value: number }[] = [];
const isLeader = roleOf(c) === 'leader';
const helpers = (state.stacks.get(c.at) ?? [])
.map((x) => state.counters.get(x)!)
.filter((o) => o && o.id !== c.id && o.side === c.side && roleOf(o) === 'leader' && o.face === 0);
const helper = helpers.sort((x, y) => leaderDRM(x) - leaderDRM(y))[0];
if (!isLeader && !helper && MORALE.rallyNeedsLeader) return { ok: false, reasons: [`${nameOf(c)} needs a good-order leader in its hex to rally.`] };
if (helper && leaderDRM(helper)) drms.push({ label: `Leader ${nameOf(helper)}`, value: leaderDRM(helper) });
if (isLeader && !helper) drms.push({ label: 'Leader rallying himself', value: MORALE.selfRallyDRM });
const cell = MAPS[state.scenario.map].cells.get(c.at)!;
if (['woods', 'wood-bldg', 'stone-bldg'].includes(cell.terrain)) drms.push({ label: 'In woods or a building', value: MORALE.rallyTerrainDRM });
if (enemyClose(state, c)) drms.push({ label: 'Desperation: enemy adjacent', value: MORALE.desperationDRM });
return { ok: true, id, target: morale(c), drms, drm: drms.reduce((s, d) => s + d.value, 0) };
}
export function resolveRally(state: TableState, plan: Extract<RallyPlan, { ok: true }>, dice: Dice): string[] {
const c = state.counters.get(plan.id)!;
state.used.add(`rally:${plan.id}`);
const [a, b] = dice.dr();
const final = a + b + plan.drm;
const head = `${nameOf(c)} rally: DR ${a}+${b}${sign(plan.drm)} = ${final} vs ${plan.target}`;
if (a + b === 12 && MORALE.rallyTwelveCasualty) return [`${head}: original 12, ${casualtyReduce(state, c, printed(c))}`];
if (final <= plan.target) {
c.face = 0;
return [`${head}: rallied`];
}
return [`${head}: failed`];
}
export type RepairPlan = { ok: true; id: string } | { ok: false; reasons: string[] };
export function planRepair(state: TableState, id: string, actor: Owner): RepairPlan {
const c = state.counters.get(id);
if (!c) return { ok: false, reasons: ['That weapon is no longer on the table.'] };
if (!inRallyPhase(state)) return { ok: false, reasons: ['Weapons are repaired in the Rally Phase.'] };
if (c.side !== actor) return { ok: false, reasons: ['You can only repair your own weapons.'] };
if (roleOf(c) !== 'weapon' || c.face !== 1) return { ok: false, reasons: [`${nameOf(c)} is not malfunctioned.`] };
if (state.used.has(`repair:${id}`)) return { ok: false, reasons: [`${nameOf(c)} was already worked on this phase.`] };
const crew = (state.stacks.get(c.at) ?? []).some((x) => {
const o = state.counters.get(x);
return o && o.side === c.side && o.face === 0 && ['squad', 'half', 'crew'].includes(roleOf(o));
});
if (!crew) return { ok: false, reasons: ['A good-order squad, half-squad or crew must be with the weapon to repair it.'] };
return { ok: true, id };
}
export function resolveRepair(state: TableState, id: string, dice: Dice): string[] {
const c = state.counters.get(id)!;
state.used.add(`repair:${id}`);
const d = dice.die();
if (d <= MORALE.repair) {
c.face = 0;
return [`${nameOf(c)} repair: dr ${d}: repaired`];
}
if (d >= MORALE.repairLoss) {
eliminate(state, c);
return [`${nameOf(c)} repair: dr ${d}: weapon lost`];
}
return [`${nameOf(c)} repair: dr ${d}: still malfunctioned`];
}
