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.

src/rules/cc.ts
// 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;
}