From a666b8b4b4d5bb23904a91496453450de9780e7e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 24 Jun 2026 10:31:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Simulation=20Lab=20=E2=80=94=20GM=20tab?= =?UTF-8?q?=20to=20run=20and=20review=20mission=20simulations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add `simulations` table to MariaDB (mission, players, result, XP, roll feed, player cards, scene log, findings — all persisted as JSON) - simulationHelpers: save / getAll / getById / delete - simulationRoutes.js: POST /api/simulations (run engine + save), GET /api/simulations (list), GET /:id (full report), DELETE /:id - Simulation engine: d100 dice, fear tests, combat rounds with initiative, conditions, damage, fate re-rolls, skill checks, auto-assign checks to best-stat player, enemy pools from scene data, per-player report cards with grade A-D, hero moments, shame list Frontend: - SimulationTab.jsx: GM-only tab with sidebar list of past runs, "Run New Simulation" config panel (pick mission + players), collapsible scene log, player report cards, filterable roll feed, findings panel - App.js: import SimulationTab, add "⚙ Sim Lab" nav button (GM only) Co-Authored-By: Claude Sonnet 4.6 --- database/mariadb.js | 180 +++++++++- database/routes/simulationRoutes.js | 526 ++++++++++++++++++++++++++++ database/server.js | 9 + src/App.js | 9 +- src/components/SimulationTab.jsx | 416 ++++++++++++++++++++++ 5 files changed, 1134 insertions(+), 6 deletions(-) create mode 100644 database/routes/simulationRoutes.js create mode 100644 src/components/SimulationTab.jsx 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' ? : tab==='shop' ? : tab==='rules' ? : tab==='weapons' ? : tab==='bestiary' ? (authedPlayer === 'gm' ? :

Access Denied

The Bestiary is only accessible to Game Masters. Please log in with a GM account.

) : tab==='players' ? : tab==='gmkit' ? : tab==='mission' ? : : tab==='shop' ? : tab==='rules' ? : tab==='weapons' ? : tab==='bestiary' ? (authedPlayer === 'gm' ? :

Access Denied

The Bestiary is only accessible to Game Masters. Please log in with a GM account.

) : tab==='players' ? : tab==='gmkit' ? : tab==='simulation' ? : tab==='mission' ? : } diff --git a/src/components/SimulationTab.jsx b/src/components/SimulationTab.jsx new file mode 100644 index 0000000..3f40740 --- /dev/null +++ b/src/components/SimulationTab.jsx @@ -0,0 +1,416 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function gradeColor(grade) { + if (grade === 'A') return 'text-green-300'; + if (grade === 'B') return 'text-yellow-300'; + if (grade === 'C') return 'text-orange-300'; + return 'text-red-400'; +} + +function resultBadge(result) { + if (result === 'VICTORY') + return VICTORY; + return PARTIAL FAILURE; +} + +function SceneRow({ scene, idx }) { + const [open, setOpen] = useState(false); + const isCombat = scene.type === 'combat' || scene.cleared !== undefined; + const passed = isCombat ? scene.cleared : scene.outcome === 'success'; + + return ( +
+ + {open && ( +
+ {isCombat && ( +
{scene.cleared + ? All enemies eliminated. + : {scene.enemiesRemaining} enemies remained — squad withdrew.} +
+ )} + {!isCombat && scene.total != null && ( +
{scene.successes}/{scene.total} checks passed — {passed ? 'Success' : 'Partial'}
+ )} + {(scene.events || []).length > 0 && ( +
+ {scene.events.map((ev, i) => ( +
+ {ev.type === 'crit' && ⚡ {ev.player} — Righteous Fury on {ev.target} ({ev.dos} DoS)} + {ev.type === 'kill' && ☠ {ev.player} slew {ev.target}} + {ev.type === 'down' && 💀 {ev.player} went down (round {ev.round})} + {ev.type === 'heroCheck' && ★ {ev.player} — {ev.check} ({ev.dos} DoS)} + {ev.type === 'complication'&& ⚡ Complication ({ev.player}): {ev.text?.slice(0, 80)}} + {ev.type === 'wound' && 🩸 {ev.player} took {ev.net} wounds from complication} +
+ ))} +
+ )} +
+ )} +
+ ); +} + +function PlayerCard({ card }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
+ {card.heroMoments?.length > 0 && ( +
+
★ Hero Moments
+ {card.heroMoments.map((h, i) =>
• {h}
)} +
+ )} + {card.shameList?.length > 0 && ( +
+
✗ Shame List
+ {card.shameList.map((s, i) =>
• {s}
)} +
+ )} + {!card.heroMoments?.length && !card.shameList?.length && ( +
No notable moments recorded.
+ )} +
+ )} +
+ ); +} + +function RollFeedPanel({ feed, filterPlayer }) { + const visible = filterPlayer ? feed.filter(r => r.player === filterPlayer) : feed; + const players = [...new Set(feed.map(r => r.player))]; + + return ( +
+ {visible.length === 0 &&
No rolls recorded.
} + {visible.map((r, i) => ( +
+ {r.success ? '✅' : '❌'} + {r.player} + {r.checkName} + {r.result}/{r.effective} + {r.success && r.dos > 0 && {r.dos}DoS} + {!r.success && r.dof > 0 && {r.dof}DoF} + {r.fateReroll && } +
+ ))} +
+ ); +} + +function SimReport({ sim }) { + const [rollFilter, setRollFilter] = useState(''); + if (!sim) return null; + const cards = Object.values(sim.player_cards || {}); + const feed = sim.roll_feed || []; + const scenes = sim.scene_results || []; + const players = [...new Set(feed.map(r => r.player))]; + + return ( +
+ {/* Header */} +
+
+

{sim.mission_name}

+ {resultBadge(sim.result)} +
+
+ 📅 {new Date(sim.run_date).toLocaleString()} + ⚔ {sim.xp_earned} XP + 🎲 {sim.total_rolls} rolls + = 60 ? 'text-green-300' : 'text-orange-300'}>{sim.success_rate}% success + {sim.total_rounds} combat rounds + Players: {(sim.players || []).join(', ')} +
+
+ + {/* Scene Summary */} +
+

SCENE LOG

+ {scenes.map((s, i) => )} +
+ + {/* Player Cards */} +
+

BATTLE-BROTHER REPORT CARDS

+
+ {cards.map(c => )} +
+
+ + {/* Roll Feed */} +
+
+

FULL ROLL FEED

+ +
+
+ +
+
+ + {/* Findings */} + {(sim.findings || []).length > 0 && ( +
+

⚡ FINDINGS FOR NEXT SESSION

+ {sim.findings.map((f, i) => ( +
• {f}
+ ))} +
+ )} +
+ ); +} + +// ─── Main Tab ───────────────────────────────────────────────────────────────── +export default function SimulationTab() { + const [missions, setMissions] = useState([]); + const [players, setPlayers] = useState([]); + const [simList, setSimList] = useState([]); + const [selected, setSelected] = useState(null); + const [fullSim, setFullSim] = useState(null); + const [running, setRunning] = useState(false); + const [runError, setRunError] = useState(''); + const [missionId, setMissionId] = useState(''); + const [chosenPlayers, setChosenPlayers] = useState([]); + const [showConfig, setShowConfig] = useState(false); + const [loadingFull, setLoadingFull] = useState(false); + + const loadList = useCallback(async () => { + try { + const [ms, ps, ss] = await Promise.all([ + axios.get('/api/missions'), + axios.get('/api/players'), + axios.get('/api/simulations'), + ]); + setMissions(ms.data || []); + const nonGm = (ps.data || []).filter(p => p.name !== 'gm').map(p => p.name); + setPlayers(nonGm); + if (!chosenPlayers.length) setChosenPlayers(nonGm); + setSimList(ss.data || []); + } catch {} + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { loadList(); }, [loadList]); + + const openSim = async (sim) => { + setSelected(sim); + setFullSim(null); + setLoadingFull(true); + try { + const { data } = await axios.get(`/api/simulations/${sim.id}`); + setFullSim(data); + } catch {} + setLoadingFull(false); + }; + + const runSim = async () => { + setRunning(true); + setRunError(''); + try { + const payload = {}; + if (missionId) payload.mission_id = Number(missionId); + if (chosenPlayers.length) payload.player_names = chosenPlayers; + const { data } = await axios.post('/api/simulations', payload); + await loadList(); + setSelected(data); + setFullSim(data); + setShowConfig(false); + } catch (err) { + setRunError(err?.response?.data?.error || 'Simulation failed'); + } + setRunning(false); + }; + + const deleteSim = async (id, e) => { + e.stopPropagation(); + try { + await axios.delete(`/api/simulations/${id}`); + if (selected?.id === id) { setSelected(null); setFullSim(null); } + await loadList(); + } catch {} + }; + + const togglePlayer = (name) => { + setChosenPlayers(prev => + prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name] + ); + }; + + return ( +
+
+ {/* Top bar */} +
+
+

⚙ Mission Simulation Lab

+

GM-only — run statistical simulations of missions against player stat sheets

+
+ +
+ + {/* Config panel */} + {showConfig && ( +
+

Configure Simulation

+
+
+ + +
+
+ +
+ {players.map(p => ( + + ))} +
+
+
+ {runError &&
{runError}
} +
+ + +
+
+ )} + +
+ {/* Sidebar — simulation list */} +
+

Past Simulations ({simList.length})

+ {simList.length === 0 && ( +
+ No simulations yet.
Run one to get started. +
+ )} +
+ {simList.map(sim => ( + +
+
+
+ {new Date(sim.run_date).toLocaleDateString()} + = 60 ? 'text-green-400' : 'text-orange-400'}>{sim.success_rate}% + {sim.xp_earned} XP +
+
{(sim.players || []).join(', ')}
+ + ))} +
+
+ + {/* Main panel — report */} +
+ {!selected && !showConfig && ( +
+
+
Select a past simulation or run a new one.
+
+ )} + {selected && loadingFull && ( +
Loading simulation report…
+ )} + {selected && !loadingFull && fullSim && ( + + )} +
+
+ + + ); +}