WIP: BUG-5 slot-array fix + FEAT-1 dead-not-skipped + skip parser
WORK IN PROGRESS — fix not complete. analyze-turns.js on 500-round replay still finds 46 real skips + 64 double-acts. turn.js changes: - computeTurnOrderAfterAddition: insert by initiative (not append end) - nextTurn wrap: no re-sort, cycle pointer - togglePause resume: no re-sort, order stable - addParticipant: patches turnOrderIds when started - applyHpChange: death no longer flips isActive or touches turnOrderIds (FEAT-1 dead-not-skipped) Tests: - shared/tests/turn.skip.test.js (NEW): deterministic skip invariants pure 100 rounds + 540 rounds w/ mutations, both green - shared/tests/turn.dead-skip.test.js: 4 green (FEAT-1) - turn.characterization.test.js: 3 sites updated to new behavior - turn.combat.test.js: boundary count fixed (wrap-turn attributed to new round), debug dump removed scripts/analyze-turns.js (NEW): deterministic replay-stdout parser. Reconstructs rounds, reports real skips + double-acts. Exit 1 on issue. Catches bugs unit tests miss (46 skips/64 double-acts in 500 rounds). TODO: FEAT-1 marked done, FEAT-2 added (upgrade app logs parseable).
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user