feat: Simulation Lab — GM tab to run and review mission simulations

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 <[email protected]>
This commit is contained in:
2026-06-24 10:31:27 +02:00
co-authored by Claude Sonnet 4.6
parent a7721006ef
commit a666b8b4b4
5 changed files with 1134 additions and 6 deletions
+175 -5
View File
@@ -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
};
+526
View File
@@ -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;
+9
View File
@@ -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');
+8 -1
View File
@@ -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
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='simulation' ? 'bg-purple-700 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'simulation' }); setTab('simulation')}}
>
Sim Lab
</button>
</>
)}
</div>
@@ -392,7 +399,7 @@ function App() {
</div>
)}
{tab==='roller' ? <DeathwatchRoller authedPlayer={authedPlayer} /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : tab==='mission' ? <MissionTab authedPlayer={authedPlayer} /> : <PlayerTab
{tab==='roller' ? <DeathwatchRoller authedPlayer={authedPlayer} /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : tab==='simulation' ? <SimulationTab /> : tab==='mission' ? <MissionTab authedPlayer={authedPlayer} /> : <PlayerTab
authedPlayer={authedPlayer}
sessionId={sessionId}
/>}
+416
View File
@@ -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 <span className="px-2 py-0.5 rounded text-xs font-bold bg-green-700/60 text-green-200">VICTORY</span>;
return <span className="px-2 py-0.5 rounded text-xs font-bold bg-red-800/60 text-red-200">PARTIAL FAILURE</span>;
}
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 (
<div className="border border-slate-700/40 rounded-lg mb-1 overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-3 py-2 bg-slate-800/60 hover:bg-slate-700/60 text-sm text-left"
>
<span className="flex items-center gap-2">
<span className="text-slate-400 w-5 text-right">{idx + 1}.</span>
<span className={passed ? 'text-green-300' : 'text-yellow-300'}>{passed ? '✅' : '⚠️'}</span>
<span className="text-slate-100 font-medium">{scene.title}</span>
<span className="text-slate-500 text-xs">[{scene.type}]</span>
</span>
<span className="text-slate-500 text-xs">{open ? '▲' : '▼'}</span>
</button>
{open && (
<div className="px-4 py-2 bg-slate-900/40 text-xs text-slate-300 space-y-1">
{isCombat && (
<div>{scene.cleared
? <span className="text-green-300">All enemies eliminated.</span>
: <span className="text-yellow-300">{scene.enemiesRemaining} enemies remained squad withdrew.</span>}
</div>
)}
{!isCombat && scene.total != null && (
<div>{scene.successes}/{scene.total} checks passed <span className={passed ? 'text-green-300' : 'text-yellow-300'}>{passed ? 'Success' : 'Partial'}</span></div>
)}
{(scene.events || []).length > 0 && (
<div className="mt-1 space-y-0.5">
{scene.events.map((ev, i) => (
<div key={i} className="text-slate-400">
{ev.type === 'crit' && <span className="text-yellow-300"> {ev.player} Righteous Fury on {ev.target} ({ev.dos} DoS)</span>}
{ev.type === 'kill' && <span className="text-green-300"> {ev.player} slew {ev.target}</span>}
{ev.type === 'down' && <span className="text-red-400">💀 {ev.player} went down (round {ev.round})</span>}
{ev.type === 'heroCheck' && <span className="text-cyan-300"> {ev.player} {ev.check} ({ev.dos} DoS)</span>}
{ev.type === 'complication'&& <span className="text-orange-300"> Complication ({ev.player}): {ev.text?.slice(0, 80)}</span>}
{ev.type === 'wound' && <span className="text-red-300">🩸 {ev.player} took {ev.net} wounds from complication</span>}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
function PlayerCard({ card }) {
const [open, setOpen] = useState(false);
return (
<div className="border border-slate-700/50 rounded-xl overflow-hidden bg-slate-800/40">
<button onClick={() => setOpen(o => !o)} className="w-full text-left p-3 hover:bg-slate-700/30">
<div className="flex items-center justify-between">
<div>
<span className="font-bold text-white text-sm">{card.name.toUpperCase()}</span>
<span className="ml-2 text-xs text-slate-400">{card.chapter} {card.spec}</span>
</div>
<span className={`text-2xl font-black ${gradeColor(card.grade)}`}>{card.grade}</span>
</div>
<div className="flex gap-3 mt-1 text-xs">
<span className={card.alive ? 'text-green-300' : 'text-red-400'}>
{card.alive ? `ALIVE (${card.woundsRemaining}/${card.maxWounds}W)` : 'DOWN'}
</span>
<span className="text-slate-400">{card.successRate}% success</span>
<span className="text-green-400">{card.successCount}</span>
<span className="text-red-400">{card.failCount}</span>
{card.fateSpends > 0 && <span className="text-purple-300"> Fate ×{card.fateSpends}</span>}
</div>
{card.conditions?.length > 0 && (
<div className="mt-1 text-xs text-yellow-300">Conditions: {card.conditions.join(', ')}</div>
)}
</button>
{open && (
<div className="border-t border-slate-700/40 px-3 py-2 text-xs space-y-2">
{card.heroMoments?.length > 0 && (
<div>
<div className="text-green-300 font-semibold mb-0.5"> Hero Moments</div>
{card.heroMoments.map((h, i) => <div key={i} className="text-slate-300 pl-2"> {h}</div>)}
</div>
)}
{card.shameList?.length > 0 && (
<div>
<div className="text-red-400 font-semibold mb-0.5"> Shame List</div>
{card.shameList.map((s, i) => <div key={i} className="text-slate-400 pl-2"> {s}</div>)}
</div>
)}
{!card.heroMoments?.length && !card.shameList?.length && (
<div className="text-slate-500">No notable moments recorded.</div>
)}
</div>
)}
</div>
);
}
function RollFeedPanel({ feed, filterPlayer }) {
const visible = filterPlayer ? feed.filter(r => r.player === filterPlayer) : feed;
const players = [...new Set(feed.map(r => r.player))];
return (
<div className="space-y-1 max-h-72 overflow-y-auto pr-1">
{visible.length === 0 && <div className="text-slate-500 text-sm">No rolls recorded.</div>}
{visible.map((r, i) => (
<div key={i} className={`flex items-start gap-2 text-xs px-2 py-1 rounded ${r.success ? 'bg-green-900/20' : 'bg-red-900/20'}`}>
<span className="mt-0.5 shrink-0">{r.success ? '✅' : '❌'}</span>
<span className="font-semibold text-slate-300 w-24 shrink-0">{r.player}</span>
<span className="text-slate-400 flex-1">{r.checkName}</span>
<span className="text-slate-500 shrink-0">{r.result}/{r.effective}</span>
{r.success && r.dos > 0 && <span className="text-cyan-400 shrink-0">{r.dos}DoS</span>}
{!r.success && r.dof > 0 && <span className="text-red-400 shrink-0">{r.dof}DoF</span>}
{r.fateReroll && <span className="text-purple-300 shrink-0"></span>}
</div>
))}
</div>
);
}
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 (
<div className="space-y-4">
{/* Header */}
<div className="bg-slate-800/60 rounded-xl p-4 border border-slate-700/50">
<div className="flex flex-wrap items-center gap-3 mb-2">
<h2 className="text-lg font-bold text-white">{sim.mission_name}</h2>
{resultBadge(sim.result)}
</div>
<div className="flex flex-wrap gap-4 text-sm">
<span className="text-slate-400">📅 {new Date(sim.run_date).toLocaleString()}</span>
<span className="text-yellow-300 font-bold"> {sim.xp_earned} XP</span>
<span className="text-slate-300">🎲 {sim.total_rolls} rolls</span>
<span className={sim.success_rate >= 60 ? 'text-green-300' : 'text-orange-300'}>{sim.success_rate}% success</span>
<span className="text-slate-400">{sim.total_rounds} combat rounds</span>
<span className="text-slate-400">Players: {(sim.players || []).join(', ')}</span>
</div>
</div>
{/* Scene Summary */}
<div>
<h3 className="text-sm font-semibold text-slate-300 mb-2">SCENE LOG</h3>
{scenes.map((s, i) => <SceneRow key={i} scene={s} idx={i} />)}
</div>
{/* Player Cards */}
<div>
<h3 className="text-sm font-semibold text-slate-300 mb-2">BATTLE-BROTHER REPORT CARDS</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{cards.map(c => <PlayerCard key={c.name} card={c} />)}
</div>
</div>
{/* Roll Feed */}
<div>
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-semibold text-slate-300">FULL ROLL FEED</h3>
<select
value={rollFilter}
onChange={e => setRollFilter(e.target.value)}
className="ml-auto text-xs bg-slate-700 border border-slate-600 rounded px-2 py-0.5 text-slate-200"
>
<option value="">All players</option>
{players.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div className="bg-slate-900/40 rounded-lg p-2">
<RollFeedPanel feed={feed} filterPlayer={rollFilter || null} />
</div>
</div>
{/* Findings */}
{(sim.findings || []).length > 0 && (
<div className="bg-amber-900/20 border border-amber-700/40 rounded-xl p-3">
<h3 className="text-sm font-semibold text-amber-300 mb-2"> FINDINGS FOR NEXT SESSION</h3>
{sim.findings.map((f, i) => (
<div key={i} className="text-sm text-amber-100 pl-2 mb-1"> {f}</div>
))}
</div>
)}
</div>
);
}
// ─── 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 (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4">
<div className="max-w-7xl mx-auto">
{/* Top bar */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h1 className="text-xl font-bold text-white"> Mission Simulation Lab</h1>
<p className="text-xs text-slate-400 mt-0.5">GM-only run statistical simulations of missions against player stat sheets</p>
</div>
<button
onClick={() => setShowConfig(c => !c)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg text-sm"
>
Run New Simulation
</button>
</div>
{/* Config panel */}
{showConfig && (
<div className="bg-slate-800/70 border border-blue-700/40 rounded-xl p-4 mb-4 space-y-3">
<h2 className="text-sm font-semibold text-blue-300">Configure Simulation</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-xs text-slate-400 block mb-1">Mission</label>
<select
value={missionId}
onChange={e => setMissionId(e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-slate-200 text-sm"
>
<option value="">Active mission</option>
{missions.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">Players</label>
<div className="flex flex-wrap gap-2">
{players.map(p => (
<button
key={p}
onClick={() => togglePlayer(p)}
className={`px-2 py-0.5 rounded text-xs font-medium border transition-all ${
chosenPlayers.includes(p)
? 'bg-blue-600/70 border-blue-500 text-white'
: 'bg-slate-700/50 border-slate-600 text-slate-400'
}`}
>
{p}
</button>
))}
</div>
</div>
</div>
{runError && <div className="text-red-400 text-xs">{runError}</div>}
<div className="flex gap-2">
<button
onClick={runSim}
disabled={running || !chosenPlayers.length}
className="px-4 py-2 bg-green-700 hover:bg-green-600 disabled:opacity-50 text-white font-semibold rounded-lg text-sm"
>
{running ? '⏳ Running…' : '▶ Run Simulation'}
</button>
<button
onClick={() => setShowConfig(false)}
className="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg text-sm"
>
Cancel
</button>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Sidebar — simulation list */}
<div className="lg:col-span-1">
<h2 className="text-xs font-semibold text-slate-400 uppercase mb-2">Past Simulations ({simList.length})</h2>
{simList.length === 0 && (
<div className="text-slate-500 text-sm bg-slate-800/40 rounded-lg p-4 text-center">
No simulations yet.<br/>Run one to get started.
</div>
)}
<div className="space-y-1 max-h-[70vh] overflow-y-auto pr-1">
{simList.map(sim => (
<button
key={sim.id}
onClick={() => openSim(sim)}
className={`w-full text-left rounded-lg p-3 border transition-all ${
selected?.id === sim.id
? 'bg-blue-900/40 border-blue-600/60'
: 'bg-slate-800/40 border-slate-700/40 hover:bg-slate-700/40'
}`}
>
<div className="flex items-center justify-between gap-1">
<span className="text-xs font-medium text-slate-200 truncate">{sim.mission_name}</span>
<div className="flex items-center gap-1 shrink-0">
{resultBadge(sim.result)}
<button
onClick={e => deleteSim(sim.id, e)}
className="text-slate-600 hover:text-red-400 text-xs px-1"
title="Delete"
></button>
</div>
</div>
<div className="flex gap-2 text-xs text-slate-500 mt-1">
<span>{new Date(sim.run_date).toLocaleDateString()}</span>
<span className={sim.success_rate >= 60 ? 'text-green-400' : 'text-orange-400'}>{sim.success_rate}%</span>
<span className="text-yellow-400">{sim.xp_earned} XP</span>
</div>
<div className="text-xs text-slate-500 mt-0.5">{(sim.players || []).join(', ')}</div>
</button>
))}
</div>
</div>
{/* Main panel — report */}
<div className="lg:col-span-2">
{!selected && !showConfig && (
<div className="text-slate-500 text-center py-20 bg-slate-800/20 rounded-xl border border-slate-700/30">
<div className="text-4xl mb-3"></div>
<div className="text-sm">Select a past simulation or run a new one.</div>
</div>
)}
{selected && loadingFull && (
<div className="text-slate-400 text-center py-20">Loading simulation report</div>
)}
{selected && !loadingFull && fullSim && (
<SimReport sim={fullSim} />
)}
</div>
</div>
</div>
</div>
);
}