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/morale.ts
// 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`];
}