Rework backend #1
@@ -63,6 +63,22 @@
|
||||
- Test: render App + DisplayView, toggle hide-HP, assert display still shows
|
||||
encounter (not paused).
|
||||
|
||||
## Pipeline
|
||||
|
||||
### BUG-5: mid-round addParticipant/revive corrupts rotation (deterministic test)
|
||||
- Test: `shared/tests/turn.combat.test.js` (jest, seeded RNG, RED).
|
||||
- 13 rotation-dupes / 100 rounds. First at round 4 (Cleric twice).
|
||||
- Pattern: Reinforce/Summon added mid-round → appears in rotation same round
|
||||
→ round wrap re-sorts by initiative → currentTurnParticipantId pointer
|
||||
stale → nextTurn revisits.
|
||||
- Root cause: `computeTurnOrderAfterAddition` appends id to turnOrderIds
|
||||
end + `togglePause` resume rebuilds order via sort but doesn't re-anchor
|
||||
currentTurn to its new position. After several mid-round adds the pointer
|
||||
drifts.
|
||||
- This is the test audit should have been. Mirrors replay-combat.js op
|
||||
sequence exactly (damage, heal, conditions, toggleActive, deathSave,
|
||||
remove, add, edit, pause/resume, reorder, revive-between-rounds).
|
||||
|
||||
## Pipeline
|
||||
- [ ] Red test: dead participant still in turnOrderIds, turn still advances to them
|
||||
- [ ] Fix `shared/turn.js`: don't drop dead from turn order
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// Combat integrity test: replay exact op sequence through pure turn.js,
|
||||
// assert rotation + state invariants per round. This IS the test the audit
|
||||
// was supposed to be. Deterministic (seeded RNG). RED on current code = BUG-5.
|
||||
//
|
||||
// Mirrors scripts/replay-combat.js op order:
|
||||
// damage, heal (cleric), conditions, toggleActive, deathSave,
|
||||
// removeParticipant, addParticipant, updateParticipant, pause/resume,
|
||||
// reorderParticipants, revive-between-rounds.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
toggleCondition, reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
|
||||
// ---- seeded RNG (deterministic, reproducible) ----
|
||||
let _seed = 12345;
|
||||
function rand() {
|
||||
// LCG
|
||||
_seed = (_seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return _seed / 0x7fffffff;
|
||||
}
|
||||
const rnd = (n) => Math.floor(rand() * n);
|
||||
const pick = (arr) => arr[rnd(arr.length)];
|
||||
|
||||
const CONDITIONS = [
|
||||
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
|
||||
'exhaustion','frightened','grappled','grazed','incapacitated',
|
||||
'invisible','paralyzed','petrified','poisoned','prone','restrained',
|
||||
'sapped','shield','slowed','stunned','unconscious','vexed',
|
||||
];
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 200, currentHp: 200,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function setupEncounter() {
|
||||
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,
|
||||
];
|
||||
// give deterministic ids to monsters for assertions
|
||||
const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' };
|
||||
ps.forEach((part) => { if (idMap[part.name]) part.id = idMap[part.name]; });
|
||||
return {
|
||||
name: 'combat-test', participants: ps,
|
||||
isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function currentParticipant(e) {
|
||||
if (!e.currentTurnParticipantId) return null;
|
||||
return (e.participants || []).find(x => x.id === e.currentTurnParticipantId) || null;
|
||||
}
|
||||
|
||||
// Apply a result patch if present.
|
||||
function apply(e, result) {
|
||||
if (!result || !result.patch) return e;
|
||||
return { ...e, ...result.patch };
|
||||
}
|
||||
|
||||
describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||
jest.setTimeout(30000);
|
||||
|
||||
const ROUNDS = 100;
|
||||
const violations = [];
|
||||
|
||||
test('every round visits each active participant exactly once', () => {
|
||||
_seed = 12345; // reset for reproducibility
|
||||
let e = setupEncounter();
|
||||
e = apply(e, startEncounter(e));
|
||||
|
||||
let totalTurns = 0;
|
||||
let lastPaused = false;
|
||||
let lastReorder = 0;
|
||||
let reinforcementsAdded = 0;
|
||||
const condQueue = [...CONDITIONS];
|
||||
|
||||
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||
const startRound = e.round;
|
||||
const seenThisRound = [];
|
||||
const cap = (e.participants.length + 2) * 2;
|
||||
let guard = 0;
|
||||
|
||||
while (e.round === startRound && guard < cap) {
|
||||
// resume if paused (must precede nextTurn)
|
||||
if (lastPaused) { e = apply(e, togglePause(e)); lastPaused = false; }
|
||||
|
||||
// advance
|
||||
let t;
|
||||
try { t = nextTurn(e); } catch (err) {
|
||||
violations.push({ round: roundN, type: 'nextTurn-throws', msg: err.message });
|
||||
break;
|
||||
}
|
||||
e = apply(e, t);
|
||||
totalTurns++;
|
||||
seenThisRound.push(e.currentTurnParticipantId);
|
||||
|
||||
const actor = currentParticipant(e);
|
||||
|
||||
// 1. damage
|
||||
if (actor) {
|
||||
const foes = e.participants.filter(
|
||||
p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false
|
||||
);
|
||||
if (foes.length > 0) {
|
||||
const tgt = pick(foes);
|
||||
const dmg = 1 + rnd(5);
|
||||
e = apply(e, applyHpChange(e, tgt.id, 'damage', dmg));
|
||||
}
|
||||
}
|
||||
// 2. heal (cleric)
|
||||
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
|
||||
const wounded = e.participants
|
||||
.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false)
|
||||
.sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp));
|
||||
if (wounded.length > 0) {
|
||||
const tgt = wounded[0];
|
||||
const amt = 2 + rnd(5);
|
||||
e = apply(e, applyHpChange(e, tgt.id, 'heal', amt));
|
||||
}
|
||||
}
|
||||
// 3. conditions
|
||||
if (condQueue.length > 0) {
|
||||
const cond = condQueue[0];
|
||||
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try { e = apply(e, toggleCondition(e, tgt.id, cond)); condQueue.shift(); }
|
||||
catch (err) { condQueue.shift(); }
|
||||
}
|
||||
} else if (totalTurns % 6 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
const cond = pick(CONDITIONS);
|
||||
try { e = apply(e, toggleCondition(e, tgt.id, cond)); } catch (err) {}
|
||||
}
|
||||
}
|
||||
// 4. toggleParticipantActive
|
||||
if (totalTurns % 9 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try { e = apply(e, toggleParticipantActive(e, tgt.id)); } catch (err) {}
|
||||
}
|
||||
}
|
||||
// 5. deathSave
|
||||
if (actor && actor.currentHp <= 0 && !actor.isNpc) {
|
||||
try { e = apply(e, deathSave(e, actor.id, 1)); } catch (err) {}
|
||||
}
|
||||
// 6. removeParticipant
|
||||
if (totalTurns % 5 === 0) {
|
||||
const dead = e.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf'));
|
||||
if (dead) { try { e = apply(e, removeParticipant(e, dead.id)); } catch (err) {} }
|
||||
}
|
||||
// 7. addParticipant
|
||||
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
|
||||
const spec = pick([
|
||||
{ name:`Reinforce${reinforcementsAdded+1}`, maxHp:120, initMod:1 },
|
||||
{ name:`Summon${reinforcementsAdded+1}`, maxHp:80, initMod:4 },
|
||||
]);
|
||||
const built = buildMonsterParticipant(spec).participant;
|
||||
try { e = apply(e, addParticipant(e, built)); reinforcementsAdded++; } catch (err) {}
|
||||
}
|
||||
// 8. updateParticipant
|
||||
if (totalTurns % 7 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try { e = apply(e, updateParticipant(e, tgt.id, { notes:`edited@turn${totalTurns}` })); } catch (err) {}
|
||||
}
|
||||
}
|
||||
// 9. pause
|
||||
if (totalTurns % 12 === 0 && !lastPaused) { e = apply(e, togglePause(e)); lastPaused = true; }
|
||||
// 10. reorderParticipants (mirror replay's buggy signature usage — swallowed no-op)
|
||||
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length >= 2) {
|
||||
const tgt = living[0];
|
||||
const newInit = (tgt.initiative || 0) + 1;
|
||||
try {
|
||||
const reordered = [...e.participants].map(p => p.id === tgt.id ? { ...p, initiative: newInit } : p);
|
||||
e = apply(e, reorderParticipants(e, reordered));
|
||||
lastReorder = totalTurns;
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
guard++;
|
||||
if (!e.isStarted) break;
|
||||
}
|
||||
if (!e.isStarted) break;
|
||||
|
||||
// === per-round invariants ===
|
||||
const uniq = new Set(seenThisRound);
|
||||
if (uniq.size !== seenThisRound.length) {
|
||||
violations.push({ round: roundN, type: 'rotation-dupe',
|
||||
seen: seenThisRound.map(id => e.participants.find(p=>p.id===id)?.name||id) });
|
||||
}
|
||||
// turnOrderIds no dup
|
||||
const orderUniq = new Set(e.turnOrderIds);
|
||||
if (orderUniq.size !== e.turnOrderIds.length) {
|
||||
violations.push({ round: roundN, type: 'turnOrder-dup-id', order: e.turnOrderIds });
|
||||
}
|
||||
// currentTurn valid + active
|
||||
if (e.currentTurnParticipantId) {
|
||||
const ct = e.participants.find(p => p.id === e.currentTurnParticipantId);
|
||||
if (!ct) violations.push({ round: roundN, type: 'currentTurn-missing' });
|
||||
else if (ct.isActive === false && e.isStarted) {
|
||||
violations.push({ round: roundN, type: 'currentTurn-inactive', id: ct.id });
|
||||
}
|
||||
}
|
||||
// HP bounds
|
||||
for (const part of e.participants) {
|
||||
if (typeof part.currentHp !== 'number' || isNaN(part.currentHp) || part.currentHp < 0 || part.currentHp > part.maxHp) {
|
||||
violations.push({ round: roundN, type: 'hp-invalid', id: part.id, hp: part.currentHp, max: part.maxHp });
|
||||
}
|
||||
}
|
||||
|
||||
// revive dead between rounds
|
||||
const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
for (const d of dead) {
|
||||
try {
|
||||
if (d.isActive === false) e = apply(e, toggleParticipantActive(e, d.id));
|
||||
e = apply(e, applyHpChange(e, d.id, 'heal', d.maxHp));
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Report
|
||||
if (violations.length > 0) {
|
||||
const byType = {};
|
||||
violations.forEach(v => { byType[v.type] = (byType[v.type]||0) + 1; });
|
||||
const summary = Object.entries(byType).sort((a,b)=>b[1]-a[1]).map(([k,n])=>`${n}x ${k}`).join(', ');
|
||||
const first5 = violations.slice(0,5).map(v => `r${v.round} ${v.type}${v.seen?': '+JSON.stringify(v.seen):''}${v.order?': '+JSON.stringify(v.order):''}${v.msg?': '+v.msg:''}`).join('\n ');
|
||||
// dump full state for first dupe for triage
|
||||
throw new Error(`combat integrity violations: ${violations.length}\n ${summary}\n first 5:\n ${first5}`);
|
||||
}
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,19 @@
|
||||
// scripts/audit-rotation.js
|
||||
// DEPRECATED — DO NOT USE.
|
||||
// Random simulation gave false 0-violations while replay (exact ops)
|
||||
// reproduced real bugs. Replay-mirror approach = duplicate work.
|
||||
// Kept for now in case parts reusable. Will delete once log analyzer
|
||||
// (scratch/) + unit tests cover the ground.
|
||||
//
|
||||
// Prefer: replay-combat.js dumps turnOrderIds per turn, log analyzer
|
||||
// finds dupes/skips from real run. Unit tests lock confirmed bugs.
|
||||
//
|
||||
// To revive: delete this early-return block below.
|
||||
if (require.main === module) {
|
||||
console.error('audit-rotation.js DEPRECATED. See header comment.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// === original (below) — exploratory rotation audit, kept for reference ===
|
||||
// Pure turn.js simulation of replay op sequence. Detects first round where
|
||||
// rotation breaks (skip or dupe). Prints minimal repro + preceding ops.
|
||||
// No backend, no WS, no sleep. Fast.
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
// scripts/audit-state.js
|
||||
// DEPRECATED — DO NOT USE.
|
||||
// Random simulation gave false 0-violations while replay (exact ops)
|
||||
// reproduced real bugs. Replay-mirror approach = duplicate work.
|
||||
// Kept for now in case parts reusable. Will delete once log analyzer
|
||||
// (scratch/) + unit tests cover the ground.
|
||||
//
|
||||
// Prefer: replay-combat.js dumps turnOrderIds per turn, log analyzer
|
||||
// finds dupes/skips from real run. Unit tests lock confirmed bugs.
|
||||
//
|
||||
// To revive: delete this early-return block below.
|
||||
if (require.main === module) {
|
||||
console.error('audit-state.js DEPRECATED. See header comment.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// === original (below) — exploratory bug-finder, kept for reference ===
|
||||
// Expanded bug-finder: runs combat through pure turn.js, audits invariant
|
||||
// checks per round across multiple bug classes (not just rotation).
|
||||
// NOT a unit test (Math.random, exploratory). Unit tests lock known bugs.
|
||||
@@ -106,6 +121,18 @@ for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||
const dead = e.participants.find(p => p.currentHp <= 0);
|
||||
if (dead) { try { e = { ...e, ...removeParticipant(e, dead.id).patch }; } catch (err) {} }
|
||||
}
|
||||
// mid-round revive: DM reactivates a downed participant's turn (mirrors
|
||||
// replay-combat.js + real play). Triggers same path as revive-between-rounds
|
||||
// but INSIDE rotation — where BUG-5 lives.
|
||||
if (totalTurns % 7 === 0 && totalTurns > 0) {
|
||||
const down = e.participants.find(p => p.currentHp <= 0 || p.isActive === false);
|
||||
if (down) {
|
||||
try {
|
||||
if (down.isActive === false) e = { ...e, ...toggleParticipantActive(e, down.id).patch };
|
||||
e = { ...e, ...applyHpChange(e, down.id, 'heal', down.maxHp).patch };
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
if (totalTurns % 10 === 0 && totalTurns > 0) {
|
||||
const np = makeParticipant({ id: `r${totalTurns}`, name:`R${totalTurns}`, type:'monster', initiative:9, maxHp:100, currentHp:100 });
|
||||
try { e = { ...e, ...addParticipant(e, np).patch }; } catch (err) {}
|
||||
|
||||
Reference in New Issue
Block a user