c314d1975e
Audit tools are test code (bug-finders), not scripts. Move to tests/audit/. scripts/ now only replay-combat (live demo tool). scratch/ = gitignored throwaway. Repro scripts, exploration, debug. Update DEVELOPMENT.md + scripts/README to match new layout.
153 lines
6.6 KiB
JavaScript
153 lines
6.6 KiB
JavaScript
// scripts/audit-rotation.js
|
|
// 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.
|
|
|
|
const shared = require('../../shared');
|
|
const {
|
|
buildCharacterParticipant, buildMonsterParticipant,
|
|
startEncounter, nextTurn, togglePause,
|
|
addParticipant, updateParticipant, removeParticipant,
|
|
toggleParticipantActive, applyHpChange, deathSave,
|
|
toggleCondition, reorderParticipants, endEncounter,
|
|
} = shared;
|
|
|
|
function makeParticipant(opts) { return shared.makeParticipant(opts); }
|
|
|
|
const ps = [
|
|
makeParticipant({ id: 'c1', name: 'Fighter', type: 'character', initiative: 14, maxHp: 200, currentHp: 200 }),
|
|
makeParticipant({ id: 'c2', name: 'Cleric', type: 'character', initiative: 10, maxHp: 180, currentHp: 180 }),
|
|
makeParticipant({ id: 'c3', name: 'Rogue', type: 'character', initiative: 15, maxHp: 160, currentHp: 160 }),
|
|
makeParticipant({ id: 'm1', name: 'Goblin1', type: 'monster', initiative: 12, maxHp: 100, currentHp: 100 }),
|
|
makeParticipant({ id: 'm2', name: 'Goblin2', type: 'monster', initiative: 12, maxHp: 100, currentHp: 100 }),
|
|
makeParticipant({ id: 'm3', name: 'OrcBoss', type: 'monster', initiative: 11, maxHp: 500, currentHp: 500 }),
|
|
makeParticipant({ id: 'm4', name: 'Wolf', type: 'monster', initiative: 13, maxHp: 120, currentHp: 120 }),
|
|
makeParticipant({ id: 'n1', name: 'Merchant', type: 'monster', initiative: 8, maxHp: 150, currentHp: 150, isNpc: true }),
|
|
];
|
|
|
|
let enc = {
|
|
name: 'audit', participants: ps,
|
|
isStarted: false, isPaused: false,
|
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
|
};
|
|
|
|
const opLog = [];
|
|
function log(label) { opLog.push({ round: enc.round, turn: currentName(enc), label }); }
|
|
|
|
function apply(result, label) {
|
|
if (!result || !result.patch) return;
|
|
enc = { ...enc, ...result.patch };
|
|
log(label);
|
|
}
|
|
|
|
function currentName(e) {
|
|
if (!e.currentTurnParticipantId) return '(none)';
|
|
const p = e.participants.find(x => x.id === e.currentTurnParticipantId);
|
|
return p ? p.name : '(missing)';
|
|
}
|
|
|
|
// start
|
|
apply(startEncounter(enc), 'startEncounter');
|
|
console.log(`start: order=${enc.turnOrderIds.join(',')} first=${currentName(enc)}`);
|
|
|
|
const ROUNDS = 100;
|
|
let totalTurns = 0;
|
|
let violations = [];
|
|
|
|
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
|
const startRound = enc.round;
|
|
const seenThisRound = [];
|
|
// record starting turn (already current at top of round)
|
|
seenThisRound.push(enc.currentTurnParticipantId);
|
|
const cap = (enc.participants.length + 2) * 2;
|
|
let guard = 0;
|
|
|
|
// BISECT: dmg+heal+cond+add+pause
|
|
const actor = enc.participants.find(p => p.id === enc.currentTurnParticipantId);
|
|
if (actor) {
|
|
const foes = enc.participants.filter(p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false);
|
|
if (foes.length > 0) {
|
|
const tgt = foes[Math.floor(Math.random() * foes.length)];
|
|
const dmg = 1 + Math.floor(Math.random() * 5);
|
|
apply(applyHpChange(enc, tgt.id, 'damage', dmg), `damage ${actor.name}→${tgt.name} -${dmg}`);
|
|
}
|
|
}
|
|
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
|
|
const wounded = enc.participants.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp).sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp));
|
|
if (wounded.length > 0) {
|
|
const tgt = wounded[0]; const amt = 2 + Math.floor(Math.random()*5);
|
|
apply(applyHpChange(enc, tgt.id, 'heal', amt), `heal ${tgt.name} +${amt}`);
|
|
}
|
|
}
|
|
if (totalTurns % 4 === 0) {
|
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
|
if (living.length > 0) {
|
|
const tgt = living[Math.floor(Math.random()*living.length)];
|
|
apply(toggleCondition(enc, tgt.id, 'stunned'), `condition stunned on ${tgt.name}`);
|
|
}
|
|
}
|
|
if (totalTurns % 9 === 0 && totalTurns > 0) {
|
|
const living = enc.participants.filter(p => p.currentHp > 0);
|
|
if (living.length > 0) {
|
|
const tgt = living[Math.floor(Math.random()*living.length)];
|
|
apply(toggleParticipantActive(enc, tgt.id), `toggleActive ${tgt.name}`);
|
|
}
|
|
}
|
|
if (totalTurns % 5 === 0 && totalTurns > 0) {
|
|
const dead = enc.participants.find(p => p.currentHp <= 0);
|
|
if (dead) apply(removeParticipant(enc, dead.id), `remove ${dead.name}`);
|
|
}
|
|
if (totalTurns % 10 === 0 && totalTurns > 0) {
|
|
const newP = makeParticipant({ id: `r${totalTurns}`, name: `R${totalTurns}`, type: 'monster', initiative: 9, maxHp: 100, currentHp: 100 });
|
|
apply(addParticipant(enc, newP), `add ${newP.name}`);
|
|
}
|
|
//REMOVED
|
|
//REMOVED
|
|
// 9. pause — re-enabled, isolating interaction
|
|
if (totalTurns % 12 === 0 && totalTurns > 0) {
|
|
apply(togglePause(enc), 'pause');
|
|
}
|
|
|
|
while (enc.round === startRound && guard < cap) {
|
|
// advance FIRST, then check wrap before recording
|
|
let t;
|
|
try { t = nextTurn(enc); } catch (e) { log(`nextTurn ERR: ${e.message}`); break; }
|
|
apply(t, 'nextTurn');
|
|
// stop at round wrap — nextTurn just rolled into new round
|
|
if (enc.round !== startRound) break;
|
|
totalTurns++;
|
|
seenThisRound.push(enc.currentTurnParticipantId);
|
|
guard++;
|
|
if (!enc.isStarted) break;
|
|
}
|
|
|
|
// audit this round
|
|
const uniq = new Set(seenThisRound);
|
|
const dupes = seenThisRound.filter(id => seenThisRound.indexOf(id) !== seenThisRound.lastIndexOf(id));
|
|
if (dupes.length > 0 || uniq.size < seenThisRound.length) {
|
|
violations.push({ round: roundN, seen: seenThisRound.map(id => enc.participants.find(p=>p.id===id)?.name||id), dupes });
|
|
if (violations.length <= 3) {
|
|
console.log(`\n=== VIOLATION round ${roundN} ===`);
|
|
console.log(` seen: ${seenThisRound.map(id => enc.participants.find(p=>p.id===id)?.name||id).join(' → ')}`);
|
|
console.log(` dupes: ${[...new Set(dupes)].map(id => enc.participants.find(p=>p.id===id)?.name||id).join(', ')}`);
|
|
// print op log for this round
|
|
const roundOps = opLog.filter(o => o.round === startRound || o.round === roundN);
|
|
console.log(` ops: ${roundOps.map(o => o.label).join(' | ')}`);
|
|
}
|
|
}
|
|
|
|
if (!enc.isStarted) { console.log('encounter ended'); break; }
|
|
|
|
// revive dead
|
|
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
|
for (const d of dead) {
|
|
if (d.isActive === false) apply(toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
|
|
apply(applyHpChange(enc, d.id, 'heal', d.maxHp), `revive-heal ${d.name} →${d.maxHp}`);
|
|
}
|
|
}
|
|
|
|
console.log(`\ntotal violations: ${violations.length} / ${ROUNDS} rounds`);
|
|
if (violations.length > 0) {
|
|
console.log('first 5:', violations.slice(0,5).map(v => `r${v.round}`));
|
|
}
|