Add standard scene actions and fix sim fire modes

This commit is contained in:
2026-06-30 10:19:50 +02:00
parent 44b2e283bd
commit c28187decd
3 changed files with 58 additions and 5 deletions

View File

@@ -162,13 +162,14 @@ function parseRof(weapon) {
if (weaponIsGrenade(weapon)) return { single: true, semi: null, full: null };
const rof = String(weapon?.rof || '').trim();
const parts = rof.split('/').map(part => part.trim());
const single = value => /^s$/i.test(value || '') || /^single$/i.test(value || '');
const numeric = value => {
if (!value || value === '-' || /^s$/i.test(value) || /^single$/i.test(value)) return null;
const n = Number(String(value).match(/\d+/)?.[0] || 0);
return n > 0 ? n : null;
};
return {
single: rof ? true : weaponUsesAmmo(weapon),
single: rof ? single(parts[0]) : weaponUsesAmmo(weapon),
semi: numeric(parts[1]),
full: numeric(parts[2]),
};
@@ -224,7 +225,7 @@ function chooseAttackWeapon(player, enemies = []) {
const weapons = Array.isArray(player.weapons) ? player.weapons : [];
const profile = player.combatProfile || 'balanced';
const availableGrenades = weapons.filter(w => weaponIsGrenade(w) && weaponUsesAmmo(w) && Number(w.ammoRemaining || 0) > 0);
const grenadeChance = profile === 'ranged' ? 0.24 : (profile === 'melee' ? 0.08 : 0.18);
const grenadeChance = profile === 'ranged' ? 0.32 : (profile === 'melee' ? 0.12 : 0.24);
if (availableGrenades.length && enemies.filter(e => e.wounds > 0).length > 1 && Math.random() < grenadeChance) {
return availableGrenades.find(w => /frag/i.test(w.name || '')) || availableGrenades[0];
}
@@ -434,7 +435,18 @@ function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, e
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) + diffCheckMod, rollFeed, sceneTitle);
const r = performCheck(player, chk.name, chk.stat, (chk.modifier || 0) + diffCheckMod, rollFeed, sceneTitle);
events.push({
type: 'checkRoll',
player: player.name,
check: chk.name,
stat: chk.stat,
roll: r.result,
tn: r.effective,
success: r.success,
dos: r.dos,
dof: r.dof,
});
} else {
const weapon = chooseAttackWeapon(player, enemies);
const fireMode = chooseFireMode(weapon);
@@ -635,6 +647,17 @@ function runSimulation(mission, playerStates, diff, options = {}) {
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 + d.checkMod, rollFeed, title);
sceneEvents.push({
type: 'checkRoll',
player: player.name,
check: a.check.name,
stat: a.stat,
roll: r.result,
tn: r.effective,
success: r.success,
dos: r.dos,
dof: r.dof,
});
if (r.success) {
sceneSuccesses++;
if (r.dos >= 3) sceneEvents.push({ type: 'heroCheck', player: player.name, check: a.check.name, dos: r.dos });

View File

@@ -99,6 +99,19 @@ function defaultChecksForScene(scene) {
];
}
const STANDARD_ACTIONS = [
{ name: 'Scan for threats', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Spot hidden enemies, traps, movement, or useful battlefield detail.' },
{ name: 'Read tactical situation', skill: 'Tactics', characteristic: 'Int', target: 45, modifier: 0, rulesNote: 'Use Tactics to assess doctrine, priority targets, ambush risk, or best approach. This is not a free Squad Mode ability.', reward: 'Identify a tactical edge or priority threat.' },
{ name: 'Coordinate squad action', skill: 'Command', characteristic: 'Fel', target: 45, modifier: 0, reward: 'Give the kill-team a clear direction or steady an ally under pressure.' },
{ name: 'Operate or bypass machine', skill: 'Tech-Use', characteristic: 'Int', target: 45, modifier: 0, reward: 'Open, disable, decode, stabilize, or exploit a machine system.' },
{ name: 'Diagnose biological threat', skill: 'Medicae', characteristic: 'Int', target: 45, modifier: 0, reward: 'Identify injury, mutation, toxin, infection, or usable biological data.' },
{ name: 'Scrutinise motive or lie', skill: 'Scrutiny', characteristic: 'Per', target: 45, modifier: 0, reward: 'Read fear, deception, corruption, or hidden intent.' },
{ name: 'Force open or hold position', skill: 'Strength', characteristic: 'S', target: 45, modifier: 0, reward: 'Move an obstacle, hold a door, brace a collapse, or force a path.' },
{ name: 'Move under pressure', skill: 'Agility', characteristic: 'Ag', target: 45, modifier: 0, reward: 'Cross a gap, dodge debris, move fast, or reach cover.' },
{ name: 'Set or disarm charges', skill: 'Demolition', characteristic: 'Int', target: 45, modifier: 0, reward: 'Place explosives, disarm charges, or identify weak structural points.' },
{ name: 'Resist fear or warp pressure', skill: 'Willpower', characteristic: 'Wp', target: 45, modifier: 0, reward: 'Hold discipline against fear, psychic pressure, or corruption.' },
];
function combatForScene(scene) {
if (scene?.combat && typeof scene.combat === 'object') return scene.combat;
const enemies = sceneEnemies(scene);
@@ -881,6 +894,7 @@ export default function MissionTab({ authedPlayer }) {
return {
player: current.player || defaultPlayer,
checkName: current.checkName || checks[0]?.name || '',
standardActionName: current.standardActionName || '',
skillDifficultyModifier: current.skillDifficultyModifier ?? scene?.skillDifficultyModifier ?? 0,
skillSituationModifiers: Array.isArray(current.skillSituationModifiers)
? current.skillSituationModifiers
@@ -977,7 +991,8 @@ export default function MissionTab({ authedPlayer }) {
async function rollMiniSceneCheck(sceneIndex, scene) {
const state = miniState(sceneIndex, scene);
const checks = listForScene(scene, 'checks').length ? listForScene(scene, 'checks') : defaultChecksForScene(scene);
const check = checks.find(c => c.name === state.checkName) || checks[0];
const skillChoices = [...checks, ...STANDARD_ACTIONS];
const check = skillChoices.find(c => c.name === state.checkName) || checks[0] || STANDARD_ACTIONS[0];
if (!check || !state.player) return;
if (!scene?.rollsUnlocked && hasMiniSkillRolled(sceneIndex, scene, state.player, check)) return;
const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex;
@@ -1154,11 +1169,12 @@ export default function MissionTab({ authedPlayer }) {
function renderMiniSceneRoller(scene, sceneIndex, opts = {}) {
if (!scene) return null;
const checks = listForScene(scene, 'checks').length ? listForScene(scene, 'checks') : defaultChecksForScene(scene);
const skillChoices = [...checks, ...STANDARD_ACTIONS];
const enemies = sceneEnemyObjects(scene);
const state = miniState(sceneIndex, scene);
const combatStateForScene = scene.combatState || {};
const initiatives = Array.isArray(combatStateForScene.initiatives) ? combatStateForScene.initiatives : [];
const selectedCheck = checks.find(check => check.name === state.checkName) || checks[0];
const selectedCheck = skillChoices.find(check => check.name === state.checkName) || checks[0] || STANDARD_ACTIONS[0];
const selectedPlayer = state.player || (isGM ? playerList[0] : authedPlayer);
const skillModifiersVisible = playerCanSeeSkillConditions(scene);
const skillBreakdown = selectedCheck && selectedPlayer
@@ -1232,6 +1248,19 @@ export default function MissionTab({ authedPlayer }) {
>
{checks.map(check => <option key={check.name} value={check.name}>{check.name}</option>)}
</select>
<label className="grid gap-1 text-xs text-slate-400">
Standard actions
<select
value={STANDARD_ACTIONS.some(action => action.name === state.checkName) ? state.checkName : ''}
onChange={e => setMiniState(sceneIndex, { checkName: e.target.value || checks[0]?.name || '' })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm text-slate-100"
>
<option value="">Use scene skill</option>
{STANDARD_ACTIONS.map(action => (
<option key={action.name} value={action.name}>{action.name} · {action.skill}</option>
))}
</select>
</label>
{isGM && (
<div className="grid gap-2 rounded border border-slate-800 bg-slate-900/50 p-2">
<div className="flex items-center justify-between gap-2">

View File

@@ -75,6 +75,7 @@ function SceneRow({ scene, idx }) {
{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 === 'checkRoll' && <span className={ev.success ? 'text-green-300' : 'text-red-300'}>{ev.player} {ev.check} [{ev.stat}] {ev.roll}/{ev.tn} {ev.success ? `${ev.dos} DoS` : `${ev.dof} DoF`}</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>}