diff --git a/database/mariadb.js b/database/mariadb.js
index 245f790..24e0dfd 100644
--- a/database/mariadb.js
+++ b/database/mariadb.js
@@ -28,6 +28,18 @@ const dbConfig = {
// Create connection pool
const pool = mysql.createPool(dbConfig);
+const ignoreDuplicateColumn = (error) => {
+ if (error.code !== 'ER_DUP_FIELDNAME') throw error;
+};
+
+const addColumnIfMissing = async (connection, table, definition) => {
+ try {
+ await connection.execute(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
+ } catch (error) {
+ ignoreDuplicateColumn(error);
+ }
+};
+
// Create tables
const createTables = async () => {
try {
@@ -80,14 +92,31 @@ const createTables = async () => {
rule_id VARCHAR(255) UNIQUE,
title VARCHAR(500),
content TEXT,
+ summary TEXT,
page INT,
+ page_end INT,
source VARCHAR(255),
source_abbr VARCHAR(50),
category VARCHAR(100),
+ tags TEXT,
+ aliases TEXT,
+ related_rules TEXT,
+ source_method VARCHAR(50),
+ confidence DECIMAL(4,2),
+ midgame_priority INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
+ await addColumnIfMissing(connection, 'rules', 'summary TEXT');
+ await addColumnIfMissing(connection, 'rules', 'page_end INT');
+ await addColumnIfMissing(connection, 'rules', 'tags TEXT');
+ await addColumnIfMissing(connection, 'rules', 'aliases TEXT');
+ await addColumnIfMissing(connection, 'rules', 'related_rules TEXT');
+ await addColumnIfMissing(connection, 'rules', 'source_method VARCHAR(50)');
+ await addColumnIfMissing(connection, 'rules', 'confidence DECIMAL(4,2)');
+ await addColumnIfMissing(connection, 'rules', 'midgame_priority INT DEFAULT 0');
+
// Missions table
await connection.execute(`
CREATE TABLE IF NOT EXISTS missions (
@@ -107,6 +136,28 @@ const createTables = async () => {
)
`);
+ // Simulation runs table
+ await connection.execute(`
+ CREATE TABLE IF NOT EXISTS simulations (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ mission_id INT,
+ mission_name VARCHAR(500),
+ players JSON DEFAULT ('[]'),
+ result VARCHAR(50),
+ xp_earned INT DEFAULT 0,
+ total_rounds INT DEFAULT 0,
+ total_rolls INT DEFAULT 0,
+ success_rate INT DEFAULT 0,
+ scene_results JSON DEFAULT ('[]'),
+ player_cards JSON DEFAULT ('{}'),
+ roll_feed JSON DEFAULT ('[]'),
+ findings JSON DEFAULT ('[]'),
+ run_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_simulations_run_date (run_date),
+ INDEX idx_simulations_mission_id (mission_id)
+ )
+ `);
+
// Shared mission roll log for GM visibility
await connection.execute(`
CREATE TABLE IF NOT EXISTS mission_rolls (
@@ -176,8 +227,27 @@ const seedRulesIfEmpty = async () => {
const connection = await pool.getConnection();
for (const rule of rules) {
await connection.execute(
- 'INSERT IGNORE INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (?, ?, ?, ?, ?, ?, ?)',
- [rule.id || null, rule.title || '', rule.content || '', rule.page || null, rule.source || null, rule.sourceAbbr || null, rule.category || null]
+ `INSERT IGNORE INTO rules
+ (rule_id, title, content, summary, page, page_end, source, source_abbr, category,
+ tags, aliases, related_rules, source_method, confidence, midgame_priority)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ rule.rule_id || rule.id || null,
+ rule.title || '',
+ rule.content || '',
+ rule.summary || '',
+ rule.page || null,
+ rule.pageEnd || rule.page_end || null,
+ rule.source || null,
+ rule.sourceAbbr || rule.source_abbr || null,
+ rule.category || null,
+ JSON.stringify(rule.tags || []),
+ JSON.stringify(rule.aliases || []),
+ JSON.stringify(rule.relatedRules || rule.related_rules || []),
+ rule.sourceMethod || rule.source_method || null,
+ rule.confidence == null ? null : Number(rule.confidence),
+ rule.midgamePriority || rule.midgame_priority || 0
+ ]
);
}
connection.release();
@@ -374,8 +444,27 @@ const rulesHelpers = {
create: async (rule) => {
try {
const [result] = await pool.execute(
- 'INSERT INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (?, ?, ?, ?, ?, ?, ?)',
- [rule.rule_id || null, rule.title, rule.content, rule.page || null, rule.source || null, rule.source_abbr || null, rule.category || null]
+ `INSERT INTO rules
+ (rule_id, title, content, summary, page, page_end, source, source_abbr, category,
+ tags, aliases, related_rules, source_method, confidence, midgame_priority)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ rule.rule_id || null,
+ rule.title,
+ rule.content,
+ rule.summary || '',
+ rule.page || null,
+ rule.page_end || rule.pageEnd || null,
+ rule.source || null,
+ rule.source_abbr || rule.sourceAbbr || null,
+ rule.category || null,
+ JSON.stringify(rule.tags || []),
+ JSON.stringify(rule.aliases || []),
+ JSON.stringify(rule.related_rules || rule.relatedRules || []),
+ rule.source_method || rule.sourceMethod || null,
+ rule.confidence == null ? null : Number(rule.confidence),
+ rule.midgame_priority || rule.midgamePriority || 0
+ ]
);
return result.insertId;
} catch (error) {
@@ -687,10 +776,90 @@ const missionRollHelpers = {
}
};
+const simulationHelpers = {
+ save: async (data) => {
+ try {
+ 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)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ data.mission_id || null,
+ data.mission_name || 'Unknown Mission',
+ JSON.stringify(data.players || []),
+ data.result || 'UNKNOWN',
+ data.xp_earned || 0,
+ data.total_rounds || 0,
+ data.total_rolls || 0,
+ data.success_rate || 0,
+ JSON.stringify(data.scene_results || []),
+ JSON.stringify(data.player_cards || {}),
+ JSON.stringify(data.roll_feed || []),
+ JSON.stringify(data.findings || []),
+ ]
+ );
+ return result.insertId;
+ } catch (error) {
+ logToFile('MariaDB: Error saving simulation', error);
+ return null;
+ }
+ },
+
+ getAll: async (limit = 50) => {
+ try {
+ 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, run_date
+ FROM simulations ORDER BY run_date DESC LIMIT ${bounded}`
+ );
+ return rows.map(r => ({
+ ...r,
+ players: typeof r.players === 'string' ? JSON.parse(r.players || '[]') : r.players,
+ findings: typeof r.findings === 'string' ? JSON.parse(r.findings || '[]') : r.findings,
+ }));
+ } catch (error) {
+ logToFile('MariaDB: Error listing simulations', error);
+ return [];
+ }
+ },
+
+ getById: async (id) => {
+ try {
+ const [rows] = await pool.execute('SELECT * FROM simulations WHERE id = ?', [id]);
+ if (!rows.length) return null;
+ const r = rows[0];
+ const parse = (v) => typeof v === 'string' ? JSON.parse(v || 'null') : v;
+ return {
+ ...r,
+ players: parse(r.players),
+ scene_results: parse(r.scene_results),
+ player_cards: parse(r.player_cards),
+ roll_feed: parse(r.roll_feed),
+ findings: parse(r.findings),
+ };
+ } catch (error) {
+ logToFile('MariaDB: Error getting simulation', id, error);
+ return null;
+ }
+ },
+
+ delete: async (id) => {
+ try {
+ const [result] = await pool.execute('DELETE FROM simulations WHERE id = ?', [id]);
+ return result.affectedRows > 0;
+ } catch (error) {
+ logToFile('MariaDB: Error deleting simulation', id, error);
+ return false;
+ }
+ },
+};
+
// Initialize database
createTables().catch(error => {
console.error('Failed to initialize MariaDB:', error);
- process.exit(1);
+ logToFile('MariaDB: Initialization failed; continuing with degraded helpers', error);
});
// Export the connection pool and helpers
@@ -704,5 +873,6 @@ module.exports = {
bestiaryHelpers,
missionHelpers,
missionRollHelpers,
+ simulationHelpers,
logToFile
};
diff --git a/database/routes/simulationRoutes.js b/database/routes/simulationRoutes.js
new file mode 100644
index 0000000..8d65161
--- /dev/null
+++ b/database/routes/simulationRoutes.js
@@ -0,0 +1,526 @@
+'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 m = String(formula).match(/(\d+)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];
+ 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 || {};
+ return {
+ name: dbPlayer.name,
+ chapter: tab.chapter || 'Unknown',
+ spec: tab.speciality || 'Unknown',
+ WS: chars.WS || 50,
+ BS: chars.BS || 50,
+ S: chars.S || 50,
+ T: chars.T || 50,
+ Ag: chars.Ag || 50,
+ Int: chars.Int || 50,
+ Per: chars.Per || 50,
+ Wp: chars.Wp || 50,
+ Fel: chars.Fel || 50,
+ wounds: tab.wounds || 12,
+ maxWounds: tab.wounds || 12,
+ fate: tab.fate || 1,
+ maxFate: tab.fate || 1,
+ weapons: tab.weapons || [],
+ conditions: [],
+ fateUsed: false,
+ rolls: [],
+ successCount: 0,
+ failCount: 0,
+ fateSpends: 0,
+ heroMoments: [],
+ shameList: [],
+ };
+}
+
+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) {
+ // 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 hit = d100() <= (enemy.bs || 45);
+ 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
+ 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, rollFeed, sceneTitle);
+ } else {
+ const weapon = player.weapons[0] || { name: 'Bolter', damage: '1d10+4', type: 'ranged' };
+ const statName = (weapon.type === 'melee') ? 'WS' : 'BS';
+ const base = player[statName] || 50;
+ const cMod = conditionMod(player, statName);
+ const r = roll(base, cMod);
+ const firstEnemy = enemies.find(e => e.wounds > 0);
+ rollFeed.push({
+ player: player.name, checkName: `Attack (${weapon.name || 'weapon'})`,
+ sceneName: sceneTitle, statName, base, modifier: cMod,
+ result: r.result, effective: r.effective, success: r.success, dos: r.dos, dof: r.dof,
+ });
+ if (r.success) {
+ player.successCount++;
+ if (firstEnemy) {
+ const dmg = dX(weapon.damage || '1d10+4') + Math.max(0, r.dos - 1);
+ 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) {
+ 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';
+ if (raw.length > 0) {
+ return raw.map((e, ei) => ({
+ name: e.name || `Enemy ${ei+1}`,
+ wounds: e.wounds || baseWounds + ei * 2,
+ bs: e.bs || baseBs,
+ ws: e.ws || baseBs - 5,
+ ap: e.ap || baseAp,
+ damage: e.damage || baseDmg,
+ agBonus: e.agBonus || 4 + Math.floor(ei / 2),
+ }));
+ }
+ // No explicit enemies — generate from threat level
+ return [
+ { name: 'Tyranid Warrior', wounds: baseWounds, bs: baseBs, ws: baseBs + 5, ap: baseAp, damage: baseDmg, agBonus: 4 },
+ { name: 'Gaunt Swarm', wounds: baseWounds - 4, bs: baseBs - 5, ws: baseBs, ap: 0, damage: '1d5+2', agBonus: 5 },
+ ];
+}
+
+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) {
+ 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 fearRating = fearRatingFromScene(scene);
+ 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
+ const enemies = enemiesFromScene(scene, si);
+ const checks = (scene.checks || []).map(c => ({ name: c.name, stat: statFromCheck(c), modifier: c.modifier || 0 }));
+ const maxRounds = si === scenes.length - 2 ? 5 : 4; // boss scene gets extra round
+
+ let round = 1;
+ while (round <= maxRounds && enemies.some(e => e.wounds > 0) && playerStates.some(isAlive)) {
+ totalRounds++;
+ combatRound(round, playerStates, enemies, checks, title, rollFeed, sceneEvents);
+ 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, enemiesRemaining: enemies.filter(e=>e.wounds>0).length, events: sceneEvents });
+ } else {
+ // Skill scene
+ 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, 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 });
+ // Minor wound for narrative complications
+ 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
+ for (const p of playerStates) {
+ if (p.conditions.includes('Fatigued') && Math.random() < 0.5) 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,
+ 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,
+ };
+}
+
+// ─── Routes ──────────────────────────────────────────────────────────────────
+
+// POST /api/simulations — run a new simulation and save it
+router.post('/', async (req, res) => {
+ try {
+ const { mission_id, player_names } = 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);
+
+ if (!playerStates.length) return res.status(400).json({ error: 'No valid players found' });
+
+ const simResult = runSimulation(mission, playerStates);
+
+ 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);
+ } 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);
+ } 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' });
+ res.json(sim);
+ } 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;
diff --git a/database/server.js b/database/server.js
index e7ac932..44aff83 100755
--- a/database/server.js
+++ b/database/server.js
@@ -12,6 +12,7 @@ const bestiaryRoutes = require('./routes/bestiaryRoutes');
const weaponsRoutes = require('./routes/weaponsRoutes');
const rulesStagingRoutes = require('./routes/rulesStagingRoutes');
const missionRoutes = require('./routes/missionRoutes');
+const simulationRoutes = require('./routes/simulationRoutes');
// const rulesRoutes = require('./routes/rulesRoutes-simple');
const gmkitDir = path.join(__dirname, '..', 'data', 'gamemasters_kit');
@@ -103,6 +104,14 @@ try {
console.error('Error mounting /api/missions:', e && e.stack ? e.stack : e);
}
+ try {
+ console.log('Registering /api/simulations');
+ app.use('/api/simulations', simulationRoutes);
+ console.log('Simulation routes registered');
+ } catch (e) {
+ console.error('Error mounting /api/simulations:', e && e.stack ? e.stack : e);
+ }
+
// Expose gamemaster kit files and a simple listing API for GM-only resources
try {
console.log('Registering /api/gmkit and /gmkit static');
diff --git a/src/App.js b/src/App.js
index fff8aa9..77e43aa 100755
--- a/src/App.js
+++ b/src/App.js
@@ -9,6 +9,7 @@ import WeaponsTab from './components/WeaponsTab';
import GMKit from './components/GMKit';
import PlayerManagement from './components/PlayerManagement';
import MissionTab from './components/MissionTab';
+import SimulationTab from './components/SimulationTab';
import { useState, useEffect } from 'react';
import axios from 'axios';
import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger';
@@ -353,6 +354,12 @@ function App() {
>
GM Kit
+
>
)}
@@ -392,7 +399,7 @@ function App() {
)}
- {tab==='roller' ?
The Bestiary is only accessible to Game Masters. Please log in with a GM account.
The Bestiary is only accessible to Game Masters. Please log in with a GM account.
GM-only — run statistical simulations of missions against player stat sheets
+