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/los.ts
// Line of sight between hex centres. Obstacles (woods, buildings, higher
// ground) block when the sight line passes below their top; grain, orchards
// and brush hinder. Elevation uses a straight line between firer and target
// levels, which approximates blind-hex rules rather than reproducing them.

import { center, parseHex, thread } from '../hex.js';
import type { TableState } from '../model.js';
import { MAPS, TERRAIN_NAMES, type MapCell } from '../scenarios.js';
import { FIRE } from './data.js';

export type Los = {
  clear: boolean;
  reason?: string;
  blockedBy?: string;
  hindrances: { hex: string; value: number; label: string }[];
  hindrance: number;
  hexes: string[]; // hexes crossed, firer first
  /** The hex the LOS enters the target from (for hexside TEM). */
  approach?: string;
};

function smokeIn(state: TableState, hex: string): boolean {
  return (state.stacks.get(hex) ?? []).some((id) => {
    const c = state.counters.get(id);
    return c?.k === 'mk-smoke' && c.face === 0;
  });
}

export function lineOfSight(state: TableState, from: string, to: string): Los {
  const map = MAPS[state.scenario.map];
  const a = parseHex(from);
  const b = parseHex(to);
  const fromCell = map.cells.get(from);
  const toCell = map.cells.get(to);
  if (!a || !b || !fromCell || !toCell) return { clear: false, reason: 'Not on the map.', hindrances: [], hindrance: 0, hexes: [] };
  const steps = thread(a, b).filter((s) => map.cells.has(s.id));
  const pa = center(a);
  const pb = center(b);
  const len2 = (pb.x - pa.x) ** 2 + (pb.y - pa.y) ** 2 || 1;
  const along = (cell: MapCell) => {
    const p = center(cell.hex);
    return Math.max(0, Math.min(1, ((p.x - pa.x) * (pb.x - pa.x) + (p.y - pa.y) * (pb.y - pa.y)) / len2));
  };
  // Eye level is one level above the ground of each hex.
  const hFrom = fromCell.level;
  const hTo = toCell.level;
  const blocks = (cell: MapCell): boolean => {
    const t = along(cell);
    const sight = hFrom + (hTo - hFrom) * t;
    const top = cell.level + (FIRE.obstacleHeight[cell.terrain] ?? 0);
    return top > sight + 1e-9;
  };
  const hindrances: Los['hindrances'] = [];
  const edgeBlockers: MapCell[] = [];
  const edgeHindrances: MapCell[] = [];
  for (const s of steps) {
    const cell = map.cells.get(s.id)!;
    const end = s.id === from || s.id === to;
    if (smokeIn(state, s.id)) hindrances.push({ hex: s.id, value: FIRE.smokeHindrance, label: `Smoke ${s.id}` });
    if (end) continue;
    if (blocks(cell)) {
      if (!s.edge) {
        return {
          clear: false,
          blockedBy: s.id,
          reason: `Blocked by ${cell.level > Math.max(hFrom, hTo) && !FIRE.obstacleHeight[cell.terrain] ? 'higher ground' : TERRAIN_NAMES[cell.terrain].toLowerCase()} in ${s.id}.`,
          hindrances,
          hindrance: 0,
          hexes: steps.map((x) => x.id),
        };
      }
      edgeBlockers.push(cell);
      continue;
    }
    const h = FIRE.hindrance[cell.terrain] ?? 0;
    const t = along(cell);
    const sight = hFrom + (hTo - hFrom) * t;
    // Hindrances only matter when the sight line is at their height.
    if (h && sight < cell.level + 1 - 1e-9) {
      if (s.edge) edgeHindrances.push(cell);
      else hindrances.push({ hex: s.id, value: h, label: `${TERRAIN_NAMES[cell.terrain]} ${s.id}` });
    }
  }
  // A line running exactly along a hexside is blocked only when both hexes are obstacles.
  for (let i = 0; i < edgeBlockers.length; i += 1) {
    for (let j = i + 1; j < edgeBlockers.length; j += 1) {
      const x = edgeBlockers[i].hex;
      const y = edgeBlockers[j].hex;
      const dx = Math.abs(center(x).x - center(y).x);
      const dy = Math.abs(center(x).y - center(y).y);
      if (Math.hypot(dx, dy) < 75) {
        return {
          clear: false,
          blockedBy: edgeBlockers[i].id,
          reason: `Blocked along the hexside between ${edgeBlockers[i].id} and ${edgeBlockers[j].id}.`,
          hindrances,
          hindrance: 0,
          hexes: steps.map((s) => s.id),
        };
      }
    }
  }
  // Along a hexside, a hindrance counts once even if both hexes hinder. VERIFY
  const counted = new Set<string>();
  for (const cell of edgeHindrances) {
    const pair = edgeHindrances.find((o) => o !== cell && counted.has(o.id) && Math.hypot(center(o.hex).x - center(cell.hex).x, center(o.hex).y - center(cell.hex).y) < 75);
    if (pair) continue;
    counted.add(cell.id);
    hindrances.push({ hex: cell.id, value: FIRE.hindrance[cell.terrain] ?? 0, label: `${TERRAIN_NAMES[cell.terrain]} ${cell.id} (hexside)` });
  }
  const hindrance = hindrances.reduce((s, h) => s + h.value, 0);
  const hexes = steps.map((s) => s.id);
  const approach = hexes.length >= 2 ? hexes[hexes.length - 2] : undefined;
  if (hindrance >= FIRE.maxHindrance) {
    return { clear: false, reason: `Hindrances total +${hindrance}, which blocks LOS.`, hindrances, hindrance, hexes, approach };
  }
  return { clear: true, hindrances, hindrance, hexes, approach };
}