848 lines
36 KiB
JavaScript
848 lines
36 KiB
JavaScript
'use strict';
|
|
const express = require('express');
|
|
const { missionHelpers, playerHelpers, simulationHelpers, logToFile } = require('../mariadb');
|
|
|
|
const router = express.Router();
|
|
|
|
// ─── 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,
|
|
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),
|
|
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) {
|
|
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) {
|
|
const dmg = dX(dmgFormula);
|
|
const absorption = Math.max(0, 6 - (armorPen || 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) {
|
|
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,
|
|
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})`,
|
|
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;
|
|
}
|
|
|
|
// ─── Combat round ────────────────────────────────────────────────────────────
|
|
function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, events, diff) {
|
|
// 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;
|
|
const target = players.filter(isAlive)[Math.floor(Math.random() * players.filter(isAlive).length)];
|
|
if (!target) continue;
|
|
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) {
|
|
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 (player.conditions.includes('Stunned')) {
|
|
removeCondition(player, 'Stunned');
|
|
continue;
|
|
}
|
|
// 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)];
|
|
performCheck(player, chk.name, chk.stat, (chk.modifier || 0) + diffCheckMod, rollFeed, sceneTitle);
|
|
} else {
|
|
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 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 + 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) {
|
|
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}`);
|
|
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 (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}`,
|
|
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', wounds: scaleW(baseWounds - 4), bs: scaleBs(baseBs - 5), ws: scaleBs(baseBs), ap: 0, damage: '1d5+2', agBonus: 5, combatProfile: enemyProfile },
|
|
];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ─── 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 = [];
|
|
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 maxRounds = (isBoss ? d.maxRounds + 1 : d.maxRounds);
|
|
|
|
let round = 1;
|
|
while (round <= maxRounds && enemies.some(e => e.wounds > 0) && playerStates.some(isAlive)) {
|
|
totalRounds++;
|
|
combatRound(round, playerStates, enemies, checks, title, rollFeed, sceneEvents, d);
|
|
round++;
|
|
}
|
|
|
|
const cleared = enemies.every(e => e.wounds <= 0);
|
|
if (cleared) { missionXp += 200; sceneSuccesses += Math.ceil(playerStates.filter(isAlive).length * 1.5); }
|
|
else { missionXp += 75; }
|
|
|
|
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);
|
|
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);
|
|
if (r.success) {
|
|
sceneSuccesses++;
|
|
if (r.dos >= 3) sceneEvents.push({ type: 'heroCheck', player: player.name, check: a.check.name, dos: r.dos });
|
|
} else {
|
|
sceneFailures++;
|
|
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 missionSuccess = aliveCount >= 2 && missionXp >= 800;
|
|
|
|
// Auto-generate findings
|
|
const findings = [];
|
|
const downPlayers = playerStates.filter(p => !isAlive(p));
|
|
if (downPlayers.length > 0) findings.push(`${downPlayers.map(p=>p.name).join(', ')} went down — consider tracking downed status visually on player cards.`);
|
|
const heroPlayers = playerStates.filter(p => p.heroMoments.length >= 2);
|
|
if (heroPlayers.length > 0) findings.push(`${heroPlayers.map(p=>p.name).join(', ')} had multiple hero moments — commendation display on roll feed entries (DoS ≥ 4) would feel rewarding.`);
|
|
if (totalRounds >= 18) findings.push('Combat ran long — enemy wound totals or count may need tuning for this player count.');
|
|
if (playerStates.every(p => p.fateSpends === 0)) findings.push('No Fate points were spent — difficulty may be too low, or Fate-spend button visibility needs improvement.');
|
|
const struggling = playerStates.filter(p => (p.successCount / Math.max(1, p.successCount+p.failCount)) < 0.40);
|
|
if (struggling.length) findings.push(`${struggling.map(p=>p.name).join(', ')} struggled (< 40% success) — their stat spread vs this mission's check profile may be mismatched.`);
|
|
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,
|
|
scene_results: sceneResults,
|
|
player_cards: playerCards,
|
|
roll_feed: rollFeed,
|
|
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) });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|