diff --git a/database/mariadb.js b/database/mariadb.js index 24e0dfd..9ab958d 100644 --- a/database/mariadb.js +++ b/database/mariadb.js @@ -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 => ({ diff --git a/database/routes/simulationRoutes.js b/database/routes/simulationRoutes.js index 8d65161..4b5e09f 100644 --- a/database/routes/simulationRoutes.js +++ b/database/routes/simulationRoutes.js @@ -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) }); diff --git a/src/components/SimulationTab.jsx b/src/components/SimulationTab.jsx index 3f40740..4873f81 100644 --- a/src/components/SimulationTab.jsx +++ b/src/components/SimulationTab.jsx @@ -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 ( + {d.name.toUpperCase()} + ); +} + // ─── Helpers ────────────────────────────────────────────────────────────────── function gradeColor(grade) { if (grade === 'A') return 'text-green-300'; @@ -150,6 +166,7 @@ function SimReport({ sim }) {
{sel.desc}
: null; + })()} +