diff --git a/TODO.md b/TODO.md index 08103a9..088f122 100644 --- a/TODO.md +++ b/TODO.md @@ -5,17 +5,27 @@ REWORK_PLAN.md. ## Feature backlog -### FEAT-1: Dead participants stay in turn order -- From user (Saturday game). Moved out of REWORK_PLAN (not milestone). -- Dead (HP=0) participants must NOT be skipped. -- Current: dead → `isActive=false` → removed from turn order → skipped. -- Desired: dead occupy initiative slot, turn still comes up. PCs get - death-save turn. -- Affects: `shared/turn.js` `nextTurn` (filters `isActive`), `applyHpChange` - (sets isActive=false on death), `computeTurnOrderAfterRemoval`. -- Characterization tests (`src/tests/Combat.characterization.test.js`) lock - CURRENT behavior — UPDATE to desired when implementing. -- RED test locked (desired state): `shared/tests/turn.dead-skip.test.js`. +### FEAT-1: Dead participants stay in turn order — DONE +- Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds` + on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get + death-save turn. `isActive` = DM toggle only. +- Tests: `shared/tests/turn.dead-skip.test.js` (4 green). Char tests updated + to new behavior. + +### FEAT-2: upgrade app internal logs to be parseable +- Goal: combat logs in Firestore store enough structured state to run + skip/rotation analysis on ANY historic round — not just replay stdout. +- Current logs: `{timestamp, message, encounterName, undo}`. Parser must + guess roster from message strings. Brittle. +- Upgrade: add structured fields at turn-state mutation log sites in + App.js (startEncounter, toggleActive, addParticipant, removeParticipant, + applyHpChange death/revive, togglePause, nextTurn): + ``` + turnSnapshot: { round, currentTurnParticipantId, turnOrderIds, activeIds } + ``` +- Then `scripts/analyze-turns.js` ingests app logs directly (adapter fetch). + Works on real game sessions, any round, deterministic. +- Parser scaffold NOW ingests replay stdout only (stopgap until FEAT-2). ## Confirmed bugs (tests written, NOT fixed) diff --git a/scripts/analyze-turns.js b/scripts/analyze-turns.js new file mode 100644 index 0000000..7f80938 --- /dev/null +++ b/scripts/analyze-turns.js @@ -0,0 +1,217 @@ +// scripts/analyze-turns.js +// Ingest replay-combat.js stdout (or any text matching its format), reconstruct +// rounds, report real skips + double-acts. Deterministic — no eyeballing. +// +// Usage: +// node scripts/analyze-turns.js [path] # analyze a saved log file +// node scripts/replay-combat.js 100 100 | node scripts/analyze-turns.js +// cat /tmp/replay.log | node scripts/analyze-turns.js +// +// Skip = participant active for WHOLE round (never deactivated/removed mid-round +// before their slot, never added mid-round) but never appeared as a turn actor. +// Double-act = same participant takes 2+ turns in one round. +// +// FEAT-2 (structured turn snapshot in app logs) will let this ingest live app +// logs too, not just replay stdout. Format-agnostic core lives in parseReplay(). + +'use strict'; + +const fs = require('fs'); + +// ---------- parsing ---------- + +const TURN_RE = /^\s*turn\s+(\d+)\s+\(round\s+(\d+)\):\s+(.+?)\s*$/; +const DEACTIVATE_RE = /^\s*\[(?:deactivate)\s+(.+?)\]\s*$/; +const REACTIVATE_RE = /^\s*\[(?:revive-reactivate|reactivate)\s+(.+?)\]\s*$/; +const ADD_RE = /^\s*\[(?:add)\s+(.+?)\]\s*$/; +const REMOVE_RE = /^\s*\[(?:remove dead|remove)\s+(.+?)\]\s*$/; +const PAUSE_RE = /^\s*\[pause\]\s*$/; +const RESUME_RE = /^\s*\[resume\]\s*$/; +const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+complete/; +const FIRST_RE = /^combat started:\s+round\s+\d+,\s+first=(.+?)\s*$/; + +function parseLine(line) { + if (TURN_RE.test(line)) { + const m = line.match(TURN_RE); + return { kind: 'turn', turn: +m[1], round: +m[2], actor: m[3].trim() }; + } + if (FIRST_RE.test(line)) { + const m = line.match(FIRST_RE); + return { kind: 'turn', turn: 0, round: 1, actor: m[1].trim() }; + } + if (DEACTIVATE_RE.test(line)) return { kind: 'deactivate', name: line.match(DEACTIVATE_RE)[1].trim() }; + if (REACTIVATE_RE.test(line)) return { kind: 'reactivate', name: line.match(REACTIVATE_RE)[1].trim() }; + if (ADD_RE.test(line)) return { kind: 'add', name: line.match(ADD_RE)[1].trim() }; + if (REMOVE_RE.test(line)) return { kind: 'remove', name: line.match(REMOVE_RE)[1].trim() }; + if (PAUSE_RE.test(line)) return { kind: 'pause' }; + if (RESUME_RE.test(line)) return { kind: 'resume' }; + if (ROUND_COMPLETE_RE.test(line)) return { kind: 'round-complete', round: +line.match(ROUND_COMPLETE_RE)[1] }; + return null; +} + +// ---------- reconstruction ---------- + +// Build per-round timeline: round -> { turns: [actor], mutations: [{stepIdx,...}] } +// Then compute skips + double-acts. +function reconstruct(events) { + // global state: active set by name. Start populated lazily from first turn. + const active = new Set(); + const rounds = new Map(); // round -> { turns: [name], events: [{...}] } + let curRound = 1; + let sawFirstTurn = false; + + for (const ev of events) { + if (ev.kind === 'turn') { + sawFirstTurn = true; + curRound = ev.round; + if (!rounds.has(curRound)) rounds.set(curRound, { turns: [], events: [], complete: false }); + const r = rounds.get(curRound); + r.turns.push(ev.actor); + r.events.push({ ...ev, idx: r.events.length }); + if (!active.has(ev.actor)) active.add(ev.actor); // first sighting = active + } else if (ev.kind === 'deactivate') { + active.delete(ev.name); + const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound); + r.events.push({ ...ev, idx: r.events.length }); + } else if (ev.kind === 'reactivate' || ev.kind === 'add') { + active.add(ev.name); + const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound); + r.events.push({ ...ev, idx: r.events.length }); + } else if (ev.kind === 'remove') { + active.delete(ev.name); + const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound); + r.events.push({ ...ev, idx: r.events.length }); + } else if (ev.kind === 'round-complete') { + if (rounds.has(ev.round)) rounds.get(ev.round).complete = true; + } + // pause/resume: rotation-affecting but no roster change; tracked in events + else if (ev.kind === 'pause' || ev.kind === 'resume') { + const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound); + r.events.push({ ...ev, idx: r.events.length }); + } + } + return rounds; +} + +// For each round, recompute active-at-start and acted, then find real skips. +function analyze(rounds) { + const report = []; + for (const [roundN, r] of [...rounds.entries()].sort((a, b) => a[0] - b[0])) { + // Replay stdout doesn't dump roster, so infer "active at round start": + // walk events IN ORDER, snapshot active set at first turn of this round. + // We replay from a clean per-round pass using a carry-over active set. + report.push(analyzeRound(roundN, r)); + } + return report; +} + +// Re-run per-round with active-set carry-over across rounds (module scope). +function analyzeRounds(rounds) { + // Carry active set forward round to round. Reset at round 1 from scratch. + let activeCarry = new Set(); + const reports = []; + const sortedRounds = [...rounds.entries()].sort((a, b) => a[0] - b[0]); + for (const [roundN, r] of sortedRounds) { + if (!r.complete) continue; // incomplete final round — can't judge skips + if (roundN === 1) activeCarry = new Set(); + const result = analyzeRoundWithCarry(roundN, r, activeCarry); + reports.push(result.report); + activeCarry = result.activeAfter; + } + return reports; +} + +function analyzeRoundWithCarry(roundN, r, activeAtStart) { + // activeAtStart: Set copy. Mutations during round adjust a working copy. + const active = new Set(activeAtStart); + const activeWholeRound = new Set(activeAtStart); // participants never toggled off/removed + const addedThisRound = new Set(); + const turns = []; // ordered actor names + + for (const ev of r.events) { + if (ev.kind === 'turn') { + turns.push(ev.actor); + if (!active.has(ev.actor)) active.add(ev.actor); // first-ever sighting + } else if (ev.kind === 'deactivate' || ev.kind === 'remove') { + active.delete(ev.name); + activeWholeRound.delete(ev.name); + } else if (ev.kind === 'reactivate' || ev.kind === 'add') { + active.add(ev.name); + if (ev.kind === 'add') addedThisRound.add(ev.name); + // reactivated = was not active at start, so not eligible for "whole round" + activeWholeRound.add(ev.name); // gives benefit of doubt; refined below + } + } + + // acted = unique names that took a turn this round + const acted = new Set(turns); + + // double-acts: turns with count > 1 + const counts = {}; + for (const n of turns) counts[n] = (counts[n] || 0) + 1; + const doubleActs = Object.entries(counts).filter(([_, c]) => c > 1).map(([n, c]) => ({ name: n, count: c })); + + // real skip: active at round start AND active at round end AND never acted. + // (deactivated/removed mid-round = legitimate skip, not a bug) + // also must have been active at END (revived back doesn't count as skip). + // Simplest defn matching the unit test: activeAtStart ∩ activeAtEnd ∩ ¬acted. + const activeAtEnd = active; + const realSkips = [...activeAtStart] + .filter(n => activeAtEnd.has(n) && !acted.has(n)); + + return { + report: { + round: roundN, + turnCount: turns.length, + uniqueActors: acted.size, + realSkips, + doubleActs, + turns, + }, + activeAfter: activeAtEnd, + }; +} + +// ---------- CLI ---------- + +function readInput() { + const arg = process.argv[2]; + if (arg) return fs.readFileSync(arg, 'utf8'); + // stdin + return fs.readFileSync(0, 'utf8'); +} + +function main() { + const text = readInput(); + const lines = text.split('\n'); + const events = lines.map(parseLine).filter(Boolean); + const rounds = reconstruct(events); + const reports = analyzeRounds(rounds); + + let totalSkips = 0; + let totalDoubles = 0; + const problemRounds = []; + + for (const rep of reports) { + const hasIssue = rep.realSkips.length > 0 || rep.doubleActs.length > 0; + if (hasIssue) problemRounds.push(rep); + totalSkips += rep.realSkips.length; + totalDoubles += rep.doubleActs.length; + } + + for (const rep of problemRounds) { + console.log(`R${rep.round}: turns=${rep.turnCount} unique=${rep.uniqueActors}`); + if (rep.realSkips.length) console.log(` REAL SKIPS: ${rep.realSkips.join(', ')}`); + if (rep.doubleActs.length) console.log(` DOUBLE-ACTS: ${rep.doubleActs.map(d => `${d.name}(${d.count}x)`).join(', ')}`); + console.log(` sequence: ${rep.turns.join(' -> ')}`); + } + + console.log(`\n=== ${reports.length} rounds analyzed ===`); + console.log(`real skips: ${totalSkips}`); + console.log(`double-acts: ${totalDoubles}`); + console.log(totalSkips === 0 && totalDoubles === 0 ? 'CLEAN — no rotation bugs' : 'ISSUES FOUND'); + + process.exit(totalSkips === 0 && totalDoubles === 0 ? 0 : 1); +} + +main(); diff --git a/shared/tests/turn.characterization.test.js b/shared/tests/turn.characterization.test.js index 9def45b..18e07c2 100644 --- a/shared/tests/turn.characterization.test.js +++ b/shared/tests/turn.characterization.test.js @@ -141,12 +141,14 @@ describe('togglePause', () => { expect(patch.isPaused).toBe(true); }); - test('resume recomputes turn order from active', () => { + test('resume preserves turn order (no re-sort)', () => { + // BUG-5 fix: resume no longer re-sorts. Re-sort displaced current pointer + // and caused skips. Order frozen at startEncounter, patched incrementally. const ps = [p('a', 5), p('b', 15)]; const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] }); const { patch } = togglePause(e); expect(patch.isPaused).toBe(false); - expect(patch.turnOrderIds).toEqual(['b', 'a']); + expect(patch.turnOrderIds).toEqual(['a', 'b']); }); }); @@ -192,11 +194,14 @@ describe('toggleParticipantActive', () => { expect(patch.currentTurnParticipantId).toBe('b'); }); - test('started: reactivating appends to turn order', () => { + test('started: reactivating inserts by initiative', () => { + // BUG-5 fix: reactivated participant slots by initiative (not appended + // to end). Preserves correct rotation order. const ps = [p('a', 10, { isActive: false }), p('b', 5)]; const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' }); const { patch } = toggleParticipantActive(e, 'a'); - expect(patch.turnOrderIds).toEqual(['b', 'a']); + // a init=10 > b init=5 → a slots before b + expect(patch.turnOrderIds).toEqual(['a', 'b']); }); }); @@ -207,17 +212,22 @@ describe('applyHpChange', () => { expect(patch.participants[0].currentHp).toBe(10); }); - test('damage to 0 deactivates + removes from turn order', () => { + test('damage to 0 keeps active + stays in turn order (FEAT-1)', () => { + // FEAT-1: death no longer deactivates or removes from turn order. + // Dead stay in rotation, nextTurn still visits them, PCs get death-save turn. const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)]; const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' }); const { patch } = applyHpChange(e, 'a', 'damage', 5); expect(patch.participants[0].currentHp).toBe(0); - expect(patch.participants[0].isActive).toBe(false); - expect(patch.currentTurnParticipantId).toBe('b'); + expect(patch.participants[0].isActive).toBe(true); + expect(patch.turnOrderIds).toBeUndefined(); + expect(patch.currentTurnParticipantId).toBeUndefined(); }); - test('heal above 0 revives + reactivates + resets death saves', () => { - const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })]; + test('heal above 0 resets death saves, keeps active (FEAT-1)', () => { + // FEAT-1: revive no longer flips isActive (was already active — death + // doesn't deactivate). deathSaves still reset. + const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })]; const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5); expect(patch.participants[0].currentHp).toBe(5); expect(patch.participants[0].isActive).toBe(true); diff --git a/shared/tests/turn.combat.test.js b/shared/tests/turn.combat.test.js index 503f94f..edf552d 100644 --- a/shared/tests/turn.combat.test.js +++ b/shared/tests/turn.combat.test.js @@ -108,7 +108,8 @@ describe('combat integrity (100 rounds, full op coverage)', () => { } e = apply(e, t); totalTurns++; - seenThisRound.push(e.currentTurnParticipantId); + // only count if turn belongs to THIS round (no wrap) + if (e.round === startRound) seenThisRound.push(e.currentTurnParticipantId); const actor = currentParticipant(e); diff --git a/shared/tests/turn.skip.test.js b/shared/tests/turn.skip.test.js new file mode 100644 index 0000000..97ee59e --- /dev/null +++ b/shared/tests/turn.skip.test.js @@ -0,0 +1,122 @@ +// Invariant: no real skip. Every active participant at round start (still +// active at round end) gets a turn. Tracks per ACTUAL round (e.round), so +// rounds spanning pause/resume across loop iterations count correctly. +// +// Guards BUG-5 fix (slot-array turn order, no re-sort on wrap/resume). +// If this goes RED, turn order rotation is skipping participants again. + +'use strict'; + +const shared = require('@ttrpg/shared'); +const { + buildCharacterParticipant, buildMonsterParticipant, + startEncounter, nextTurn, togglePause, addParticipant, removeParticipant, + toggleParticipantActive, +} = shared; + +const apply = (e, r) => (r && r.patch) ? { ...e, ...r.patch } : e; +const nm = (enc) => (id) => { + const f = enc.participants.find(p => p.id === id); + return f ? f.name : id; +}; + +function setup() { + const ps = [ + buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant, + buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant, + buildCharacterParticipant({ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 }).participant, + buildMonsterParticipant({ name: 'Goblin1', maxHp: 100, initMod: 2 }).participant, + buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant, + buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant, + buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant, + buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true }).participant, + ]; + let e = { + name: 't', participants: ps, isStarted: false, isPaused: false, + round: 0, currentTurnParticipantId: null, turnOrderIds: [], + }; + return apply(e, startEncounter(e)); +} + +describe('BUG-5: turn-order rotation never skips (deterministic)', () => { + jest.setTimeout(15000); + + test('pure nextTurn: 0 skips across 100 rounds', () => { + let e = setup(); + let totalSkips = 0; + for (let roundN = 1; roundN <= 100; roundN++) { + const startRound = e.round; + const activeAtStart = new Set(e.participants.filter(p => p.isActive).map(p => p.id)); + const acted = new Set(); + acted.add(e.currentTurnParticipantId); + let guard = 0; + const cap = e.participants.length + 1; + while (e.round === startRound && guard < cap) { + e = apply(e, nextTurn(e)); + if (e.round === startRound) acted.add(e.currentTurnParticipantId); + guard++; + } + const skipped = [...activeAtStart].filter(id => { + const p = e.participants.find(x => x.id === id); + return p && p.isActive && !acted.has(id); + }); + totalSkips += skipped.length; + } + expect(totalSkips).toBe(0); + }); + + test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', () => { + let e = setup(); + const N = nm(e); + let curRound = null; + let activeAtRoundStart = new Set(); + let actedThisRound = new Set(); + const onRoundStart = (enc) => { + curRound = enc.round; + activeAtRoundStart = new Set(enc.participants.filter(p => p.isActive).map(p => p.id)); + actedThisRound = new Set(); + if (enc.currentTurnParticipantId) actedThisRound.add(enc.currentTurnParticipantId); + }; + onRoundStart(e); + + let totalRealSkips = 0; + let added = 0; + let turns = 0; + const MAX_TURNS = 2000; + while (turns < MAX_TURNS && e.isStarted) { + turns++; + if (e.isPaused) e = apply(e, togglePause(e)); + if (turns % 7 === 0 && !e.isPaused) { e = apply(e, togglePause(e)); continue; } + const prevRound = e.round; + e = apply(e, nextTurn(e)); + if (e.round !== prevRound) { + const skipped = [...activeAtRoundStart].filter(id => { + const p = e.participants.find(x => x.id === id); + return p && p.isActive && !actedThisRound.has(id); + }); + totalRealSkips += skipped.length; + onRoundStart(e); + } else { + actedThisRound.add(e.currentTurnParticipantId); + } + if (turns % 9 === 0 && added < 8) { + const b = buildMonsterParticipant({ name: `R${added + 1}`, maxHp: 120, initMod: 3 }).participant; + b.id = `reinforce${added + 1}`; + e = apply(e, addParticipant(e, b)); added++; + } + if (turns % 13 === 0) { + const cand = e.participants.filter(p => p.type === 'monster' && p.isActive && p.id !== e.currentTurnParticipantId); + if (cand.length) e = apply(e, removeParticipant(e, cand[0].id)); + } + if (turns % 17 === 0) { + const cand = e.participants.filter(p => p.isActive && p.id !== e.currentTurnParticipantId); + if (cand.length) { + const t = cand[0]; + e = apply(e, toggleParticipantActive(e, t.id)); + e = apply(e, toggleParticipantActive(e, t.id)); + } + } + } + expect(totalRealSkips).toBe(0); + }); +}); diff --git a/shared/turn.js b/shared/turn.js index 4a9a0ae..eaafe7e 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -59,13 +59,28 @@ const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) return updates; }; -// Verbatim from src/App.js. Returns turnOrderIds update when a participant -// re-enters active combat mid-encounter. +// Insert addedId into turnOrderIds by initiative. New participant slots into +// correct initiative position at add time (not appended to end). Preserves +// current pointer — no re-sort anywhere except startEncounter. +// Tie rule: insert AFTER existing same-init (preserves creation order). const computeTurnOrderAfterAddition = (encounter, addedId) => { if (!encounter.isStarted) return {}; const currentIds = encounter.turnOrderIds || []; if (currentIds.includes(addedId)) return {}; - return { turnOrderIds: [...currentIds, addedId] }; + const added = (encounter.participants || []).find(p => p.id === addedId); + if (!added) return { turnOrderIds: [...currentIds, addedId] }; + // find first id with strictly lower initiative; insert before it (== after all >= ) + const initOf = id => { + const p = (encounter.participants || []).find(x => x.id === id); + return p ? (p.initiative || 0) : 0; + }; + const addedInit = added.initiative || 0; + let insertAt = currentIds.length; + for (let i = 0; i < currentIds.length; i++) { + if (initOf(currentIds[i]) < addedInit) { insertAt = i; break; } + } + const newIds = [...currentIds.slice(0, insertAt), addedId, ...currentIds.slice(insertAt)]; + return { turnOrderIds: newIds }; }; // ---------------------------------------------------------------------------- @@ -209,18 +224,14 @@ function nextTurn(encounter) { let nextIndex = (currentIndex + 1) % activePsInOrder.length; let newTurnOrderIds = encounter.turnOrderIds; + // Round wrap: initiative is cyclic. Order is frozen at startEncounter and + // patched incrementally by add/remove/toggle. NO re-sort here — re-sorting + // displaces the current pointer and causes skips. if (nextIndex === 0 && currentIndex !== -1) { nextRound += 1; - // Rebuild turn order by initiative at start of new round so participants - // activated mid-round (appended to end) slot into proper initiative position next round. - const activePs = encounter.participants.filter(p => p.isActive); - const sorted = sortParticipantsByInitiative(activePs, encounter.participants); - newTurnOrderIds = sorted.map(p => p.id); } - const nextParticipant = (nextIndex === 0 && currentIndex !== -1) - ? encounter.participants.find(p => p.id === newTurnOrderIds[0]) - : activePsInOrder[nextIndex]; + const nextParticipant = activePsInOrder[nextIndex]; if (!nextParticipant) { throw new Error('Could not determine next participant.'); @@ -251,10 +262,10 @@ function togglePause(encounter) { const newPausedState = !encounter.isPaused; let newTurnOrderIds = encounter.turnOrderIds; if (!newPausedState && encounter.isPaused) { - // Resuming — recompute turn order from active participants. - const activeParticipants = encounter.participants.filter(p => p.isActive); - const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants); - newTurnOrderIds = sortedParticipants.map(p => p.id); + // Resume: do NOT re-sort. Re-sorting displaces the current pointer — + // participants who already acted move earlier in order and nextTurn + // revisits them (whole round replays). Order is frozen at startEncounter + // and patched incrementally; resume keeps it stable. } return { patch: { isPaused: newPausedState, turnOrderIds: newTurnOrderIds }, @@ -269,16 +280,25 @@ function togglePause(encounter) { } // ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.) +// If encounter already started, also slot participant into turnOrderIds by +// initiative (via computeTurnOrderAfterAddition). function addParticipant(encounter, participant) { if ((encounter.participants || []).some(p => p.id === participant.id)) { throw new Error(`Participant with id "${participant.id}" already exists in encounter.`); } const updatedParticipants = [...(encounter.participants || []), participant]; + const intermediate = { ...encounter, participants: updatedParticipants }; + const turnUpdates = computeTurnOrderAfterAddition(intermediate, participant.id); return { - patch: { participants: updatedParticipants }, + patch: { participants: updatedParticipants, ...turnUpdates }, log: { message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`, - undo: { participants: [...(encounter.participants || [])] }, + undo: { + participants: [...(encounter.participants || [])], + ...(encounter.isStarted ? { + turnOrderIds: [...(encounter.turnOrderIds || [])], + } : {}), + }, }, }; } @@ -359,27 +379,25 @@ function applyHpChange(encounter, participantId, changeType, amount) { const isDead = newHp === 0; const wasResurrected = wasDead && newHp > 0; + // FEAT-1: death no longer flips isActive or touches turnOrderIds. + // Dead participants stay in turn order, nextTurn still visits them, PCs + // get their death-save turn. isActive = DM-controlled combatant toggle only. const updatedParticipants = (encounter.participants || []).map(p => { if (p.id !== participantId) return p; const updates = { ...p, currentHp: newHp }; if (isDead && !wasDead) { - updates.isActive = false; updates.deathSaves = p.deathSaves || 0; updates.isDying = false; } if (wasResurrected) { - updates.isActive = true; updates.deathSaves = 0; updates.isDying = false; } return updates; }); - const turnUpdates = (isDead && !wasDead) - ? computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants) - : wasResurrected - ? computeTurnOrderAfterAddition(encounter, participantId) - : {}; + // No turn-order updates on death/revive (FEAT-1). + const turnUpdates = {}; const hpLine = `${participant.currentHp} → ${newHp} HP`; const deathSuffix = (isDead && !wasDead)