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:
+175
-5
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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}
|
||||
/>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user