diff --git a/database/routes/missionRoutes.js b/database/routes/missionRoutes.js index c819a2e..76b1885 100644 --- a/database/routes/missionRoutes.js +++ b/database/routes/missionRoutes.js @@ -1,7 +1,41 @@ const express = require('express'); const { missionHelpers, missionRollHelpers, logToFile } = require('../mariadb'); +const { battlemapVisibleForPlayer, buildBattlemapStateForScene } = require('../../src/shared/battlemapEngine'); const router = express.Router(); +function ensureMissionBattlemaps(missionData = {}) { + const scenes = Array.isArray(missionData.scenes) ? missionData.scenes : []; + let changed = false; + const nextScenes = scenes.map((scene) => { + if (!scene || typeof scene !== 'object') return scene; + const nextBattlemapState = buildBattlemapStateForScene(scene, scene.battlemapState || {}); + const sceneChanged = JSON.stringify(scene.battlemapState || null) !== JSON.stringify(nextBattlemapState); + if (sceneChanged) changed = true; + return { + ...scene, + battlemapState: nextBattlemapState, + }; + }); + return { + changed, + mission: { + ...missionData, + scenes: nextScenes, + }, + }; +} + +async function persistMissionBattlemapsIfNeeded(mission) { + if (!mission?.id) return mission; + const normalized = ensureMissionBattlemaps(mission); + if (!normalized.changed) return normalized.mission; + await missionHelpers.updateProgress(mission.id, { + currentScene: Number.isInteger(mission.current_scene) ? mission.current_scene : 0, + scenes: normalized.mission.scenes, + }); + return normalized.mission; +} + function playerScene(scene) { if (!scene || typeof scene !== 'object') return null; const { @@ -25,6 +59,9 @@ function playerScene(scene) { delete safeScene.skillSituationModifiers; delete safeScene.skillCustomModifier; } + if (!battlemapVisibleForPlayer(scene, 'player')) { + delete safeScene.battlemapState; + } return safeScene; } @@ -89,7 +126,8 @@ function ensureSceneStoryHooks(missionData = {}) { router.get('/', async (req, res) => { try { const missions = await missionHelpers.getAll(); - res.json(missions); + const normalized = missions.map(mission => ensureMissionBattlemaps(mission).mission); + res.json(normalized); } catch (error) { logToFile('API: Failed to get missions', error); res.status(500).json({ error: String(error) }); @@ -100,7 +138,8 @@ router.get('/', async (req, res) => { router.get('/active/current', async (req, res) => { try { const mission = await missionHelpers.getActive(); - res.json(mission || null); + const normalized = mission ? await persistMissionBattlemapsIfNeeded(mission) : null; + res.json(normalized || null); } catch (error) { logToFile('API: Failed to get active mission', error); res.status(500).json({ error: String(error) }); @@ -112,14 +151,15 @@ router.get('/active/player', async (req, res) => { try { const mission = await missionHelpers.getActive(); if (!mission) return res.json(null); + const normalizedMission = await persistMissionBattlemapsIfNeeded(mission); - const scenes = Array.isArray(mission.scenes) ? mission.scenes : []; - const currentIndex = Math.max(0, Math.min(Number(mission.current_scene || 0), Math.max(scenes.length - 1, 0))); + const scenes = Array.isArray(normalizedMission.scenes) ? normalizedMission.scenes : []; + const currentIndex = Math.max(0, Math.min(Number(normalizedMission.current_scene || 0), Math.max(scenes.length - 1, 0))); const currentScene = scenes[currentIndex] || null; res.json({ - id: mission.id, - name: mission.name, + id: normalizedMission.id, + name: normalizedMission.name, current_scene: 0, active_scene_index: currentIndex, scenes: currentScene ? [playerScene(currentScene)] : [], @@ -160,20 +200,22 @@ router.put('/active/scene', async (req, res) => { try { const mission = await missionHelpers.getActive(); if (!mission) return res.status(404).json({ error: 'No active mission' }); - const scenes = Array.isArray(mission.scenes) ? mission.scenes : []; - const currentIndex = Math.max(0, Math.min(Number(mission.current_scene || 0), Math.max(scenes.length - 1, 0))); + const normalizedMission = ensureMissionBattlemaps(mission).mission; + const scenes = Array.isArray(normalizedMission.scenes) ? normalizedMission.scenes : []; + const currentIndex = Math.max(0, Math.min(Number(normalizedMission.current_scene || 0), Math.max(scenes.length - 1, 0))); const currentScene = scenes[currentIndex] || {}; const patch = req.body?.patch || {}; const nextScenes = scenes.map((scene, index) => ( index === currentIndex ? { ...currentScene, ...patch } : scene )); + const ensuredMission = ensureMissionBattlemaps({ ...normalizedMission, scenes: nextScenes }).mission; const ok = await missionHelpers.updateProgress(mission.id, { currentScene: currentIndex, - scenes: nextScenes, + scenes: ensuredMission.scenes, }); if (!ok) return res.status(500).json({ error: 'Failed to patch active scene' }); const updated = await missionHelpers.getById(mission.id); - res.json({ success: true, mission: updated, scene: nextScenes[currentIndex], currentScene: currentIndex }); + res.json({ success: true, mission: updated, scene: ensuredMission.scenes[currentIndex], currentScene: currentIndex }); } catch (error) { logToFile('API: Failed to patch active scene', error); res.status(500).json({ error: String(error) }); @@ -209,7 +251,8 @@ router.get('/:id', async (req, res) => { try { const mission = await missionHelpers.getById(req.params.id); if (!mission) return res.status(404).json({ error: 'Mission not found' }); - res.json(mission); + const normalized = await persistMissionBattlemapsIfNeeded(mission); + res.json(normalized); } catch (error) { logToFile('API: Failed to get mission', req.params.id, error); res.status(500).json({ error: String(error) }); @@ -219,7 +262,7 @@ router.get('/:id', async (req, res) => { // Create mission router.post('/', async (req, res) => { try { - const missionData = ensureSceneStoryHooks(req.body || {}); + const missionData = ensureMissionBattlemaps(ensureSceneStoryHooks(req.body || {})).mission; const id = await missionHelpers.create(missionData); if (!id) return res.status(500).json({ error: 'Failed to create mission' }); logToFile('API: Created mission', missionData.name); @@ -236,7 +279,8 @@ router.post('/:id/active', async (req, res) => { const ok = await missionHelpers.setActive(req.params.id); if (!ok) return res.status(404).json({ error: 'Mission not found' }); const mission = await missionHelpers.getById(req.params.id); - res.json({ success: true, mission }); + const normalized = mission ? await persistMissionBattlemapsIfNeeded(mission) : null; + res.json({ success: true, mission: normalized }); } catch (error) { logToFile('API: Failed to set active mission', req.params.id, error); res.status(500).json({ error: String(error) }); @@ -246,11 +290,14 @@ router.post('/:id/active', async (req, res) => { // Update active play progress router.put('/:id/progress', async (req, res) => { try { - const progressData = ensureSceneStoryHooks(req.body || {}); - const ok = await missionHelpers.updateProgress(req.params.id, progressData); + const progressData = ensureMissionBattlemaps(ensureSceneStoryHooks(req.body || {})).mission; + const ok = await missionHelpers.updateProgress(req.params.id, { + currentScene: Number.isInteger(progressData.currentScene) ? progressData.currentScene : Number(progressData.current_scene || 0), + scenes: progressData.scenes, + }); if (!ok) return res.status(404).json({ error: 'Mission not found' }); const mission = await missionHelpers.getById(req.params.id); - res.json({ success: true, mission }); + res.json({ success: true, mission: mission ? ensureMissionBattlemaps(mission).mission : null }); } catch (error) { logToFile('API: Failed to update mission progress', req.params.id, error); res.status(500).json({ error: String(error) }); @@ -260,10 +307,11 @@ router.put('/:id/progress', async (req, res) => { // Update mission router.put('/:id', async (req, res) => { try { - const missionData = ensureSceneStoryHooks(req.body || {}); + const missionData = ensureMissionBattlemaps(ensureSceneStoryHooks(req.body || {})).mission; const ok = await missionHelpers.update(req.params.id, missionData); if (!ok) return res.status(404).json({ error: 'Mission not found' }); - res.json({ success: true }); + const mission = await missionHelpers.getById(req.params.id); + res.json({ success: true, mission: mission ? ensureMissionBattlemaps(mission).mission : null }); } catch (error) { logToFile('API: Failed to update mission', req.params.id, error); res.status(500).json({ error: String(error) }); diff --git a/src/App.js b/src/App.js index 8d5bd85..45aa03f 100755 --- a/src/App.js +++ b/src/App.js @@ -11,6 +11,7 @@ import PlayerManagement from './components/PlayerManagement'; import MissionTab from './components/MissionTab'; import SimulationTab from './components/SimulationTab'; import ErrorReports from './components/ErrorReports'; +import BattlemapTab from './components/BattlemapTab'; import { useState, useEffect } from 'react'; import axios from 'axios'; import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger'; @@ -322,6 +323,12 @@ function App() { > Weapons + )} -{tab==='roller' ? : tab==='shop' ? : tab==='rules' ? : tab==='weapons' ? : tab==='bestiary' ? (authedPlayer === 'gm' ? :

Access Denied

The Bestiary is only accessible to Game Masters. Please log in with a GM account.

) : tab==='players' ? : tab==='gmkit' ? : tab==='simulation' ? : tab==='errors' ? : tab==='mission' ? : : tab==='shop' ? : tab==='rules' ? : tab==='weapons' ? : tab==='battlemap' ? : tab==='bestiary' ? (authedPlayer === 'gm' ? :

Access Denied

The Bestiary is only accessible to Game Masters. Please log in with a GM account.

) : tab==='players' ? : tab==='gmkit' ? : tab==='simulation' ? : tab==='errors' ? : tab==='mission' ? : } diff --git a/src/components/BestiaryTab.jsx b/src/components/BestiaryTab.jsx index 30bf50e..90af800 100644 --- a/src/components/BestiaryTab.jsx +++ b/src/components/BestiaryTab.jsx @@ -15,7 +15,6 @@ const STAT_DEFS = [ { key: 'fel', label: 'Fel', ring: 'border-fuchsia-500/40', text: 'text-fuchsia-300' }, ] -const ARMOUR_LABELS = { all: 'All', head: 'Head', body: 'Body', arm: 'Arms', arms: 'Arms', leg: 'Legs', legs: 'Legs', ra: 'R.Arm', la: 'L.Arm', rl: 'R.Leg', ll: 'L.Leg' } const BOOK_ABBR = { 'Mark of the Xenos': 'MoX', 'First Founding': 'FF', @@ -168,16 +167,141 @@ function attackLines(weapons, profile, maxLines = 4) { // Render armour that may be a number, a string, or an object of locations. function formatArmour(a){ + const lines = formatArmourLines(a) + return lines ? lines.join(' / ') : null +} + +function formatArmourLines(a){ if (a == null || a === '') return null - if (typeof a === 'number') return `All ${a}` - if (typeof a === 'string') return a - if (typeof a === 'object') { - const parts = Object.entries(a) - .filter(([, v]) => v != null && v !== '' && !Number.isNaN(+v)) - .map(([k, v]) => `${ARMOUR_LABELS[k] || k} ${v}`) - return parts.length ? parts.join(' · ') : null + + const normalized = { + all: null, + head: null, + body: null, + arms: null, + legs: null, + leftArm: null, + rightArm: null, + leftLeg: null, + rightLeg: null, + raw: null, } - return String(a) + + const setNum = (key, value) => { + const n = Number(value) + if (Number.isFinite(n)) normalized[key] = n + } + + const parseText = (text) => { + const value = String(text || '').trim() + if (!value) return + if (/^none\.?$/i.test(value)) { + normalized.raw = 'None' + return + } + const compact = value.toLowerCase().replace(/\s+/g, ' ') + const matchers = [ + ['all', /\ball\s*(\d+)/i], + ['head', /\bhead\s*(\d+)/i], + ['body', /\bbody\s*(\d+)/i], + ['arms', /\barms?\s*(\d+)/i], + ['legs', /\blegs?\s*(\d+)/i], + ['leftArm', /\bleft\s*arm\s*(\d+)/i], + ['rightArm', /\bright\s*arm\s*(\d+)/i], + ['leftLeg', /\bleft\s*leg\s*(\d+)/i], + ['rightLeg', /\bright\s*leg\s*(\d+)/i], + ] + for (const [key, regex] of matchers) { + const match = compact.match(regex) + if (match) setNum(key, match[1]) + } + if (normalized.all == null) { + const generic = value.match(/\b(\d+)\b/) + if (generic && !normalized.raw) setNum('all', generic[1]) + } + if (!normalized.raw) normalized.raw = value + } + + if (typeof a === 'number') { + normalized.all = a + } else if (typeof a === 'string') { + parseText(a) + } else if (typeof a === 'object') { + const entries = Object.entries(a) + for (const [key, value] of entries) { + if (/^\d+$/.test(key)) continue + const k = key.toLowerCase() + if (k === 'all' || k === 'value') setNum('all', value) + else if (k === 'head') setNum('head', value) + else if (k === 'body') setNum('body', value) + else if (k === 'arms' || k === 'arm') setNum('arms', value) + else if (k === 'legs' || k === 'leg') setNum('legs', value) + else if (k === 'leftarm' || k === 'la') setNum('leftArm', value) + else if (k === 'rightarm' || k === 'ra') setNum('rightArm', value) + else if (k === 'leftleg' || k === 'll') setNum('leftLeg', value) + else if (k === 'rightleg' || k === 'rl') setNum('rightLeg', value) + else if (k === 'locations' && Array.isArray(value)) { + const nums = value.map(v => Number(v)).filter(v => Number.isFinite(v)) + if (nums.length) normalized.all = Math.max(...nums) + } else if (k === 'type' && !normalized.raw) { + normalized.raw = String(value || '').trim() || null + } + } + if (normalized.all == null) { + const fallback = Object.values(a).find(v => Number.isFinite(Number(v))) + if (fallback != null) normalized.all = Number(fallback) + } + if (!normalized.raw) normalized.raw = null + } + + if ( + normalized.all == null && + normalized.head == null && + normalized.body == null && + normalized.arms == null && + normalized.legs == null && + normalized.leftArm == null && + normalized.rightArm == null && + normalized.leftLeg == null && + normalized.rightLeg == null + ) { + return normalized.raw ? [normalized.raw] : null + } + + const lines = [] + if (normalized.all != null) { + lines.push(`All ${normalized.all}`) + return lines + } + + const main = [] + if (normalized.head != null) main.push(`H${normalized.head}`) + if (normalized.body != null) main.push(`B${normalized.body}`) + if (normalized.arms != null) main.push(`A${normalized.arms}`) + if (normalized.legs != null) main.push(`L${normalized.legs}`) + if (main.length) lines.push(main.join(' ')) + + const split = [] + if (normalized.leftArm != null || normalized.rightArm != null) { + if (normalized.leftArm != null && normalized.rightArm != null && normalized.leftArm === normalized.rightArm) { + split.push(`A${normalized.leftArm}`) + } else { + if (normalized.leftArm != null) split.push(`LA${normalized.leftArm}`) + if (normalized.rightArm != null) split.push(`RA${normalized.rightArm}`) + } + } + if (normalized.leftLeg != null || normalized.rightLeg != null) { + if (normalized.leftLeg != null && normalized.rightLeg != null && normalized.leftLeg === normalized.rightLeg) { + split.push(`L${normalized.leftLeg}`) + } else { + if (normalized.leftLeg != null) split.push(`LL${normalized.leftLeg}`) + if (normalized.rightLeg != null) split.push(`RL${normalized.rightLeg}`) + } + } + if (split.length) lines.push(split.join(' ')) + + if (!lines.length && normalized.raw) lines.push(normalized.raw) + return lines.length ? lines : null } // Colour a card by threat tier, inferred from Wounds when no explicit tier. @@ -223,6 +347,19 @@ function DamageBlock({ weapons, profile }) { ) } +function ArmourBlock({ armour, title }) { + const lines = formatArmourLines(armour) + if (!lines || !lines.length) return null + return ( +
+
AP
+
+ {lines.map(line =>
{line}
)} +
+
+ ) +} + function QuickBox({ label, value, tone, title, wrap = false, className = '' }) { return (
@@ -290,6 +427,7 @@ function normalizeEntry(en){ movement: formatField(en.movement || stats.movement), toughness: formatField(en.toughness || stats.toughness), armour: formatArmour(en.armour || stats.armour), + armourLines: formatArmourLines(en.armour || stats.armour), skills: formatField(en.skills || stats.skills), talents: formatField(en.talents || stats.talents), traits: formatField(en.traits || stats.traits), @@ -529,7 +667,7 @@ export default function BestiaryTab(){
- +
diff --git a/src/components/DeathwatchRoller.jsx b/src/components/DeathwatchRoller.jsx index 4a60688..1ea6f48 100755 --- a/src/components/DeathwatchRoller.jsx +++ b/src/components/DeathwatchRoller.jsx @@ -54,11 +54,49 @@ function parseDice(spec) { return { terms, flat } } +function weaponDamageScore(weapon) { + const text = String(weapon?.damage || '').replace(/\s+/g, '') + const match = text.match(/(\d+)d(\d+)(?:([+-])(\d+))?/i) + if (!match) return -1 + const count = Math.max(1, Number(match[1]) || 1) + const faces = Math.max(2, Number(match[2]) || 10) + const sign = match[3] === '-' ? -1 : 1 + const flat = match[4] ? sign * (Number(match[4]) || 0) : 0 + return count * ((faces + 1) / 2) + flat +} + +function weaponIsGrenade(weapon) { + return /grenade/i.test(String(weapon?.name || '')) || String(weapon?.class || '').toLowerCase() === 'grenade' +} + +function pickMainWeapon(weapons) { + const list = Array.isArray(weapons) ? weapons.filter(w => w && w.name) : [] + const nonGrenades = list.filter(w => !weaponIsGrenade(w)) + const pool = nonGrenades.length ? nonGrenades : list + return pool.slice().sort((a, b) => weaponDamageScore(b) - weaponDamageScore(a))[0] || null +} + +function hasMeleeWeapon(weapons) { + return Array.isArray(weapons) && weapons.some(w => String(w?.class || '').toLowerCase() === 'melee') +} + function extractDiceDamage(value) { const match = String(value || '').match(/\d+d\d+(?:\s*[+-]\s*\d+)?/i) return match ? match[0].replace(/\s+/g, '') : '' } +function normalizeDamageExpression(value, fallback = '1d10') { + if (typeof value === 'string') { + const cleaned = extractDiceDamage(value) || value.trim() + return cleaned || fallback + } + if (value && typeof value === 'object') { + const nested = extractDiceDamage(value.damage ?? value.value ?? value.text ?? value.stats?.damage) + return nested || fallback + } + return fallback +} + function extractPen(value) { const match = String(value || '').match(/\bPen\s*(\d+)/i) return match ? Number(match[1]) : undefined @@ -104,6 +142,7 @@ const STORAGE_PRESETS = 'dw:presets:v3' const STORAGE_HISTORY = 'dw:history:v2' const STORAGE_WEAPONS = 'dw:weapons:v3' const STORAGE_TRACKER = 'dw:tracker:v1' +const STORAGE_PLAYER_TRACKER = 'dw:player-tracker:v1' // Condition → attack modifier (Deathwatch core rules) const CONDITION_MODS = { @@ -115,15 +154,32 @@ const CONDITION_MODS = { Bleeding: { bs: 0, ws: 0, label: 'Bleeding (1 wound/turn)' }, } +const PHASE_TIMING = { + attack: 700, + defense: 900, + damage: 900, + wounds: 1400, +} + function mapPlayerWeapon(w) { const type = String(w.type || w.class || '').toLowerCase() - const clsMap = { heavy: 'heavy', pistol: 'pistol', basic: 'basic', melee: 'melee', bolt: 'basic', bolter: 'basic' } - const cls = clsMap[type] || (type.includes('pistol') ? 'pistol' : type.includes('melee') || type.includes('sword') || type.includes('axe') || type.includes('fist') || type.includes('hammer') ? 'melee' : 'basic') + const name = String(w.name || '').toLowerCase() + const hint = `${type} ${name}` + const clsMap = { heavy: 'heavy', pistol: 'pistol', basic: 'basic', melee: 'melee', bolt: 'basic', bolter: 'basic', thrown: 'grenade', grenade: 'grenade' } + const cls = clsMap[type] || ( + hint.includes('grenade') || hint.includes('thrown') ? 'grenade' + : hint.includes('pistol') ? 'pistol' + : hint.includes('melee') || hint.includes('sword') || hint.includes('axe') || hint.includes('fist') || hint.includes('hammer') || hint.includes('knife') || hint.includes('crozius') || hint.includes('maul') || hint.includes('staff') || hint.includes('claw') || hint.includes('chain') + ? 'melee' + : hint.includes('heavy') + ? 'heavy' + : 'basic' + ) const modesDefault = cls === 'melee' ? ['single'] : cls === 'pistol' ? ['single'] : cls === 'heavy' ? ['single'] : ['single', 'semi'] return normalizeWeapon({ name: w.name, class: cls, - damage: w.damage || '1d10', + damage: normalizeDamageExpression(w.damage, '1d10'), pen: w.pen != null ? +w.pen : (w.ap != null ? Math.abs(+w.ap) : 0), rof: w.rof || 1, modes: Array.isArray(w.modes) && w.modes.length ? w.modes : modesDefault, @@ -467,6 +523,10 @@ function DeathwatchRoller({ authedPlayer }) { const [actingAs, setActingAs] = useState('') // "player:Name" | "enemy:Name" | '' (self/manual) const [rollerTab, setRollerTab] = useState('combat') const [lastCombatResult, setLastCombatResult] = useState(null) + + // ── Horde combat state ───────────────────────────────────────────────────── + const [hordeCombat, setHordeCombat] = useState(null) // { enemy, players: [{name, ag, bs, ws, wounds, maxWounds, initiative, alive}], round, turnIndex, log } + const [selectedHordePlayers, setSelectedHordePlayers] = useState([]) // names of selected players const isGM = authedPlayer === 'gm' // Fate belongs to whichever player is acting: the logged-in player normally, or // the player a GM is "rolling as" (GM/enemy actors have no fate pool). @@ -478,19 +538,36 @@ function DeathwatchRoller({ authedPlayer }) { // Load the player roster (GM only) so the GM can roll as any player. Full // tabInfo is only returned when authenticated, so send the GM/session headers. useEffect(() => { - if (!isGM) return ;(async () => { try { - const headers = { 'x-gm-secret': process.env.REACT_APP_GM_SECRET || 'defaultsecret' } - try { - const sid = window.localStorage.getItem('dw:shop:sessionId') - if (sid) headers['x-session-id'] = JSON.parse(sid) - } catch { /* ignore */ } + const headers = {} + if (isGM) { + headers['x-gm-secret'] = process.env.REACT_APP_GM_SECRET || 'defaultsecret' + try { + const sid = window.localStorage.getItem('dw:shop:sessionId') + if (sid) headers['x-session-id'] = JSON.parse(sid) + } catch { /* ignore */ } + } const res = await fetch('/api/players', { cache: 'no-store', headers }) if (!res.ok) return const data = await res.json() const list = Array.isArray(data) ? data : (data.players || []) - setPlayers(list.filter(p => p && p.name)) + const names = list.filter(p => p && p.name).map(p => p.name) + if (isGM && list.some(p => p.tabInfo)) { + setPlayers(list.filter(p => p && p.name)) + return + } + const hydrated = await Promise.all(names.map(async name => { + try { + const playerRes = await fetch(`/api/players/${encodeURIComponent(name)}`, { cache: 'no-store' }) + if (!playerRes.ok) return { name } + const player = await playerRes.json() + return player && player.name ? player : { name } + } catch { + return { name } + } + })) + setPlayers(hydrated.filter(p => p && p.name)) } catch (e) { console.info('[DW] roster load failed', e && e.message) } @@ -577,8 +654,12 @@ function DeathwatchRoller({ authedPlayer }) { }, []) const trackerInit = safeGet(STORAGE_TRACKER) + const playerTrackerInit = safeGet(STORAGE_PLAYER_TRACKER) + const [isHordeMode, setIsHordeMode] = useState(false) const [maxWounds,setMaxWounds] = useState(() => Math.max(0, trackerInit?.maxWounds ?? 20)) const [curWounds,setCurWounds] = useState(() => Math.max(0, Math.min(trackerInit?.curWounds ?? 20, trackerInit?.maxWounds ?? 20))) + const [playerMaxWounds,setPlayerMaxWounds] = useState(() => Math.max(0, playerTrackerInit?.maxWounds ?? 20)) + const [playerCurWounds,setPlayerCurWounds] = useState(() => Math.max(0, Math.min(playerTrackerInit?.curWounds ?? 20, playerTrackerInit?.maxWounds ?? 20))) const [partDamage,setPartDamage] = useState(() => { const zero = { 'Head':0, 'Body':0, 'Left Arm':0, 'Right Arm':0, 'Left Leg':0, 'Right Leg':0 } if (!trackerInit?.partDamage) return zero @@ -601,8 +682,10 @@ function DeathwatchRoller({ authedPlayer }) { const [combatRound, setCombatRound] = useState(() => { try { const s = JSON.parse(localStorage.getItem('dw:combat-state') || 'null'); return s || null; } catch { return null; } }) + const [normalCombat, setNormalCombat] = useState(null) const [progressStep, setProgressStep] = useState('idle') // idle | attack | defense | damage | wounds const [lastAppliedDamage, setLastAppliedDamage] = useState(0) + const [lastRollState, setLastRollState] = useState({ label: 'Ready', tone: 'slate' }) // Manual mode state variables const [manualMode, setManualMode] = useState(false) @@ -630,7 +713,6 @@ function DeathwatchRoller({ authedPlayer }) { } if (ctx.weapon) setWeaponName(ctx.weapon) if (ctx.enemy) setEnemyName(ctx.enemy) - setInfo(`Loaded mission roll context${ctx.sceneTitle ? `: ${ctx.sceneTitle}` : ''}`) }, []) const usingSkill = useMemo(()=>{ const w = weapons.find(x=>x.name===weaponName); if (!w) return 'BS'; return w.class==='melee' ? 'WS' : 'BS' },[weaponName, weapons]) @@ -672,6 +754,7 @@ function DeathwatchRoller({ authedPlayer }) { useEffect(()=>{ safeSet(STORAGE_WEAPONS, weapons) },[weapons]) // Removed localStorage persistence - using database API instead useEffect(()=>{ safeSet(STORAGE_TRACKER, { maxWounds, curWounds, partDamage }) },[maxWounds, curWounds, partDamage]) + useEffect(()=>{ safeSet(STORAGE_PLAYER_TRACKER, { maxWounds: playerMaxWounds, curWounds: playerCurWounds }) },[playerMaxWounds, playerCurWounds]) useEffect(() => { function readContext() { @@ -713,10 +796,8 @@ function DeathwatchRoller({ authedPlayer }) { // Wounds may be a number or { total, current }. const { max: sheetMaxWounds, cur: sheetCurWounds } = playerWoundsTotals(tab.wounds) if (sheetMaxWounds > 0) { - setMaxWounds(sheetMaxWounds) - // Only reset curWounds if tracker wasn't already customised this session - const stored = safeGet(STORAGE_TRACKER) - if (!stored || stored.maxWounds !== sheetMaxWounds) setCurWounds(sheetCurWounds) + setPlayerMaxWounds(sheetMaxWounds) + setPlayerCurWounds(sheetCurWounds) } const playerWeapons = Array.isArray(tab.weapons) ? tab.weapons.map(mapPlayerWeapon).filter(w => w.name) : [] @@ -775,6 +856,7 @@ function DeathwatchRoller({ authedPlayer }) { }, []) useEffect(()=>{ if (curWounds>maxWounds) setCurWounds(maxWounds) },[maxWounds, curWounds]) + useEffect(()=>{ if (playerCurWounds>playerMaxWounds) setPlayerCurWounds(playerMaxWounds) },[playerMaxWounds, playerCurWounds]) const filteredWeapons = useMemo(()=>{ const q = weaponFilter.trim().toLowerCase() @@ -802,14 +884,24 @@ function DeathwatchRoller({ authedPlayer }) { setEnemyName(name) const e = enemies.find(x=>x.name===name) if (!e) return + const isHorde = !!e.horde + setIsHordeMode(isHorde) setTargetTB(e.tb); setTargetArmour(e.armour ?? 0) - if (e.armourByLoc) setArmourMap({ ...uniformArmourMap(0), ...e.armourByLoc }) - else setArmourMap(uniformArmourMap(e.armour ?? 0)) - // Apply wounds — coerce robustly (bestiary wounds can arrive as strings). Fall - // back to a usable default so a missing/zero value doesn't leave a dead tracker. - const woundsNum = Number(e.wounds) - const woundsVal = Number.isFinite(woundsNum) && woundsNum > 0 ? Math.round(woundsNum) : 15 - setMaxWounds(woundsVal); setCurWounds(woundsVal); setPartDamage({ 'Head':0, 'Body':0, 'Left Arm':0, 'Right Arm':0, 'Left Leg':0, 'Right Leg':0 }) + if (isHorde) { + // Horde: no body parts, just magnitude pool + setArmourMap(uniformArmourMap(e.armour ?? 0)) + const mag = Number(e.magnitude ?? e.wounds ?? 20) + const magVal = Number.isFinite(mag) && mag > 0 ? Math.round(mag) : 20 + setMaxWounds(magVal); setCurWounds(magVal); setPartDamage({}) + } else { + if (e.armourByLoc) setArmourMap({ ...uniformArmourMap(0), ...e.armourByLoc }) + else setArmourMap(uniformArmourMap(e.armour ?? 0)) + // Apply wounds — coerce robustly (bestiary wounds can arrive as strings). Fall + // back to a usable default so a missing/zero value doesn't leave a dead tracker. + const woundsNum = Number(e.wounds) + const woundsVal = Number.isFinite(woundsNum) && woundsNum > 0 ? Math.round(woundsNum) : 15 + setMaxWounds(woundsVal); setCurWounds(woundsVal); setPartDamage({ 'Head':0, 'Body':0, 'Left Arm':0, 'Right Arm':0, 'Left Leg':0, 'Right Leg':0 }) + } // Robustly extract AG/WS/BS from common enemy shapes: top-level, .characteristics, .tabInfo.characteristics function findNumeric(o, keys) { if (!o || typeof o !== 'object') return undefined @@ -895,9 +987,8 @@ function DeathwatchRoller({ authedPlayer }) { setTargetArmour(amap['Body'] || 0) const { max, cur } = playerWoundsTotals(tab.wounds) if (max > 0) { - setMaxWounds(max) - setCurWounds(cur) - setPartDamage({ 'Head': 0, 'Body': 0, 'Left Arm': 0, 'Right Arm': 0, 'Left Leg': 0, 'Right Leg': 0 }) + setPlayerMaxWounds(max) + setPlayerCurWounds(cur) } setDefenderModifier(0) } @@ -908,6 +999,194 @@ function DeathwatchRoller({ authedPlayer }) { onSelectEnemy(value) } + function getAuthedPlayerRecord() { + if (!authedPlayer || authedPlayer === 'gm') return null + return players.find(p => String(p.name).toLowerCase() === String(authedPlayer).toLowerCase()) || null + } + + function buildPlayerCombatant(player, fallbackName) { + const tab = player?.tabInfo || {} + const chars = tab.characteristics || {} + const { max, cur } = playerWoundsTotals(tab.wounds) + const weaponsList = Array.isArray(tab.weapons) ? tab.weapons.map(mapPlayerWeapon).filter(w => w.name) : [] + const selected = weaponsList.find(w => w.name === weaponName) || pickMainWeapon(weaponsList) || weaponsList[0] || null + const armour = playerArmourToMap(tab.armour) + return { + name: player?.name || fallbackName || 'Player', + side: 'player', + kind: 'player', + bs: readChar(chars, 'bs') ?? bs, + ws: readChar(chars, 'ws') ?? ws, + ag: readChar(chars, 'ag') ?? defenderAg, + tb: Math.floor((readChar(chars, 't') ?? 40) / 10), + armourMap: armour, + armourBody: armour.Body || 0, + wounds: max > 0 ? cur : playerCurWounds, + maxWounds: max > 0 ? max : playerMaxWounds, + weapons: weaponsList, + weaponName: selected?.name || weaponName || 'Weapon', + weaponClass: selected?.class || (usingSkill === 'WS' ? 'melee' : 'basic'), + damage: normalizeDamageExpression(selected?.damage || damage, damage || '1d10+5'), + tearing: !!(selected?.tearing ?? tearing), + proven: selected?.proven || proven || 0, + pen: selected?.pen ?? pen ?? 0, + rof: selected?.rof || rof || 1, + mode: (selected?.modes && selected.modes.includes(mode)) ? mode : ((selected?.modes && selected.modes[0]) || mode || 'single'), + reactionRound: 0, + alive: (max > 0 ? cur : playerCurWounds) > 0 + } + } + + function buildEnemyCombatant(enemy) { + const chars = enemy?.characteristics || (enemy?.tabInfo && enemy.tabInfo.characteristics) || {} + const damageExpr = normalizeDamageExpression(enemy?.damage, damage || '1d10+3') + const rawEnemyWounds = Number(enemy?.wounds) + const enemyMaxWounds = Number.isFinite(rawEnemyWounds) && rawEnemyWounds > 0 ? rawEnemyWounds : maxWounds + const enemyCurrentWounds = enemy?.name === enemyName ? Math.min(curWounds, enemyMaxWounds) : enemyMaxWounds + return { + name: enemy?.name || 'Enemy', + side: 'enemy', + kind: 'enemy', + bs: (typeof enemy?.bs === 'number' ? enemy.bs : (readChar(chars, 'bs') ?? defenderBS)), + ws: (typeof enemy?.ws === 'number' ? enemy.ws : (readChar(chars, 'ws') ?? defenderWS)), + ag: (typeof enemy?.ag === 'number' ? enemy.ag : (readChar(chars, 'ag') ?? 30)), + tb: Number(enemy?.tb ?? targetTB ?? 4), + armourMap: enemy?.armourByLoc || uniformArmourMap(enemy?.armour ?? targetArmour ?? 0), + armourBody: Number(enemy?.armour ?? targetArmour ?? 0), + wounds: enemyCurrentWounds, + maxWounds: enemyMaxWounds, + weapons: [], + weaponName: enemy?.name || 'Attack', + weaponClass: ((typeof enemy?.ws === 'number' ? enemy.ws : 0) > (typeof enemy?.bs === 'number' ? enemy.bs : 0)) ? 'melee' : 'basic', + damage: damageExpr, + tearing: /tearing/i.test(String(enemy?.special || enemy?.damage || '')), + proven: 0, + pen: Number(enemy?.pen ?? 0), + rof: 1, + mode: 'single', + reactionRound: 0, + alive: curWounds > 0 + } + } + + function buildManualCombatant() { + return { + name: authedPlayer && authedPlayer !== 'gm' ? authedPlayer : 'Attacker', + side: 'player', + kind: 'manual', + bs, + ws, + ag: defenderAg || 35, + tb: Math.max(0, Math.floor((playerMaxWounds || 20) / 10)), + armourMap: uniformArmourMap(0), + armourBody: 0, + wounds: playerCurWounds, + maxWounds: playerMaxWounds, + weapons: [], + weaponName: weaponName || 'Weapon', + weaponClass: usingSkill === 'WS' ? 'melee' : 'basic', + damage: normalizeDamageExpression(damage, '1d10+5'), + tearing, + proven, + pen, + rof, + mode, + reactionRound: 0, + alive: playerCurWounds > 0 + } + } + + function syncNormalCombatTrackers(combat) { + if (!combat) return + const playerActor = combat.order.find(actor => actor.side === 'player') || null + const enemyActor = combat.order.find(actor => actor.side === 'enemy') || null + if (playerActor) { + setPlayerMaxWounds(playerActor.maxWounds) + setPlayerCurWounds(Math.max(0, playerActor.wounds)) + } + if (enemyActor) { + setMaxWounds(enemyActor.maxWounds) + setCurWounds(Math.max(0, enemyActor.wounds)) + } + } + + function startNormalCombat() { + const actorPlayer = actingAs.startsWith('player:') ? players.find(p => p.name === actingAs.slice('player:'.length)) : null + const actorEnemy = actingAs.startsWith('enemy:') ? enemies.find(en => en.name === actingAs.slice('enemy:'.length)) : null + const attacker = actorEnemy + ? buildEnemyCombatant(actorEnemy) + : actorPlayer + ? buildPlayerCombatant(actorPlayer, actorPlayer.name) + : (getAuthedPlayerRecord() ? buildPlayerCombatant(getAuthedPlayerRecord(), authedPlayer) : buildManualCombatant()) + + const targetPlayer = players.find(p => p.name === enemyName) || null + const targetEnemy = enemies.find(en => en.name === enemyName) || null + const defender = targetPlayer + ? buildPlayerCombatant(targetPlayer, targetPlayer.name) + : targetEnemy + ? buildEnemyCombatant(targetEnemy) + : null + + if (!defender) { + setInfo('Select a valid target to start combat.') + return + } + if (attacker.name === defender.name && attacker.side === defender.side) { + setInfo('Attacker and defender cannot be the same combatant.') + return + } + + const baseOrder = [attacker, defender].map(actor => { + const initiativeRoll = Math.floor(Math.random() * 10) + 1 + const initiative = initiativeRoll + Math.floor((actor.ag || 30) / 10) + return { ...actor, initiativeRoll, initiative } + }).sort((a, b) => b.initiative - a.initiative) + + const combat = { + active: true, + round: 1, + turnIndex: 0, + order: baseOrder, + currentTurn: { name: baseOrder[0].name, initiative: baseOrder[0].initiative }, + nextTurn: { name: baseOrder[1].name, initiative: baseOrder[1].initiative } + } + syncNormalCombatTrackers(combat) + setNormalCombat(combat) + setInfo(`Combat started. ${baseOrder[0].name} acts first.`) + } + + function endNormalCombat() { + setNormalCombat(null) + setLastRollState({ label: 'Ready', tone: 'slate' }) + setInfo('Normal combat ended.') + } + + function advanceNormalCombat(combat) { + if (!combat?.active) return + const living = combat.order.filter(actor => actor.alive) + if (living.length <= 1) { + const survivor = living[0] + setNormalCombat({ ...combat, active: false }) + setInfo(survivor ? `${survivor.name} wins the combat.` : 'Combat ended.') + return + } + const nextIndex = (combat.turnIndex + 1) % combat.order.length + const nextRound = nextIndex <= combat.turnIndex ? combat.round + 1 : combat.round + const nextOrder = nextIndex <= combat.turnIndex + ? combat.order.map(actor => ({ ...actor, reactionRound: 0 })) + : combat.order + const nextCombat = { + ...combat, + order: nextOrder, + round: nextRound, + turnIndex: nextIndex, + currentTurn: { name: nextOrder[nextIndex].name, initiative: nextOrder[nextIndex].initiative }, + nextTurn: { name: nextOrder[(nextIndex + 1) % nextOrder.length].name, initiative: nextOrder[(nextIndex + 1) % nextOrder.length].initiative } + } + setNormalCombat(nextCombat) + setInfo(`Round ${nextCombat.round} - ${nextCombat.currentTurn.name}'s turn.`) + } + function importFromJSONText(txt) { try { const arr = JSON.parse(txt) @@ -1001,9 +1280,168 @@ function DeathwatchRoller({ authedPlayer }) { function armourFor(where){ return (armourMap[where] ?? targetArmour) } + function runNormalCombatAttack() { + if (!normalCombat?.active) return + setError('') + setInfo('') + setProgressStep('attack') + const combat = { + ...normalCombat, + order: normalCombat.order.map(actor => ({ ...actor, armourMap: { ...(actor.armourMap || {}) } })) + } + const attacker = combat.order[combat.turnIndex] + const targetIndex = combat.order.findIndex((actor, idx) => idx !== combat.turnIndex && actor.alive) + const defender = targetIndex >= 0 ? combat.order[targetIndex] : null + if (!attacker || !defender || !attacker.alive || !defender.alive) { + setInfo('No valid combatants remain.') + setNormalCombat({ ...combat, active: false }) + return + } + + let attackRoll + if (manualMode && manualAttackRoll.trim()) { + const manualRoll = parseInt(manualAttackRoll.trim(), 10) + if (isNaN(manualRoll) || manualRoll < 1 || manualRoll > 100) { + setError('Manual attack roll must be a number between 1-100') + return + } + attackRoll = manualRoll + } else { + attackRoll = d100() + } + + const isRanged = String(attacker.weaponClass || '').toLowerCase() !== 'melee' + const attackTarget = Math.max(0, Math.min(100, isRanged ? attacker.bs : attacker.ws)) + const dg = degrees(attackTarget, attackRoll) + const where = hitLocationFromRoll(attackRoll) + + if (!dg.success) { + addRollResult({ id: uid(), ts: Date.now(), attackRoll, target: attackTarget, success: false, dos: 0, dof: dg.dof, mode: attacker.mode, rof: attacker.rof, hits: 0, where, jammed: false, perHit: [], weapon: attacker.weaponName, actor: attacker.name, enemy: defender.name, using: isRanged ? 'BS' : 'WS' }) + setLastRollState({ label: 'Fail', tone: 'rose' }) + setInfo(`${attacker.name} misses ${defender.name} (DoF ${dg.dof}).`) + setTimeout(() => { + setProgressStep('idle') + advanceNormalCombat(combat) + }, PHASE_TIMING.attack) + return + } + + const hits = hitsFromDoS(attacker.mode, dg.dos, attacker.rof) + let negatedHits = 0 + let defenseRoll = null + let defenseTarget = null + let defenseTypeName = null + + if (defender.side === 'player' && defender.alive) { + const canParry = !isRanged && hasMeleeWeapon(defender.weapons) + defenseTypeName = canParry ? 'Parry' : 'Dodge' + defenseTarget = Math.max(0, Math.min(100, canParry ? defender.ws : defender.ag)) + const reactionAvailable = (defender.reactionRound || 0) !== combat.round + if (reactionAvailable && defenseTarget > 0) { + setProgressStep('defense') + defenseRoll = (manualMode && manualDefenseRoll.trim()) + ? parseInt(manualDefenseRoll.trim(), 10) + : d100() + const defenseResult = degrees(defenseTarget, defenseRoll) + defender.reactionRound = combat.round + negatedHits = defenseResult.success ? Math.min(hits, defenseResult.dos) : 0 + if (defenseResult.success) setLastRollState({ label: 'Defended', tone: 'amber' }) + else setLastRollState({ label: 'Defense Failed', tone: 'rose' }) + } else { + defenseTypeName = 'No Reaction' + } + } + + const hitsToApply = Math.max(0, hits - negatedHits) + const perHit = [] + let totalApplied = 0 + let nextParts = { ...partDamage } + let dmgSpec + try { + dmgSpec = parseDice(normalizeDamageExpression(attacker.damage, '1d10+5')) + } catch { + dmgSpec = parseDice('1d10+5') + } + + for (let i = 0; i < hitsToApply; i += 1) { + const rolled = (manualMode && manualDamageRolls.trim()) + ? (() => { + const manualRolls = manualDamageRolls.trim().split(/[,\s]+/).map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n >= 1) + return { rolls: manualRolls, total: manualRolls.reduce((a, b) => a + b, 0) } + })() + : rollDice(dmgSpec.terms, { tearing: !!attacker.tearing, proven: attacker.proven || 0 }) + const raw = rolled.total + dmgSpec.flat + const arForLoc = Math.max(0, ((defender.armourMap && defender.armourMap[where]) ?? defender.armourBody ?? 0) - (attacker.pen || 0)) + const mitigated = Math.max(0, raw - defender.tb - arForLoc) + perHit.push({ where, raw, mitigated, detail: `${rolled.rolls.join(', ')}${dmgSpec.flat ? ` + ${dmgSpec.flat}` : ''} - TB ${defender.tb} - Armour(${where}) ${arForLoc} = ${mitigated}` }) + totalApplied += mitigated + if (defender.side === 'enemy' && BODY_PARTS.includes(where)) nextParts[where] = (nextParts[where] || 0) + mitigated + } + + defender.wounds = Math.max(0, defender.wounds - totalApplied) + defender.alive = defender.wounds > 0 + + addRollResult({ + id: uid(), + ts: Date.now(), + attackRoll, + target: attackTarget, + success: true, + dos: dg.dos, + dof: 0, + mode: attacker.mode, + rof: attacker.rof, + hits, + hitsNegated: negatedHits, + hitsApplied: hitsToApply, + where, + jammed: false, + perHit, + weapon: attacker.weaponName, + actor: attacker.name, + enemy: defender.name, + using: isRanged ? 'BS' : 'WS', + defenseRoll, + defenseTarget, + defenseType: defenseTypeName, + defenseFailed: defenseRoll != null ? negatedHits === 0 : false + }) + + if (defender.side === 'enemy') { + setPartDamage(nextParts) + setCurWounds(defender.wounds) + } else { + setPlayerCurWounds(defender.wounds) + } + syncNormalCombatTrackers(combat) + setLastAppliedDamage(totalApplied) + setLastRollState({ label: totalApplied > 0 ? 'Hit' : 'No Damage', tone: totalApplied > 0 ? 'emerald' : 'amber' }) + const defenseText = defenseRoll != null + ? ` ${defender.name} ${defenseTypeName?.toLowerCase() || 'defends'}${negatedHits > 0 ? ` and negates ${negatedHits}` : ' fails to negate the hit'}.` + : '' + setInfo(`${attacker.name} hits ${defender.name}.${defenseText} ${totalApplied > 0 ? `${totalApplied} damage applied.` : 'No damage applied.'}`) + setNormalCombat(combat) + const afterAttackDelay = defenseRoll != null ? PHASE_TIMING.defense : PHASE_TIMING.attack + setTimeout(() => { + setProgressStep('damage') + setTimeout(() => { + setProgressStep('wounds') + setTimeout(() => { + setProgressStep('idle') + advanceNormalCombat(combat) + }, PHASE_TIMING.wounds) + }, PHASE_TIMING.damage) + }, afterAttackDelay) + } + function runAttack() { + if (normalCombat?.active) { + runNormalCombatAttack() + return + } setError(''); setInfo('') setProgressStep('attack') + setLastRollState({ label: 'Rolling', tone: 'sky' }) setReactionUsed(false) // Reset reaction for new attack let dmgSpec try { dmgSpec = parseDice(damage) } catch (e) { setError('Invalid damage expression'); return } @@ -1049,13 +1487,15 @@ function DeathwatchRoller({ authedPlayer }) { }) setAwaitingDefense(true) setProgressStep('defense') + setLastRollState({ label: 'Hit', tone: 'emerald' }) setInfo(`Attack hit! Roll defense to determine if damage is applied.`) return } // Attack failed - no damage - const res = { id: uid(), ts: Date.now(), attackRoll, target, success: dg.success, dos: dg.dos, dof: dg.dof, mode, rof, hits: 0, where, jammed, perHit: [], weapon: weaponName || undefined, enemy: enemyName || undefined, using: usingSkill } + const res = { id: uid(), ts: Date.now(), attackRoll, target, success: dg.success, dos: dg.dos, dof: dg.dof, mode, rof, hits: 0, where, jammed, perHit: [], weapon: weaponName || undefined, actor: actingAs ? actingAs.split(':').slice(1).join(':') : (authedPlayer || 'Attacker'), enemy: enemyName || undefined, using: usingSkill } addRollResult(res) + setLastRollState({ label: 'Fail', tone: 'rose' }) setInfo(`Attack missed with DoF ${dg.dof}`) // Clear progress after a short delay setTimeout(()=> setProgressStep('idle'), 1200) @@ -1069,6 +1509,7 @@ function DeathwatchRoller({ authedPlayer }) { setReactionUsed(false) setProgressStep('idle') setLastAppliedDamage(0) + setLastRollState({ label: 'Ready', tone: 'slate' }) setError('') setInfo('') setManualAttackRoll('') @@ -1076,6 +1517,10 @@ function DeathwatchRoller({ authedPlayer }) { setManualDamageRolls('') } + function resetPlayerTracker() { + setPlayerCurWounds(playerMaxWounds) + } + function saveCurrentAsPreset() { if (!presetName.trim()) return; const p = { name: presetName.trim(), bs, ws, modifier, aim, mode, rof, damage, tearing, proven, pen, reliable, rfConfirm, targetTB, targetArmour, weapon: weaponName || undefined, enemy: enemyName || undefined }; setPresets(prev => { const others = prev.filter(x => x.name !== p.name); return [...others, p].sort((a,b)=>a.name.localeCompare(b.name)) }); setPresetName('') } function applyPreset(p) { setBS(p.bs ?? bs); setWS(p.ws ?? ws); setModifier(p.modifier); setAim(p.aim); setMode(p.mode); setRof(p.rof); setDamage(p.damage); setTearing(p.tearing); setProven(p.proven); setPen(p.pen); setReliable(p.reliable); setRfConfirm(p.rfConfirm); setTargetTB(p.targetTB); setTargetArmour(p.targetArmour); if (p.weapon) { onSelectWeapon(p.weapon) } else { setWeaponName('') } if (p.enemy) { onSelectEnemy(p.enemy) } else { setEnemyName('Custom/None') } } function deletePreset(name) { setPresets(prev => prev.filter(x => x.name !== name)) } @@ -1152,6 +1597,7 @@ function DeathwatchRoller({ authedPlayer }) { if (hitsToApply <= 0) { setInfo(`${defenseTypeName} successful! ${negatedHits} hit${negatedHits === 1 ? '' : 's'} evaded (DoS ${dg.dos}).`) + setLastRollState({ label: 'Defended', tone: 'amber' }) const res = { id: uid(), ts: Date.now(), @@ -1165,6 +1611,7 @@ function DeathwatchRoller({ authedPlayer }) { jammed: pendingHits.jammed, perHit: [], weapon: weaponName || undefined, + actor: actingAs ? actingAs.split(':').slice(1).join(':') : (authedPlayer || 'Attacker'), enemy: enemyName || undefined, using: pendingHits.isRanged ? 'BS' : 'WS', defenseRoll, @@ -1225,15 +1672,25 @@ function DeathwatchRoller({ authedPlayer }) { rfExtras = Array.from({length: tenCount}, ()=> Math.floor(Math.random()*10)+1) } } - const raw = r.total + rfExtras.reduce((a,b)=>a+b,0) + pendingHits.dmgSpec.flat - const arForLoc = Math.max(0, armourFor(where) - pen) - const mitigated = mitigateDamage(raw, targetTB, arForLoc) - const flatText = pendingHits.dmgSpec.flat ? (' + ' + pendingHits.dmgSpec.flat) : '' - const rollsText = r.rolls.join(', ') + (rfExtras.length ? (' | RF: ' + rfExtras.join(', ')) : '') - const damageText = mitigated > 0 ? `${mitigated}` : `${mitigated} (Negated)` - perHit.push({ where, raw, mitigated, detail: `${rollsText}${flatText} - TB (Toughness Bonus) ${targetTB} - Armour(${where}) ${arForLoc} = ${damageText}` }) - if (BODY_PARTS.includes(where)) nextParts[where] = (nextParts[where]||0) + mitigated - totalApplied += Math.max(0, mitigated|0) + const raw = r.total + rfExtras.reduce((a,b)=>a+b,0) + pendingHits.dmgSpec.flat + const flatText = pendingHits.dmgSpec.flat ? (' + ' + pendingHits.dmgSpec.flat) : '' + const rollsText = r.rolls.join(', ') + (rfExtras.length ? (' | RF: ' + rfExtras.join(', ')) : '') + + if (isHordeMode) { + // Horde mode: damage vs TB only (no armour, no body parts) + // Each point of damage above TB = 1 creature killed from the horde + const magnitudeLoss = Math.max(0, raw - targetTB) + const damageText = magnitudeLoss > 0 ? `${magnitudeLoss} creatures` : `0 (Negated)` + perHit.push({ where: 'Horde', raw, mitigated: magnitudeLoss, detail: `${rollsText}${flatText} - TB ${targetTB} = ${damageText}` }) + totalApplied += magnitudeLoss + } else { + const arForLoc = Math.max(0, armourFor(where) - pen) + const mitigated = mitigateDamage(raw, targetTB, arForLoc) + const damageText = mitigated > 0 ? `${mitigated}` : `${mitigated} (Negated)` + perHit.push({ where, raw, mitigated, detail: `${rollsText}${flatText} - TB (Toughness Bonus) ${targetTB} - Armour(${where}) ${arForLoc} = ${damageText}` }) + if (BODY_PARTS.includes(where)) nextParts[where] = (nextParts[where]||0) + mitigated + totalApplied += Math.max(0, mitigated|0) + } } const res = { @@ -1249,6 +1706,7 @@ function DeathwatchRoller({ authedPlayer }) { jammed: pendingHits.jammed, perHit, weapon: weaponName || undefined, + actor: actingAs ? actingAs.split(':').slice(1).join(':') : (authedPlayer || 'Attacker'), enemy: enemyName || undefined, using: pendingHits.isRanged ? 'BS' : 'WS', defenseRoll, @@ -1266,21 +1724,38 @@ function DeathwatchRoller({ authedPlayer }) { addRollResult(res) if (totalApplied>0) { const applied = Math.max(0, Math.floor(totalApplied)) - setPartDamage(nextParts) - setCurWounds(prev => { - const next = Math.max(0, Number(prev) - applied) - console.info('[DW] Applied damage', applied, '-> wounds', prev, '=>', next) - return next - }) - setLastAppliedDamage(applied) - setInfo(`${defenseSummary}. Applied ${applied} damage to ${where}`) + if (isHordeMode) { + setCurWounds(prev => { + const next = Math.max(0, Number(prev) - applied) + console.info('[DW] Horde: Applied', applied, '-> magnitude', prev, '=>', next) + return next + }) + setLastAppliedDamage(applied) + setLastRollState({ label: applied > 0 ? 'Hit' : 'No Damage', tone: applied > 0 ? 'emerald' : 'amber' }) + setInfo(`${defenseSummary}. ${applied} creatures killed from horde.`) + } else { + setPartDamage(nextParts) + setCurWounds(prev => { + const next = Math.max(0, Number(prev) - applied) + console.info('[DW] Applied damage', applied, '-> wounds', prev, '=>', next) + return next + }) + setLastAppliedDamage(applied) + setLastRollState({ label: applied > 0 ? 'Hit' : 'No Damage', tone: applied > 0 ? 'emerald' : 'amber' }) + setInfo(`${defenseSummary}. Applied ${applied} damage to ${where}`) + } // Show damage then wounds setProgressStep('damage') setTimeout(()=> setProgressStep('wounds'), 250) setTimeout(()=> setProgressStep('idle'), 2200) } else { - setInfo(`${defenseSummary}. No damage applied - attack completely negated by armour and toughness.`) + if (isHordeMode) { + setInfo(`${defenseSummary}. No creatures killed - damage did not exceed TB.`) + } else { + setInfo(`${defenseSummary}. No damage applied - attack completely negated by armour and toughness.`) + } setLastAppliedDamage(0) + setLastRollState({ label: 'No Damage', tone: 'amber' }) setProgressStep('wounds') setTimeout(()=> setProgressStep('idle'), 1200) } @@ -1293,8 +1768,19 @@ function DeathwatchRoller({ authedPlayer }) { const allowedModes = useMemo(()=>{ const w = weapons.find(x=>x.name===weaponName); return w && Array.isArray(w.modes) && w.modes.length ? w.modes : ['single','semi','full'] },[weapons, weaponName]) const woundPct = maxWounds>0 ? Math.max(0, Math.min(100, (curWounds/maxWounds)*100)) : 0 + const playerWoundPct = playerMaxWounds>0 ? Math.max(0, Math.min(100, (playerCurWounds/playerMaxWounds)*100)) : 0 const status = curWounds<=0 ? 'Dead' : (curWounds [result, ...prev].slice(0, 50)) @@ -1362,17 +1848,17 @@ function DeathwatchRoller({ authedPlayer }) {

Deathwatch Roller Pro

- {combatRound?.active && ( - Round {combatRound.round} + {activeCombatRound?.active && ( + Round {activeCombatRound.round} )} - {combatRound?.active && combatRound?.currentTurn?.name && ( + {activeCombatRound?.active && activeCombatRound?.currentTurn?.name && ( - Now {combatRound.currentTurn.name} + Now {activeCombatRound.currentTurn.name} )} - {combatRound?.active && combatRound?.nextTurn?.name && ( + {activeCombatRound?.active && activeCombatRound?.nextTurn?.name && ( - Next {combatRound.nextTurn.name} + Next {activeCombatRound.nextTurn.name} )}
@@ -1432,6 +1918,7 @@ function DeathwatchRoller({ authedPlayer }) {
+
{rollerTab === 'skill' && (
+
Target {target}
Wounds
Phase: {progressStep}
Last Applied: {lastAppliedDamage}
+
{lastRollState.label}
{manageWeapons && ( @@ -1878,19 +2370,31 @@ function DeathwatchRoller({ authedPlayer }) { )} {history[0] && history[0].rollKind !== 'skill' && (
-
{history[0].weapon ? `${history[0].weapon} • ` : ''}{history[0].using} • Attack {history[0].attackRoll} → Hit!
+ {(history[0].actor || history[0].enemy) && ( +
+ {history[0].actor && {history[0].actor}} + vs + {history[0].enemy && {history[0].enemy}} +
+ )} +
{history[0].weapon ? `${history[0].weapon} • ` : ''}{history[0].using} • Attack {history[0].attackRoll} → {history[0].success ? 'Hit!' : 'Miss!'}
{history[0].defenseRoll && (
{history[0].defenseType || 'Defense'} {history[0].defenseRoll} vs {history[0].defenseTarget} → {history[0].defenseFailed ? Failed! : Defended!}
)}
{history[0].mode?.toUpperCase() || 'SINGLE'} • RoF {history[0].rof || 1} • Hits {history[0].hits} • {history[0].where}{history[0].jammed && JAM}
- {history[0].perHit && history[0].perHit.length > 0 && ( -
-
Damage
+
+
+ Damage applied: {(history[0].perHit || []).reduce((sum, h) => sum + (h.mitigated || 0), 0)} + {(!history[0].perHit || history[0].perHit.length === 0) && history[0].hits > 0 && ( + — negated{history[0].defenseType ? ` by ${history[0].defenseType}` : ''} + )} +
+ {history[0].perHit && history[0].perHit.length > 0 && (
    {history[0].perHit.map((h, i)=>(
  • {h.mitigated} ({h.detail})
  • ))}
-
- )} + )} +
)}
@@ -1908,40 +2412,128 @@ function DeathwatchRoller({ authedPlayer }) {
)} + {rollerTab === 'horde' && ( + + )}
-
Enemy Wounds Tracker
+
{((rollerTab === 'horde' || isHordeMode) && hordeCombat) ? 'Player Wound Tracker' : 'Enemy Wounds Tracker'}
-
-
- - setMaxWounds(Math.max(0, parseInt(e.target.value||'0')))} /> -
-
- - setCurWounds(Math.max(0, parseInt(e.target.value||'0')))} /> -
-
-
-
-
-
{curWounds}/{maxWounds} remaining
-
-
-
- {BODY_PARTS.map(p => ( -
-
{p}
-
{partDamage[p]||0}
-
- AR {armourMap[p]??targetArmour} + {((rollerTab === 'horde' || isHordeMode) && hordeCombat) ? ( + (() => { + const playerRows = hordeCombat.players + .map(p => ({ ...p, kind: 'player' })) + .sort((a, b) => b.initiative - a.initiative); + const combinedOrder = [ + ...hordeCombat.players.map(p => ({ ...p, kind: 'player' })), + ...(hordeCombat.enemy ? [{ ...hordeCombat.enemy, kind: 'enemy' }] : []) + ].sort((a, b) => b.initiative - a.initiative); + const currentActor = hordeCombat.turnIndex >= 0 ? combinedOrder[hordeCombat.turnIndex] || null : null; + return ( +
+ {playerRows.map((actor, idx) => { + const isCurrent = currentActor?.kind === 'player' && actor.name === currentActor.name; + const woundPct = actor.maxWounds > 0 ? Math.max(0, (actor.wounds / actor.maxWounds) * 100) : 0; + return ( +
+
+
+ #{idx + 1} + {isCurrent && Now} + {actor.name} +
+ + {actor.alive ? 'Up' : 'Down'} + +
+
+
+
+
+ {actor.wounds}/{actor.maxWounds} wounds + Init {actor.initiative} +
+
+ ); + })} +
+ ); + })() + ) : ( + // NORMAL MODE: wounds + body parts +
+
+
+ + setMaxWounds(Math.max(0, parseInt(e.target.value||'0')))} /> +
+
+ + setCurWounds(Math.max(0, parseInt(e.target.value||'0')))} /> +
+
+
+
+
+
{curWounds}/{maxWounds} remaining
- ))} -
+
+ {BODY_PARTS.map(p => ( +
+
{p}
+
{partDamage[p]||0}
+
+ AR {armourMap[p]??targetArmour} +
+
+ ))} +
+
+
+
Player Wound Tracker
+ +
+
+
+ + setPlayerMaxWounds(Math.max(0, parseInt(e.target.value||'0')))} /> +
+
+ + setPlayerCurWounds(Math.max(0, parseInt(e.target.value||'0')))} /> +
+
+
+
+
+
{playerCurWounds}/{playerMaxWounds} remaining
+
+
+
+
+ )}
@@ -1950,21 +2542,39 @@ function DeathwatchRoller({ authedPlayer }) {
{history.length===0 &&
No rolls yet
} - {history.map(r => ( + {history.map(r => { + const perHitList = Array.isArray(r.perHit) ? r.perHit : []; + const totalDamage = perHitList.reduce((sum, h) => sum + (h.mitigated || 0), 0); + const wasNegated = r.rollKind !== 'skill' && totalDamage === 0 && (r.hitsNegated > 0 || (r.success && r.hits > 0 && perHitList.length === 0)); + return (
{new Date(r.ts).toLocaleTimeString()}
{r.rollKind === 'skill' ? `${r.skillName} • ${r.using}` : `${r.weapon ? `${r.weapon} • ` : ''}${r.using || 'Unknown'} • ${r.mode ? r.mode.toUpperCase() : 'SINGLE'} • RoF ${r.rof || 1}`}
+ {r.rollKind !== 'skill' && (r.actor || r.enemy) && ( +
+ {r.actor && {r.actor}} + vs + {r.enemy && {r.enemy}} +
+ )}
{r.success ? Success : Fail} • Roll {r.attackRoll || '?'} • Target {r.target || '?'} {r.rollKind === 'skill' ? ` • ${r.success ? `${r.dos || 0} DoS` : `${r.dof || 0} DoF`} • ${r.training || ''}` : ` • Hits ${r.hits || 0} • ${r.where || 'Unknown'}${r.jammed ? ' • JAM' : ''}`}
- {r.success && r.hits > 0 && r.perHit && Array.isArray(r.perHit) && ( -
{r.enemy? (r.enemy + ' • '): ''}{r.perHit.map((h,i)=>`${i+1}:${h.mitigated || 0}`).join(' ')}
+ {r.rollKind !== 'skill' && ( +
+ Damage applied: {totalDamage} + {perHitList.length > 1 && ( + ({perHitList.map((h, i) => `${i + 1}:${h.mitigated || 0}`).join(' ')}) + )} + {wasNegated && — negated{r.defenseType ? ` by ${r.defenseType}` : ''}} +
)}
- ))} + ); + })}
@@ -1974,6 +2584,721 @@ function DeathwatchRoller({ authedPlayer }) { ) } +// ── Horde Combat Panel ────────────────────────────────────────────────────── +function HordeCombatPanel({ + players, enemies, hordeCombat, setHordeCombat, authedPlayer, + selectedHordePlayers, setSelectedHordePlayers, + d100, degrees, parseDice, rollDice, setInfo, setError, + lastRollState, rollStateColor, setLastRollState, addRollResult +}) { + const hordeEnemies = useMemo(() => enemies.filter(e => e.horde), [enemies]); + const orderedPlayers = useMemo(() => { + return [...players] + .filter(p => p && p.name && String(p.name).toLowerCase() !== 'gm') + .sort((a, b) => a.name.localeCompare(b.name)); + }, [players]); + const [enemyName, setEnemyName] = useState(''); + const [hordeMagnitude, setHordeMagnitude] = useState(0); + const canEditMagnitude = authedPlayer === 'gm'; + + useEffect(() => { + const enemy = enemies.find(e => e.name === enemyName); + const mag = Number(enemy?.magnitude ?? enemy?.wounds ?? 20); + setHordeMagnitude(Number.isFinite(mag) && mag > 0 ? mag : 20); + }, [enemyName, enemies]); + + useEffect(() => { + if (!authedPlayer || authedPlayer === 'gm') return; + if (selectedHordePlayers.length > 0) return; + const match = orderedPlayers.find(p => String(p.name).toLowerCase() === String(authedPlayer).toLowerCase()); + const nextPlayer = orderedPlayers.find(p => !match || p.name !== match.name); + const defaults = []; + if (match) defaults.push(match.name); + if (nextPlayer) defaults.push(nextPlayer.name); + if (defaults.length > 0) setSelectedHordePlayers(defaults); + }, [authedPlayer, orderedPlayers, selectedHordePlayers.length, setSelectedHordePlayers]); + + function selectPlayerCount(count) { + const next = orderedPlayers.slice(0, Math.max(0, count)).map(p => p.name); + setSelectedHordePlayers(next); + } + + function startCombat() { + const enemy = enemies.find(e => e.name === enemyName); + if (!enemy) { setInfo('Select a horde enemy first'); return; } + if (selectedHordePlayers.length === 0) { setInfo('Select at least one player'); return; } + + const playerList = selectedHordePlayers.map(name => { + const p = players.find(pl => pl.name === name); + if (!p) return null; + const tab = p.tabInfo || {}; + const chars = tab.characteristics || {}; + const ag = (typeof chars.ag === 'number' ? chars.ag : 40); + const bs = (typeof chars.bs === 'number' ? chars.bs : 40); + const ws = (typeof chars.ws === 'number' ? chars.ws : 40); + const t = (typeof chars.t === 'number' ? chars.t : 40); + const currentWoundsRaw = typeof tab.wounds === 'object' && tab.wounds !== null + ? Number(tab.wounds.current ?? tab.wounds.total ?? 12) + : Number(tab.wounds || 12); + const totalWoundsRaw = typeof tab.wounds === 'object' && tab.wounds !== null + ? Number(tab.wounds.total ?? tab.wounds.current ?? 12) + : Number(tab.wounds || 12); + const maxWounds = Number.isFinite(totalWoundsRaw) && totalWoundsRaw > 0 ? totalWoundsRaw : 12; + const currentWounds = Number.isFinite(currentWoundsRaw) && currentWoundsRaw >= 0 ? Math.min(currentWoundsRaw, maxWounds) : maxWounds; + const weapons = Array.isArray(tab.weapons) ? tab.weapons.map(mapPlayerWeapon).filter(w => w.name) : []; + const mainWeapon = pickMainWeapon(weapons); + return { + name, + ag, + bs, + ws, + tb: Math.max(0, Math.floor(t / 10)), + wounds: currentWounds, + maxWounds, + weapons, + mainWeaponName: mainWeapon?.name || '', + mainWeaponDamage: normalizeDamageExpression(mainWeapon?.damage, ''), + initiative: 0, + alive: true, + reactionRound: 0 + }; + }).filter(Boolean); + + if (playerList.length === 0) { setInfo('No valid players found'); return; } + + playerList.forEach(p => { + const die = Math.floor(Math.random() * 10) + 1; + const agBonus = Math.floor(p.ag / 10); + p.initiative = die + agBonus; + p.initiativeRoll = die; + }); + playerList.sort((a, b) => b.initiative - a.initiative); + + const enemyMag = Number.isFinite(Number(hordeMagnitude)) && Number(hordeMagnitude) >= 0 + ? Number(hordeMagnitude) + : Number(enemy.magnitude ?? enemy.wounds ?? 20); + const enemyObj = { + name: enemy.name, horde: true, magnitude: enemyMag, maxMagnitude: enemyMag, + tb: enemy.tb || 4, damage: enemy.damage || '1d10+3', + ws: enemy.ws || 30, bs: enemy.bs || 30, ag: enemy.ag || 30, + initiativeRoll: Math.floor(Math.random() * 10) + 1, + initiative: 0, + alive: true + }; + enemyObj.initiative = enemyObj.initiativeRoll + Math.floor((enemy.ag || 30) / 10); + const initiativeOrder = [...playerList, enemyObj].sort((a, b) => b.initiative - a.initiative); + const firstActorName = initiativeOrder[0]?.kind === 'enemy' ? enemyObj.name : initiativeOrder[0]?.name; + + setHordeCombat({ + enemy: enemyObj, players: playerList, round: 1, turnIndex: 0, phase: 'idle', lastApplied: 0, + lastDetail: null, + log: [ + `Combat started: ${playerList.map(p=>p.name).join(', ')} vs ${enemy.name} (Mag ${enemyMag}, TB ${enemyObj.tb})`, + `Initiative rolls: ${initiativeOrder.map(entry => `${entry.name} rolled d10 ${entry.initiativeRoll ?? '?'} + ${Math.floor((entry.ag || 30) / 10)} = ${entry.initiative}`).join(' | ')}`, + `Round 1 turn order begins with ${firstActorName || 'no-one'}.` + ], + playerDamage: '1d10+5' + }); + setInfo('Horde combat started! Use Roll to advance the fight.'); + } + + function getHordeTurnOrder(combat) { + if (!combat) return []; + const playersOrder = combat.players.map(p => ({ ...p, kind: 'player' })); + const enemyOrder = combat.enemy ? [{ ...combat.enemy, kind: 'enemy' }] : []; + return [...playersOrder, ...enemyOrder].sort((a, b) => b.initiative - a.initiative); + } + + function nextTurn(baseCombat = null) { + const combat = baseCombat || hordeCombat; + if (!combat) return; + setError(''); setInfo(''); + let nc = { ...combat, players: combat.players.map(p => ({...p})), enemy: {...combat.enemy} }; + const turnOrder = getHordeTurnOrder(nc); + let nextTurnIndex = nc.turnIndex; + let nextActor = null; + for (let i = 0; i < turnOrder.length; i += 1) { + nextTurnIndex = (nextTurnIndex + 1) % turnOrder.length; + nextActor = turnOrder[nextTurnIndex]; + let nextAlive = false; + if (nextActor.kind === 'enemy') nextAlive = !!nc.enemy?.alive; + else { + for (const p of nc.players) { + if (p.name === nextActor.name && p.alive) { nextAlive = true; break; } + } + } + if (nextActor && nextAlive) break; + nextActor = null; + } + if (!nextActor) { + nc.log.push(`R${nc.round} no living players remain`); + setHordeCombat(nc); + setInfo('DEFEAT! All players down.'); + return; + } + if (nextTurnIndex <= nc.turnIndex) nc.round += 1; + if (nextTurnIndex <= nc.turnIndex) { + nc.players = nc.players.map(p => ({ ...p, reactionRound: 0 })); + } + nc.turnIndex = nextTurnIndex; + nc.phase = 'idle'; + const currentLabel = nextActor.kind === 'enemy' ? nc.enemy.name : nextActor.name; + nc.log.push(`R${nc.round} turn: ${currentLabel} (Init ${nextActor.initiative})`); + setHordeCombat(nc); + setInfo(`Round ${nc.round} - ${currentLabel}'s turn (Init ${nextActor.initiative}).`); + } + + function playerAttack() { + if (!hordeCombat) return; + setError(''); + setLastRollState({ label: 'Rolling', tone: 'sky' }); + let nc = { ...hordeCombat, players: hordeCombat.players.map(p => ({...p})), enemy: {...hordeCombat.enemy} }; + const turnOrder = getHordeTurnOrder(nc); + const actor = turnOrder[nc.turnIndex] || turnOrder.find(entry => entry.kind === 'enemy' ? nc.enemy?.alive : entry.alive) || null; + if (!actor) { setInfo('No active horde turn'); return; } + + if (actor.kind === 'player') { + const player = nc.players.find(p => p.name === actor.name) || null; + const enemy = nc.enemy; + if (!player || !player.alive) { setInfo('No active player turn'); return; } + if (!enemy.alive) { setInfo('Horde already destroyed!'); return; } + nc.phase = 'attack'; + nc.lastApplied = 0; + + const attackRoll = d100(); + const selectedWeapon = player.weapons?.find(w => w.name === player.mainWeaponName) || pickMainWeapon(player.weapons) || player.weapons?.[0] || null; + const usesMelee = String(selectedWeapon?.class || '').toLowerCase() === 'melee'; + const attackTarget = usesMelee ? player.ws : player.bs; + const dg = degrees(attackTarget, attackRoll); + const damageExpr = normalizeDamageExpression(selectedWeapon?.damage || player.mainWeaponDamage || nc.playerDamage, '1d10+5'); + + if (dg.success) { + let dmgSpec; + try { + dmgSpec = parseDice(damageExpr); + } catch { + dmgSpec = parseDice('1d10+5'); + } + const dmgResult = rollDice(dmgSpec.terms, { tearing: !!selectedWeapon?.tearing, proven: selectedWeapon?.proven || 0 }); + const rawDmg = dmgResult.total + dmgSpec.flat; + nc.phase = 'damage'; + const magnitudeLoss = rawDmg > enemy.tb ? 1 : 0; + enemy.magnitude -= magnitudeLoss; + if (enemy.magnitude <= 0) { enemy.magnitude = 0; enemy.alive = false; } + nc.lastApplied = magnitudeLoss; + nc.lastDetail = { + title: `${player.name} damage`, + lines: [ + `${selectedWeapon ? selectedWeapon.name : 'Main weapon'} • ${damageExpr}`, + `Attack ${attackRoll} vs ${attackTarget} ${usesMelee ? 'WS' : 'BS'} -> ${dg.dos} DoS`, + `Damage rolls: ${dmgResult.rolls.join(', ')}${dmgSpec.flat ? ` + ${dmgSpec.flat}` : ''} = ${rawDmg}`, + `TB ${enemy.tb} -> ${magnitudeLoss > 0 ? `${magnitudeLoss} magnitude lost` : '0 magnitude lost'}` + ] + }; + setLastRollState({ label: magnitudeLoss > 0 ? 'Hit' : 'No Damage', tone: magnitudeLoss > 0 ? 'emerald' : 'amber' }); + addRollResult({ + id: uid(), + ts: Date.now(), + attackRoll, + target: attackTarget, + success: true, + dos: dg.dos, + dof: 0, + mode: 'HORDE', + rof: 1, + hits: 1, + hitsNegated: 0, + hitsApplied: magnitudeLoss > 0 ? 1 : 0, + where: 'Horde', + jammed: false, + perHit: [{ where: 'Horde', raw: rawDmg, mitigated: magnitudeLoss, detail: `Rolls ${dmgResult.rolls.join(', ')}${dmgSpec.flat ? ` + ${dmgSpec.flat}` : ''} - TB ${enemy.tb} = ${magnitudeLoss}` }], + weapon: selectedWeapon ? selectedWeapon.name : 'Main weapon', + actor: player.name, + enemy: enemy.name, + using: usesMelee ? 'WS' : 'BS' + }); + nc.log.push(`R${nc.round} ${player.name} attacks with ${selectedWeapon ? selectedWeapon.name : 'main weapon'}: ${attackRoll} vs ${attackTarget} ${usesMelee ? 'WS' : 'BS'} = ${dg.dos} DoS, dmg ${rawDmg} vs TB ${enemy.tb} = ${magnitudeLoss} magnitude${magnitudeLoss ? '' : ' (negated)'} (Mag ${enemy.magnitude}/${enemy.maxMagnitude})`); + setInfo(`${player.name} hits! ${magnitudeLoss} creature(s) killed. ${enemy.alive ? `Mag ${enemy.magnitude}/${enemy.maxMagnitude}` : 'Horde destroyed!'}`); + } else { + nc.lastDetail = { + title: `${player.name} attack`, + lines: [ + `${selectedWeapon ? selectedWeapon.name : 'Main weapon'} • ${damageExpr}`, + `Attack ${attackRoll} vs ${attackTarget} ${usesMelee ? 'WS' : 'BS'} -> MISS (DoF ${dg.dof})` + ] + }; + setLastRollState({ label: 'Fail', tone: 'rose' }); + addRollResult({ + id: uid(), + ts: Date.now(), + attackRoll, + target: attackTarget, + success: false, + dos: 0, + dof: dg.dof, + mode: 'HORDE', + rof: 1, + hits: 0, + where: 'Horde', + jammed: false, + perHit: [], + weapon: selectedWeapon ? selectedWeapon.name : 'Main weapon', + actor: player.name, + enemy: enemy.name, + using: usesMelee ? 'WS' : 'BS' + }); + nc.log.push(`R${nc.round} ${player.name} attacks: ${attackRoll} vs ${attackTarget} ${usesMelee ? 'WS' : 'BS'} = MISS (DoF ${dg.dof})`); + setInfo(`${player.name} misses (DoF ${dg.dof}).`); + } + + setHordeCombat(nc); + setTimeout(() => { + setHordeCombat(prev => prev ? { ...prev, phase: 'wounds' } : prev); + if (!enemy.alive) setInfo(`VICTORY! Survivors: ${nc.players.filter(p=>p.alive).map(p=>p.name).join(', ')}`); + else if (nc.players.every(p => !p.alive)) setInfo('DEFEAT! All players down.'); + else { + setTimeout(() => { + setHordeCombat(prev => prev ? { ...prev, phase: 'idle' } : prev); + nextTurn(nc); + }, PHASE_TIMING.wounds); + } + }, PHASE_TIMING.damage); + return; + } + + const livingTargets = nc.players.filter(p => p.alive); + const target = livingTargets.length + ? livingTargets[Math.floor(Math.random() * livingTargets.length)] + : null; + if (!target) { setInfo('DEFEAT! All players down.'); return; } + const enemyUsesMelee = Number(nc.enemy.ws || 0) > Number(nc.enemy.bs || 0); + const canParry = enemyUsesMelee && hasMeleeWeapon(target.weapons); + const reactionType = canParry ? 'Parry' : 'Dodge'; + const reactionStat = canParry ? Number(target.ws || 0) : Number(target.ag || 0); + const reactionAvailable = (target.reactionRound || 0) !== nc.round; + let reactionRoll = null; + let reactionDos = 0; + let reactionSucceeded = false; + if (reactionAvailable && reactionStat > 0) { + reactionRoll = d100(); + nc.phase = 'defense'; + const reactionResult = degrees(reactionStat, reactionRoll); + reactionSucceeded = !!reactionResult.success; + reactionDos = reactionSucceeded ? reactionResult.dos : 0; + target.reactionRound = nc.round; + nc.log.push(` ${target.name} ${reactionType}: ${reactionRoll} vs ${reactionStat} = ${reactionSucceeded ? `SUCCESS (${reactionDos} DoS)` : `FAIL (DoF ${reactionResult.dof})`}`); + if (reactionSucceeded) { + nc.lastApplied = 0; + nc.phase = 'defense'; + nc.lastDetail = { + title: `${target.name} defense`, + lines: [ + `${nc.enemy.name} targets ${target.name}`, + `${reactionType} ${reactionRoll} vs ${reactionStat} -> SUCCESS (${reactionDos} DoS)`, + `Attack negated before damage` + ] + }; + setLastRollState({ label: 'Defended', tone: 'amber' }); + addRollResult({ + id: uid(), + ts: Date.now(), + attackRoll: reactionRoll, + target: reactionStat, + success: true, + dos: reactionDos, + dof: 0, + mode: 'HORDE', + rof: 1, + hits: 1, + hitsNegated: 1, + hitsApplied: 0, + where: 'Self', + jammed: false, + perHit: [], + weapon: nc.enemy.name, + actor: nc.enemy.name, + enemy: target.name, + using: enemyUsesMelee ? 'WS' : 'BS', + defenseType: reactionType, + defenseRoll: reactionRoll, + defenseTarget: reactionStat, + defenseFailed: false + }); + setInfo(`Round ${nc.round} - ${target.name} ${reactionType.toLowerCase()}s the horde attack.`); + setHordeCombat(nc); + setTimeout(() => { + setHordeCombat(prev => prev ? { ...prev, phase: 'idle' } : prev); + nextTurn(nc); + }, PHASE_TIMING.defense); + return; + } + } else { + nc.log.push(` ${target.name} has no reaction available this round`); + } + let eDmgSpec; + try { + eDmgSpec = parseDice(normalizeDamageExpression(nc.enemy.damage, '1d10+3')); + } catch { + eDmgSpec = parseDice('1d10+3'); + } + const eDmgResult = rollDice(eDmgSpec.terms, { tearing: false, proven: 0 }); + const eRaw = eDmgResult.total + eDmgSpec.flat; + const tb = Number.isFinite(target.tb) ? target.tb : Math.floor(target.maxWounds / 10); + const dmgToPlayer = Math.max(0, eRaw - tb); + target.wounds -= dmgToPlayer; + if (target.wounds <= 0) { target.wounds = 0; target.alive = false; } + nc.lastDetail = { + title: `${nc.enemy.name} damage`, + lines: [ + `${nc.enemy.name} targets ${target.name} randomly`, + reactionRoll ? `${reactionType} ${reactionRoll} vs ${reactionStat} -> FAIL` : `${target.name} has no reaction available`, + `Damage rolls: ${eDmgResult.rolls.join(', ')}${eDmgSpec.flat ? ` + ${eDmgSpec.flat}` : ''} = ${eRaw}`, + `TB ${tb} -> ${dmgToPlayer} damage (${target.wounds}/${target.maxWounds} wounds left${target.alive ? '' : ', down'})` + ] + }; + setLastRollState({ label: dmgToPlayer > 0 ? 'Hit' : 'No Damage', tone: dmgToPlayer > 0 ? 'emerald' : 'amber' }); + addRollResult({ + id: uid(), + ts: Date.now(), + attackRoll: reactionRoll || eRaw, + target: reactionRoll ? reactionStat : tb, + success: dmgToPlayer > 0, + dos: 0, + dof: 0, + mode: 'HORDE', + rof: 1, + hits: 1, + hitsNegated: 0, + hitsApplied: dmgToPlayer > 0 ? 1 : 0, + where: 'Body', + jammed: false, + perHit: [{ where: 'Body', raw: eRaw, mitigated: dmgToPlayer, detail: `Rolls ${eDmgResult.rolls.join(', ')}${eDmgSpec.flat ? ` + ${eDmgSpec.flat}` : ''} - TB ${tb} = ${dmgToPlayer}` }], + weapon: nc.enemy.name, + actor: nc.enemy.name, + enemy: target.name, + using: enemyUsesMelee ? 'WS' : 'BS', + defenseType: reactionRoll ? reactionType : 'No Reaction', + defenseRoll: reactionRoll || undefined, + defenseTarget: reactionRoll ? reactionStat : undefined, + defenseFailed: !!reactionRoll + }); + nc.log.push(`R${nc.round} turn: ${nc.enemy.name} (Init ${nc.enemy.initiative})`); + nc.log.push(` ${nc.enemy.name} targets ${target.name} randomly`); + nc.log.push(` ${nc.enemy.name} hits ${target.name}: dmg ${eRaw} vs TB ${tb} = ${dmgToPlayer} (${target.wounds}/${target.maxWounds}${target.alive ? '' : ' - DOWN!'})`); + nc.lastApplied = dmgToPlayer; + const reactionText = reactionRoll ? ` ${target.name} ${reactionType.toLowerCase()} roll ${reactionRoll} vs ${reactionStat} failed.` : ` ${target.name} had no reaction left.`; + setInfo(`Round ${nc.round} - ${nc.enemy.name} hits ${target.name}.${reactionText}`); + nc.phase = 'damage'; + setHordeCombat(nc); + if (nc.players.every(p => !p.alive)) setInfo('DEFEAT! All players down.'); + else { + setTimeout(() => { + setHordeCombat(prev => prev ? { ...prev, phase: 'wounds' } : prev); + setTimeout(() => { + setHordeCombat(prev => prev ? { ...prev, phase: 'idle' } : prev); + nextTurn(nc); + }, PHASE_TIMING.wounds); + }, PHASE_TIMING.damage); + } + } + + function skipTurn() { + if (!hordeCombat) return; + let nc = { ...hordeCombat, players: hordeCombat.players.map(p => ({...p})), enemy: {...hordeCombat.enemy} }; + const turnOrder = getHordeTurnOrder(nc); + const actor = turnOrder[nc.turnIndex] || turnOrder.find(entry => entry.kind === 'enemy' ? nc.enemy?.alive : entry.alive) || null; + const player = actor?.kind === 'player' ? nc.players.find(p => p.name === actor.name) || null : null; + nc.log.push(`R${nc.round} ${player?.name || nc.enemy.name || '?'} skips`); + nc.phase = 'wounds'; + setHordeCombat(nc); + setTimeout(() => nextTurn(nc), 0); + } + + function endCombat() { + setHordeCombat(null); setSelectedHordePlayers([]); setEnemyName(''); setInfo('Combat ended.'); + } + + const sortedCombatants = useMemo(() => { + return getHordeTurnOrder(hordeCombat); + }, [hordeCombat]); + + const activeActor = hordeCombat?.turnIndex >= 0 ? sortedCombatants[hordeCombat.turnIndex] || null : null; + const activePlayer = activeActor?.kind === 'player' ? hordeCombat.players.find(p => p.name === activeActor.name) || null : null; + const selectedWeapon = useMemo(() => { + if (!activePlayer) return null; + const available = Array.isArray(activePlayer.weapons) ? activePlayer.weapons : []; + return available.find(w => w.name === activePlayer.mainWeaponName) || pickMainWeapon(available) || available[0] || null; + }, [activePlayer]); + const attackDamageExpr = selectedWeapon?.damage || activePlayer?.mainWeaponDamage || hordeCombat?.playerDamage || '1d10+5'; + + useEffect(() => { + if (!hordeCombat) return; + if (hordeCombat.turnIndex >= 0 && hordeCombat.turnIndex < sortedCombatants.length) return; + const firstAliveIndex = sortedCombatants.findIndex(actor => actor.kind === 'enemy' ? hordeCombat.enemy?.alive : actor.alive); + const normalizedIndex = firstAliveIndex >= 0 ? firstAliveIndex : 0; + setHordeCombat(prev => prev ? { ...prev, turnIndex: normalizedIndex } : prev); + }, [hordeCombat, setHordeCombat, sortedCombatants]); + + if (!hordeCombat) { + return ( +
+
+
+
Horde Combat
+
Setup
+
+
{selectedHordePlayers.length} selected
+
+
+
+ + +
+
+ + setHordeMagnitude(Math.max(0, parseInt(e.target.value || '0')))} + /> +
+
+ +
+
+
+ + {orderedPlayers.map((_, idx) => ( + + ))} +
+
+
+ +
+ {attackDamageExpr} +
+
+ {selectedWeapon ? `Main weapon: ${selectedWeapon.name}` : 'Used when a selected player has no weapon data.'} +
+
+
+
+
+ +
+ + +
+
+
+ {orderedPlayers.map(p => { + const selected = selectedHordePlayers.includes(p.name); + return ( + + ); + })} +
+
{selectedHordePlayers.length} player(s) selected
+
+
+ +
+
+ ); + } + + const currentP = activePlayer || null; + const enemy = hordeCombat.enemy; + const magPct = enemy.maxMagnitude > 0 ? Math.max(0, (enemy.magnitude / enemy.maxMagnitude) * 100) : 0; + const hordePhase = hordeCombat.phase || 'idle'; + + return ( +
+
+
Horde Combat: {enemy.name}
+
+ Round {hordeCombat.round} + {activeActor && ( + + {activeActor.kind === 'enemy' ? `${enemy.name}'s turn` : `${currentP.name}'s turn`} + + )} + +
+
+
+
+ {enemy.name} + + {enemy.alive ? `${enemy.magnitude}/${enemy.maxMagnitude} creatures` : 'DESTROYED'} + +
+
+
+
+
+
+ + {canEditMagnitude ? ( + { + const nextMag = Math.max(0, parseInt(e.target.value || '0')) + setHordeCombat(prev => prev ? { ...prev, enemy: { ...prev.enemy, magnitude: nextMag, alive: nextMag > 0 } } : prev) + }} + /> + ) : ( +
{enemy.magnitude}
+ )} +
+
+ + {canEditMagnitude ? ( + { + const nextMax = Math.max(0, parseInt(e.target.value || '0')) + setHordeCombat(prev => prev ? { ...prev, enemy: { ...prev.enemy, maxMagnitude: nextMax, magnitude: Math.min(prev.enemy.magnitude, nextMax), alive: Math.min(prev.enemy.magnitude, nextMax) > 0 } } : prev) + }} + /> + ) : ( +
{enemy.maxMagnitude}
+ )} +
+
+
TB {enemy.tb} • Damage {enemy.damage}
+
+
+
+
Attack
+
Defense
+
Damage
+
Wounds
+
Phase: {hordePhase}
+
Last Applied: {hordeCombat.lastApplied || 0}
+
{lastRollState.label}
+
+
+ {hordeCombat.lastDetail && Array.isArray(hordeCombat.lastDetail.lines) && hordeCombat.lastDetail.lines.length > 0 && ( +
+
Damage Detail
+
{hordeCombat.lastDetail.title}
+
+ {hordeCombat.lastDetail.lines.map((line, idx) => ( +
+ {line} +
+ ))} +
+
+ )} +
+
Initiative Order
+
+ {sortedCombatants.map((actor, idx) => { + const isCurrent = actor.name === activeActor?.name && actor.kind === activeActor?.kind; + const isNext = idx === ((hordeCombat.turnIndex + 1) % sortedCombatants.length); + return ( +
+
+ #{idx+1} + {isCurrent && Now} + {!isCurrent && isNext && Next} + {actor.kind === 'enemy' ? `${actor.name}` : actor.name} + Init {actor.initiative} +
+
+
+
+
+ {actor.wounds}/{actor.maxWounds} + + {actor.alive ? 'Alive' : 'Down'} + +
+
+ ); + })} +
+
+
+
+ + {currentP && Array.isArray(currentP.weapons) && currentP.weapons.length > 0 ? ( +
+ {selectedWeapon?.name || currentP.mainWeaponName || selectedWeapon?.name || 'Main weapon'} +
+ ) : ( +
No sheet weapons found
+ )} +
+
+ + {selectedWeapon ? ( +
+ {selectedWeapon.damage} +
+ ) : ( +
+ {hordeCombat.playerDamage || '1d10+5'} +
+ )} +
+ + +
+ {!enemy.alive &&
VICTORY! Horde destroyed.
} + {hordeCombat.players.every(p => !p.alive) &&
DEFEAT! All players down.
} +
+ ); +} + function __run_tests__() { const a = parseDice('1d10+5'); if (!(a.terms.length===1 && a.terms[0].count===1 && a.terms[0].faces===10 && a.flat===5)) throw new Error('parseDice a') const b = parseDice('2d10+3'); if (!(b.terms.length===1 && b.terms[0].count===2 && b.terms[0].faces===10 && b.flat===3)) throw new Error('parseDice b') diff --git a/src/components/MissionTab.jsx b/src/components/MissionTab.jsx index 498889f..b0ff444 100644 --- a/src/components/MissionTab.jsx +++ b/src/components/MissionTab.jsx @@ -1096,6 +1096,7 @@ export default function MissionTab({ authedPlayer }) { modifier: current.modifier ?? 0, initPlayer: current.initPlayer || defaultPlayer, initAgBonus: current.initAgBonus || '', + enemyInitAgBonus: current.enemyInitAgBonus || '', }; } @@ -1305,6 +1306,23 @@ export default function MissionTab({ authedPlayer }) { await recordMissionRollForScene(feedSceneIndex, name, 'initiative', 'Initiative', { mini: true, agBonus: ab, dieRoll, total }); } + async function addMiniEnemyInitiative(sceneIndex, scene) { + const state = miniState(sceneIndex, scene); + const enemies = sceneEnemyObjects(scene); + const enemy = enemies.find(e => e.name === state.enemyName) || enemies[0]; + if (!enemy) return; + const ab = state.enemyInitAgBonus === '' ? Math.floor((Number(enemy.ag) || 30) / 10) : (parseInt(state.enemyInitAgBonus, 10) || 0); + const dieRoll = Math.floor(Math.random() * 10) + 1; + const total = dieRoll + ab; + const entry = { player: enemy.name, agBonus: ab, dieRoll, total, type: 'npc', ts: Date.now() }; + const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex; + const current = scene?.combatState || {}; + const existing = Array.isArray(current.initiatives) ? current.initiatives : []; + const next = [...existing, entry]; + await updateSceneCombatState(sceneIndex, { initiatives: next }); + await recordMissionRollForScene(feedSceneIndex, enemy.name, 'initiative', 'Initiative', { mini: true, agBonus: ab, dieRoll, total, enemy: true }); + } + async function advanceMiniCombatTurn(sceneIndex, scene, delta = 1) { if (!scene?.combatState) return; const order = sortInitiatives(scene.combatState.initiatives); @@ -1706,6 +1724,27 @@ export default function MissionTab({ authedPlayer }) { Roll Initiative
+ {isGM && ( +
+
+ Enemy: {selectedEnemy ? selectedEnemy.name : 'No enemy selected'} +
+ setMiniState(sceneIndex, { enemyInitAgBonus: e.target.value })} + className="min-w-0 w-full rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm" + /> + +
+ )}
@@ -3306,6 +3345,10 @@ export default function MissionTab({ authedPlayer }) { seEnemy(i, 'ws', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" /> +
+ + seEnemy(i, 'ag', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" /> +
seEnemy(i, 'damage', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />