-
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 (
+
+
+
+
{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