Rework backend #1
@@ -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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+42
-24
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user