const GRID_WIDTH = 40;
const GRID_HEIGHT = 30;
const METERS_PER_CHECKER = 10;
const DEFAULT_DOOR_WIDTH = 3;
const MATERIAL_AP = {
  'Light Wood': 4,
  'Armour-glas': 4,
  'Light Metal': 4,
  'Heavy Wood': 8,
  Flakboard: 8,
  Sandbags: 8,
  Ice: 8,
  Rockcrete: 16,
  'Thick Iron': 16,
  Stone: 16,
  Plasteel: 32,
  Armaplas: 32,
};
// Rooms + the connecting corridor form the structural "wall base": every
// room shares a touching edge with the corridor (zero rectangle overlap
// anywhere — see validateBattlemapLayout), so the floor plan is fully
// connected and every boundary line is a real wall. Cover, elevation,
// hazards, and blockers are placed on top of this base afterward as
// decoration/tactical detail — they never change room connectivity.
const DEFAULT_ZONES = [
  { id: 'extraction', name: 'Extraction Zone', x: 1, y: 11, w: 7, h: 8, tone: 'spawn', elevation: 0, lighting: 'bright', purpose: 'Start and extract here.', cover: 'Light cover from crates, barricades, and wreckage.' },
  { id: 'main-corridor', name: 'Main Kill-Corridor', x: 8, y: 13, w: 24, h: 4, tone: 'corridor', elevation: 0, lighting: 'normal', purpose: 'Long firing lane for bolters, heavy weapons, overwatch, and suppression.', cover: 'Alternating half-cover every 5-6 squares.' },
  { id: 'left-wing', name: 'Left Stabilization Wing', x: 12, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: 'bright', purpose: 'Tech-Use or Medicae station under heavy pressure.', cover: 'Heavy cover from cogitator banks and machinery.' },
  { id: 'right-wing', name: 'Right Stabilization Wing', x: 25, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: 'dim', purpose: 'Flank route objective room with mixed fire angles.', cover: 'Mixed light and heavy cover.' },
  { id: 'maintenance', name: 'Lower Maintenance Wing', x: 8, y: 17, w: 15, h: 8, tone: 'hazard', elevation: -1, lighting: 'dark', purpose: 'Ambush route and third stabilization station.', cover: 'Pipes, tanks, and broken wall sections.' },
  { id: 'vault', name: 'Central Gene-Vault', x: 23, y: 17, w: 12, h: 8, tone: 'vault', elevation: 0, lighting: 'dim', purpose: 'Secure the gene-core for three rounds.', cover: 'Heavy central containment pillars and shattered equipment.' },
];
const DEFAULT_STATIONS = [
  { id: 'left-station', name: 'Alpha Stabilization', zoneId: 'left-wing', x: 17, y: 7, action: 'Full Action / Tech-Use / Medicae', note: 'Heavy machinery blocks easy charges.' },
  { id: 'right-station', name: 'Beta Stabilization', zoneId: 'right-wing', x: 30, y: 7, action: 'Full Action / Tech-Use / Medicae', note: 'Side access makes this wing harder to lock down.' },
  { id: 'maintenance-station', name: 'Gamma Stabilization', zoneId: 'maintenance', x: 20, y: 22, action: 'Full Action / Tech-Use / Medicae', note: 'Maintenance sludge and pipework slow support.' },
];
const DEFAULT_SPAWN_POINTS = [
  { id: 'north-hatch', name: 'North maintenance hatch', x: 17, y: 4, lane: 'Melee swarm pressure into the left wing.' },
  { id: 'east-corridor', name: 'East corrupted corridor', x: 33, y: 15, lane: 'Ranged traitors push the right side and corridor.' },
  { id: 'south-tunnel', name: 'South service tunnel', x: 16, y: 22, lane: 'Ambush route through maintenance.' },
  { id: 'vault-breach', name: 'Vault breach point', x: 31, y: 20, lane: 'Elite contest at the core chamber.' },
];
const DEFAULT_COVER = [
  { id: 'ext-crates-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 3, y: 12, w: 2, h: 2, label: 'Crates' },
  { id: 'ext-wreck-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 5, y: 16, w: 2, h: 2, label: 'Wreckage' },
  { id: 'corr-cover-1', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 11, y: 13, w: 1, h: 4, label: 'Bulkhead' },
  { id: 'corr-cover-2', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 17, y: 13, w: 1, h: 4, label: 'Bulkhead' },
  { id: 'corr-cover-3', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 23, y: 13, w: 1, h: 4, label: 'Bulkhead' },
  { id: 'corr-cover-4', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 29, y: 13, w: 1, h: 4, label: 'Bulkhead' },
  { id: 'left-cog-1', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 13, y: 4, w: 3, h: 2, label: 'Cogitators' },
  { id: 'left-cog-2', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 18, y: 9, w: 3, h: 2, label: 'Machinery' },
  { id: 'right-cog-1', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 26, y: 4, w: 3, h: 2, label: 'Cogitators' },
  { id: 'right-cog-2', kind: 'light', material: 'Armour-glas', elevation: 1, x: 31, y: 9, w: 2, h: 2, label: 'Consoles' },
  { id: 'maint-pipe-1', kind: 'light', material: 'Light Metal', elevation: -1, x: 11, y: 18, w: 3, h: 1, label: 'Pipes' },
  { id: 'maint-tank-1', kind: 'heavy', material: 'Thick Iron', elevation: -1, x: 19, y: 21, w: 2, h: 3, label: 'Tank' },
  { id: 'vault-pillar-1', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 26, y: 19, w: 2, h: 2, label: 'Pillar' },
  { id: 'vault-pillar-2', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 32, y: 19, w: 2, h: 2, label: 'Pillar' },
  { id: 'vault-pillar-3', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 26, y: 22, w: 2, h: 2, label: 'Pillar' },
  { id: 'vault-pillar-4', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 32, y: 22, w: 2, h: 2, label: 'Pillar' },
  { id: 'vault-shatter-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 29, y: 20, w: 2, h: 1, label: 'Shattered gear' },
];
const DEFAULT_BLOCKERS = [
  { id: 'blast-door-west', material: 'Plasteel', x: 16, y: 5, w: 2, h: 1, label: 'Blast Door W' },
  { id: 'blast-door-east', material: 'Plasteel', x: 29, y: 5, w: 2, h: 1, label: 'Blast Door E' },
  { id: 'cylinder-1', material: 'Thick Iron', x: 33, y: 19, w: 2, h: 4, label: 'Containment' },
  { id: 'collapsed-slab', material: 'Rockcrete', x: 11, y: 20, w: 4, h: 2, label: 'Collapsed slab' },
];
const DEFAULT_HAZARDS = [
  { id: 'sludge', kind: 'difficult', x: 11, y: 21, w: 5, h: 2, label: 'Toxic sludge' },
  { id: 'decking', kind: 'agility', x: 12, y: 20, w: 4, h: 2, label: 'Collapsed decking' },
  { id: 'corpses', kind: 'no-charge', x: 24, y: 22, w: 4, h: 2, label: 'Corpse pile' },
  { id: 'vent', kind: 'vent', x: 29, y: 22, w: 2, h: 2, label: 'Toxic vent' },
];
const DEFAULT_DYNAMIC_FEATURES = [
  { id: 'doors', name: 'Blast doors', rule: 'GM can open, seal, or lock them to change line of sight and movement.' },
  { id: 'vents', name: 'Toxic vents', rule: 'Temporary hazardous zones that deny easy movement.' },
  { id: 'alarm', name: 'Alarm lights', rule: 'Low visibility sectors can apply ranged penalties.' },
  { id: 'turrets', name: 'Automated turrets', rule: 'Hack, destroy, or use cover to avoid them.' },
  { id: 'walkways', name: 'Collapsing walkways', rule: 'Force Agility tests and open or close routes mid-fight.' },
];
const DEFAULT_LAYOUT_KEY = 'vault';

function zonesOverlap(a, b) {
  return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}

function pointInZone(zone, x, y) {
  return x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h;
}

function pointInAnyZone(zones, x, y) {
  return (Array.isArray(zones) ? zones : []).some(zone => pointInZone(zone, x, y));
}

function zonesShareEdge(a, b) {
  if (!a || !b) return false;
  const horizontalTouch = (a.y + a.h === b.y || b.y + b.h === a.y)
    && Math.min(a.x + a.w, b.x + b.w) > Math.max(a.x, b.x);
  const verticalTouch = (a.x + a.w === b.x || b.x + b.w === a.x)
    && Math.min(a.y + a.h, b.y + b.h) > Math.max(a.y, b.y);
  return horizontalTouch || verticalTouch;
}

// Rooms and the corridor are the structural "wall base": each room's
// boundary is a real wall, and rooms only connect where they share a
// touching edge with the corridor (or another room) — never by overlapping.
// Cover/elevation/hazards/blockers are decoration placed on top and never
// affect this base. validateBattlemapLayout guards against the three
// battlemap-layout bugs that have shipped already: (1) room rectangles
// overlapping each other or the corridor (drawn borders visually
// cross/collide — corridors included, no exemptions), (2) hardcoded anchor
// points landing outside every room, and (3) rooms that are geometrically
// present but unreachable from the spawn zone (movement pathfinding treats
// gaps between zones as walls, so a room that doesn't touch the connected
// structure is permanently unreachable even though it "exists").
function zoneCellIsWalkable(zones, x, y) {
  return pointInAnyZone(zones, x, y);
}

function reachableZoneIds(zones, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }) {
  const zoneList = Array.isArray(zones) ? zones : [];
  const start = zoneList.find(zone => zone.tone === 'spawn') || zoneList[0];
  if (!start) return new Set();
  const startCell = [Math.floor(start.x), Math.floor(start.y)];
  const visited = new Set([startCell.join(',')]);
  const queue = [startCell];
  const dirs = [[0, -1], [-1, 0], [1, 0], [0, 1]];
  while (queue.length) {
    const [cx, cy] = queue.shift();
    for (const [dx, dy] of dirs) {
      const nx = cx + dx;
      const ny = cy + dy;
      if (nx < 0 || ny < 0 || nx >= bounds.width || ny >= bounds.height) continue;
      const key = `${nx},${ny}`;
      if (visited.has(key)) continue;
      if (!zoneCellIsWalkable(zoneList, nx + 0.5, ny + 0.5)) continue;
      visited.add(key);
      queue.push([nx, ny]);
    }
  }
  const reached = new Set();
  visited.forEach((key) => {
    const [x, y] = key.split(',').map(Number);
    zoneList.forEach((zone) => { if (pointInZone(zone, x + 0.5, y + 0.5)) reached.add(zone.id); });
  });
  return reached;
}

function validateBattlemapLayout(zones, points = [], bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }) {
  const issues = [];
  const zoneList = Array.isArray(zones) ? zones : [];

  zoneList.forEach((zone) => {
    if (zone.x < 0 || zone.y < 0 || zone.x + zone.w > bounds.width || zone.y + zone.h > bounds.height) {
      issues.push(`Zone "${zone.id}" (x:${zone.x}, y:${zone.y}, w:${zone.w}, h:${zone.h}) extends outside the ${bounds.width}x${bounds.height} map.`);
    }
  });

  for (let i = 0; i < zoneList.length; i += 1) {
    for (let j = i + 1; j < zoneList.length; j += 1) {
      const a = zoneList[i];
      const b = zoneList[j];
      if (zonesOverlap(a, b)) {
        issues.push(`Zones "${a.id}" and "${b.id}" overlap — room borders would cross/collide.`);
      }
    }
  }

  zoneList.forEach((zone) => {
    if (zoneList.length <= 1) return;
    const hasConnection = zoneList.some((other) => other !== zone && zonesShareEdge(zone, other));
    if (!hasConnection) {
      issues.push(`Zone "${zone.id}" does not share an edge with any room or hallway.`);
    }
  });

  if (zoneList.length > 1) {
    const reached = reachableZoneIds(zoneList, bounds);
    zoneList.forEach((zone) => {
      if (!reached.has(zone.id)) {
        issues.push(`Zone "${zone.id}" is not reachable from the spawn zone — it doesn't touch the connected structure.`);
      }
    });
  }

  points.forEach(([x, y], index) => {
    if (!pointInAnyZone(zoneList, x, y)) {
      issues.push(`Point ${index} (${x}, ${y}) does not fall inside any zone.`);
    }
  });

  return issues;
}

function battlemapVisibleForPlayer(scene, authedPlayer) {
  if (authedPlayer === 'gm') return true;
  const reveal = scene?.reveal || {};
  if (typeof reveal.battlemap === 'boolean') return reveal.battlemap;
  return scene?.revealed === true;
}

function battlemapActorKey(side, name) {
  return `${side}:${String(name || '').trim()}`;
}

function parseRangeNumber(value) {
  const match = String(value || '').match(/(\d+)\s*m?/i);
  return match ? Math.max(1, Number(match[1]) || 0) : 0;
}

function checkersForMeters(metres, minimum = 1) {
  const numeric = Math.max(0, Number(metres || 0) || 0);
  return Math.max(minimum, Math.ceil(numeric / METERS_PER_CHECKER));
}

function defaultRangeForWeaponName(name = '') {
  const lower = String(name).toLowerCase();
  if (/lascannon|missile launcher|cyclone/.test(lower)) return 300;
  if (/heavy bolter|plasma cannon|assault cannon/.test(lower)) return 150;
  if (/bolter|boltgun|storm bolter|lasgun|plasma gun/.test(lower)) return 100;
  if (/shotgun|autogun/.test(lower)) return 30;
  if (/bolt pistol|plasma pistol|autopistol|laspistol/.test(lower)) return 30;
  if (/melta/.test(lower)) return 20;
  if (/flamer/.test(lower)) return 20;
  if (/grenade/.test(lower)) return 20;
  if (/melee|sword|axe|knife|hammer|fist|claw|staff|chain/.test(lower)) return 2;
  return 30;
}

function normalizeWeaponRange(weapon = {}) {
  const explicit = parseRangeNumber(weapon.range);
  return explicit || defaultRangeForWeaponName(weapon.name || weapon.weaponName || weapon.type || '');
}

function normalizeMovement(movement = {}) {
  const half = Math.max(1, Number(movement.half || 3) || 3);
  const full = Math.max(half, Number(movement.full || half * 2) || half * 2);
  const charge = Math.max(half, Number(movement.charge || full) || full);
  const run = Math.max(full, Number(movement.run || full * 2) || full * 2);
  return { half, full, charge, run };
}

function normalizeTurnState(existing = {}, turnKey = '') {
  if (existing?.turnKey === turnKey) {
    return {
      turnKey,
      movedBy: existing.movedBy && typeof existing.movedBy === 'object' ? existing.movedBy : {},
    };
  }
  return { turnKey, movedBy: {} };
}

function movementBudget(movement, mode, isCarrier = false) {
  const profile = normalizeMovement(movement);
  if (isCarrier && (mode === 'charge' || mode === 'run')) return 0;
  if (mode === 'half') return checkersForMeters(profile.half);
  if (mode === 'charge') return checkersForMeters(profile.charge);
  if (mode === 'run') return checkersForMeters(profile.run);
  return checkersForMeters(profile.full);
}

function distanceCells(from, to) {
  const dx = Number(to.x || 0) - Number(from.x || 0);
  const dy = Number(to.y || 0) - Number(from.y || 0);
  return Math.sqrt((dx * dx) + (dy * dy));
}

function distance(from, to) {
  return distanceCells(from, to) * METERS_PER_CHECKER;
}

function pointInRect(point, rect) {
  if (!point || !rect) return false;
  const x = Number(point.x || 0);
  const y = Number(point.y || 0);
  const rx = Number(rect.x || 0);
  const ry = Number(rect.y || 0);
  const rw = Number(rect.w || rect.width || 0);
  const rh = Number(rect.h || rect.height || 0);
  return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
}

function segmentIntersectsRect(from, to, rect) {
  if (!from || !to || !rect) return false;
  const steps = Math.max(1, Math.ceil(distanceCells(from, to) * 2));
  for (let index = 0; index <= steps; index += 1) {
    const t = index / steps;
    const point = {
      x: Number(from.x || 0) + ((Number(to.x || 0) - Number(from.x || 0)) * t),
      y: Number(from.y || 0) + ((Number(to.y || 0) - Number(from.y || 0)) * t),
    };
    if (pointInRect(point, rect)) return true;
  }
  return false;
}

function materialAp(material, fallbackKind = '') {
  if (MATERIAL_AP[material]) return MATERIAL_AP[material];
  if (fallbackKind === 'heavy') return 16;
  if (fallbackKind === 'light') return 8;
  return 4;
}

function coverModifier(cover) {
  if (!cover) return 0;
  if (cover.kind === 'heavy') return -20;
  if (cover.kind === 'light') return -10;
  return -10;
}

function coverForPoint(layout = {}, point) {
  const items = Array.isArray(layout.cover) ? layout.cover : [];
  return items.find(item => pointInRect(point, item)) || null;
}

function blockerBetween(layout = {}, from, to) {
  const items = Array.isArray(layout.blockers) ? layout.blockers : [];
  return items.find(item => segmentIntersectsRect(from, to, item)) || null;
}

function rangesOverlap(a1, a2, b1, b2) {
  return Math.max(Math.min(a1, a2), Math.min(b1, b2)) < Math.min(Math.max(a1, a2), Math.max(b1, b2));
}

function doorwaySpan(start, end, width = 3) {
  const min = Math.min(start, end);
  const max = Math.max(start, end);
  const span = max - min;
  if (span <= 0) return null;
  const opening = Math.min(width, span);
  const center = (min + max) / 2;
  return {
    from: center - (opening / 2),
    to: center + (opening / 2),
  };
}

function computeZoneDoorways(zones, width = DEFAULT_DOOR_WIDTH) {
  const doorways = [];
  const zoneList = Array.isArray(zones) ? zones : [];
  zoneList.forEach((zone, zoneIndex) => {
    zoneList.slice(zoneIndex + 1).forEach((other) => {
      if (other.y === zone.y + zone.h || zone.y === other.y + other.h) {
        const overlapFrom = Math.max(zone.x, other.x);
        const overlapTo = Math.min(zone.x + zone.w, other.x + other.w);
        if (overlapTo > overlapFrom) {
          const opening = doorwaySpan(overlapFrom, overlapTo, width);
          if (opening) {
            doorways.push({
              id: `door:${zone.id}:${other.id}:h`,
              axis: 'h',
              at: other.y === zone.y + zone.h ? zone.y + zone.h : other.y + other.h,
              from: opening.from,
              to: opening.to,
              zones: [zone.id, other.id],
              x: opening.from,
              y: (other.y === zone.y + zone.h ? zone.y + zone.h : other.y + other.h) - 0.25,
              w: opening.to - opening.from,
              h: 0.5,
              label: `${zone.name || zone.id} / ${other.name || other.id} door`,
            });
          }
        }
      }
      if (other.x === zone.x + zone.w || zone.x === other.x + other.w) {
        const overlapFrom = Math.max(zone.y, other.y);
        const overlapTo = Math.min(zone.y + zone.h, other.y + other.h);
        if (overlapTo > overlapFrom) {
          const opening = doorwaySpan(overlapFrom, overlapTo, width);
          if (opening) {
            doorways.push({
              id: `door:${zone.id}:${other.id}:v`,
              axis: 'v',
              at: other.x === zone.x + zone.w ? zone.x + zone.w : other.x + other.w,
              from: opening.from,
              to: opening.to,
              zones: [zone.id, other.id],
              x: (other.x === zone.x + zone.w ? zone.x + zone.w : other.x + other.w) - 0.25,
              y: opening.from,
              w: 0.5,
              h: opening.to - opening.from,
              label: `${zone.name || zone.id} / ${other.name || other.id} door`,
            });
          }
        }
      }
    });
  });
  return doorways;
}

// Room walls are derived from the zone rectangles themselves: every edge of
// every room is a solid wall EXCEPT a short centered doorway on the stretch
// that coincides with another zone's touching edge. This prevents entire
// shared edges from acting like open-air seams, so combatants need to shoot
// or move through a distinct doorway rather than "through the whole wall".
function computeZoneWalls(zones, doors = []) {
  const walls = [];
  const doorways = Array.isArray(doors) && doors.length ? doors : computeZoneDoorways(zones);
  zones.forEach((zone) => {
    const horizontalEdges = [zone.y, zone.y + zone.h];
    const verticalEdges = [zone.x, zone.x + zone.w];
    horizontalEdges.forEach((atY) => {
      const doorway = doorways.find((entry) => entry.axis === 'h' && entry.at === atY && entry.from < zone.x + zone.w && entry.to > zone.x);
      if (!doorway) {
        walls.push({ axis: 'h', at: atY, from: zone.x, to: zone.x + zone.w });
      } else {
        if (zone.x < doorway.from) walls.push({ axis: 'h', at: atY, from: zone.x, to: doorway.from });
        if (doorway.to < zone.x + zone.w) walls.push({ axis: 'h', at: atY, from: doorway.to, to: zone.x + zone.w });
      }
    });
    verticalEdges.forEach((atX) => {
      const doorway = doorways.find((entry) => entry.axis === 'v' && entry.at === atX && entry.from < zone.y + zone.h && entry.to > zone.y);
      if (!doorway) {
        walls.push({ axis: 'v', at: atX, from: zone.y, to: zone.y + zone.h });
      } else {
        if (zone.y < doorway.from) walls.push({ axis: 'v', at: atX, from: zone.y, to: doorway.from });
        if (doorway.to < zone.y + zone.h) walls.push({ axis: 'v', at: atX, from: doorway.to, to: zone.y + zone.h });
      }
    });
  });
  return walls;
}

function segmentCrossesWall(from, to, wall) {
  if (wall.axis === 'h') {
    if (from.y === to.y) return false;
    if ((from.y - wall.at) * (to.y - wall.at) > 0) return false;
    const t = (wall.at - from.y) / (to.y - from.y);
    if (t < 0 || t > 1) return false;
    const x = from.x + (t * (to.x - from.x));
    return x > wall.from && x < wall.to;
  }
  if (from.x === to.x) return false;
  if ((from.x - wall.at) * (to.x - wall.at) > 0) return false;
  const t = (wall.at - from.x) / (to.x - from.x);
  if (t < 0 || t > 1) return false;
  const y = from.y + (t * (to.y - from.y));
  return y > wall.from && y < wall.to;
}

function wallBetween(layout = {}, from, to) {
  const zones = Array.isArray(layout.zones) ? layout.zones : [];
  if (zones.length < 2 || !from || !to) return null;
  const walls = computeZoneWalls(zones, layout.doors);
  return walls.find(wall => segmentCrossesWall(from, to, wall)) || null;
}

function zoneForPoint(layout = {}, point) {
  const zones = Array.isArray(layout.zones) ? layout.zones : [];
  return zones.find(zone => pointInRect(point, zone)) || null;
}

function elevationForPoint(layout = {}, point) {
  const cover = coverForPoint(layout, point);
  if (cover && Number.isFinite(Number(cover.elevation))) return Number(cover.elevation);
  const zone = zoneForPoint(layout, point);
  if (zone && Number.isFinite(Number(zone.elevation))) return Number(zone.elevation);
  return 0;
}

function lightingForPoint(layout = {}, point) {
  const zone = zoneForPoint(layout, point);
  return String(zone?.lighting || 'normal').toLowerCase();
}

function detectionRangeForLighting(lighting) {
  if (lighting === 'dark') return 10;
  if (lighting === 'dim') return 18;
  if (lighting === 'bright') return 40;
  if (lighting === 'glow') return 28;
  return 30;
}

function visibilityBetween(layout = {}, from, to) {
  const dist = distance(from, to);
  const blocking = blockerBetween(layout, from, to);
  if (blocking) {
    return {
      visible: false,
      blocked: true,
      distance: dist,
      reasons: [`Blocked by ${blocking.label || 'terrain'}`],
      sourceLighting: lightingForPoint(layout, from),
      targetLighting: lightingForPoint(layout, to),
      maxSight: 0,
    };
  }
  const wall = wallBetween(layout, from, to);
  if (wall) {
    return {
      visible: false,
      blocked: true,
      distance: dist,
      reasons: ['Blocked by a wall — no line of sight'],
      sourceLighting: lightingForPoint(layout, from),
      targetLighting: lightingForPoint(layout, to),
      maxSight: 0,
    };
  }
  const sourceLighting = lightingForPoint(layout, from);
  const targetLighting = lightingForPoint(layout, to);
  const maxSight = Math.min(
    detectionRangeForLighting(sourceLighting),
    detectionRangeForLighting(targetLighting)
  );
  if (dist > maxSight) {
    return {
      visible: false,
      blocked: false,
      distance: dist,
      reasons: [`Visibility limit ${maxSight}m (${sourceLighting}/${targetLighting})`],
      sourceLighting,
      targetLighting,
      maxSight,
    };
  }
  return {
    visible: true,
    blocked: false,
    distance: dist,
    reasons: [],
    sourceLighting,
    targetLighting,
    maxSight,
  };
}

function rangeBand(distanceMetres, rangeMetres) {
  const range = Math.max(1, Number(rangeMetres || 0) || 1);
  if (distanceMetres <= METERS_PER_CHECKER * 2) return { band: 'point_blank', modifier: 30 };
  if (distanceMetres <= range / 2) return { band: 'short', modifier: 10 };
  if (distanceMetres <= range) return { band: 'standard', modifier: 0 };
  if (distanceMetres <= range * 2) return { band: 'long', modifier: -10 };
  if (distanceMetres <= range * 4) return { band: 'extreme', modifier: -30 };
  return { band: 'out_of_range', modifier: -60 };
}

// A ranged shot can't be taken clean through an ally standing between the
// shooter and the target — this checks whether any ally position lies close
// enough to the attacker-defender line (excluding the very ends, so allies
// standing right next to the attacker or target don't falsely block it).
function pointNearSegment(point, from, to, tolerance = 0.45) {
  const totalLenSq = Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2);
  if (totalLenSq < 0.0001) return distanceCells(point, from) <= tolerance;
  const t = Math.max(0, Math.min(1, (
    ((point.x - from.x) * (to.x - from.x)) + ((point.y - from.y) * (to.y - from.y))
  ) / totalLenSq));
  if (t <= 0.08 || t >= 0.92) return false;
  const projX = from.x + (t * (to.x - from.x));
  const projY = from.y + (t * (to.y - from.y));
  return distanceCells(point, { x: projX, y: projY }) <= tolerance;
}

function allyBetween(allies = [], from, to, tolerance = 0.45) {
  return (Array.isArray(allies) ? allies : []).find(point => point && pointNearSegment(point, from, to, tolerance)) || null;
}

function attackPositioning(layout = {}, attacker, defender, weapon = {}, attackMode = 'ranged', allies = []) {
  const dist = distance(attacker, defender);
  const melee = attackMode === 'melee';
  const reasons = [];
  const blocking = blockerBetween(layout, attacker, defender);
  if (blocking) {
    return {
      distance: dist,
      blocked: true,
      totalModifier: -60,
      coverAp: 0,
      reasons: [`Blocked by ${blocking.label || 'terrain'}`],
      range: { band: melee ? 'melee' : 'blocked', modifier: -60 },
      cover: null,
      elevation: 0,
    };
  }
  const wall = wallBetween(layout, attacker, defender);
  if (wall) {
    return {
      distance: dist,
      blocked: true,
      totalModifier: -60,
      coverAp: 0,
      reasons: ['Blocked by a wall — no line of sight'],
      range: { band: melee ? 'melee' : 'blocked', modifier: -60 },
      cover: null,
      elevation: 0,
    };
  }
  if (!melee && allyBetween(allies, attacker, defender)) {
    return {
      distance: dist,
      blocked: true,
      totalModifier: -60,
      coverAp: 0,
      reasons: ['Blocked by an ally in the line of fire'],
      range: { band: 'blocked', modifier: -60 },
      cover: null,
      elevation: 0,
    };
  }
  if (melee) {
    if (dist > 2.5 * METERS_PER_CHECKER) {
      return {
        distance: dist,
        blocked: false,
        totalModifier: -60,
        coverAp: 0,
        reasons: ['Not in melee range'],
        range: { band: 'melee_out_of_range', modifier: -60 },
        cover: null,
        elevation: 0,
      };
    }
    let total = 0;
    const attackerElevation = elevationForPoint(layout, attacker);
    const defenderElevation = elevationForPoint(layout, defender);
    const elevationMod = attackerElevation > defenderElevation ? 10 : 0;
    if (elevationMod) reasons.push('Higher ground +10');
    total += elevationMod;
    return {
      distance: dist,
      blocked: false,
      totalModifier: total,
      coverAp: 0,
      reasons,
      range: { band: 'melee', modifier: 0 },
      cover: null,
      elevation: elevationMod,
    };
  }

  const weaponRange = normalizeWeaponRange(weapon);
  const rangeInfo = rangeBand(dist, weaponRange);
  if (rangeInfo.modifier) reasons.push(`Range ${rangeInfo.band} ${rangeInfo.modifier > 0 ? '+' : ''}${rangeInfo.modifier}`);
  const defenderCover = coverForPoint(layout, defender);
  const coverMod = coverModifier(defenderCover);
  const coverApValue = defenderCover ? Math.max(0, materialAp(defenderCover.material, defenderCover.kind) - Math.max(0, Number(weapon.pen || 0))) : 0;
  if (defenderCover && coverMod) reasons.push(`${defenderCover.material || defenderCover.kind || 'Cover'} ${coverMod}`);
  const attackerElevation = elevationForPoint(layout, attacker);
  const defenderElevation = elevationForPoint(layout, defender);
  const elevationMod = attackerElevation > defenderElevation ? 10 : 0;
  if (elevationMod) reasons.push('Higher ground +10');
  return {
    distance: dist,
    blocked: false,
    totalModifier: rangeInfo.modifier + coverMod + elevationMod,
    coverAp: coverApValue,
    reasons,
    range: rangeInfo,
    cover: defenderCover,
    elevation: elevationMod,
  };
}

function snapCoordinate(value, max) {
  const snapped = Math.round(value - 0.5) + 0.5;
  return Math.max(0.5, Math.min(max - 0.5, snapped));
}

function pointToCell(point) {
  return {
    x: Math.max(0, Math.round(Number(point?.x || 0) - 0.5)),
    y: Math.max(0, Math.round(Number(point?.y || 0) - 0.5)),
  };
}

function cellToPoint(cell) {
  return { x: Number(cell.x) + 0.5, y: Number(cell.y) + 0.5 };
}

function cellKey(cell) {
  return `${cell.x},${cell.y}`;
}

function pointBlocked(layout = {}, point) {
  if (!pointInAnyZone(layout.zones || [], point.x, point.y)) return true;
  return Boolean(blockerBetween({ blockers: layout.blockers || [] }, point, point));
}

function movementBlockedBetween(layout = {}, from, to) {
  if (!from || !to) return true;
  if (pointBlocked(layout, to)) return true;
  if (blockerBetween(layout, from, to)) return true;
  if (wallBetween(layout, from, to)) return true;
  return false;
}

function pointTerrainCost(layout = {}, point) {
  const hazards = Array.isArray(layout.hazards) ? layout.hazards : [];
  const hazard = hazards.find((item) => pointInRect(point, item));
  if (!hazard) return 1;
  if (hazard.kind === 'difficult') return 2;
  if (hazard.kind === 'agility') return 2;
  if (hazard.kind === 'vent') return 2;
  if (hazard.kind === 'no-charge') return 3;
  return 1;
}

function buildOccupiedSet(occupied = [], allow = []) {
  const allowed = new Set((allow || []).map((point) => cellKey(pointToCell(point))));
  return new Set(
    (occupied || [])
      .map((point) => pointToCell(point))
      .map(cellKey)
      .filter((key) => !allowed.has(key))
  );
}

function targetGoalCells(targetPoint, stopRange, bounds, layout, occupiedSet) {
  const targetCell = pointToCell(targetPoint);
  const goals = [];
  const maxRadius = Math.max(0, Number(stopRange || 0));
  for (let dx = -maxRadius; dx <= maxRadius; dx += 1) {
    for (let dy = -maxRadius; dy <= maxRadius; dy += 1) {
      const chebyshev = Math.max(Math.abs(dx), Math.abs(dy));
      if (maxRadius === 0 && (dx !== 0 || dy !== 0)) continue;
      if (maxRadius > 0 && (chebyshev === 0 || chebyshev > maxRadius)) continue;
      const cell = { x: targetCell.x + dx, y: targetCell.y + dy };
      if (cell.x < 0 || cell.y < 0 || cell.x >= bounds.width || cell.y >= bounds.height) continue;
      const point = cellToPoint(cell);
      if (pointBlocked(layout, point)) continue;
      if (occupiedSet.has(cellKey(cell))) continue;
      goals.push(cell);
    }
  }
  if (goals.length) return goals;
  return maxRadius === 0 ? [targetCell] : [];
}

function moveWithinRange(from, to, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) {
  const startCell = pointToCell(from);
  const layout = options.layout || {};
  const occupiedSet = buildOccupiedSet(options.occupied || [], [from, ...(options.allowOccupied || [])]);
  const goalCells = targetGoalCells(to, options.stopRange || 0, bounds, layout, occupiedSet);
  if (!goalCells.length) {
    return { x: snapCoordinate(Number(from.x || 0), bounds.width), y: snapCoordinate(Number(from.y || 0), bounds.height), travelled: 0 };
  }

  const goalKeys = new Set(goalCells.map(cellKey));
  const startKey = cellKey(startCell);
  const queue = [{ cell: startCell, cost: 0 }];
  const visited = new Map([[startKey, { cost: 0, prev: null }]]);
  let bestKey = startKey;
  let bestScore = Infinity;

  while (queue.length) {
    queue.sort((a, b) => a.cost - b.cost);
    const current = queue.shift();
    const currentKey = cellKey(current.cell);
    const currentInfo = visited.get(currentKey);
    if (!currentInfo || current.cost !== currentInfo.cost) continue;

    const distanceToGoal = Math.min(...goalCells.map((goal) => Math.max(Math.abs(goal.x - current.cell.x), Math.abs(goal.y - current.cell.y))));
    const score = distanceToGoal * 100 + current.cost;
    if (score < bestScore) {
      bestScore = score;
      bestKey = currentKey;
    }
    if (goalKeys.has(currentKey)) {
      bestKey = currentKey;
      break;
    }

    const directions = [
      [-1, -1], [0, -1], [1, -1],
      [-1, 0],            [1, 0],
      [-1, 1],  [0, 1],   [1, 1],
    ];
    for (const [dx, dy] of directions) {
      const nextCell = { x: current.cell.x + dx, y: current.cell.y + dy };
      if (nextCell.x < 0 || nextCell.y < 0 || nextCell.x >= bounds.width || nextCell.y >= bounds.height) continue;
      const nextKey = cellKey(nextCell);
      if (occupiedSet.has(nextKey)) continue;
      const nextPoint = cellToPoint(nextCell);
      if (movementBlockedBetween(layout, cellToPoint(current.cell), nextPoint)) continue;
      const stepCost = pointTerrainCost(layout, nextPoint);
      const nextCost = current.cost + stepCost;
      if (nextCost > maxDistance) continue;
      const previous = visited.get(nextKey);
      if (!previous || nextCost < previous.cost) {
        visited.set(nextKey, { cost: nextCost, prev: currentKey });
        queue.push({ cell: nextCell, cost: nextCost });
      }
    }
  }

  const best = visited.get(bestKey) || { cost: 0, prev: null };
  const bestPoint = cellToPoint(bestKey.split(',').reduce((acc, part, index) => {
    acc[index === 0 ? 'x' : 'y'] = Number(part);
    return acc;
  }, {}));
  return {
    x: snapCoordinate(bestPoint.x, bounds.width),
    y: snapCoordinate(bestPoint.y, bounds.height),
    travelled: best.cost,
  };
}

function stepToward(from, to, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) {
  return moveWithinRange(from, to, maxDistance, bounds, options);
}

function enemyMoveTowardNearest(enemyToken, playerTokens, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) {
  const alivePlayers = Array.isArray(playerTokens) ? playerTokens : [];
  if (!enemyToken || !alivePlayers.length) return null;
  const target = alivePlayers
    .map(token => ({ token, dist: distance(enemyToken, token) }))
    .sort((a, b) => a.dist - b.dist)[0]?.token;
  if (!target) return null;
  const next = stepToward(enemyToken, target, maxDistance, bounds, {
    ...options,
    stopRange: Math.max(1, Number(options.stopRange || 1)),
  });
  return { target, ...next };
}

function fallbackPoint(id) {
  const map = {
    extraction: { x: 5, y: 15 },
    'left-station': { x: 17, y: 7 },
    'right-station': { x: 30, y: 7 },
    'maintenance-station': { x: 20, y: 25 },
    vault: { x: 29, y: 15 },
    core: { x: 29, y: 15 },
    'north-hatch': { x: 17, y: 4 },
    'east-corridor': { x: 33, y: 15 },
    'south-tunnel': { x: 16, y: 22 },
    'vault-breach': { x: 31, y: 20 },
  };
  return map[id] || { x: 20, y: 15 };
}

function pointFromLayout(layout = {}, id, fallbackId = id) {
  const zones = Array.isArray(layout.zones) ? layout.zones : [];
  const stations = Array.isArray(layout.stations) ? layout.stations : [];
  const spawns = Array.isArray(layout.spawnPoints) ? layout.spawnPoints : [];
  const zone = zones.find(entry => entry.id === id);
  if (zone && Number.isFinite(Number(zone.x)) && Number.isFinite(Number(zone.y))) {
    return { x: Number(zone.x) + Number(zone.w || 1) / 2, y: Number(zone.y) + Number(zone.h || 1) / 2 };
  }
  const station = stations.find(entry => entry.id === id);
  if (station && Number.isFinite(Number(station.x)) && Number.isFinite(Number(station.y))) {
    return { x: Number(station.x), y: Number(station.y) };
  }
  const spawn = spawns.find(entry => entry.id === id);
  if (spawn && Number.isFinite(Number(spawn.x)) && Number.isFinite(Number(spawn.y))) {
    return { x: Number(spawn.x), y: Number(spawn.y) };
  }
  return fallbackPoint(fallbackId);
}

function createBattlemapPositions(layout = {}, players = [], enemies = []) {
  const extraction = pointFromLayout(layout, 'extraction');
  const vault = pointFromLayout(layout, 'vault');
  const rightStation = pointFromLayout(layout, 'right-station');
  const maintenanceStation = pointFromLayout(layout, 'maintenance-station');
  const playerOffsets = [[-1.5, -1.5], [1.5, -1.5], [-1.5, 1.5], [1.5, 1.5], [0, 3]];
  const enemyAnchors = [
    { x: vault.x, y: vault.y },
    { x: rightStation.x, y: rightStation.y },
    { x: maintenanceStation.x, y: maintenanceStation.y },
    { x: Math.max(24.5, vault.x - 3), y: Math.min(23.5, vault.y + 3) },
  ];
  const positions = {};
  players.forEach((player, index) => {
    const offset = playerOffsets[index % playerOffsets.length];
    positions[battlemapActorKey('player', player.name)] = { x: extraction.x + offset[0], y: extraction.y + offset[1] };
  });
  enemies.forEach((enemy, index) => {
    const anchor = enemyAnchors[index % enemyAnchors.length];
    positions[battlemapActorKey('enemy', enemy.name)] = { x: anchor.x, y: anchor.y };
  });
  return positions;
}

function sceneEnemyObjects(scene = {}) {
  if (!Array.isArray(scene.enemies)) return [];
  return scene.enemies
    .map(enemy => (typeof enemy === 'string' ? { name: enemy } : enemy))
    .filter(enemy => enemy && (enemy.name || enemy.type))
    .map((enemy, index) => ({ ...enemy, name: enemy.name || enemy.type || `Enemy ${index + 1}` }));
}

function scenePlayerEntries(scene = {}) {
  const initiatives = Array.isArray(scene?.combatState?.initiatives) ? scene.combatState.initiatives : [];
  const names = initiatives
    .filter(entry => String(entry?.type || 'player').toLowerCase() !== 'npc')
    .map(entry => String(entry.player || entry.name || '').trim())
    .filter(Boolean);
  return names.filter((name, index, list) => list.indexOf(name) === index);
}

function normalizeToken(token, index) {
  return {
    id: token.id || `token-${index + 1}`,
    name: token.name || `Token ${index + 1}`,
    side: token.side === 'enemy' ? 'enemy' : 'player',
    x: Number.isFinite(Number(token.x)) ? Number(token.x) : 1.5,
    y: Number.isFinite(Number(token.y)) ? Number(token.y) : 1.5,
    hp: token.hp ?? null,
    maxHp: token.maxHp ?? null,
    color: token.color || (token.side === 'enemy' ? '#ef4444' : '#38bdf8'),
  };
}

function normalizeTokens(tokens) {
  return Array.isArray(tokens) ? tokens.map(normalizeToken) : [];
}

function defaultBattlemapObjectives(existing = {}) {
  return {
    stations: DEFAULT_STATIONS.map((station) => ({
      id: station.id,
      active: Boolean(existing.stations?.find(item => item.id === station.id)?.active),
    })),
    geneCoreSecuredRounds: Math.max(0, Number(existing.geneCoreSecuredRounds || 0) || 0),
    extractionProgress: Math.max(0, Number(existing.extractionProgress || 0) || 0),
    carrier: String(existing.carrier || ''),
    activeSpawnId: existing.activeSpawnId || DEFAULT_SPAWN_POINTS[0].id,
  };
}

function defaultBattlemapLayout(scene = {}) {
  const theme = sceneBattlemapTheme(scene);
  const preset = layoutPresetForKey(theme.layoutKey);
  return {
    title: theme.boardTitle,
    summary: theme.boardSummary,
    gridWidth: GRID_WIDTH,
    gridHeight: GRID_HEIGHT,
    metersPerChecker: METERS_PER_CHECKER,
    zones: preset.zones,
    doors: preset.doors,
    stations: preset.stations,
    spawnPoints: preset.spawnPoints,
    cover: preset.cover,
    blockers: preset.blockers,
    hazards: preset.hazards,
    dynamicFeatures: DEFAULT_DYNAMIC_FEATURES,
    viewBox: `0 0 ${GRID_WIDTH} ${GRID_HEIGHT}`,
  };
}

function normalizeBattlemapLayout(scene = {}, existingLayout = {}) {
  const base = defaultBattlemapLayout(scene);
  const incoming = existingLayout && typeof existingLayout === 'object' ? existingLayout : {};
  return {
    ...base,
    ...incoming,
    title: incoming.title || base.title,
    summary: incoming.summary || base.summary,
    gridWidth: Number(incoming.gridWidth || base.gridWidth) || base.gridWidth,
    gridHeight: Number(incoming.gridHeight || base.gridHeight) || base.gridHeight,
    metersPerChecker: Number(incoming.metersPerChecker || base.metersPerChecker) || base.metersPerChecker,
    zones: Array.isArray(incoming.zones) && incoming.zones.length ? incoming.zones : base.zones,
    doors: Array.isArray(incoming.doors) && incoming.doors.length ? incoming.doors : base.doors,
    stations: Array.isArray(incoming.stations) && incoming.stations.length ? incoming.stations : base.stations,
    spawnPoints: Array.isArray(incoming.spawnPoints) && incoming.spawnPoints.length ? incoming.spawnPoints : base.spawnPoints,
    cover: Array.isArray(incoming.cover) ? incoming.cover : base.cover,
    blockers: Array.isArray(incoming.blockers) ? incoming.blockers : base.blockers,
    hazards: Array.isArray(incoming.hazards) ? incoming.hazards : base.hazards,
    dynamicFeatures: Array.isArray(incoming.dynamicFeatures) ? incoming.dynamicFeatures : base.dynamicFeatures,
    viewBox: incoming.viewBox || base.viewBox,
  };
}

function defaultBattlemapTokens(scene = {}, existingTokens = []) {
  const priorByName = new Map(normalizeTokens(existingTokens).map(token => [String(token.name).toLowerCase(), token]));
  const players = scenePlayerEntries(scene);
  const enemies = sceneEnemyObjects(scene);
  const layout = defaultBattlemapLayout(scene);
  const anchors = createBattlemapPositions(layout, players.map(name => ({ name })), enemies);
  const tokens = [];
  players.forEach((name, index) => {
    const previous = priorByName.get(name.toLowerCase());
    const anchor = anchors[battlemapActorKey('player', name)] || { x: 1.5 + index, y: 1.5 };
    tokens.push(normalizeToken({
      id: `player:${name.toLowerCase()}`,
      name,
      side: 'player',
      x: previous ? previous.x : anchor.x,
      y: previous ? previous.y : anchor.y,
      color: '#38bdf8',
    }, index));
  });
  enemies.forEach((enemy, index) => {
    const previous = priorByName.get(enemy.name.toLowerCase());
    const anchor = anchors[battlemapActorKey('enemy', enemy.name)] || { x: 30.5, y: 14.5 + index };
    tokens.push(normalizeToken({
      id: `enemy:${enemy.name.toLowerCase()}:${index + 1}`,
      name: enemy.name,
      side: 'enemy',
      x: previous ? previous.x : anchor.x,
      y: previous ? previous.y : anchor.y,
      hp: enemy.wounds ?? enemy.magnitude ?? null,
      maxHp: enemy.wounds ?? enemy.magnitude ?? null,
      color: '#ef4444',
    }, players.length + index));
  });
  return tokens;
}

function buildBattlemapStateForScene(scene = {}, existingState = {}) {
  const turnKey = scene?.combatState?.turnKey || '';
  return {
    layout: normalizeBattlemapLayout(scene, existingState.layout),
    tokens: defaultBattlemapTokens(scene, existingState.tokens),
    objectives: defaultBattlemapObjectives(existingState.objectives),
    turnState: normalizeTurnState(existingState.turnState, turnKey),
    updatedAt: Number(existingState.updatedAt || Date.now()) || Date.now(),
  };
}

function sceneBattlemapTheme(scene = {}) {
  const title = String(scene.title || scene.name || '').toLowerCase();
  const checks = Array.isArray(scene.checks) ? scene.checks.map(check => String(check.name || '').toLowerCase()).join(' ') : '';
  const combined = `${title} ${checks}`;
  // Checked before the more generic landing/vault/sanctum/corridor keywords
  // below, since words like "core" or "reactor" also appear in these themes.
  if (/starship|voidship|frigate|cruiser|bridge|boarding torpedo|void|bulkhead|airlock/.test(combined)) {
    return {
      layoutKey: 'spaceship',
      boardTitle: 'Boarding Action',
      boardSummary: 'Companionways, void-sealed bulkheads, and a bridge objective tuned to the current mission scene.',
    };
  }
  if (/factory|foundry|manufactorum|forge|assembly line|industrial|mill/.test(combined)) {
    return {
      layoutKey: 'factory',
      boardTitle: 'Manufactorum Floor Fight',
      boardSummary: 'Assembly lines, foundry control rooms, and a reactor core objective tuned to the current mission scene.',
    };
  }
  if (/city|hive|street|habblock|urban|precinct|spire|boulevard/.test(combined)) {
    return {
      layoutKey: 'city',
      boardTitle: 'Hive Streets Skirmish',
      boardSummary: 'Rubble checkpoints, collapsed habblocks, and a governor’s vault objective tuned to the current mission scene.',
    };
  }
  if (/jungle|feral|overgrown|canopy|swamp|bog|xenos ruin/.test(combined)) {
    return {
      layoutKey: 'jungle',
      boardTitle: 'Feral Canopy Ambush',
      boardSummary: 'Game trails, overgrown shrines, and a xenos ruin objective tuned to the current mission scene.',
    };
  }
  if (/landing|dock|breach|hangar/.test(combined)) {
    return {
      layoutKey: 'landing',
      boardTitle: 'Landing Zone Breach',
      boardSummary: 'Landing pad, gantries, cargo lanes, and breach routes tuned to the current mission scene.',
    };
  }
  if (/vault|gene|lab|reactor|core/.test(combined)) {
    return {
      layoutKey: 'vault',
      boardTitle: 'Vault Control Fight',
      boardSummary: 'Objective rooms, crossfire lanes, and extraction pressure for the current mission scene.',
    };
  }
  if (/shrine|temple|cathedral|sanctum/.test(combined)) {
    return {
      layoutKey: 'sanctum',
      boardTitle: 'Sanctum Assault',
      boardSummary: 'Central shrine lanes, elevated firing points, and flanking routes fitted to this scene.',
    };
  }
  if (/corridor|tunnel|passage|service/.test(combined)) {
    return {
      layoutKey: 'corridor',
      boardTitle: 'Corridor Kill Zone',
      boardSummary: 'Long fire lanes, choke points, and service routes fitted to this scene.',
    };
  }
  return {
    layoutKey: DEFAULT_LAYOUT_KEY,
    boardTitle: String(scene.title || scene.name || 'Battlemap'),
    boardSummary: 'Dynamic tactical board generated from the active mission scene.',
  };
}

// Every preset below shares one proven floor plan (see DEFAULT_ZONES): a
// spawn room, a corridor spine, two flanking rooms, a hazard wing, and a
// vault/core room, laid out so every room touches the corridor with zero
// rectangle overlap anywhere. Reusing this exact geometry per theme means
// every new environment is guaranteed connected and non-overlapping by
// construction — only names, materials, and hazard flavor change per theme.
// Cover/blockers/hazards are added on top and never affect this base.
function standardZones(labels) {
  return [
    { id: 'extraction', name: labels.spawn, x: 1, y: 11, w: 7, h: 8, tone: 'spawn', elevation: 0, lighting: labels.spawnLighting || 'bright', purpose: labels.spawnPurpose },
    { id: 'main-corridor', name: labels.corridor, x: 8, y: 13, w: 24, h: 4, tone: 'corridor', elevation: 0, lighting: labels.corridorLighting || 'normal', purpose: labels.corridorPurpose },
    { id: 'left-wing', name: labels.left, x: 12, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: labels.leftLighting || 'bright', purpose: labels.leftPurpose },
    { id: 'right-wing', name: labels.right, x: 25, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: labels.rightLighting || 'dim', purpose: labels.rightPurpose },
    { id: 'maintenance', name: labels.hazardRoom, x: 8, y: 17, w: 15, h: 8, tone: 'hazard', elevation: -1, lighting: labels.hazardLighting || 'dark', purpose: labels.hazardPurpose },
    { id: 'vault', name: labels.core, x: 23, y: 17, w: 12, h: 8, tone: 'vault', elevation: 0, lighting: labels.coreLighting || 'dim', purpose: labels.corePurpose },
  ];
}

function standardStations(labels) {
  return [
    { id: 'left-station', name: labels.leftStation, zoneId: 'left-wing', x: 17, y: 7, action: 'Full Action / Tech-Use / Medicae' },
    { id: 'right-station', name: labels.rightStation, zoneId: 'right-wing', x: 30, y: 7, action: 'Full Action / Tech-Use / Medicae' },
    { id: 'maintenance-station', name: labels.hazardStation, zoneId: 'maintenance', x: 20, y: 22, action: 'Full Action / Tech-Use / Medicae' },
  ];
}

function standardSpawnPoints(labels) {
  return [
    { id: 'north-hatch', name: labels.spawnNorth, x: 17, y: 4 },
    { id: 'east-corridor', name: labels.spawnEast, x: 33, y: 15 },
    { id: 'south-tunnel', name: labels.spawnSouth, x: 16, y: 22 },
    { id: 'vault-breach', name: labels.spawnCore, x: 31, y: 20 },
  ];
}

function standardCover(materials) {
  return [
    { id: 'ext-cover-1', kind: 'light', material: materials.spawn, elevation: 0, x: 3, y: 12, w: 2, h: 2 },
    { id: 'ext-cover-2', kind: 'light', material: materials.spawn, elevation: 0, x: 5, y: 16, w: 2, h: 2 },
    { id: 'corr-cover-1', kind: 'light', material: materials.corridor, elevation: 0, x: 11, y: 13, w: 1, h: 4 },
    { id: 'corr-cover-2', kind: 'light', material: materials.corridor, elevation: 0, x: 17, y: 13, w: 1, h: 4 },
    { id: 'corr-cover-3', kind: 'light', material: materials.corridor, elevation: 0, x: 23, y: 13, w: 1, h: 4 },
    { id: 'corr-cover-4', kind: 'light', material: materials.corridor, elevation: 0, x: 29, y: 13, w: 1, h: 4 },
    { id: 'left-cover-1', kind: 'heavy', material: materials.room, elevation: 1, x: 13, y: 4, w: 3, h: 2 },
    { id: 'left-cover-2', kind: 'heavy', material: materials.room, elevation: 1, x: 18, y: 9, w: 3, h: 2 },
    { id: 'right-cover-1', kind: 'heavy', material: materials.room, elevation: 1, x: 26, y: 4, w: 3, h: 2 },
    { id: 'right-cover-2', kind: 'light', material: materials.roomLight, elevation: 1, x: 31, y: 9, w: 2, h: 2 },
    { id: 'hazard-cover-1', kind: 'light', material: materials.spawn, elevation: -1, x: 11, y: 18, w: 3, h: 1 },
    { id: 'hazard-cover-2', kind: 'heavy', material: materials.hazard, elevation: -1, x: 19, y: 21, w: 2, h: 3 },
    { id: 'core-pillar-1', kind: 'heavy', material: materials.core, elevation: 0, x: 26, y: 19, w: 2, h: 2 },
    { id: 'core-pillar-2', kind: 'heavy', material: materials.core, elevation: 0, x: 32, y: 19, w: 2, h: 2 },
    { id: 'core-pillar-3', kind: 'heavy', material: materials.core, elevation: 0, x: 26, y: 22, w: 2, h: 2 },
    { id: 'core-pillar-4', kind: 'heavy', material: materials.core, elevation: 0, x: 32, y: 22, w: 2, h: 2 },
    { id: 'core-cover', kind: 'light', material: materials.spawn, elevation: 0, x: 29, y: 20, w: 2, h: 1 },
  ];
}

function standardBlockers(material) {
  return [
    { id: 'blocker-left', material, x: 16, y: 5, w: 2, h: 1 },
    { id: 'blocker-right', material, x: 29, y: 5, w: 2, h: 1 },
    { id: 'blocker-core', material, x: 33, y: 19, w: 2, h: 4 },
    { id: 'blocker-hazard', material, x: 11, y: 20, w: 4, h: 2 },
  ];
}

function standardHazards(kinds) {
  return [
    { id: 'hazard-1', kind: kinds.difficult, x: 11, y: 21, w: 5, h: 2 },
    { id: 'hazard-2', kind: kinds.agility, x: 12, y: 20, w: 4, h: 2 },
    { id: 'hazard-3', kind: kinds.noCharge, x: 24, y: 22, w: 4, h: 2 },
    { id: 'hazard-4', kind: kinds.vent, x: 29, y: 22, w: 2, h: 2 },
  ];
}

function layoutPresetForKey(key = DEFAULT_LAYOUT_KEY) {
  if (key === 'landing') {
    const zones = standardZones({
      spawn: 'Dropship LZ', corridor: 'Cargo Tarmac', left: 'West Gantry', right: 'East Gantry',
      hazardRoom: 'Fuel Line', core: 'Breach Crater',
    });
    return {
      zones,
      doors: computeZoneDoorways(zones),
      stations: standardStations({ leftStation: 'West Vox Relay', rightStation: 'East Loader Controls', hazardStation: 'Fuel Cutoff' }),
      spawnPoints: standardSpawnPoints({ spawnNorth: 'North maglift', spawnEast: 'East dock tunnel', spawnSouth: 'South rubble path', spawnCore: 'Central breach' }),
      cover: standardCover({ spawn: 'Light Metal', corridor: 'Heavy Wood', room: 'Plasteel', roomLight: 'Armour-glas', hazard: 'Thick Iron', core: 'Rockcrete' }),
      blockers: standardBlockers('Plasteel'),
      hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'vent' }),
    };
  }
  if (key === 'corridor') {
    const zones = standardZones({
      spawn: 'Rear Bulkhead', corridor: 'Main Corridor', left: 'West Junction', right: 'East Junction',
      hazardRoom: 'Service Ducts', core: 'Lockdown Node',
    });
    return {
      zones,
      doors: computeZoneDoorways(zones),
      stations: standardStations({ leftStation: 'Junction Console', rightStation: 'Blast Door Relay', hazardStation: 'Vent Purge Controls' }),
      spawnPoints: standardSpawnPoints({ spawnNorth: 'Ceiling hatch', spawnEast: 'Forward seal', spawnSouth: 'Lower conduit', spawnCore: 'Lockdown iris' }),
      cover: standardCover({ spawn: 'Light Metal', corridor: 'Heavy Wood', room: 'Plasteel', roomLight: 'Armour-glas', hazard: 'Thick Iron', core: 'Rockcrete' }),
      blockers: standardBlockers('Plasteel'),
      hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'vent' }),
    };
  }
  if (key === 'sanctum') {
    const zones = standardZones({
      spawn: 'Nave Entry', corridor: 'Central Nave', left: 'Choir Gallery', right: 'Reliquary',
      hazardRoom: 'Crypt Passage', core: 'Sanctum Dais', coreLighting: 'glow',
    });
    return {
      zones,
      doors: computeZoneDoorways(zones),
      stations: standardStations({ leftStation: 'Choir Vox Pulpit', rightStation: 'Reliquary Seal', hazardStation: 'Crypt Winch' }),
      spawnPoints: standardSpawnPoints({ spawnNorth: 'Rose window breach', spawnEast: 'Reliquary side door', spawnSouth: 'Crypt stair', spawnCore: 'Dais warp-rift' }),
      cover: standardCover({ spawn: 'Light Metal', corridor: 'Heavy Wood', room: 'Stone', roomLight: 'Armour-glas', hazard: 'Thick Iron', core: 'Rockcrete' }),
      blockers: standardBlockers('Thick Iron'),
      hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'vent' }),
    };
  }
  if (key === 'spaceship') {
    const zones = standardZones({
      spawn: 'Boarding Torpedo Bay', corridor: 'Central Companionway', left: 'Port Reactor Control', right: 'Starboard Weapons Battery',
      hazardRoom: 'Lower Cargo Hold', core: 'Bridge Command Sanctum', spawnLighting: 'dim', hazardLighting: 'dark',
    });
    return {
      zones,
      doors: computeZoneDoorways(zones),
      stations: standardStations({ leftStation: 'Reactor Vox Relay', rightStation: 'Battery Fire Control', hazardStation: 'Cargo Bay Winch' }),
      spawnPoints: standardSpawnPoints({ spawnNorth: 'Dorsal airlock', spawnEast: 'Starboard breach', spawnSouth: 'Bilge access', spawnCore: 'Bridge blast door' }),
      cover: standardCover({ spawn: 'Light Metal', corridor: 'Plasteel', room: 'Plasteel', roomLight: 'Armour-glas', hazard: 'Thick Iron', core: 'Rockcrete' }),
      blockers: standardBlockers('Plasteel'),
      hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'vent' }),
    };
  }
  if (key === 'factory') {
    const zones = standardZones({
      spawn: 'Loading Dock', corridor: 'Assembly Line Floor', left: 'Foundry Control Room', right: 'Smelter Overwatch',
      hazardRoom: 'Waste Processing Pit', core: 'Reactor Core Chamber',
    });
    return {
      zones,
      doors: computeZoneDoorways(zones),
      stations: standardStations({ leftStation: 'Foundry Console', rightStation: 'Smelter Relay', hazardStation: 'Waste Purge Controls' }),
      spawnPoints: standardSpawnPoints({ spawnNorth: 'Gantry maglift', spawnEast: 'Freight tunnel', spawnSouth: 'Drainage conduit', spawnCore: 'Reactor blast door' }),
      cover: standardCover({ spawn: 'Light Metal', corridor: 'Heavy Wood', room: 'Plasteel', roomLight: 'Armour-glas', hazard: 'Thick Iron', core: 'Rockcrete' }),
      blockers: standardBlockers('Thick Iron'),
      hazards: standardHazards({ difficult: 'no-charge', agility: 'agility', noCharge: 'difficult', vent: 'vent' }),
    };
  }
  if (key === 'city') {
    const zones = standardZones({
      spawn: 'Rubble Checkpoint', corridor: 'Main Boulevard', left: 'Collapsed Habblock', right: 'Comms Spire',
      hazardRoom: 'Sewer Access', core: "Governor's Vault",
    });
    return {
      zones,
      doors: computeZoneDoorways(zones),
      stations: standardStations({ leftStation: 'Habblock Vox Relay', rightStation: 'Spire Uplink', hazardStation: 'Sewer Floodgate' }),
      spawnPoints: standardSpawnPoints({ spawnNorth: 'Rooftop maglift', spawnEast: 'Boulevard barricade', spawnSouth: 'Sewer grate', spawnCore: 'Vault blast door' }),
      cover: standardCover({ spawn: 'Rockcrete', corridor: 'Heavy Wood', room: 'Rockcrete', roomLight: 'Light Metal', hazard: 'Thick Iron', core: 'Plasteel' }),
      blockers: standardBlockers('Rockcrete'),
      hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'vent' }),
    };
  }
  if (key === 'jungle') {
    const zones = standardZones({
      spawn: 'Landing Clearing', corridor: 'Game Trail', left: 'Overgrown Shrine', right: 'Canopy Watch Post',
      hazardRoom: 'Bog Crossing', core: 'Xenos Ruin Core', spawnLighting: 'normal', corridorLighting: 'dim', hazardLighting: 'dark',
    });
    return {
      zones,
      doors: computeZoneDoorways(zones),
      stations: standardStations({ leftStation: 'Shrine Vox Relay', rightStation: 'Watch Post Signal', hazardStation: 'Bog Winch' }),
      spawnPoints: standardSpawnPoints({ spawnNorth: 'Canopy drop zone', spawnEast: 'Overgrown trailhead', spawnSouth: 'Bog crossing path', spawnCore: 'Ruin breach' }),
      cover: standardCover({ spawn: 'Light Wood', corridor: 'Heavy Wood', room: 'Stone', roomLight: 'Light Wood', hazard: 'Light Wood', core: 'Stone' }),
      blockers: standardBlockers('Light Wood'),
      hazards: standardHazards({ difficult: 'difficult', agility: 'agility', noCharge: 'no-charge', vent: 'agility' }),
    };
  }
  return {
    zones: DEFAULT_ZONES,
    doors: computeZoneDoorways(DEFAULT_ZONES),
    stations: DEFAULT_STATIONS,
    spawnPoints: DEFAULT_SPAWN_POINTS,
    cover: DEFAULT_COVER,
    blockers: DEFAULT_BLOCKERS,
    hazards: DEFAULT_HAZARDS,
  };
}

module.exports = {
  GRID_WIDTH,
  GRID_HEIGHT,
  METERS_PER_CHECKER,
  MATERIAL_AP,
  DEFAULT_ZONES,
  DEFAULT_STATIONS,
  DEFAULT_SPAWN_POINTS,
  DEFAULT_COVER,
  DEFAULT_BLOCKERS,
  DEFAULT_HAZARDS,
  DEFAULT_DYNAMIC_FEATURES,
  battlemapVisibleForPlayer,
  battlemapActorKey,
  parseRangeNumber,
  normalizeWeaponRange,
  normalizeMovement,
  normalizeTurnState,
  movementBudget,
  distance,
  pointInRect,
  segmentIntersectsRect,
  materialAp,
  coverForPoint,
  blockerBetween,
  elevationForPoint,
  lightingForPoint,
  visibilityBetween,
  rangeBand,
  attackPositioning,
  snapCoordinate,
  moveWithinRange,
  stepToward,
  enemyMoveTowardNearest,
  pointFromLayout,
  createBattlemapPositions,
  sceneEnemyObjects,
  scenePlayerEntries,
  normalizeTokens,
  defaultBattlemapObjectives,
  defaultBattlemapLayout,
  normalizeBattlemapLayout,
  defaultBattlemapTokens,
  buildBattlemapStateForScene,
  sceneBattlemapTheme,
  zonesOverlap,
  pointInZone,
  pointInAnyZone,
  validateBattlemapLayout,
  pointNearSegment,
  allyBetween,
  computeZoneDoorways,
  computeZoneWalls,
  wallBetween,
  movementBlockedBetween,
};
