'use strict';
const express = require('express');
const { missionHelpers, playerHelpers, simulationHelpers, logToFile } = require('../mariadb');
const {
  normalizeMovement,
  movementBudget,
  stepToward,
  enemyMoveTowardNearest,
  battlemapActorKey,
  pointFromLayout,
  createBattlemapPositions,
  normalizeWeaponRange,
  attackPositioning,
  visibilityBetween,
  distance,
  normalizeBattlemapLayout,
} = require('../../src/shared/battlemapEngine');

const router = express.Router();

// Battlemap objective mini-game tuning. Loosened from the original
// 2 stations / 3-round core hold / 2 extraction progress / every-3-rounds
// reinforcements after simulation runs showed every squad going down without
// ever clearing a single mapped combat scene (fights ran 16-21 rounds,
// hitting the round cap on attrition alone). See docs/incident notes.
const STATIONS_REQUIRED = 1;
const GENE_CORE_ROUNDS_REQUIRED = 2;
const EXTRACTION_PROGRESS_REQUIRED = 1;
const REINFORCEMENT_INTERVAL_ROUNDS = 5;
const OBJECTIVE_CHECK_PENALTY = 0; // was a flat -10 on top of difficulty checkMod

// ─── Dice engine ─────────────────────────────────────────────────────────────
function d(sides) { return Math.floor(Math.random() * sides) + 1; }
function d100()   { return d(100); }
function d10()    { return d(10); }

function dX(formula) {
  const text = String(formula || '');
  const m = text.match(/(\d+)d(\d+)/);
  if (!m) return 4;
  let total = 0;
  for (let i = 0; i < +m[1]; i++) total += Math.floor(Math.random() * +m[2]) + 1;
  const rest = text.slice(m.index + m[0].length);
  const mods = rest.match(/[+-]\s*\d+/g) || [];
  for (const mod of mods) total += Number(mod.replace(/\s+/g, ''));
  return Math.max(1, total);
}

function roll(target, modifier = 0) {
  const effective = Math.max(5, Math.min(95, target + modifier));
  const result    = d100();
  const success   = result <= effective;
  const dos       = success ? Math.floor((effective - result) / 10) + 1 : 0;
  const dof       = !success ? Math.floor((result - effective) / 10) + 1 : 0;
  return { result, effective, success, dos, dof };
}

// ─── Build player state from DB tabInfo ──────────────────────────────────────
function buildPlayerState(dbPlayer) {
  const tab   = dbPlayer.tabInfo || {};
  const chars = tab.characteristics || {};
  const charValue = (upper, lower, fallback = 50) => chars[upper] || chars[lower] || fallback;
  const woundsValue = typeof tab.wounds === 'object' && tab.wounds !== null
    ? Number(tab.wounds.current ?? tab.wounds.total ?? 12)
    : Number(tab.wounds || 12);
  const maxWoundsValue = typeof tab.wounds === 'object' && tab.wounds !== null
    ? Number(tab.wounds.total ?? tab.wounds.current ?? woundsValue)
    : Number(tab.wounds || woundsValue || 12);
  const fateValue = typeof tab.fate === 'object' && tab.fate !== null
    ? Number(tab.fate.current ?? tab.fate.total ?? 1)
    : Number(tab.fate || 1);
  const maxFateValue = typeof tab.fate === 'object' && tab.fate !== null
    ? Number(tab.fate.total ?? tab.fate.current ?? fateValue)
    : Number(tab.fate || fateValue || 1);
  const gearText = [
    ...(Array.isArray(tab.gear) ? tab.gear.map(g => `${g.name || ''} ${g.note || ''}`) : []),
    String(tab.notes || ''),
  ].join(' ');
  const weapons = Array.isArray(tab.weapons) ? tab.weapons.map(weapon => normalizeSimWeapon(weapon, gearText)) : [];
  return {
    name:        dbPlayer.name,
    chapter:     tab.chapter     || 'Unknown',
    spec:        tab.speciality  || 'Unknown',
    WS:   charValue('WS', 'ws'),
    BS:   charValue('BS', 'bs'),
    S:    charValue('S', 's'),
    T:    charValue('T', 't'),
    Ag:   charValue('Ag', 'ag'),
    Int:  charValue('Int', 'int'),
    Per:  charValue('Per', 'per'),
    Wp:   charValue('Wp', 'wp'),
    Fel:  charValue('Fel', 'fel'),
    wounds:    woundsValue,
    maxWounds: maxWoundsValue,
    fate:      fateValue,
    maxFate:   maxFateValue,
    movement:  normalizeMovement(tab.movement || {}),
    weapons,
    conditions: [],
    fateUsed: false,
    rolls: [],
    successCount: 0,
    failCount: 0,
    fateSpends: 0,
    heroMoments: [],
    shameList: [],
  };
}

function inferredGrenadeCount(weapon, gearText = '') {
  const name = String(weapon.name || '').toLowerCase();
  if (!/grenade/.test(name)) return 0;
  const kind = /krak/.test(name) ? 'krak' : (/frag/.test(name) ? 'frag' : '');
  const escapedKind = kind ? `${kind}\\s+` : '';
  const re = new RegExp(`(\\d+)\\s+(?:astartes\\s+)?${escapedKind}grenades?`, 'i');
  const match = gearText.match(re);
  return Number(match?.[1] || 3);
}

function defaultClipForWeapon(weapon) {
  const name = String(weapon.name || '').toLowerCase();
  if (/grenade/.test(name)) return 0;
  if (/bolt pistol/.test(name)) return 14;
  if (/heavy bolter/.test(name)) return 60;
  if (/bolter|boltgun/.test(name)) return 28;
  return 0;
}

function defaultDamageForWeapon(weapon) {
  const name = String(weapon.name || '').toLowerCase();
  if (weapon.damage) return weapon.damage;
  if (/krak.*grenade/.test(name)) return '3d10+4';
  if (/frag.*grenade/.test(name)) return '2d10+4';
  if (/bolt pistol/.test(name)) return '2d10+5';
  if (/bolter|boltgun/.test(name)) return '2d10+5';
  if (/crozius/.test(name)) return '1d10+10';
  if (/combat knife/.test(name)) return '1d10+2';
  return '1d5';
}

function normalizeSimWeapon(weapon, gearText = '') {
  const explicitClip = Number(String(weapon.clip ?? '').match(/\d+/)?.[0] || 0);
  const grenadeCount = inferredGrenadeCount(weapon, gearText);
  const clip = explicitClip || grenadeCount || defaultClipForWeapon(weapon);
  const className = String(weapon.class || '').toLowerCase();
  const weaponName = String(weapon.name || '').toLowerCase();
  const gearLower = String(gearText || '').toLowerCase();
  const weaponText = `${weapon.name || ''} ${weapon.special || ''}`.toLowerCase();
  const isGrenade = /grenade/.test(String(weapon.name || '').toLowerCase());
  const isMelee = !isGrenade && (className.includes('melee') || String(weapon.range || '').trim() === 'melee');
  const usesAmmo = !isMelee && clip > 0;
  const isBoltWeapon = /bolt|bolter/.test(weaponName);
  const isBoltPistol = /bolt pistol/.test(weaponName);
  const gearKrakenForWeapon = isBoltPistol
    ? /(?:(?:bolt\s+)?pistol\s+(?:with|using|loaded\s+with)\s+kraken|kraken\s+rounds?\s+for\s+(?:bolt\s+)?pistol)/.test(gearLower)
    : /(?:(?:bolter|boltgun)\s+(?:with|using|loaded\s+with)\s+kraken|kraken\s+rounds?\s+for\s+(?:bolter|boltgun))/.test(gearLower);
  const hasKraken = usesAmmo && isBoltWeapon && (/kraken/.test(weaponText) || gearKrakenForWeapon);
  const ammoType = hasKraken ? 'Kraken' : 'Standard';
  return {
    ...weapon,
    damage: defaultDamageForWeapon(weapon),
    range: weapon.range || `${normalizeWeaponRange(weapon)}m`,
    isGrenade,
    clipSize: usesAmmo ? clip : 0,
    ammoRemaining: usesAmmo ? clip : null,
    ammoType,
    specialAmmo: hasKraken,
    shotsFired: 0,
  };
}

function weaponUsesAmmo(weapon) {
  return Number(weapon?.clipSize || 0) > 0 && weapon.ammoRemaining !== null;
}

function weaponIsMelee(weapon) {
  return String(weapon?.class || '').toLowerCase().includes('melee') || String(weapon?.range || '').trim().toLowerCase() === 'melee';
}

function weaponIsGrenade(weapon) {
  return Boolean(weapon?.isGrenade) || /grenade/.test(String(weapon?.name || '').toLowerCase());
}

function attackGlyphForWeapon(weapon, mode = 'ranged') {
  const name = String(weapon?.name || weapon || '').toLowerCase();
  if (/grenade|missile|rocket|bomb|blast/.test(name)) return 'explosive';
  if (mode === 'melee' || /sword|axe|knife|hammer|fist|claw|staff|chain/.test(name)) return 'melee';
  return 'ranged';
}

function weaponIsRanged(weapon) {
  return weaponUsesAmmo(weapon) && !weaponIsGrenade(weapon) && !weaponIsMelee(weapon);
}

function parseRof(weapon) {
  if (weaponIsGrenade(weapon)) return { single: true, semi: null, full: null };
  const rof = String(weapon?.rof || '').trim();
  const parts = rof.split('/').map(part => part.trim());
  const single = value => /^s$/i.test(value || '') || /^single$/i.test(value || '');
  const numeric = value => {
    if (!value || value === '-' || /^s$/i.test(value) || /^single$/i.test(value)) return null;
    const n = Number(String(value).match(/\d+/)?.[0] || 0);
    return n > 0 ? n : null;
  };
  return {
    single: rof ? single(parts[0]) : weaponUsesAmmo(weapon),
    semi: numeric(parts[1]),
    full: numeric(parts[2]),
  };
}

function chooseFireMode(weapon) {
  if (!weaponUsesAmmo(weapon)) return { mode: 'melee', shots: 1 };
  const rof = parseRof(weapon);
  const available = Math.max(0, Number(weapon.ammoRemaining || 0));
  const options = [
    rof.full ? { mode: 'full_auto', shots: rof.full } : null,
    rof.semi ? { mode: 'semi_auto', shots: rof.semi } : null,
    rof.single ? { mode: 'single', shots: 1 } : null,
  ].filter(Boolean).filter(option => option.shots <= available);
  if (!options.length) return { mode: 'dry', shots: 0 };
  if (options.length === 1) return options[0];
  const roll = Math.random();
  if (options[0]?.mode === 'full_auto' && roll < 0.35) return options[0];
  const semi = options.find(option => option.mode === 'semi_auto');
  if (semi && roll < 0.80) return semi;
  return options[options.length - 1];
}

function fireModeAttackModifier(mode) {
  if (mode === 'semi_auto') return 10;
  if (mode === 'full_auto') return 20;
  return 0;
}

function hitsForFireMode(mode, degreesOfSuccess, shotsDeclared) {
  if (!degreesOfSuccess) return 0;
  if (mode === 'melee') return 1;
  if (shotsDeclared <= 0) return 0;
  if (mode === 'full_auto') return Math.min(shotsDeclared, 1 + degreesOfSuccess);
  if (mode === 'semi_auto') return Math.min(shotsDeclared, 1 + Math.floor(degreesOfSuccess / 2));
  return 1;
}

function chooseEnemyAttackProfile(enemy, distanceToTarget = 0) {
  const profile = enemy.combatProfile || 'balanced';
  const ws = Number(enemy.ws || 45);
  const bs = Number(enemy.bs || 45);
  const rangedRange = Number(enemy.range || 30) || 30;
  if (distanceToTarget <= 2.5 && ws >= bs - 10) return { mode: 'melee', stat: 'WS', target: ws, range: 2 };
  if (profile === 'melee') return { mode: 'melee', stat: 'WS', target: ws, range: 2 };
  if (profile === 'ranged') return { mode: 'ranged', stat: 'BS', target: bs, range: rangedRange };
  if (profile === 'auto') {
    return ws >= bs && distanceToTarget <= 2.5 ? { mode: 'melee', stat: 'WS', target: ws, range: 2 } : { mode: 'ranged', stat: 'BS', target: bs, range: rangedRange };
  }
  if (Math.abs(ws - bs) >= 10) {
    return ws > bs && distanceToTarget <= 2.5 ? { mode: 'melee', stat: 'WS', target: ws, range: 2 } : { mode: 'ranged', stat: 'BS', target: bs, range: rangedRange };
  }
  return Math.random() < 0.5 && distanceToTarget <= 2.5
    ? { mode: 'melee', stat: 'WS', target: ws, range: 2 }
    : { mode: 'ranged', stat: 'BS', target: bs, range: rangedRange };
}

function chooseAttackWeapon(player, enemies = [], distanceToTarget = 0) {
  const weapons = Array.isArray(player.weapons) ? player.weapons : [];
  const profile = player.combatProfile || 'balanced';
  const availableGrenades = weapons.filter(w => weaponIsGrenade(w) && weaponUsesAmmo(w) && Number(w.ammoRemaining || 0) > 0);
  const grenadeChance = profile === 'ranged' ? 0.32 : (profile === 'melee' ? 0.12 : 0.24);
  if (availableGrenades.length && enemies.filter(e => e.wounds > 0).length > 1 && Math.random() < grenadeChance) {
    return availableGrenades.find(w => /frag/i.test(w.name || '')) || availableGrenades[0];
  }
  if (availableGrenades.length && enemies.some(e => Number(e.wounds || 0) >= 14) && Math.random() < grenadeChance * 0.75) {
    return availableGrenades.find(w => /krak/i.test(w.name || '')) || availableGrenades[0];
  }
  const bestMelee = weapons.find(weaponIsMelee);
  const rangedOptions = weapons.filter(w => weaponIsRanged(w) && Number(w.ammoRemaining || 0) > 0);
  const bestRanged = rangedOptions
    .slice()
    .sort((a, b) => {
      const aRange = normalizeWeaponRange(a);
      const bRange = normalizeWeaponRange(b);
      const aFit = Math.abs(aRange - distanceToTarget);
      const bFit = Math.abs(bRange - distanceToTarget);
      return aFit - bFit;
    })[0];
  if (distanceToTarget <= 2.5 && bestMelee) return bestMelee;
  if (profile === 'melee' && bestMelee) return bestMelee;
  if (profile === 'ranged' && bestRanged) return bestRanged;
  if (profile === 'auto') {
    const meleeScore = (player.WS || 0) + (bestMelee ? 5 : -100);
    const rangedScore = (player.BS || 0) + (bestRanged ? 5 : -100);
    if (meleeScore > rangedScore) return bestMelee;
    if (bestRanged) return bestRanged;
  }
  if (profile === 'balanced') {
    const meleeScore = bestMelee ? (player.WS || 0) : -100;
    const rangedScore = bestRanged ? (player.BS || 0) : -100;
    if (meleeScore >= rangedScore + 15 && Math.random() < 0.65) return bestMelee;
    if (bestRanged) return bestRanged;
    if (bestMelee) return bestMelee;
  }
  return weapons.find(w => weaponUsesAmmo(w) && Number(w.ammoRemaining || 0) > 0)
    || weapons.find(weaponIsMelee)
    || weapons[0]
    || { name: 'Unarmed', damage: '1d5', class: 'Melee', type: 'melee', pen: 0 };
}

function spendAmmo(weapon, amount = 1) {
  if (!weaponUsesAmmo(weapon)) return { spent: 0, remaining: null };
  const available = Math.max(0, Number(weapon.ammoRemaining || 0));
  const spent = Math.min(available, amount);
  weapon.ammoRemaining = available - spent;
  weapon.shotsFired = Number(weapon.shotsFired || 0) + spent;
  return { spent, remaining: weapon.ammoRemaining };
}

function ammoSummary(player) {
  return (Array.isArray(player.weapons) ? player.weapons : [])
    .filter(weaponUsesAmmo)
    .map(w => ({
      name: w.name || 'Weapon',
      ammoType: w.ammoType || 'Standard',
      specialAmmo: Boolean(w.specialAmmo),
      isGrenade: Boolean(w.isGrenade),
      clipSize: Number(w.clipSize || 0),
      remaining: Number(w.ammoRemaining || 0),
      shotsFired: Number(w.shotsFired || 0),
    }));
}

function isAlive(p) { return p.wounds > 0; }

function addCondition(p, cond) {
  if (!p.conditions.includes(cond)) p.conditions.push(cond);
}
function removeCondition(p, cond) {
  const i = p.conditions.indexOf(cond);
  if (i !== -1) p.conditions.splice(i, 1);
}
function conditionMod(p, type) {
  let m = 0;
  for (const c of p.conditions) {
    if (c === 'Pinned')       m += type === 'BS' ? -20 : 0;
    if (c === 'Stunned')      m += -20;
    if (c === 'Knocked Down') m += -20;
    if (c === 'Fatigued')     m += -10;
    if (c === 'Grappled')     m += type === 'WS' ? -20 : 0;
  }
  return m;
}

function dealDamage(player, dmgFormula, armorPen, extraAbsorption = 0) {
  const dmg        = dX(dmgFormula);
  const absorption = Math.max(0, 6 - (armorPen || 0)) + Math.max(0, Number(extraAbsorption || 0));
  const net        = Math.max(0, dmg - absorption);
  player.wounds   -= net;
  return { dmg, absorption, net };
}

function spendFate(player, checkName) {
  if (player.fate > 0 && !player.fateUsed) {
    player.fate--;
    player.fateUsed = true;
    player.fateSpends++;
    return true;
  }
  return false;
}

// ─── Perform one skill check ──────────────────────────────────────────────────
function performCheck(player, checkName, statName, modifier, rollFeed, sceneTitle, category = 'puzzle') {
  const base = player[statName] || 50;
  const cMod = conditionMod(player, statName === 'WS' ? 'WS' : 'BS');
  const r    = roll(base, modifier + cMod);

  const entry = {
    player:    player.name,
    checkName,
    category,
    sceneName: sceneTitle,
    statName,
    base,
    modifier,
    result:    r.result,
    effective: r.effective,
    success:   r.success,
    dos:       r.dos,
    dof:       r.dof,
    fateReroll: false,
  };

  // Auto fate-spend on catastrophic failure (3+ DoF)
  if (!r.success && r.dof >= 3 && spendFate(player, checkName)) {
    const r2 = roll(base, modifier + cMod);
    Object.assign(r, r2);
    entry.fateReroll = true;
    entry.result    = r.result;
    entry.effective = r.effective;
    entry.success   = r.success;
    entry.dos       = r.dos;
    entry.dof       = r.dof;
  }

  rollFeed.push(entry);
  if (r.success) player.successCount++; else player.failCount++;

  if (r.success && r.dos >= 3)  player.heroMoments.push(`${checkName} (${r.dos} DoS) in ${sceneTitle}`);
  if (!r.success && r.dof >= 3) player.shameList.push(`Botched ${checkName} in ${sceneTitle}`);

  return r;
}

// ─── Fear test ───────────────────────────────────────────────────────────────
function fearTest(player, fearRating, sceneTitle, rollFeed) {
  const penalty  = [0, 0, -10, -20, -30, -40][fearRating] || 0;
  const r        = roll(player.Wp, penalty + conditionMod(player, 'WS'));
  rollFeed.push({
    player: player.name, checkName: `Fear Test (Rating ${fearRating})`,
    category: 'fear',
    sceneName: sceneTitle, statName: 'Wp', base: player.Wp, modifier: penalty,
    result: r.result, effective: r.effective, success: r.success, dos: r.dos, dof: r.dof,
  });
  if (!r.success) {
    if (r.dof >= 4) {
      addCondition(player, 'Stunned');
      player.wounds -= 2;
      player.shameList.push(`Broke under Fear ${fearRating} in ${sceneTitle}`);
    } else if (r.dof >= 2) {
      addCondition(player, 'Fatigued');
      player.shameList.push(`Failed fear test in ${sceneTitle}`);
    }
  } else {
    if (r.dos >= 3) player.heroMoments.push(`Withstood Fear ${fearRating} with ${r.dos} DoS in ${sceneTitle}`);
    if (r.success) player.successCount++;
  }
  if (!r.success) player.failCount++;
  return r;
}

function visibleTargetsForActor(battlemapContext, actorPos, candidates, side) {
  if (!battlemapContext || !actorPos) return [];
  return candidates
    .map((entry) => {
      const pos = battlemapContext.positions?.[battlemapActorKey(side, entry.name)];
      if (!pos) return null;
      const visibility = visibilityBetween(battlemapContext, actorPos, pos);
      if (!visibility.visible) return null;
      return { entry, pos, visibility, dist: visibility.distance };
    })
    .filter(Boolean)
    .sort((a, b) => a.dist - b.dist);
}

// ─── Combat round ────────────────────────────────────────────────────────────
function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, events, diff, options = {}) {
  const battlemapContext = options.battlemapContext || null;
  const objectiveActors = options.objectiveActors || new Set();
  // Initiative
  const initiatives = [
    ...players.map(p => ({ name: p.name, total: Math.floor(p.Ag / 10) + d10(), isNpc: false })),
    ...enemies.map(e => ({ name: e.name, total: (e.agBonus || 4) + d10(), isNpc: true })),
  ].sort((a, b) => b.total - a.total);

  events.push({ type: 'initiative', round: roundNum, order: initiatives.map(i => `${i.name}(${i.total})`) });

  for (const actor of initiatives) {
    if (actor.isNpc) {
      const enemy  = enemies.find(e => e.name === actor.name);
      if (!enemy || enemy.wounds <= 0) continue;
      if (battlemapContext) {
        scriptBattlemapEnemyAdvance(roundNum, battlemapContext, enemy, players, sceneTitle, events);
      }
      const livingPlayers = players.filter(isAlive);
      const enemyPos = battlemapContext?.positions?.[battlemapActorKey('enemy', enemy.name)] || null;
      const target = enemyPos && battlemapContext
        ? visibleTargetsForActor(battlemapContext, enemyPos, livingPlayers, 'player')[0]?.entry
        : livingPlayers[Math.floor(Math.random() * livingPlayers.length)];
      if (!target) continue;
      const targetPos = battlemapContext?.positions?.[battlemapActorKey('player', target.name)] || null;
      const targetDistance = enemyPos && targetPos ? distance(enemyPos, targetPos) : 0;
      const enemyAttack = chooseEnemyAttackProfile(enemy, targetDistance);
      const enemyAllyPositions = enemies
        .filter(other => other !== enemy && other.wounds > 0)
        .map(other => battlemapContext?.positions?.[battlemapActorKey('enemy', other.name)])
        .filter(Boolean);
      const positioning = (battlemapContext && enemyPos && targetPos)
        ? attackPositioning(battlemapContext, enemyPos, targetPos, { name: enemy.name, range: `${enemyAttack.range || 30}m`, pen: enemy.ap || 0 }, enemyAttack.mode, enemyAllyPositions)
        : null;
      if (positioning?.blocked || (enemyAttack.mode === 'melee' && positioning?.range?.band === 'melee_out_of_range')) {
        events.push({
          type: 'battlemapAttack',
          attacker: enemy.name,
          target: target.name,
          mode: enemyAttack.mode,
          blocked: true,
          glyph: attackGlyphForWeapon(enemy.name, enemyAttack.mode),
          reasons: positioning?.reasons || ['No valid line'],
        });
        continue;
      }
      const attackRoll = d100();
      const battlemapMod = positioning ? positioning.totalModifier : 0;
      const enemyTarget = Math.max(5, Math.min(95, enemyAttack.target + battlemapMod));
      const hit = attackRoll <= enemyTarget;
      events.push({
        type: 'enemyAttack',
        enemy: enemy.name,
        target: target.name,
        mode: enemyAttack.mode,
        glyph: attackGlyphForWeapon(enemy.name, enemyAttack.mode),
        stat: enemyAttack.stat,
        roll: attackRoll,
        tn: enemyTarget,
        distance: Number(targetDistance || 0).toFixed(1),
        reasons: positioning?.reasons || [],
        hit,
      });
      if (hit) {
        const { net } = dealDamage(target, enemy.damage || '1d5', enemy.ap || 0, enemyAttack.mode === 'ranged' ? positioning?.coverAp || 0 : 0);
        if (net > 0) {
          if (net >= 5) addCondition(target, d(2) === 1 ? 'Pinned' : 'Stunned');
          if (!isAlive(target)) {
            target.shameList.push(`Went down in ${sceneTitle} round ${roundNum}`);
            events.push({ type: 'down', player: target.name, round: roundNum, scene: sceneTitle });
          }
        }
      }
    } else {
      const player = players.find(p => p.name === actor.name);
      if (!player || !isAlive(player)) continue;
      if (objectiveActors.has(player.name)) continue;
      if (player.conditions.includes('Stunned')) {
        removeCondition(player, 'Stunned');
        continue;
      }
      if (battlemapContext.hasAuthoredLayout) {
        scriptBattlemapPlayerAdvance(roundNum, battlemapContext, player, sceneTitle, events);
      } else {
        scriptBattlemapPlayerAdvanceGeneric(roundNum, battlemapContext, player, enemies, sceneTitle, events);
      }
      const livingEnemies = enemies.filter(e => e.wounds > 0);
      const playerPos = battlemapContext?.positions?.[battlemapActorKey('player', player.name)] || null;
      const firstEnemy = playerPos && battlemapContext
        ? visibleTargetsForActor(battlemapContext, playerPos, livingEnemies, 'enemy')[0]?.entry
        : livingEnemies[0];
      // 50% chance to do a check vs attack
      const diffCheckMod = (diff && diff.checkMod) || 0;
      if (checks.length > 0 && Math.random() < 0.45) {
        const chk = checks[Math.floor(Math.random() * checks.length)];
        const r = performCheck(player, chk.name, chk.stat, (chk.modifier || 0) + diffCheckMod, rollFeed, sceneTitle, 'combat');
        events.push({
          type: 'checkRoll',
          player: player.name,
          check: chk.name,
          stat: chk.stat,
          roll: r.result,
          tn: r.effective,
          success: r.success,
          dos: r.dos,
          dof: r.dof,
        });
      } else {
        if (!firstEnemy) continue;
        const targetPos = firstEnemy ? battlemapContext?.positions?.[battlemapActorKey('enemy', firstEnemy.name)] || null : null;
        const targetDistance = playerPos && targetPos ? distance(playerPos, targetPos) : 0;
        const weapon   = chooseAttackWeapon(player, enemies, targetDistance);
        const fireMode = chooseFireMode(weapon);
        const ammo     = spendAmmo(weapon, fireMode.shots);
        const statName = weaponIsMelee(weapon) ? 'WS' : 'BS';
        const base     = player[statName] || 50;
        const cMod     = conditionMod(player, statName);
        const modeMod  = fireModeAttackModifier(fireMode.mode);
        const playerAllyPositions = players
          .filter(other => other !== player && isAlive(other))
          .map(other => battlemapContext?.positions?.[battlemapActorKey('player', other.name)])
          .filter(Boolean);
        const positioning = (battlemapContext && firstEnemy && playerPos && targetPos)
          ? attackPositioning(battlemapContext, playerPos, targetPos, weapon, weaponIsMelee(weapon) ? 'melee' : 'ranged', playerAllyPositions)
          : null;
        if (positioning?.blocked || positioning?.range?.band === 'out_of_range' || positioning?.range?.band === 'melee_out_of_range') {
          player.failCount++;
          events.push({
            type: 'battlemapAttack',
            attacker: player.name,
            target: firstEnemy?.name || 'enemy',
            mode: weaponIsMelee(weapon) ? 'melee' : 'ranged',
            weapon: weapon.name || 'weapon',
            blocked: true,
            glyph: attackGlyphForWeapon(weapon, weaponIsMelee(weapon) ? 'melee' : 'ranged'),
            reasons: positioning?.reasons || ['Target out of range'],
          });
          continue;
        }
        const mapMod   = positioning ? positioning.totalModifier : 0;
        const r        = roll(base, cMod + modeMod + mapMod);
        const hits = r.success ? hitsForFireMode(fireMode.mode, r.dos, fireMode.shots || 1) : 0;
        rollFeed.push({
          player: player.name, checkName: `Attack (${weapon.name || 'weapon'})`,
          category: 'combat',
          sceneName: sceneTitle, statName, base, modifier: cMod + modeMod + mapMod,
          result: r.result, effective: r.effective, success: r.success, dos: r.dos, dof: r.dof,
          fireMode: fireMode.mode,
          fireModeModifier: modeMod,
          shotsDeclared: fireMode.shots,
          hits,
          ammoType: weapon.ammoType || 'Standard',
          specialAmmo: Boolean(weapon.specialAmmo),
          ammoSpent: ammo.spent,
          ammoRemaining: ammo.remaining,
          battlemapReasons: positioning?.reasons || [],
          targetName: firstEnemy?.name || null,
          mode: weaponIsMelee(weapon) ? 'melee' : 'ranged',
          distance: Number(targetDistance || 0).toFixed(1),
        });
        if (ammo.spent > 0) {
          events.push({
            type: 'ammo',
            player: player.name,
            weapon: weapon.name,
            ammoType: weapon.ammoType || 'Standard',
            specialAmmo: Boolean(weapon.specialAmmo),
            fireMode: fireMode.mode,
            spent: ammo.spent,
            remaining: ammo.remaining,
          });
        }
        if (positioning) {
          events.push({
            type: 'battlemapAttack',
            attacker: player.name,
            target: firstEnemy?.name || 'enemy',
            mode: weaponIsMelee(weapon) ? 'melee' : 'ranged',
            weapon: weapon.name || 'weapon',
            glyph: attackGlyphForWeapon(weapon, weaponIsMelee(weapon) ? 'melee' : 'ranged'),
            reasons: positioning.reasons,
          });
        }
        if (r.success) {
          player.successCount++;
          if (firstEnemy) {
            if (firstEnemy.horde) {
              const tb = firstEnemy.tb || 4;
              // Horde: each hit that inflicts any damage after TB kills 1 creature
              let magnitudeLoss = 0;
              for (let hit = 0; hit < Math.max(1, hits); hit++) {
                const hitDmg = Math.max(0, dX(weapon.damage || '1d10+4') - (weaponIsMelee(weapon) ? 0 : (positioning?.coverAp || 0)));
                if (hitDmg > tb) magnitudeLoss += 1;
              }
              firstEnemy.wounds -= magnitudeLoss;
              if (magnitudeLoss > 0) {
                events.push({ type: 'horde_damage', player: player.name, target: firstEnemy.name, creaturesKilled: magnitudeLoss, scene: sceneTitle });
              }
            } else {
              let dmg = 0;
              for (let hit = 0; hit < Math.max(1, hits); hit++) {
                dmg += Math.max(0, dX(weapon.damage || '1d10+4') - (weaponIsMelee(weapon) ? 0 : (positioning?.coverAp || 0)));
              }
              firstEnemy.wounds -= dmg;
            }
            if (r.dos >= 4) {
              player.heroMoments.push(`Critical hit on ${firstEnemy.name} with ${weapon.name || 'weapon'} (${r.dos} DoS) in ${sceneTitle}`);
              events.push({ type: 'crit', player: player.name, target: firstEnemy.name, dos: r.dos });
            }
            if (firstEnemy.wounds <= 0) {
              events.push({ type: 'kill', player: player.name, target: firstEnemy.name, scene: sceneTitle });
            }
          }
        } else {
          player.failCount++;
          if (r.dof >= 3) {
            addCondition(player, 'Pinned');
            player.shameList.push(`Exposed flank against ${firstEnemy?.name || 'enemy'} in ${sceneTitle}`);
          }
        }
        if (battlemapContext && Math.random() < battlemapContext.hazardFatigueChance) {
          addCondition(player, 'Fatigued');
          events.push({ type: 'hazard', player: player.name, scene: sceneTitle, round: roundNum, text: 'Hazard pressure from sludge, vents, or collapsed decking.' });
        }
      }
      if (player.conditions.includes('Pinned') && Math.random() < 0.4) removeCondition(player, 'Pinned');
    }
  }
}

// ─── Infer checks from scene ──────────────────────────────────────────────────
const STAT_FOR_CHAR = { WS:'WS', BS:'BS', S:'S', T:'T', Ag:'Ag', Int:'Int', Per:'Per', Wp:'Wp', Fel:'Fel', Str:'S', Per2:'Per' };

function statFromCheck(check) {
  // Map characteristic label to player stat key
  const char = (check.characteristic || 'Int').trim();
  return STAT_FOR_CHAR[char] || char;
}

// Assign scene checks round-robin to living players, picking the best stat
function assignChecks(scene, players) {
  const checks = scene.checks || [];
  if (!checks.length) return [];
  const alive = players.filter(isAlive);
  if (!alive.length) return [];
  return checks.map((check, i) => {
    const stat = statFromCheck(check);
    // Pick the player with the best stat for this check
    const best = alive.reduce((a, b) => ((a[stat] || 50) >= (b[stat] || 50) ? a : b));
    return { player: best.name, check, stat, modifier: check.modifier || 0 };
  });
}

// ─── Derive enemy pool from scene ────────────────────────────────────────────
function enemiesFromScene(scene, sceneIndex, diff, enemyProfile = 'balanced') {
  const d          = diff || DIFFICULTY[2];
  const raw        = scene.enemies || [];
  const baseWounds = [6, 8, 10, 12, 14, 16, 18, 22, 30, 8][sceneIndex] || 10;
  const baseBs     = [45, 45, 50, 45, 50, 55, 55, 50, 60, 40][sceneIndex] || 45;
  const baseAp     = [0,  0,  0,  0,  2,  2,  3,  3,  4,  0][sceneIndex] || 0;
  const baseDmg    = ['1d5+2','1d5+3','1d5+3','1d5+2','1d5+3','1d10+4','1d10+6','1d10+5','1d10+8','1d5'][sceneIndex] || '1d5+3';

  const scaleW  = (w)  => Math.max(1, Math.round(w * d.woundsMult));
  const scaleBs = (bs) => Math.min(85, Math.max(5, bs + d.bsMod));

  let enemies;
  if (raw.length > 0) {
    enemies = raw.map((e, ei) => ({
      name:    e.name || `Enemy ${ei+1}`,
      horde:   !!e.horde,
      magnitude: e.magnitude || (e.horde ? scaleW(e.wounds || (baseWounds + ei * 2)) : null),
      tb:      e.tb || 4,
      wounds:  scaleW(e.wounds || (baseWounds + ei * 2)),
      bs:      scaleBs(e.bs || baseBs),
      ws:      scaleBs(e.ws || (baseBs - 5)),
      ap:      e.ap   || baseAp,
      damage:  e.damage || baseDmg,
      agBonus: e.agBonus || 4 + Math.floor(ei / 2),
      combatProfile: enemyProfile,
    }));
  } else {
    enemies = [
      { name: 'Tyranid Warrior', wounds: scaleW(baseWounds),     bs: scaleBs(baseBs),     ws: scaleBs(baseBs + 5), ap: baseAp, damage: baseDmg,   agBonus: 4, combatProfile: enemyProfile },
      { name: 'Gaunt Swarm',     horde: true, magnitude: scaleW(baseWounds - 2), wounds: scaleW(baseWounds - 2), bs: scaleBs(baseBs - 5), ws: scaleBs(baseBs), ap: 0, damage: '1d5+2', agBonus: 5, combatProfile: enemyProfile === 'ranged' ? 'balanced' : 'melee' },
    ];
  }

  if (d.extraEnemy) {
    enemies.push({
      name:    'Elite Guard',
      wounds:  scaleW(Math.round(baseWounds * 0.9)),
      bs:      scaleBs(baseBs),
      ws:      scaleBs(baseBs + 5),
      ap:      baseAp + 1,
      damage:  baseDmg,
      agBonus: 5,
      combatProfile: enemyProfile,
    });
  }

  return enemies;
}

function fearRatingFromScene(scene) {
  if (scene.combatState?.fearRating) return scene.combatState.fearRating;
  if (scene.type === 'boss' || (scene.title || '').toLowerCase().includes('boss')) return 4;
  if (scene.type === 'combat') return 2;
  if (scene.type === 'puzzle' || scene.type === 'investigation') return 1;
  return 0;
}

function titleForScene(scene, index) {
  return scene?.title || scene?.name || `Scene ${index + 1}`;
}

function battlemapObjectiveDefaults(layout = {}, objectives = {}) {
  const stations = Array.isArray(layout.stations) ? layout.stations : [];
  const spawnPoints = Array.isArray(layout.spawnPoints) ? layout.spawnPoints : [];
  return {
    stations: stations.map((station, index) => ({
      id: station.id || `station-${index + 1}`,
      name: station.name || `Station ${index + 1}`,
      active: Boolean(objectives?.stations?.find(item => item.id === station.id)?.active),
    })),
    geneCoreSecuredRounds: Math.max(0, Number(objectives?.geneCoreSecuredRounds || 0) || 0),
    extractionProgress: Math.max(0, Number(objectives?.extractionProgress || 0) || 0),
    carrier: String(objectives?.carrier || ''),
    activeSpawnId: objectives?.activeSpawnId || spawnPoints[0]?.id || null,
  };
}

function buildBattlemapContext(scene = {}) {
  // Always resolve a real layout (the scene's authored one, or the default
  // battlemap) so melee/ranged range-gating and movement limits are based on
  // actual positions for every simulated combat scene, not skipped whenever
  // nobody has opened the Battlemap tab and saved a layout for that scene.
  const state = scene?.battlemapState;
  // Only scenes where a GM actually authored/saved a battlemap (via the
  // Battlemap tab) get the vault-stations/gene-core/extraction objective
  // mini-game and reinforcement waves — those are specific to that authored
  // map, not something every plain combat scene should be required to clear.
  // Position, movement, and melee/ranged range-gating apply regardless.
  const hasAuthoredLayout = Boolean(state?.layout && Number(state.layout.gridWidth) && Number(state.layout.gridHeight));
  const layout = normalizeBattlemapLayout(scene, state?.layout || {});
  const zones = Array.isArray(layout.zones) ? layout.zones : [];
  const cover = Array.isArray(layout.cover) ? layout.cover : [];
  const hazards = Array.isArray(layout.hazards) ? layout.hazards : [];
  const spawnPoints = Array.isArray(layout.spawnPoints) ? layout.spawnPoints : [];
  const objectives = battlemapObjectiveDefaults(layout, state?.objectives);
  const objectiveZones = new Set(
    zones.filter(zone => /objective|vault|maintenance/i.test(String(zone.tone || zone.id || ''))).map(zone => zone.id)
  );
  return {
    hasAuthoredLayout,
    gridWidth: Number(layout.gridWidth),
    gridHeight: Number(layout.gridHeight),
    metersPerChecker: Number(layout.metersPerChecker || 10) || 10,
    zones,
    doors: Array.isArray(layout.doors) ? layout.doors : [],
    cover,
    hazards,
    blockers: Array.isArray(layout.blockers) ? layout.blockers : [],
    stations: Array.isArray(layout.stations) ? layout.stations : [],
    spawnPoints,
    objectives,
    reinforcementInterval: hasAuthoredLayout && spawnPoints.length ? REINFORCEMENT_INTERVAL_ROUNDS : 0,
    reinforcementWaves: 0,
    spawnHistory: objectives.activeSpawnId ? [objectives.activeSpawnId] : [],
    objectiveActions: [],
    objectiveZones,
    playerRangedAttackMod: cover.length >= 8 ? -5 : 0,
    enemyRangedAttackMod: cover.length >= 8 ? -10 : -5,
    playerMeleeAttackMod: objectiveZones.size ? 5 : 0,
    hazardFatigueChance: hazards.length ? 0.18 : 0,
    positions: {},
  };
}

function nextBattlemapSpawn(context) {
  if (!context?.spawnPoints?.length) return null;
  const currentId = context.objectives.activeSpawnId;
  const currentIndex = Math.max(0, context.spawnPoints.findIndex(point => point.id === currentId));
  const point = context.spawnPoints[currentIndex] || context.spawnPoints[0];
  const next = context.spawnPoints[(currentIndex + 1) % context.spawnPoints.length];
  context.objectives.activeSpawnId = next?.id || point.id;
  if (point?.id) context.spawnHistory.push(point.id);
  return point;
}

function battlemapObjectiveActor(players) {
  const alive = players.filter(isAlive);
  if (!alive.length) return null;
  return alive.reduce((best, player) => {
    const bestScore = (best.Int || 0) + (best.Per || 0) + (best.Wp || 0);
    const nextScore = (player.Int || 0) + (player.Per || 0) + (player.Wp || 0);
    return nextScore > bestScore ? player : best;
  });
}

function moveBattlemapActor(context, key, targetPoint, maxDistance, sceneEvents, roundNum, sceneTitle, actorName) {
  if (!context?.positions?.[key] || !targetPoint) return null;
  const start = context.positions[key];
  const occupied = Object.entries(context.positions)
    .filter(([otherKey]) => otherKey !== key)
    .map(([, point]) => point);
  const moved = stepToward(start, targetPoint, maxDistance, {
    width: context.gridWidth || 40,
    height: context.gridHeight || 30,
  }, {
    layout: context,
    occupied,
  });
  context.positions[key] = { x: moved.x, y: moved.y };
  sceneEvents.push({
    type: 'battlemapMove',
    round: roundNum,
    actor: actorName,
    side: 'player',
    player: actorName,
    scene: sceneTitle,
    x: moved.x,
    y: moved.y,
    travelled: Number(moved.travelled || 0).toFixed(1),
  });
  const remaining = Math.sqrt(Math.pow(targetPoint.x - moved.x, 2) + Math.pow(targetPoint.y - moved.y, 2));
  return { ...moved, remaining };
}

function playerBattlemapGoal(context) {
  if (!context) return null;
  const activeStations = context.objectives.stations.filter(station => station.active).length;
  const inactive = context.objectives.stations.filter(station => !station.active);
  if (inactive.length > 0 && activeStations < STATIONS_REQUIRED) {
    return pointFromLayout({ stations: context.stations }, inactive[0].id, inactive[0].id);
  }
  if (context.objectives.geneCoreSecuredRounds < GENE_CORE_ROUNDS_REQUIRED) {
    return pointFromLayout({ zones: context.zones }, 'vault', 'core');
  }
  if (context.objectives.extractionProgress < EXTRACTION_PROGRESS_REQUIRED) {
    return pointFromLayout({ zones: context.zones }, 'extraction', 'extraction');
  }
  return pointFromLayout({ zones: context.zones }, 'vault', 'vault');
}

function scriptBattlemapPlayerAdvance(roundNum, context, player, sceneTitle, sceneEvents) {
  if (!context || !player || !isAlive(player)) return;
  if (player.combatProfile === 'melee') {
    const activeEnemies = (context.enemyRoster || [])
      .filter(enemy => enemy.wounds > 0)
      .map(enemy => {
        const point = context.positions?.[battlemapActorKey('enemy', enemy.name)];
        return point ? { name: enemy.name, x: point.x, y: point.y } : null;
      })
      .filter(Boolean);
    if (activeEnemies.length) {
      scriptBattlemapPlayerAdvanceGeneric(roundNum, context, player, context.enemyRoster || [], sceneTitle, sceneEvents);
      return;
    }
  }
  const key = battlemapActorKey('player', player.name);
  const targetPoint = playerBattlemapGoal(context);
  if (!context.positions[key] || !targetPoint) return;
  moveBattlemapActor(context, key, targetPoint, movementBudget(player.movement, 'full'), sceneEvents, roundNum, sceneTitle, player.name);
}

// Used for scenes without an authored battlemap (no stations/vault/extraction
// objectives to advance toward): players close on the nearest living enemy
// instead, the same way scriptBattlemapEnemyAdvance moves enemies toward the
// nearest player.
function scriptBattlemapPlayerAdvanceGeneric(roundNum, context, player, enemies, sceneTitle, sceneEvents) {
  if (!context || !player || !isAlive(player)) return;
  const key = battlemapActorKey('player', player.name);
  const playerPos = context.positions[key];
  if (!playerPos) return;
  const enemyTokens = enemies
    .filter(e => e.wounds > 0)
    .map(enemy => {
      const position = context.positions[battlemapActorKey('enemy', enemy.name)];
      return position ? { name: enemy.name, x: position.x, y: position.y } : null;
    })
    .filter(Boolean);
  if (!enemyTokens.length) return;
  const scripted = enemyMoveTowardNearest(playerPos, enemyTokens, movementBudget(player.movement, 'full'), {
    width: context.gridWidth || 40,
    height: context.gridHeight || 30,
  }, {
    layout: context,
    occupied: Object.entries(context.positions)
      .filter(([otherKey]) => otherKey !== key)
      .map(([, point]) => point),
  });
  if (!scripted) return;
  context.positions[key] = { x: scripted.x, y: scripted.y };
  sceneEvents.push({
    type: 'battlemapMove',
    round: roundNum,
    actor: player.name,
    side: 'player',
    player: player.name,
    target: scripted.target.name,
    scene: sceneTitle,
    x: scripted.x,
    y: scripted.y,
    travelled: Number(scripted.travelled || 0).toFixed(1),
  });
}

function scriptBattlemapEnemyAdvance(roundNum, context, enemy, players, sceneTitle, sceneEvents) {
  if (!context || !enemy || enemy.wounds <= 0) return;
  const key = battlemapActorKey('enemy', enemy.name);
  const enemyPos = context.positions[key];
  if (!enemyPos) return;
  const playerTokens = players
    .filter(isAlive)
    .map(player => {
      const position = context.positions[battlemapActorKey('player', player.name)];
      return position ? { name: player.name, x: position.x, y: position.y } : null;
    })
    .filter(Boolean);
  const moveDistance = enemy.combatProfile === 'melee' || enemy.horde ? 2 : 1;
  const scripted = enemyMoveTowardNearest(enemyPos, playerTokens, moveDistance, {
    width: context.gridWidth || 40,
    height: context.gridHeight || 30,
  }, {
    layout: context,
    occupied: Object.entries(context.positions)
      .filter(([otherKey]) => otherKey !== key)
      .map(([, point]) => point),
  });
  const charged = enemyMoveTowardNearest(enemyPos, playerTokens, moveDistance, {
    width: context.gridWidth || 40,
    height: context.gridHeight || 30,
  }, {
    layout: context,
    occupied: Object.entries(context.positions)
      .filter(([otherKey]) => otherKey !== key)
      .map(([, point]) => point),
  });
  const move = charged || scripted;
  if (!move) return;
  context.positions[key] = { x: move.x, y: move.y };
  sceneEvents.push({
    type: 'battlemapMove',
    round: roundNum,
    actor: enemy.name,
    side: 'enemy',
    target: move.target.name,
    scene: sceneTitle,
    x: move.x,
    y: move.y,
    travelled: Number(move.travelled || 0).toFixed(1),
  });
}

function runBattlemapObjectiveRound(roundNum, context, players, sceneTitle, rollFeed, sceneEvents, diff) {
  if (!context) return new Set();
  const occupied = new Set();
  const actor = battlemapObjectiveActor(players);
  if (!actor) return occupied;

  const activeStations = context.objectives.stations.filter(station => station.active).length;
  const inactive = context.objectives.stations.filter(station => !station.active);
  if (inactive.length > 0 && activeStations < STATIONS_REQUIRED) {
    const station = inactive[0];
    const stationPoint = pointFromLayout({ stations: context.stations }, station.id, station.id);
    const moved = moveBattlemapActor(context, `player:${actor.name}`, stationPoint, actor.movement?.full || 6, sceneEvents, roundNum, sceneTitle, actor.name);
    occupied.add(actor.name);
    if (moved?.remaining > 1.5) return occupied;
    const check = performCheck(actor, `Stabilize ${station.name}`, 'Int', (diff?.checkMod || 0) - OBJECTIVE_CHECK_PENALTY, rollFeed, sceneTitle, 'combat');
    sceneEvents.push({
      type: 'battlemapObjective',
      objective: station.name,
      player: actor.name,
      action: 'stabilize',
      roll: check.result,
      tn: check.effective,
      success: check.success,
      dos: check.dos,
      dof: check.dof,
      round: roundNum,
    });
    context.objectiveActions.push({ round: roundNum, player: actor.name, objective: station.name, success: check.success });
    if (check.success) station.active = true;
    return occupied;
  }

  if (context.objectives.geneCoreSecuredRounds < GENE_CORE_ROUNDS_REQUIRED) {
    const corePoint = pointFromLayout({ zones: context.zones }, 'vault', 'core');
    const moved = moveBattlemapActor(context, `player:${actor.name}`, corePoint, actor.movement?.full || 6, sceneEvents, roundNum, sceneTitle, actor.name);
    occupied.add(actor.name);
    if (moved?.remaining > 1.5) return occupied;
    const check = performCheck(actor, 'Secure gene-core', 'Int', (diff?.checkMod || 0) - OBJECTIVE_CHECK_PENALTY, rollFeed, sceneTitle, 'combat');
    sceneEvents.push({
      type: 'battlemapObjective',
      objective: 'Gene-Core',
      player: actor.name,
      action: 'secure-core',
      roll: check.result,
      tn: check.effective,
      success: check.success,
      dos: check.dos,
      dof: check.dof,
      round: roundNum,
    });
    context.objectiveActions.push({ round: roundNum, player: actor.name, objective: 'Gene-Core', success: check.success });
    if (check.success) {
      context.objectives.geneCoreSecuredRounds += 1;
      context.objectives.carrier = actor.name;
    }
    return occupied;
  }

  if (context.objectives.extractionProgress < EXTRACTION_PROGRESS_REQUIRED) {
    const extractionPoint = pointFromLayout({ zones: context.zones }, 'extraction', 'extraction');
    const moved = moveBattlemapActor(context, `player:${actor.name}`, extractionPoint, actor.movement?.full || 6, sceneEvents, roundNum, sceneTitle, actor.name);
    occupied.add(actor.name);
    if (moved?.remaining > 1.5) return occupied;
    const check = performCheck(actor, 'Withdraw gene-core to extraction', 'Ag', (diff?.checkMod || 0) - OBJECTIVE_CHECK_PENALTY, rollFeed, sceneTitle, 'combat');
    sceneEvents.push({
      type: 'battlemapObjective',
      objective: 'Extraction',
      player: actor.name,
      action: 'extract-core',
      roll: check.result,
      tn: check.effective,
      success: check.success,
      dos: check.dos,
      dof: check.dof,
      round: roundNum,
    });
    context.objectiveActions.push({ round: roundNum, player: actor.name, objective: 'Extraction', success: check.success });
    if (check.success) context.objectives.extractionProgress += check.dos >= 2 ? 2 : 1;
  }

  return occupied;
}

function spawnBattlemapReinforcement(roundNum, context, enemies, sceneIndex, diff, sceneEvents) {
  if (!context?.reinforcementInterval || roundNum % context.reinforcementInterval !== 0) return;
  const point = nextBattlemapSpawn(context);
  if (!point) return;
  const d = diff || DIFFICULTY[2];
  context.reinforcementWaves += 1;
  const isElite = /vault|breach/i.test(String(point.name || ''));
  const reinforcement = {
    name: isElite ? `Corrupted Marine ${context.reinforcementWaves}` : `Reinforcement Horde ${context.reinforcementWaves}`,
    horde: /maintenance|service/i.test(String(point.name || '')),
    magnitude: /maintenance|service/i.test(String(point.name || '')) ? Math.max(8, Math.round(10 * d.woundsMult)) : null,
    tb: isElite ? 6 : 4,
    wounds: Math.max(4, Math.round((isElite ? 16 : 8) * d.woundsMult)),
    bs: Math.min(85, 40 + d.bsMod + (isElite ? 10 : 0)),
    ws: Math.min(85, 45 + d.bsMod + (isElite ? 10 : 0)),
    ap: isElite ? 4 : 1,
    damage: isElite ? '1d10+6' : '1d5+3',
    agBonus: isElite ? 5 : 4,
    combatProfile: isElite ? 'melee' : 'melee',
    reinforcement: true,
  };
  enemies.push(reinforcement);
  if (context?.positions) {
    const spawnPoint = pointFromLayout({ spawnPoints: context.spawnPoints }, point.id, point.id);
    context.positions[`enemy:${reinforcement.name}`] = { x: spawnPoint.x, y: spawnPoint.y };
  }
  sceneEvents.push({
    type: 'reinforcement',
    round: roundNum,
    spawn: point.name,
    enemy: reinforcement.name,
    sceneIndex,
  });
}

function compactStoryText(value, fallback = '') {
  let text = '';
  if (Array.isArray(value)) text = value.map(v => compactStoryText(v)).filter(Boolean).join(' ');
  else if (value && typeof value === 'object') text = Object.values(value).map(v => compactStoryText(v)).filter(Boolean).join(' ');
  else text = String(value || '');
  text = text.replace(/\s+/g, ' ').trim();
  return text ? text.slice(0, 260) : fallback;
}

function firstSceneSecret(scene) {
  return compactStoryText(scene?.secret || scene?.secrets || scene?.hidden || scene?.gmNotes || scene?.gm_notes || scene?.gmOnly || scene?.gm_only);
}

function storyHookForScene(scene, index, scenes, mission) {
  const hook = scene?.storyHook && typeof scene.storyHook === 'object' ? scene.storyHook : {};
  const title = titleForScene(scene, index);
  const next = scenes[index + 1];
  const nextTitle = next ? titleForScene(next, index + 1) : 'the mission debrief';
  const clue = compactStoryText(hook.clue || hook.reveal || hook.text);
  const secret = compactStoryText(hook.secret || firstSceneSecret(scene));
  return {
    thread: compactStoryText(hook.thread, `Main thread for ${mission?.name || 'the mission'}`),
    clue: clue || `The result in ${title} points toward the main mission threat.`,
    secret: secret || `The key information in ${title} points toward ${nextTitle}.`,
    transition: compactStoryText(hook.transition, next ? `Point the kill-team toward ${nextTitle}.` : 'Frame the mission result and the next assignment.'),
    gmAction: compactStoryText(hook.gmAction || hook.gm_action, 'Reveal the clue on a successful skill check; on failure, reveal a partial clue with a cost.'),
    failForward: compactStoryText(hook.failForward || hook.fail_forward, 'Failure should add pressure or cost without blocking the main story.'),
  };
}

function storyHookFromCheck({ scene, sceneIndex, scenes, mission, assignment, player, rollResult }) {
  const hook = storyHookForScene(scene, sceneIndex, scenes, mission);
  const reward = compactStoryText(assignment?.check?.reward);
  const checkName = assignment?.check?.name || 'scene check';
  if (rollResult.success) {
    return {
      scene_index: sceneIndex,
      scene: titleForScene(scene, sceneIndex),
      type: rollResult.dos >= 3 ? 'major_reveal' : 'reveal',
      trigger: `${player.name} succeeded ${checkName}${rollResult.dos >= 3 ? ` with ${rollResult.dos} DoS` : ''}`,
      thread: hook.thread,
      clue: reward || hook.clue,
      secret: hook.secret,
      gm_action: hook.gmAction,
      transition: hook.transition,
    };
  }
  return {
    scene_index: sceneIndex,
    scene: titleForScene(scene, sceneIndex),
    type: 'fail_forward',
    trigger: `${player.name} failed ${checkName} with ${rollResult.dof} DoF`,
    thread: hook.thread,
    clue: `Give a partial clue: ${reward || hook.clue}`,
    secret: hook.secret,
    gm_action: hook.failForward,
    transition: hook.transition,
  };
}

function combatStoryHook({ scene, sceneIndex, scenes, mission, cleared }) {
  const hook = storyHookForScene(scene, sceneIndex, scenes, mission);
  return {
    scene_index: sceneIndex,
    scene: titleForScene(scene, sceneIndex),
    type: cleared ? 'combat_transition' : 'combat_escalation',
    trigger: cleared ? 'combat cleared' : 'combat unresolved',
    thread: hook.thread,
    clue: cleared ? hook.clue : `The team gets the clue under pressure: ${hook.clue}`,
    secret: hook.secret,
    gm_action: cleared
      ? `Use the victory beat to reveal the hook. ${hook.gmAction}`
      : `Do not stall after withdrawal. Reveal a partial hook, then apply cost: ${hook.failForward}`,
    transition: hook.transition,
  };
}

// ─── Run full simulation ──────────────────────────────────────────────────────
function runSimulation(mission, playerStates, diff, options = {}) {
  const d           = diff || DIFFICULTY[2];
  const enemyProfile= options.enemyProfile || 'balanced';
  const rollFeed    = [];
  const sceneResults= [];
  const allEvents   = [];
  const storyHooks  = [];
  let missionXp     = 0;
  let totalRounds   = 0;

  const scenes = Array.isArray(mission.scenes) ? mission.scenes : [];

  for (let si = 0; si < scenes.length; si++) {
    const scene      = scenes[si];
    const title      = scene.title || `Scene ${si + 1}`;
    const type       = scene.type  || 'intro';
    const complications = scene.complications || [];
    const rawFear    = fearRatingFromScene(scene);
    const fearRating = Math.min(4, Math.max(0, rawFear + d.fearBonus));
    let sceneSuccesses = 0;
    let sceneFailures  = 0;
    const sceneEvents  = [];

    // Fear tests for combat/puzzle scenes
    if (fearRating > 0) {
      for (const p of playerStates) {
        if (!isAlive(p)) continue;
        fearTest(p, fearRating, title, rollFeed);
      }
    }

    if (type === 'combat' || (scene.enemies && scene.enemies.length > 0)) {
      // Combat scene — difficulty scales enemy stats and max rounds
      const enemies   = enemiesFromScene(scene, si, d, enemyProfile);
      const checks    = (scene.checks || []).map(c => ({ name: c.name, stat: statFromCheck(c), modifier: c.modifier || 0 }));
      const isBoss    = si >= scenes.length - 2;
      const battlemapContext = buildBattlemapContext(scene);
  battlemapContext.positions = createBattlemapPositions({
        zones: battlemapContext.zones,
        stations: battlemapContext.stations,
        spawnPoints: battlemapContext.spawnPoints,
      }, playerStates, enemies);
      battlemapContext.enemyRoster = enemies;
      // battlemapContext.positions gets mutated in place every round as actors
      // move, so it holds end-of-combat positions by the time the scene report
      // is built below. Snapshot the true starting positions here — the replay
      // UI seeds from this, not the final positions, or every token would
      // render at its last position instead of where the fight actually began.
      const startingPositions = Object.fromEntries(
        Object.entries(battlemapContext.positions).map(([key, pos]) => [key, { ...pos }])
      );
      const maxRounds = (isBoss ? d.maxRounds + 1 : d.maxRounds) + (battlemapContext.hasAuthoredLayout ? 4 : 0);
      const objectivesMetNow = () => !battlemapContext.hasAuthoredLayout || (
        battlemapContext.objectives.stations.filter(station => station.active).length >= STATIONS_REQUIRED
        && battlemapContext.objectives.geneCoreSecuredRounds >= GENE_CORE_ROUNDS_REQUIRED
        && battlemapContext.objectives.extractionProgress >= EXTRACTION_PROGRESS_REQUIRED
      );

      let round = 1;
      while (
        round <= maxRounds
        && playerStates.some(isAlive)
        && (enemies.some(e => e.wounds > 0) || !objectivesMetNow())
      ) {
        totalRounds++;
        const objectiveActors = battlemapContext.hasAuthoredLayout
          ? runBattlemapObjectiveRound(round, battlemapContext, playerStates, title, rollFeed, sceneEvents, d)
          : new Set();
        spawnBattlemapReinforcement(round, battlemapContext, enemies, si, d, sceneEvents);
        combatRound(round, playerStates, enemies, checks, title, rollFeed, sceneEvents, d, {
          battlemapContext,
          objectiveActors,
        });
        round++;
      }

      const objectivesMet = objectivesMetNow();
      const cleared = enemies.every(e => e.wounds <= 0) && objectivesMet;
      if (cleared) { missionXp += 200; sceneSuccesses += Math.ceil(playerStates.filter(isAlive).length * 1.5); }
      else          { missionXp += 75; }
      storyHooks.push(combatStoryHook({ scene, sceneIndex: si, scenes, mission, cleared }));

      sceneResults.push({
        title,
        type,
        cleared,
        enemyProfile,
        enemiesRemaining: enemies.filter(e=>e.wounds>0).length,
        battlemap: {
          active: battlemapContext.hasAuthoredLayout,
          stationsOnline: battlemapContext.objectives.stations.filter(station => station.active).length,
          geneCoreSecuredRounds: battlemapContext.objectives.geneCoreSecuredRounds,
          extractionProgress: battlemapContext.objectives.extractionProgress,
          carrier: battlemapContext.objectives.carrier,
          reinforcementWaves: battlemapContext.reinforcementWaves,
          activeSpawnId: battlemapContext.objectives.activeSpawnId,
          objectiveActions: battlemapContext.objectiveActions,
          positions: battlemapContext.positions,
          startingPositions,
          objectivesMet,
          // Map shape for the frontend's live replay view — every combat
          // scene now has real positions (see buildBattlemapContext), so
          // this is populated regardless of whether a GM authored a layout.
          gridWidth: battlemapContext.gridWidth,
          gridHeight: battlemapContext.gridHeight,
          zones: battlemapContext.zones,
          doors: battlemapContext.doors,
          cover: battlemapContext.cover,
          blockers: battlemapContext.blockers,
          hazards: battlemapContext.hazards,
        },
        events: sceneEvents,
      });
    } else {
      // Skill scene — difficulty applies check modifier
      const assignments = assignChecks(scene, playerStates);
      for (const a of assignments) {
        const player = playerStates.find(p => p.name === a.player);
        if (!player || !isAlive(player)) { sceneFailures++; continue; }
        const r = performCheck(player, a.check.name, a.stat, a.modifier + d.checkMod, rollFeed, title, 'puzzle');
        sceneEvents.push({
          type: 'checkRoll',
          player: player.name,
          check: a.check.name,
          stat: a.stat,
          roll: r.result,
          tn: r.effective,
          success: r.success,
          dos: r.dos,
          dof: r.dof,
        });
        if (r.success) {
          sceneSuccesses++;
          if (r.dos >= 3) sceneEvents.push({ type: 'heroCheck', player: player.name, check: a.check.name, dos: r.dos });
          storyHooks.push(storyHookFromCheck({ scene, sceneIndex: si, scenes, mission, assignment: a, player, rollResult: r }));
        } else {
          sceneFailures++;
          if (r.dof >= 2) storyHooks.push(storyHookFromCheck({ scene, sceneIndex: si, scenes, mission, assignment: a, player, rollResult: r }));
          if (r.dof >= 3 && complications.length > 0) {
            const comp = complications[Math.floor(Math.random() * complications.length)];
            sceneEvents.push({ type: 'complication', player: player.name, text: comp });
            if (/damage|wound|feeder|corrosive|spore|backlash/i.test(comp)) {
              const { net } = dealDamage(player, '1d5', 0);
              if (net > 0) sceneEvents.push({ type: 'wound', player: player.name, net, cause: comp });
            }
          }
        }
      }
      const outcome = sceneSuccesses >= Math.ceil(assignments.length / 2) ? 'success' : 'partial';
      missionXp += sceneSuccesses * 50 + (outcome === 'success' ? 100 : 0);
      sceneResults.push({ title, type, outcome, successes: sceneSuccesses, total: assignments.length, events: sceneEvents });
    }

    allEvents.push(...sceneEvents);

    // Post-scene condition reset (harder difficulties offer less recovery)
    for (const p of playerStates) {
      if (p.conditions.includes('Fatigued') && Math.random() < d.healChance) removeCondition(p, 'Fatigued');
    }
  }

  // Build player cards
  const playerCards = {};
  for (const p of playerStates) {
    const rolls = p.successCount + p.failCount;
    const pct   = Math.round(p.successCount / Math.max(1, rolls) * 100);
    playerCards[p.name] = {
      name:           p.name,
      chapter:        p.chapter,
      spec:           p.spec,
      alive:          isAlive(p),
      woundsRemaining:p.wounds,
      maxWounds:      p.maxWounds,
      successCount:   p.successCount,
      failCount:      p.failCount,
      totalRolls:     rolls,
      successRate:    pct,
      fateRemaining:  p.fate,
      fateSpends:     p.fateSpends,
      combatProfile:  p.combatProfile || 'balanced',
      roleFit:        roleFitForPlayer(p),
      ammo:           ammoSummary(p),
      conditions:     p.conditions,
      heroMoments:    p.heroMoments,
      shameList:      p.shameList,
      grade:          pct >= 70 ? 'A' : pct >= 55 ? 'B' : pct >= 40 ? 'C' : 'D',
    };
  }

  const aliveCount     = playerStates.filter(isAlive).length;
  const totalRolls     = rollFeed.length;
  const totalSuccesses = rollFeed.filter(r => r.success).length;
  const successRate    = Math.round(totalSuccesses / Math.max(1, totalRolls) * 100);
  const combatRolls    = rollFeed.filter(r => r.category === 'combat');
  const puzzleRolls    = rollFeed.filter(r => r.category === 'puzzle');
  const combatSuccessRate = Math.round(combatRolls.filter(r => r.success).length / Math.max(1, combatRolls.length) * 100);
  const puzzleSuccessRate = Math.round(puzzleRolls.filter(r => r.success).length / Math.max(1, puzzleRolls.length) * 100);
  const missionSuccess = aliveCount >= 2 && missionXp >= 800;

  // Auto-generate GM-facing findings. These are scripted heuristics, not AI.
  const findings = [];
  const downPlayers = playerStates.filter(p => !isAlive(p));
  if (downPlayers.length > 0) findings.push(`Combat: ${downPlayers.map(p=>p.name).join(', ')} went down. As GM, reduce enemy count/wounds, add cover, or telegraph Dodge/Parry/withdraw options before the next lethal exchange.`);
  const heroPlayers = playerStates.filter(p => p.heroMoments.length >= 2);
  if (heroPlayers.length > 0) findings.push(`Table pacing: ${heroPlayers.map(p=>p.name).join(', ')} had multiple high-DoS moments. Spotlight those results with narrative rewards, bonus intel, or a visible commendation.`);
  if (totalRounds >= 18) findings.push('Combat: combat ran long. Lower enemy wounds by 15-25%, remove one lesser enemy, or add a mission objective that ends the fight before every enemy is killed.');
  if (totalRounds > 0 && combatSuccessRate < 45) findings.push(`Combat: ${combatSuccessRate}% combat success is harsh. Add Aim/cover/bracing prompts, lower enemy BS/WS by 5-10, or give the kill-team a tactical advantage from Awareness/Tactics.`);
  if (totalRounds > 0 && combatSuccessRate > 80) findings.push(`Combat: ${combatSuccessRate}% combat success is very favorable. Raise pressure with cover penalties, suppression/pinning, reinforcements, or an objective timer instead of only adding wounds.`);
  if (combatRolls.length === 0) findings.push('Combat: no combat rolls were recorded. If this mission should test battle readiness, add at least one combat scene or enemy wave.');
  if (puzzleRolls.length === 0) findings.push('Puzzle/skills: no non-combat skill checks were recorded. Add investigation, Tech-Use, Command, Medicae, Awareness, or Tactics gates so the mission is not only combat.');
  if (puzzleRolls.length > 0 && puzzleSuccessRate < 45) findings.push(`Puzzle/skills: ${puzzleSuccessRate}% puzzle/check success is low. Reduce difficulty by +10/+20, allow assists, add alternate skills, or make failures reveal partial clues with complications.`);
  if (puzzleRolls.length > 0 && puzzleSuccessRate > 85) findings.push(`Puzzle/skills: ${puzzleSuccessRate}% puzzle/check success is very high. Add time pressure, opposed tests, layered clues, or consequences on high DoF rather than making the clue mandatory.`);

  const failedPuzzleCounts = puzzleRolls
    .filter(r => !r.success)
    .reduce((acc, r) => {
      const key = r.checkName || 'Unknown check';
      acc[key] = (acc[key] || 0) + 1;
      return acc;
    }, {});
  const hardestChecks = Object.entries(failedPuzzleCounts).sort((a, b) => b[1] - a[1]).slice(0, 3);
  if (hardestChecks.length > 0) {
    findings.push(`Puzzle/skills: hardest checks were ${hardestChecks.map(([name, count]) => `${name} (${count} fail${count === 1 ? '' : 's'})`).join(', ')}. As GM, prepare alternate approaches or clearer clues for those rules moments.`);
  }

  const failedCombatStats = combatRolls
    .filter(r => !r.success)
    .reduce((acc, r) => {
      const key = r.statName || 'combat';
      acc[key] = (acc[key] || 0) + 1;
      return acc;
    }, {});
  const weakCombatStats = Object.entries(failedCombatStats).sort((a, b) => b[1] - a[1]).slice(0, 2);
  if (weakCombatStats.length > 0) {
    findings.push(`Combat: most failed combat stats were ${weakCombatStats.map(([stat, count]) => `${stat} (${count})`).join(', ')}. Use this to decide whether the scene needs more melee threats, ranged cover, or non-attack objectives.`);
  }

  const combatScenes = sceneResults.filter(s => s.cleared !== undefined);
  const unclearedScenes = combatScenes.filter(s => !s.cleared);
  if (unclearedScenes.length > 0) {
    findings.push(`Combat: ${unclearedScenes.length}/${Math.max(1, combatScenes.length)} combat scene${combatScenes.length === 1 ? '' : 's'} ended uncleared. Add retreat conditions, stagger enemy arrivals, or let a successful objective roll bypass the remaining enemies.`);
  }
  const battlemapScenes = combatScenes.filter(s => s.battlemap?.active);
  if (battlemapScenes.length > 0) {
    const failedObjectives = battlemapScenes.filter(s => !s.battlemap.objectivesMet);
    const totalWaves = battlemapScenes.reduce((sum, scene) => sum + Number(scene.battlemap.reinforcementWaves || 0), 0);
    const coreProgress = battlemapScenes.map(scene => Number(scene.battlemap.geneCoreSecuredRounds || 0));
    if (failedObjectives.length > 0) {
      findings.push(`Battlemap: ${failedObjectives.length}/${battlemapScenes.length} mapped combat scene${battlemapScenes.length === 1 ? '' : 's'} failed the station/core objective. Ease objective timing, give safer console access, or reduce reinforcement tempo.`);
    }
    if (totalWaves > battlemapScenes.length * 2) {
      findings.push(`Battlemap: reinforcements hit ${totalWaves} times across ${battlemapScenes.length} mapped scene${battlemapScenes.length === 1 ? '' : 's'}. Consider 3-round spacing, a telegraphed spawn, or one spawn lane locked by a successful Tech-Use action.`);
    }
    if (coreProgress.length > 0 && Math.max(...coreProgress) < 3) {
      findings.push('Battlemap: the gene-core was never held for the full 3 rounds. Add more vault cover, reduce elite contest pressure, or allow one round of progress to persist after a failed hold check.');
    }
    const extractionProgress = battlemapScenes.map(scene => Number(scene.battlemap.extractionProgress || 0));
    if (extractionProgress.length > 0 && Math.max(...extractionProgress) < 2) {
      findings.push('Battlemap: the carrier never completed the withdrawal to extraction. Shorten the retreat path, reduce rear-spawn pressure, or give the carrier more supporting cover.');
    }
  }

  const skillScenes = sceneResults.filter(s => s.total !== undefined);
  const partialSkillScenes = skillScenes.filter(s => s.outcome !== 'success');
  if (partialSkillScenes.length > 0) {
    findings.push(`Puzzle/skills: ${partialSkillScenes.length}/${Math.max(1, skillScenes.length)} skill scene${skillScenes.length === 1 ? '' : 's'} landed partial. Make failures cost time, wounds, or resources, but avoid blocking core mission progress behind one failed roll.`);
  }

  const fearRolls = rollFeed.filter(r => r.category === 'fear');
  const failedFear = fearRolls.filter(r => !r.success);
  if (failedFear.length >= Math.max(2, Math.ceil(fearRolls.length / 2))) {
    findings.push('Rules pressure: fear tests are dominating the run. Consider lowering fear rating, giving preparation bonuses, or using fear as an opener instead of repeating it every scene.');
  }

  const conditionCounts = playerStates.reduce((acc, p) => {
    for (const condition of p.conditions || []) acc[condition] = (acc[condition] || 0) + 1;
    return acc;
  }, {});
  const commonConditions = Object.entries(conditionCounts).filter(([, count]) => count >= 2);
  if (commonConditions.length > 0) {
    findings.push(`Rules pressure: common lingering conditions were ${commonConditions.map(([name, count]) => `${name} (${count})`).join(', ')}. Add recovery beats, Medicae/Command opportunities, or clearer condition reminders.`);
  }

  const checkNames = new Set(puzzleRolls.map(r => String(r.checkName || '').toLowerCase()));
  if (skillScenes.length > 0 && ![...checkNames].some(name => name.includes('tactics'))) {
    findings.push('Puzzle/skills: no Tactics-style check appeared in non-combat scenes. Add an optional Tactics read to reveal enemy doctrine, priority targets, ambush risk, or a safer approach.');
  }

  const attackRolls = combatRolls.filter(r => String(r.checkName || '').startsWith('Attack'));
  const tacticalCombatChecks = combatRolls.length - attackRolls.length;
  if (combatRolls.length > 0 && tacticalCombatChecks === 0) {
    findings.push('Combat: combat was pure attacks. Add battlefield actions such as Awareness to spot threats, Tactics to identify priority targets, Command to coordinate, or Tech-Use/Demolition to change the terrain.');
  }

  if (playerStates.every(p => p.fateSpends === 0)) findings.push('Fate: no Fate points were spent. Difficulty may be too low, catastrophic failures may be rare, or Fate-spend affordances may need to be more visible.');
  const struggling = playerStates.filter(p => (p.successCount / Math.max(1, p.successCount+p.failCount)) < 0.40);
  if (struggling.length) findings.push(`Player fit: ${struggling.map(p=>p.name).join(', ')} struggled below 40% success. Reassign scene checks to their strengths, add assists, or give them a role-specific objective.`);
  if (findings.length === 0) findings.push('Run was clean — no critical issues detected in this iteration.');

  return {
    result:          missionSuccess ? 'VICTORY' : 'PARTIAL_FAILURE',
    xp_earned:       missionXp,
    total_rounds:    totalRounds,
    total_rolls:     totalRolls,
    success_rate:    successRate,
    combat_success_rate: combatSuccessRate,
    puzzle_success_rate: puzzleSuccessRate,
    scene_results:   sceneResults,
    player_cards:    playerCards,
    roll_feed:       rollFeed,
    story_hooks:     storyHooks.slice(0, 18),
    findings,
    difficulty_level: d.level,
    difficulty_name:  d.name,
    enemy_profile:    enemyProfile,
  };
}

function roleFitForPlayer(player) {
  const hasMelee = (player.weapons || []).some(weaponIsMelee);
  const hasRanged = (player.weapons || []).some(weaponIsRanged);
  const meleeScore = hasMelee ? Number(player.WS || 0) : 0;
  const rangedScore = hasRanged ? Number(player.BS || 0) : 0;
  const best = meleeScore > rangedScore + 10 ? 'melee' : (rangedScore > meleeScore + 10 ? 'ranged' : 'balanced');
  const why = [];
  why.push(`WS ${player.WS || 0} vs BS ${player.BS || 0}`);
  if (hasMelee) why.push('has melee weapon');
  if (hasRanged) why.push('has ammo weapon');
  if (!hasRanged) why.push('limited ranged ammo profile');
  return { best, meleeScore, rangedScore, why: why.join(', ') };
}

// ─── Difficulty config ────────────────────────────────────────────────────────
const DIFFICULTY = {
  1: { level: 1, name: 'Recruit',   woundsMult: 0.60, bsMod: -10, checkMod: +10, maxRounds: 3, healChance: 0.70, extraEnemy: false, fearBonus: -1 },
  2: { level: 2, name: 'Standard',  woundsMult: 1.00, bsMod:   0, checkMod:   0, maxRounds: 4, healChance: 0.50, extraEnemy: false, fearBonus:  0 },
  3: { level: 3, name: 'Hard',      woundsMult: 1.25, bsMod:  +5, checkMod: -10, maxRounds: 5, healChance: 0.30, extraEnemy: false, fearBonus:  0 },
  4: { level: 4, name: 'Brutal',    woundsMult: 1.50, bsMod: +10, checkMod: -20, maxRounds: 5, healChance: 0.00, extraEnemy: true,  fearBonus: +1 },
  5: { level: 5, name: 'Legendary', woundsMult: 2.00, bsMod: +15, checkMod: -30, maxRounds: 6, healChance: 0.00, extraEnemy: true,  fearBonus: +2 },
};

// ─── Routes ──────────────────────────────────────────────────────────────────

// POST /api/simulations — run a new simulation and save it
router.post('/', async (req, res) => {
  try {
    const { mission_id, player_names, difficulty: diffInput = 2, combat_profiles = {}, enemy_profile = 'balanced' } = req.body || {};

    // Load mission
    let mission;
    if (mission_id) {
      const all = await missionHelpers.getAll();
      mission = all.find(m => m.id === Number(mission_id));
    } else {
      mission = await missionHelpers.getActive();
    }
    if (!mission) return res.status(404).json({ error: 'Mission not found' });

    // Load players
    const allPlayers = await playerHelpers.getAll();
    let targetNames = Array.isArray(player_names) && player_names.length
      ? player_names
      : allPlayers.filter(p => p.name !== 'gm').map(p => p.name);

    const playerStates = targetNames
      .map(name => allPlayers.find(p => p.name === name))
      .filter(Boolean)
      .map(buildPlayerState);

    const allowedProfiles = new Set(['auto', 'balanced', 'melee', 'ranged']);
    playerStates.forEach(player => {
      const requested = String(combat_profiles[player.name] || combat_profiles.default || 'balanced').toLowerCase();
      player.combatProfile = allowedProfiles.has(requested) ? requested : 'balanced';
    });
    const requestedEnemyProfile = String(enemy_profile || 'balanced').toLowerCase();
    const enemyProfile = allowedProfiles.has(requestedEnemyProfile) ? requestedEnemyProfile : 'balanced';

    if (!playerStates.length) return res.status(400).json({ error: 'No valid players found' });

    const diff = DIFFICULTY[Math.min(5, Math.max(1, Number(diffInput) || 2))] || DIFFICULTY[2];
    const simResult = runSimulation(mission, playerStates, diff, { enemyProfile });

    const id = await simulationHelpers.save({
      mission_id:   mission.id,
      mission_name: mission.name,
      players:      playerStates.map(p => p.name),
      ...simResult,
    });

    if (!id) return res.status(500).json({ error: 'Failed to save simulation' });

    const saved = await simulationHelpers.getById(id);
    logToFile(`Simulation #${id} completed: ${mission.name} — ${simResult.result}`);
    res.json({ ...saved, difficulty_name: diff.name });
  } catch (error) {
    logToFile('API: Simulation run error', error);
    res.status(500).json({ error: String(error) });
  }
});

// GET /api/simulations — list all simulations (summary)
router.get('/', async (req, res) => {
  try {
    const limit = Math.min(Number(req.query.limit) || 50, 200);
    const sims  = await simulationHelpers.getAll(limit);
    res.json(sims.map(s => ({ ...s, difficulty_name: DIFFICULTY[s.difficulty_level || 2]?.name || 'Standard' })));
  } catch (error) {
    logToFile('API: Error listing simulations', error);
    res.status(500).json({ error: String(error) });
  }
});

// GET /api/simulations/:id — get full simulation report
router.get('/:id', async (req, res) => {
  try {
    const sim = await simulationHelpers.getById(Number(req.params.id));
    if (!sim) return res.status(404).json({ error: 'Simulation not found' });
    const diffLevel = sim.difficulty_level || 2;
    res.json({ ...sim, difficulty_name: DIFFICULTY[diffLevel]?.name || 'Standard' });
  } catch (error) {
    logToFile('API: Error getting simulation', req.params.id, error);
    res.status(500).json({ error: String(error) });
  }
});

// DELETE /api/simulations/:id — remove a simulation
router.delete('/:id', async (req, res) => {
  try {
    const ok = await simulationHelpers.delete(Number(req.params.id));
    if (!ok) return res.status(404).json({ error: 'Simulation not found' });
    res.json({ success: true });
  } catch (error) {
    logToFile('API: Error deleting simulation', req.params.id, error);
    res.status(500).json({ error: String(error) });
  }
});

router.__testables = {
  runSimulation,
  buildBattlemapContext,
  buildPlayerState,
  combatRound,
  chooseEnemyAttackProfile,
  DIFFICULTY,
};

module.exports = router;
