feat: simulation difficulty control (Recruit → Legendary)
5 tiers selectable in Sim Lab config panel. Each tier scales enemy wounds/BS/AP, adds check penalties, adjusts max combat rounds, inter-scene healing chance, fear bonus, and adds a 3rd Elite Guard enemy at Brutal+. Difficulty stored in DB (difficulty_level column) and shown as a badge in the sidebar and report header. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -185,6 +185,12 @@ const createTables = async () => {
|
||||
if (error.code !== 'ER_DUP_FIELDNAME') throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.execute(`ALTER TABLE simulations ADD COLUMN difficulty_level INT NOT NULL DEFAULT 2`);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ER_DUP_FIELDNAME') throw error;
|
||||
}
|
||||
|
||||
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_created_at ON missions(created_at)`);
|
||||
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_theme ON missions(theme)`);
|
||||
|
||||
@@ -782,8 +788,8 @@ const simulationHelpers = {
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
total_rolls, success_rate, scene_results, player_cards, roll_feed, findings, difficulty_level)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
data.mission_id || null,
|
||||
data.mission_name || 'Unknown Mission',
|
||||
@@ -797,6 +803,7 @@ const simulationHelpers = {
|
||||
JSON.stringify(data.player_cards || {}),
|
||||
JSON.stringify(data.roll_feed || []),
|
||||
JSON.stringify(data.findings || []),
|
||||
data.difficulty_level || 2,
|
||||
]
|
||||
);
|
||||
return result.insertId;
|
||||
@@ -811,7 +818,7 @@ const simulationHelpers = {
|
||||
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
|
||||
total_rounds, total_rolls, success_rate, findings, difficulty_level, run_date
|
||||
FROM simulations ORDER BY run_date DESC LIMIT ${bounded}`
|
||||
);
|
||||
return rows.map(r => ({
|
||||
|
||||
@@ -168,7 +168,7 @@ function fearTest(player, fearRating, sceneTitle, rollFeed) {
|
||||
}
|
||||
|
||||
// ─── Combat round ────────────────────────────────────────────────────────────
|
||||
function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, events) {
|
||||
function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, events, diff) {
|
||||
// Initiative
|
||||
const initiatives = [
|
||||
...players.map(p => ({ name: p.name, total: Math.floor(p.Ag / 10) + d10(), isNpc: false })),
|
||||
@@ -202,9 +202,10 @@ function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, e
|
||||
continue;
|
||||
}
|
||||
// 50% chance to do a check vs attack
|
||||
const diffCheckMod = (diff && diff.checkMod) || 0;
|
||||
if (checks.length > 0 && Math.random() < 0.45) {
|
||||
const chk = checks[Math.floor(Math.random() * checks.length)];
|
||||
performCheck(player, chk.name, chk.stat, chk.modifier || 0, rollFeed, sceneTitle);
|
||||
performCheck(player, chk.name, chk.stat, (chk.modifier || 0) + diffCheckMod, rollFeed, sceneTitle);
|
||||
} else {
|
||||
const weapon = player.weapons[0] || { name: 'Bolter', damage: '1d10+4', type: 'ranged' };
|
||||
const statName = (weapon.type === 'melee') ? 'WS' : 'BS';
|
||||
@@ -267,28 +268,48 @@ function assignChecks(scene, players) {
|
||||
}
|
||||
|
||||
// ─── Derive enemy pool from scene ────────────────────────────────────────────
|
||||
function enemiesFromScene(scene, sceneIndex) {
|
||||
const raw = scene.enemies || [];
|
||||
function enemiesFromScene(scene, sceneIndex, diff) {
|
||||
const d = diff || DIFFICULTY[2];
|
||||
const raw = scene.enemies || [];
|
||||
const baseWounds = [6, 8, 10, 12, 14, 16, 18, 22, 30, 8][sceneIndex] || 10;
|
||||
const baseBs = [45, 45, 50, 45, 50, 55, 55, 50, 60, 40][sceneIndex] || 45;
|
||||
const baseAp = [0, 0, 0, 0, 2, 2, 3, 3, 4, 0][sceneIndex] || 0;
|
||||
const baseDmg = ['1d5+2','1d5+3','1d5+3','1d5+2','1d5+3','1d10+4','1d10+6','1d10+5','1d10+8','1d5'][sceneIndex] || '1d5+3';
|
||||
|
||||
const scaleW = (w) => Math.max(1, Math.round(w * d.woundsMult));
|
||||
const scaleBs = (bs) => Math.min(85, Math.max(5, bs + d.bsMod));
|
||||
|
||||
let enemies;
|
||||
if (raw.length > 0) {
|
||||
return raw.map((e, ei) => ({
|
||||
enemies = 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,
|
||||
wounds: scaleW(e.wounds || (baseWounds + ei * 2)),
|
||||
bs: scaleBs(e.bs || baseBs),
|
||||
ws: scaleBs(e.ws || (baseBs - 5)),
|
||||
ap: e.ap || baseAp,
|
||||
damage: e.damage || baseDmg,
|
||||
agBonus: e.agBonus || 4 + Math.floor(ei / 2),
|
||||
}));
|
||||
} else {
|
||||
enemies = [
|
||||
{ name: 'Tyranid Warrior', wounds: scaleW(baseWounds), bs: scaleBs(baseBs), ws: scaleBs(baseBs + 5), ap: baseAp, damage: baseDmg, agBonus: 4 },
|
||||
{ name: 'Gaunt Swarm', wounds: scaleW(baseWounds - 4), bs: scaleBs(baseBs - 5), ws: scaleBs(baseBs), ap: 0, damage: '1d5+2', agBonus: 5 },
|
||||
];
|
||||
}
|
||||
// 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 },
|
||||
];
|
||||
|
||||
if (d.extraEnemy) {
|
||||
enemies.push({
|
||||
name: 'Elite Guard',
|
||||
wounds: scaleW(Math.round(baseWounds * 0.9)),
|
||||
bs: scaleBs(baseBs),
|
||||
ws: scaleBs(baseBs + 5),
|
||||
ap: baseAp + 1,
|
||||
damage: baseDmg,
|
||||
agBonus: 5,
|
||||
});
|
||||
}
|
||||
|
||||
return enemies;
|
||||
}
|
||||
|
||||
function fearRatingFromScene(scene) {
|
||||
@@ -300,7 +321,8 @@ function fearRatingFromScene(scene) {
|
||||
}
|
||||
|
||||
// ─── Run full simulation ──────────────────────────────────────────────────────
|
||||
function runSimulation(mission, playerStates) {
|
||||
function runSimulation(mission, playerStates, diff) {
|
||||
const d = diff || DIFFICULTY[2];
|
||||
const rollFeed = [];
|
||||
const sceneResults= [];
|
||||
const allEvents = [];
|
||||
@@ -314,7 +336,8 @@ function runSimulation(mission, playerStates) {
|
||||
const title = scene.title || `Scene ${si + 1}`;
|
||||
const type = scene.type || 'intro';
|
||||
const complications = scene.complications || [];
|
||||
const fearRating = fearRatingFromScene(scene);
|
||||
const rawFear = fearRatingFromScene(scene);
|
||||
const fearRating = Math.min(4, Math.max(0, rawFear + d.fearBonus));
|
||||
let sceneSuccesses = 0;
|
||||
let sceneFailures = 0;
|
||||
const sceneEvents = [];
|
||||
@@ -328,15 +351,16 @@ function runSimulation(mission, playerStates) {
|
||||
}
|
||||
|
||||
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
|
||||
// Combat scene — difficulty scales enemy stats and max rounds
|
||||
const enemies = enemiesFromScene(scene, si, d);
|
||||
const checks = (scene.checks || []).map(c => ({ name: c.name, stat: statFromCheck(c), modifier: c.modifier || 0 }));
|
||||
const isBoss = si >= scenes.length - 2;
|
||||
const maxRounds = (isBoss ? d.maxRounds + 1 : d.maxRounds);
|
||||
|
||||
let round = 1;
|
||||
while (round <= maxRounds && enemies.some(e => e.wounds > 0) && playerStates.some(isAlive)) {
|
||||
totalRounds++;
|
||||
combatRound(round, playerStates, enemies, checks, title, rollFeed, sceneEvents);
|
||||
combatRound(round, playerStates, enemies, checks, title, rollFeed, sceneEvents, d);
|
||||
round++;
|
||||
}
|
||||
|
||||
@@ -346,12 +370,12 @@ function runSimulation(mission, playerStates) {
|
||||
|
||||
sceneResults.push({ title, type, cleared, enemiesRemaining: enemies.filter(e=>e.wounds>0).length, events: sceneEvents });
|
||||
} else {
|
||||
// Skill scene
|
||||
// Skill scene — difficulty applies check modifier
|
||||
const assignments = assignChecks(scene, playerStates);
|
||||
for (const a of assignments) {
|
||||
const player = playerStates.find(p => p.name === a.player);
|
||||
if (!player || !isAlive(player)) { sceneFailures++; continue; }
|
||||
const r = performCheck(player, a.check.name, a.stat, a.modifier, rollFeed, title);
|
||||
const r = performCheck(player, a.check.name, a.stat, a.modifier + d.checkMod, rollFeed, title);
|
||||
if (r.success) {
|
||||
sceneSuccesses++;
|
||||
if (r.dos >= 3) sceneEvents.push({ type: 'heroCheck', player: player.name, check: a.check.name, dos: r.dos });
|
||||
@@ -360,7 +384,6 @@ function runSimulation(mission, playerStates) {
|
||||
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 });
|
||||
@@ -375,9 +398,9 @@ function runSimulation(mission, playerStates) {
|
||||
|
||||
allEvents.push(...sceneEvents);
|
||||
|
||||
// Post-scene condition reset
|
||||
// Post-scene condition reset (harder difficulties offer less recovery)
|
||||
for (const p of playerStates) {
|
||||
if (p.conditions.includes('Fatigued') && Math.random() < 0.5) removeCondition(p, 'Fatigued');
|
||||
if (p.conditions.includes('Fatigued') && Math.random() < d.healChance) removeCondition(p, 'Fatigued');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,24 +448,35 @@ function runSimulation(mission, playerStates) {
|
||||
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,
|
||||
result: missionSuccess ? 'VICTORY' : 'PARTIAL_FAILURE',
|
||||
xp_earned: missionXp,
|
||||
total_rounds: totalRounds,
|
||||
total_rolls: totalRolls,
|
||||
success_rate: successRate,
|
||||
scene_results: sceneResults,
|
||||
player_cards: playerCards,
|
||||
roll_feed: rollFeed,
|
||||
findings,
|
||||
difficulty_level: d.level,
|
||||
difficulty_name: d.name,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Difficulty config ────────────────────────────────────────────────────────
|
||||
const DIFFICULTY = {
|
||||
1: { level: 1, name: 'Recruit', woundsMult: 0.60, bsMod: -10, checkMod: +10, maxRounds: 3, healChance: 0.70, extraEnemy: false, fearBonus: -1 },
|
||||
2: { level: 2, name: 'Standard', woundsMult: 1.00, bsMod: 0, checkMod: 0, maxRounds: 4, healChance: 0.50, extraEnemy: false, fearBonus: 0 },
|
||||
3: { level: 3, name: 'Hard', woundsMult: 1.25, bsMod: +5, checkMod: -10, maxRounds: 5, healChance: 0.30, extraEnemy: false, fearBonus: 0 },
|
||||
4: { level: 4, name: 'Brutal', woundsMult: 1.50, bsMod: +10, checkMod: -20, maxRounds: 5, healChance: 0.00, extraEnemy: true, fearBonus: +1 },
|
||||
5: { level: 5, name: 'Legendary', woundsMult: 2.00, bsMod: +15, checkMod: -30, maxRounds: 6, healChance: 0.00, extraEnemy: true, fearBonus: +2 },
|
||||
};
|
||||
|
||||
// ─── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/simulations — run a new simulation and save it
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { mission_id, player_names } = req.body || {};
|
||||
const { mission_id, player_names, difficulty: diffInput = 2 } = req.body || {};
|
||||
|
||||
// Load mission
|
||||
let mission;
|
||||
@@ -467,7 +501,8 @@ router.post('/', async (req, res) => {
|
||||
|
||||
if (!playerStates.length) return res.status(400).json({ error: 'No valid players found' });
|
||||
|
||||
const simResult = runSimulation(mission, playerStates);
|
||||
const diff = DIFFICULTY[Math.min(5, Math.max(1, Number(diffInput) || 2))] || DIFFICULTY[2];
|
||||
const simResult = runSimulation(mission, playerStates, diff);
|
||||
|
||||
const id = await simulationHelpers.save({
|
||||
mission_id: mission.id,
|
||||
@@ -480,7 +515,7 @@ router.post('/', async (req, res) => {
|
||||
|
||||
const saved = await simulationHelpers.getById(id);
|
||||
logToFile(`Simulation #${id} completed: ${mission.name} — ${simResult.result}`);
|
||||
res.json(saved);
|
||||
res.json({ ...saved, difficulty_name: diff.name });
|
||||
} catch (error) {
|
||||
logToFile('API: Simulation run error', error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
@@ -492,7 +527,7 @@ 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);
|
||||
res.json(sims.map(s => ({ ...s, difficulty_name: DIFFICULTY[s.difficulty_level || 2]?.name || 'Standard' })));
|
||||
} catch (error) {
|
||||
logToFile('API: Error listing simulations', error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
@@ -504,7 +539,8 @@ 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);
|
||||
const diffLevel = sim.difficulty_level || 2;
|
||||
res.json({ ...sim, difficulty_name: DIFFICULTY[diffLevel]?.name || 'Standard' });
|
||||
} catch (error) {
|
||||
logToFile('API: Error getting simulation', req.params.id, error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
// ─── Difficulty config ────────────────────────────────────────────────────────
|
||||
const DIFFICULTIES = [
|
||||
{ level: 1, name: 'Recruit', desc: 'Nerfed enemies, +10 to checks, fast combat', badge: 'bg-emerald-800/60 border-emerald-600 text-emerald-200' },
|
||||
{ level: 2, name: 'Standard', desc: 'Default campaign balance', badge: 'bg-blue-800/60 border-blue-600 text-blue-200' },
|
||||
{ level: 3, name: 'Hard', desc: '×1.25 wounds, BS+5, −10 to checks', badge: 'bg-yellow-800/60 border-yellow-500 text-yellow-200' },
|
||||
{ level: 4, name: 'Brutal', desc: '×1.5 wounds, BS+10, −20 checks, extra enemy, fear escalates', badge: 'bg-orange-800/60 border-orange-500 text-orange-200' },
|
||||
{ level: 5, name: 'Legendary', desc: '×2 wounds, BS+15, −30 checks, 2 extra fear, no recovery', badge: 'bg-red-900/60 border-red-600 text-red-200' },
|
||||
];
|
||||
|
||||
function difficultyBadge(level) {
|
||||
const d = DIFFICULTIES.find(d => d.level === level) || DIFFICULTIES[1];
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-bold border ${d.badge}`}>{d.name.toUpperCase()}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function gradeColor(grade) {
|
||||
if (grade === 'A') return 'text-green-300';
|
||||
@@ -150,6 +166,7 @@ function SimReport({ sim }) {
|
||||
<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)}
|
||||
{sim.difficulty_level && difficultyBadge(sim.difficulty_level)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span className="text-slate-400">📅 {new Date(sim.run_date).toLocaleString()}</span>
|
||||
@@ -219,6 +236,7 @@ export default function SimulationTab() {
|
||||
const [chosenPlayers, setChosenPlayers] = useState([]);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [loadingFull, setLoadingFull] = useState(false);
|
||||
const [difficulty, setDifficulty] = useState(2);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
try {
|
||||
@@ -252,7 +270,7 @@ export default function SimulationTab() {
|
||||
setRunning(true);
|
||||
setRunError('');
|
||||
try {
|
||||
const payload = {};
|
||||
const payload = { difficulty };
|
||||
if (missionId) payload.mission_id = Number(missionId);
|
||||
if (chosenPlayers.length) payload.player_names = chosenPlayers;
|
||||
const { data } = await axios.post('/api/simulations', payload);
|
||||
@@ -333,6 +351,31 @@ export default function SimulationTab() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Difficulty selector */}
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-2">Difficulty</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DIFFICULTIES.map(d => (
|
||||
<button
|
||||
key={d.level}
|
||||
onClick={() => setDifficulty(d.level)}
|
||||
title={d.desc}
|
||||
className={`px-3 py-1.5 rounded border text-xs font-semibold transition-all ${
|
||||
difficulty === d.level
|
||||
? d.badge + ' ring-2 ring-offset-1 ring-offset-slate-800 ring-white/30'
|
||||
: 'bg-slate-700/40 border-slate-600 text-slate-400 hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{d.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(() => {
|
||||
const sel = DIFFICULTIES.find(d => d.level === difficulty);
|
||||
return sel ? <p className="mt-1.5 text-xs text-slate-500">{sel.desc}</p> : null;
|
||||
})()}
|
||||
</div>
|
||||
{runError && <div className="text-red-400 text-xs">{runError}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -375,6 +418,7 @@ export default function SimulationTab() {
|
||||
<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">
|
||||
{sim.difficulty_level && sim.difficulty_level !== 2 && difficultyBadge(sim.difficulty_level)}
|
||||
{resultBadge(sim.result)}
|
||||
<button
|
||||
onClick={e => deleteSim(sim.id, e)}
|
||||
|
||||
Reference in New Issue
Block a user