Add ammo and combat profiles to simulations
This commit is contained in:
@@ -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 => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' && <span className="text-cyan-300">★ {ev.player} — {ev.check} ({ev.dos} DoS)</span>}
|
||||
{ev.type === 'complication'&& <span className="text-orange-300">⚡ Complication ({ev.player}): {ev.text?.slice(0, 80)}</span>}
|
||||
{ev.type === 'wound' && <span className="text-red-300">🩸 {ev.player} took {ev.net} wounds from complication</span>}
|
||||
{ev.type === 'ammo' && <span className={ev.specialAmmo ? 'text-cyan-300' : 'text-slate-400'}>{ev.player} fired {ev.spent} {ev.ammoType || 'Standard'} round{ev.spent === 1 ? '' : 's'} from {ev.weapon} ({ev.fireMode}, {ev.remaining} left)</span>}
|
||||
{ev.type === 'enemyAttack' && <span className={ev.hit ? 'text-red-300' : 'text-slate-500'}>{ev.enemy} {ev.mode} vs {ev.target}: {ev.roll}/{ev.tn} {ev.hit ? 'hit' : 'miss'}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -100,14 +109,39 @@ function PlayerCard({ card }) {
|
||||
<span className="text-slate-400">{card.successRate}% success</span>
|
||||
<span className="text-green-400">{card.successCount}✅</span>
|
||||
<span className="text-red-400">{card.failCount}❌</span>
|
||||
{card.combatProfile && <span className="text-cyan-300">Profile: {card.combatProfile}</span>}
|
||||
{card.fateSpends > 0 && <span className="text-purple-300">✧ Fate ×{card.fateSpends}</span>}
|
||||
</div>
|
||||
{card.roleFit && (
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
Best fit: <span className="text-cyan-200">{card.roleFit.best}</span> · {card.roleFit.why}
|
||||
</div>
|
||||
)}
|
||||
{card.conditions?.length > 0 && (
|
||||
<div className="mt-1 text-xs text-yellow-300">Conditions: {card.conditions.join(', ')}</div>
|
||||
)}
|
||||
{card.ammo?.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1 text-[11px]">
|
||||
{card.ammo.map((ammo, i) => (
|
||||
<span key={`${ammo.name}-${i}`} className={ammo.specialAmmo ? 'rounded bg-cyan-950/50 px-1.5 py-0.5 text-cyan-200' : 'rounded bg-slate-900/60 px-1.5 py-0.5 text-slate-300'}>
|
||||
{ammo.name}: {ammo.remaining}/{ammo.clipSize} {ammo.ammoType || 'Standard'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-slate-700/40 px-3 py-2 text-xs space-y-2">
|
||||
{card.ammo?.length > 0 && (
|
||||
<div>
|
||||
<div className="text-cyan-300 font-semibold mb-0.5">Ammo</div>
|
||||
{card.ammo.map((ammo, i) => (
|
||||
<div key={`${ammo.name}-${i}`} className="text-slate-300 pl-2">
|
||||
• {ammo.name}: {ammo.remaining}/{ammo.clipSize} {ammo.ammoType || 'Standard'} left, {ammo.shotsFired} fired
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{card.heroMoments?.length > 0 && (
|
||||
<div>
|
||||
<div className="text-green-300 font-semibold mb-0.5">★ Hero Moments</div>
|
||||
@@ -131,7 +165,6 @@ function PlayerCard({ card }) {
|
||||
|
||||
function RollFeedPanel({ feed, filterPlayer }) {
|
||||
const visible = filterPlayer ? feed.filter(r => r.player === filterPlayer) : feed;
|
||||
const players = [...new Set(feed.map(r => r.player))];
|
||||
|
||||
return (
|
||||
<div className="space-y-1 max-h-72 overflow-y-auto pr-1">
|
||||
@@ -141,6 +174,19 @@ function RollFeedPanel({ feed, filterPlayer }) {
|
||||
<span className="mt-0.5 shrink-0">{r.success ? '✅' : '❌'}</span>
|
||||
<span className="font-semibold text-slate-300 w-24 shrink-0">{r.player}</span>
|
||||
<span className="text-slate-400 flex-1">{r.checkName}</span>
|
||||
{r.fireMode && (
|
||||
<span className="text-slate-500 shrink-0">
|
||||
{r.fireMode.replace('_', ' ')}{r.fireModeModifier ? ` ${r.fireModeModifier > 0 ? '+' : ''}${r.fireModeModifier}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{r.hits > 0 && (
|
||||
<span className="text-amber-300 shrink-0">{r.hits} hit{r.hits === 1 ? '' : 's'}</span>
|
||||
)}
|
||||
{r.ammoSpent > 0 && (
|
||||
<span className={r.specialAmmo ? 'text-cyan-300 shrink-0' : 'text-slate-500 shrink-0'}>
|
||||
{r.ammoType || 'Ammo'} -{r.ammoSpent} ({r.ammoRemaining} left)
|
||||
</span>
|
||||
)}
|
||||
<span className="text-slate-500 shrink-0">{r.result}/{r.effective}</span>
|
||||
{r.success && r.dos > 0 && <span className="text-cyan-400 shrink-0">{r.dos}DoS</span>}
|
||||
{!r.success && r.dof > 0 && <span className="text-red-400 shrink-0">{r.dof}DoF</span>}
|
||||
@@ -175,6 +221,7 @@ function SimReport({ sim }) {
|
||||
<span className={sim.success_rate >= 60 ? 'text-green-300' : 'text-orange-300'}>{sim.success_rate}% success</span>
|
||||
<span className="text-slate-400">{sim.total_rounds} combat rounds</span>
|
||||
<span className="text-slate-400">Players: {(sim.players || []).join(', ')}</span>
|
||||
{sim.enemy_profile && <span className="text-red-300">Enemies: {sim.enemy_profile}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -234,6 +281,8 @@ export default function SimulationTab() {
|
||||
const [runError, setRunError] = useState('');
|
||||
const [missionId, setMissionId] = useState('');
|
||||
const [chosenPlayers, setChosenPlayers] = useState([]);
|
||||
const [combatProfiles, setCombatProfiles] = useState({});
|
||||
const [enemyProfile, setEnemyProfile] = useState('balanced');
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [loadingFull, setLoadingFull] = useState(false);
|
||||
const [difficulty, setDifficulty] = useState(2);
|
||||
@@ -273,6 +322,8 @@ export default function SimulationTab() {
|
||||
const payload = { difficulty };
|
||||
if (missionId) payload.mission_id = Number(missionId);
|
||||
if (chosenPlayers.length) payload.player_names = chosenPlayers;
|
||||
payload.combat_profiles = Object.fromEntries(chosenPlayers.map(name => [name, combatProfiles[name] || 'balanced']));
|
||||
payload.enemy_profile = enemyProfile;
|
||||
const { data } = await axios.post('/api/simulations', payload);
|
||||
await loadList();
|
||||
setSelected(data);
|
||||
@@ -299,6 +350,13 @@ export default function SimulationTab() {
|
||||
);
|
||||
};
|
||||
|
||||
const setAllProfiles = (profile) => {
|
||||
setCombatProfiles(prev => ({
|
||||
...prev,
|
||||
...Object.fromEntries(chosenPlayers.map(name => [name, profile])),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
@@ -376,6 +434,72 @@ export default function SimulationTab() {
|
||||
return sel ? <p className="mt-1.5 text-xs text-slate-500">{sel.desc}</p> : null;
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<label className="text-xs text-slate-400">Combat profiles</label>
|
||||
<div className="ml-auto flex flex-wrap gap-1">
|
||||
{COMBAT_PROFILES.map(profile => (
|
||||
<button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
onClick={() => setAllProfiles(profile.id)}
|
||||
className="rounded border border-slate-600 bg-slate-700/50 px-2 py-0.5 text-[11px] text-slate-300 hover:border-slate-500"
|
||||
title={`Set all to ${profile.desc}`}
|
||||
>
|
||||
All {profile.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{chosenPlayers.map(name => (
|
||||
<div key={name} className="rounded border border-slate-700 bg-slate-900/40 p-2">
|
||||
<div className="mb-1 text-xs font-semibold capitalize text-slate-200">{name}</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{COMBAT_PROFILES.map(profile => {
|
||||
const active = (combatProfiles[name] || 'balanced') === profile.id;
|
||||
return (
|
||||
<button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
onClick={() => setCombatProfiles(prev => ({ ...prev, [name]: profile.id }))}
|
||||
title={profile.desc}
|
||||
className={`rounded border px-2 py-1 text-[11px] ${active ? 'border-cyan-500 bg-cyan-900/50 text-cyan-100' : 'border-slate-700 bg-slate-800/50 text-slate-400 hover:border-slate-600'}`}
|
||||
>
|
||||
{profile.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Use this to compare who performs best as melee, ranged, or balanced without editing their character sheet.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs text-slate-400">Enemy profile</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COMBAT_PROFILES.map(profile => {
|
||||
const active = enemyProfile === profile.id;
|
||||
return (
|
||||
<button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
onClick={() => setEnemyProfile(profile.id)}
|
||||
title={profile.desc}
|
||||
className={`rounded border px-3 py-1.5 text-xs font-semibold ${active ? 'border-red-500 bg-red-900/50 text-red-100' : 'border-slate-700 bg-slate-800/50 text-slate-400 hover:border-slate-600'}`}
|
||||
>
|
||||
{profile.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Enemy profile controls whether enemies prefer WS melee, BS ranged, balanced mix, or strongest stat.
|
||||
</p>
|
||||
</div>
|
||||
{runError && <div className="text-red-400 text-xs">{runError}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user