diff --git a/database/mariadb.js b/database/mariadb.js index 4eb8084..e6a271a 100644 --- a/database/mariadb.js +++ b/database/mariadb.js @@ -154,6 +154,7 @@ const createTables = async () => { player_cards JSON DEFAULT ('{}'), roll_feed JSON DEFAULT ('[]'), findings JSON DEFAULT ('[]'), + enemy_profile VARCHAR(50) NOT NULL DEFAULT 'balanced', run_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_simulations_run_date (run_date), INDEX idx_simulations_mission_id (mission_id) @@ -213,6 +214,12 @@ const createTables = async () => { if (error.code !== 'ER_DUP_FIELDNAME') throw error; } + try { + await connection.execute(`ALTER TABLE simulations ADD COLUMN enemy_profile VARCHAR(50) NOT NULL DEFAULT 'balanced'`); + } catch (error) { + if (error.code !== 'ER_DUP_FIELDNAME') throw error; + } + await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_created_at ON missions(created_at)`); await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_theme ON missions(theme)`); @@ -812,8 +819,8 @@ const simulationHelpers = { const [result] = await pool.execute( `INSERT INTO simulations (mission_id, mission_name, players, result, xp_earned, total_rounds, - total_rolls, success_rate, scene_results, player_cards, roll_feed, findings, difficulty_level) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + total_rolls, success_rate, scene_results, player_cards, roll_feed, findings, difficulty_level, enemy_profile) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ data.mission_id || null, data.mission_name || 'Unknown Mission', @@ -828,6 +835,7 @@ const simulationHelpers = { JSON.stringify(data.roll_feed || []), JSON.stringify(data.findings || []), data.difficulty_level || 2, + data.enemy_profile || 'balanced', ] ); return result.insertId; @@ -842,7 +850,7 @@ const simulationHelpers = { const bounded = Math.max(1, Math.min(Number(limit) || 50, 200)); const [rows] = await pool.execute( `SELECT id, mission_id, mission_name, players, result, xp_earned, - total_rounds, total_rolls, success_rate, findings, difficulty_level, run_date + total_rounds, total_rolls, success_rate, findings, difficulty_level, enemy_profile, run_date FROM simulations ORDER BY run_date DESC LIMIT ${bounded}` ); return rows.map(r => ({ diff --git a/database/routes/simulationRoutes.js b/database/routes/simulationRoutes.js index 7a88e28..3bce954 100644 --- a/database/routes/simulationRoutes.js +++ b/database/routes/simulationRoutes.js @@ -10,11 +10,14 @@ function d100() { return d(100); } function d10() { return d(10); } function dX(formula) { - const m = String(formula).match(/(\d+)d(\d+)([+-]\d+)?/); + 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; - if (m[3]) total += +m[3]; + 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); } @@ -44,6 +47,11 @@ function buildPlayerState(dbPlayer) { 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', @@ -61,7 +69,7 @@ function buildPlayerState(dbPlayer) { maxWounds: maxWoundsValue, fate: fateValue, maxFate: maxFateValue, - weapons: tab.weapons || [], + weapons, conditions: [], fateUsed: false, rolls: [], @@ -73,6 +81,202 @@ function buildPlayerState(dbPlayer) { }; } +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), + 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 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 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 ? true : weaponUsesAmmo(weapon), + semi: numeric(parts[1]), + full: numeric(parts[2]), + }; +} + +function chooseFireMode(weapon) { + if (!weaponUsesAmmo(weapon)) return { mode: 'melee', shots: 0 }; + 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 || 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) { + const profile = enemy.combatProfile || 'balanced'; + const ws = Number(enemy.ws || 45); + const bs = Number(enemy.bs || 45); + if (profile === 'melee') return { mode: 'melee', stat: 'WS', target: ws }; + if (profile === 'ranged') return { mode: 'ranged', stat: 'BS', target: bs }; + if (profile === 'auto') { + return ws >= bs ? { mode: 'melee', stat: 'WS', target: ws } : { mode: 'ranged', stat: 'BS', target: bs }; + } + if (Math.abs(ws - bs) >= 10) { + return ws > bs ? { mode: 'melee', stat: 'WS', target: ws } : { mode: 'ranged', stat: 'BS', target: bs }; + } + return Math.random() < 0.5 ? { mode: 'melee', stat: 'WS', target: ws } : { mode: 'ranged', stat: 'BS', target: bs }; +} + +function chooseAttackWeapon(player, enemies = []) { + 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.24 : (profile === 'melee' ? 0.08 : 0.18); + 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 bestRanged = weapons.find(w => weaponIsRanged(w) && Number(w.ammoRemaining || 0) > 0); + 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) { @@ -196,7 +400,19 @@ function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, e if (!enemy || enemy.wounds <= 0) continue; const target = players.filter(isAlive)[Math.floor(Math.random() * players.filter(isAlive).length)]; if (!target) continue; - const hit = d100() <= (enemy.bs || 45); + const enemyAttack = chooseEnemyAttackProfile(enemy); + const attackRoll = d100(); + const hit = attackRoll <= enemyAttack.target; + events.push({ + type: 'enemyAttack', + enemy: enemy.name, + target: target.name, + mode: enemyAttack.mode, + stat: enemyAttack.stat, + roll: attackRoll, + tn: enemyAttack.target, + hit, + }); if (hit) { const { net } = dealDamage(target, enemy.damage || '1d5', enemy.ap || 0); if (net > 0) { @@ -220,21 +436,48 @@ function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, e const chk = checks[Math.floor(Math.random() * checks.length)]; performCheck(player, chk.name, chk.stat, (chk.modifier || 0) + diffCheckMod, rollFeed, sceneTitle); } else { - const weapon = player.weapons[0] || { name: 'Bolter', damage: '1d10+4', type: 'ranged' }; - const statName = (weapon.type === 'melee') ? 'WS' : 'BS'; + const weapon = chooseAttackWeapon(player, enemies); + 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 r = roll(base, cMod); + const modeMod = fireModeAttackModifier(fireMode.mode); + const r = roll(base, cMod + modeMod); const firstEnemy = enemies.find(e => e.wounds > 0); + const hits = r.success ? hitsForFireMode(fireMode.mode, r.dos, fireMode.shots || 1) : 0; rollFeed.push({ player: player.name, checkName: `Attack (${weapon.name || 'weapon'})`, - sceneName: sceneTitle, statName, base, modifier: cMod, + sceneName: sceneTitle, statName, base, modifier: cMod + modeMod, 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, }); + 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 (r.success) { player.successCount++; if (firstEnemy) { - const dmg = dX(weapon.damage || '1d10+4') + Math.max(0, r.dos - 1); + let dmg = 0; + for (let hit = 0; hit < Math.max(1, hits); hit++) { + dmg += dX(weapon.damage || '1d10+4'); + } firstEnemy.wounds -= dmg; if (r.dos >= 4) { player.heroMoments.push(`Critical hit on ${firstEnemy.name} with ${weapon.name || 'weapon'} (${r.dos} DoS) in ${sceneTitle}`); @@ -281,7 +524,7 @@ function assignChecks(scene, players) { } // ─── Derive enemy pool from scene ──────────────────────────────────────────── -function enemiesFromScene(scene, sceneIndex, diff) { +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; @@ -302,11 +545,12 @@ function enemiesFromScene(scene, sceneIndex, diff) { 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 }, - { name: 'Gaunt Swarm', wounds: scaleW(baseWounds - 4), bs: scaleBs(baseBs - 5), ws: scaleBs(baseBs), ap: 0, damage: '1d5+2', agBonus: 5 }, + { name: 'Tyranid Warrior', wounds: scaleW(baseWounds), bs: scaleBs(baseBs), ws: scaleBs(baseBs + 5), ap: baseAp, damage: baseDmg, agBonus: 4, combatProfile: enemyProfile }, + { name: 'Gaunt Swarm', wounds: scaleW(baseWounds - 4), bs: scaleBs(baseBs - 5), ws: scaleBs(baseBs), ap: 0, damage: '1d5+2', agBonus: 5, combatProfile: enemyProfile }, ]; } @@ -319,6 +563,7 @@ function enemiesFromScene(scene, sceneIndex, diff) { ap: baseAp + 1, damage: baseDmg, agBonus: 5, + combatProfile: enemyProfile, }); } @@ -334,8 +579,9 @@ function fearRatingFromScene(scene) { } // ─── Run full simulation ────────────────────────────────────────────────────── -function runSimulation(mission, playerStates, diff) { +function runSimulation(mission, playerStates, diff, options = {}) { const d = diff || DIFFICULTY[2]; + const enemyProfile= options.enemyProfile || 'balanced'; const rollFeed = []; const sceneResults= []; const allEvents = []; @@ -365,7 +611,7 @@ function runSimulation(mission, playerStates, diff) { if (type === 'combat' || (scene.enemies && scene.enemies.length > 0)) { // Combat scene — difficulty scales enemy stats and max rounds - const enemies = enemiesFromScene(scene, si, d); + 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 maxRounds = (isBoss ? d.maxRounds + 1 : d.maxRounds); @@ -381,7 +627,7 @@ function runSimulation(mission, playerStates, diff) { if (cleared) { missionXp += 200; sceneSuccesses += Math.ceil(playerStates.filter(isAlive).length * 1.5); } else { missionXp += 75; } - sceneResults.push({ title, type, cleared, enemiesRemaining: enemies.filter(e=>e.wounds>0).length, events: sceneEvents }); + sceneResults.push({ title, type, cleared, enemyProfile, enemiesRemaining: enemies.filter(e=>e.wounds>0).length, events: sceneEvents }); } else { // Skill scene — difficulty applies check modifier const assignments = assignChecks(scene, playerStates); @@ -435,6 +681,9 @@ function runSimulation(mission, playerStates, diff) { 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, @@ -472,9 +721,24 @@ function runSimulation(mission, playerStates, diff) { 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 }, @@ -489,7 +753,7 @@ const DIFFICULTY = { // POST /api/simulations — run a new simulation and save it router.post('/', async (req, res) => { try { - const { mission_id, player_names, difficulty: diffInput = 2 } = req.body || {}; + const { mission_id, player_names, difficulty: diffInput = 2, combat_profiles = {}, enemy_profile = 'balanced' } = req.body || {}; // Load mission let mission; @@ -512,10 +776,18 @@ router.post('/', async (req, res) => { .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); + const simResult = runSimulation(mission, playerStates, diff, { enemyProfile }); const id = await simulationHelpers.save({ mission_id: mission.id, diff --git a/src/components/SimulationTab.jsx b/src/components/SimulationTab.jsx index 4873f81..7ef631a 100644 --- a/src/components/SimulationTab.jsx +++ b/src/components/SimulationTab.jsx @@ -10,6 +10,13 @@ const DIFFICULTIES = [ { level: 5, name: 'Legendary', desc: '×2 wounds, BS+15, −30 checks, 2 extra fear, no recovery', badge: 'bg-red-900/60 border-red-600 text-red-200' }, ]; +const COMBAT_PROFILES = [ + { id: 'auto', name: 'Auto', desc: 'Sim chooses by WS/BS and gear.' }, + { id: 'balanced', name: 'Balanced', desc: 'Prefers ranged, but uses melee when clearly better.' }, + { id: 'ranged', name: 'Ranged', desc: 'Prioritises bolters, pistols, fire modes, and grenades.' }, + { id: 'melee', name: 'Melee', desc: 'Prioritises melee weapons, with fewer grenade throws.' }, +]; + function difficultyBadge(level) { const d = DIFFICULTIES.find(d => d.level === level) || DIFFICULTIES[1]; return ( @@ -71,6 +78,8 @@ function SceneRow({ scene, idx }) { {ev.type === 'heroCheck' && ★ {ev.player} — {ev.check} ({ev.dos} DoS)} {ev.type === 'complication'&& ⚡ Complication ({ev.player}): {ev.text?.slice(0, 80)}} {ev.type === 'wound' && 🩸 {ev.player} took {ev.net} wounds from complication} + {ev.type === 'ammo' && {ev.player} fired {ev.spent} {ev.ammoType || 'Standard'} round{ev.spent === 1 ? '' : 's'} from {ev.weapon} ({ev.fireMode}, {ev.remaining} left)} + {ev.type === 'enemyAttack' && {ev.enemy} {ev.mode} vs {ev.target}: {ev.roll}/{ev.tn} {ev.hit ? 'hit' : 'miss'}} ))} @@ -100,14 +109,39 @@ function PlayerCard({ card }) { {card.successRate}% success {card.successCount}✅ {card.failCount}❌ + {card.combatProfile && Profile: {card.combatProfile}} {card.fateSpends > 0 && ✧ Fate ×{card.fateSpends}} + {card.roleFit && ( +
{sel.desc}
: null; })()}+ Use this to compare who performs best as melee, ranged, or balanced without editing their character sheet. +
++ Enemy profile controls whether enemies prefer WS melee, BS ranged, balanced mix, or strongest stat. +
+