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.
// Close Combat: hexes holding both sides fight; both sides attack at once.
import { TEMPLATE_BY_KEY } from '../catalog.js';
import type { Counter, Owner, SideId, TableState } from '../model.js';
import { MAPS, PHASES } from '../scenarios.js';
import { CLOSE_COMBAT } from './data.js';
import { roleOf } from './movement.js';
import type { Dice } from './ops.js';
import { casualtyReduce, eliminate, personnelIn, statsOf } from './units.js';
const printed = (c: Counter) => TEMPLATE_BY_KEY.get(c.k)?.label ?? '';
const nameOf = (c: Counter) => c.label ?? printed(c);
export function inCloseCombat(state: TableState): boolean {
return state.turn > 0 && !state.over && PHASES[state.phase] === 'Close Combat';
}
/** Hexes where both sides have personnel. */
export function meleeHexes(state: TableState): string[] {
const map = MAPS[state.scenario.map];
return [...state.stacks.keys()].filter((hex) => map.cells.has(hex) && personnelIn(state, hex, 'a').length && personnelIn(state, hex, 'b').length);
}
function strength(units: Counter[], attacking: boolean): number {
return units.reduce((sum, c) => {
if (attacking && c.face === 1) return sum;
if (!attacking && c.face === 1 && !CLOSE_COMBAT.brokenDefend) return sum;
if (roleOf(c) === 'leader') return sum + CLOSE_COMBAT.leaderFP;
return sum + (statsOf(c, printed(c))?.fp ?? 0);
}, 0);
}
export type CcAttack = {
side: SideId;
attackers: string[];
defenders: string[];
atk: number;
def: number;
odds: string;
kill: number;
drm: number;
leader: string | null;
};
export type CcPlan = { ok: true; hex: string; attacks: CcAttack[] } | { ok: false; reasons: string[] };
function attackOf(state: TableState, hex: string, side: SideId): CcAttack | null {
const mine = personnelIn(state, hex, side);
const theirs = personnelIn(state, hex, side === 'a' ? 'b' : 'a');
const atk = strength(mine, true);
const def = strength(theirs, false);
if (atk <= 0 || def <= 0) return null;
const ratio = atk / def;
const col = [...CLOSE_COMBAT.odds].reverse().find((o) => ratio + 1e-9 >= o.ratio);
if (!col) return null;
const leaders = mine.filter((c) => roleOf(c) === 'leader' && c.face === 0);
const best = leaders.sort((x, y) => (statsOf(x, printed(x))?.leaderDRM ?? 0) - (statsOf(y, printed(y))?.leaderDRM ?? 0))[0];
return {
side,
attackers: mine.filter((c) => c.face === 0).map((c) => c.id),
defenders: theirs.map((c) => c.id),
atk,
def,
odds: col.label,
kill: col.kill,
drm: best ? (statsOf(best, printed(best))?.leaderDRM ?? 0) : 0,
leader: best?.id ?? null,
};
}
export function planCloseCombat(state: TableState, hex: string, actor: Owner): CcPlan {
if (!inCloseCombat(state)) return { ok: false, reasons: ['Close combat happens in the Close Combat Phase.'] };
if (actor === 'n') return { ok: false, reasons: ['Take a seat to fight.'] };
if (!meleeHexes(state).includes(hex)) return { ok: false, reasons: [`There is no close combat in ${hex}.`] };
if (state.used.has(`cc:${hex}`)) return { ok: false, reasons: [`Close combat in ${hex} is already resolved this phase.`] };
const attacks = (['a', 'b'] as const).map((s) => attackOf(state, hex, s)).filter((x): x is CcAttack => Boolean(x));
return { ok: true, hex, attacks };
}
/** Both attacks are rolled against the situation before either takes effect. */
export function resolveCloseCombat(state: TableState, plan: Extract<CcPlan, { ok: true }>, dice: Dice): string[] {
state.used.add(`cc:${plan.hex}`);
const out: string[] = [];
const effects: { id: string; kind: 'kill' | 'reduce' }[] = [];
for (const at of plan.attacks) {
const [a, b] = dice.dr();
const final = a + b + at.drm;
const who = state.scenario.sides[at.side].name;
let result = 'no effect';
if (final < at.kill) {
result = 'all defenders eliminated';
for (const id of at.defenders) effects.push({ id, kind: 'kill' });
} else if (final === at.kill && CLOSE_COMBAT.equalReduces) {
const victim = at.defenders[dice.pick(at.defenders.length)];
result = 'casualty reduction';
effects.push({ id: victim, kind: 'reduce' });
}
out.push(
`${who} close combat in ${plan.hex}: ${at.atk} vs ${at.def} (${at.odds}, kill ${at.kill}), DR ${a}+${b}${at.drm ? ` ${at.drm}` : ''} = ${final} → ${result}`,
);
}
for (const e of effects) {
const c = state.counters.get(e.id);
if (!c || c.at !== plan.hex) continue;
if (e.kind === 'kill') {
eliminate(state, c);
out.push(`${nameOf(c)} eliminated`);
} else out.push(casualtyReduce(state, c, printed(c)));
}
if (personnelIn(state, plan.hex, 'a').length && personnelIn(state, plan.hex, 'b').length) out.push(`${plan.hex} stays locked in melee`);
return out;
}
