chore: move audit tools tests/audit, add scratch/ gitignored
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.
This commit is contained in:
+7
-34
@@ -1,13 +1,11 @@
|
||||
# scripts/
|
||||
|
||||
Manual tools. NOT unit tests. Math.random, non-deterministic.
|
||||
|
||||
Used to FIND bugs. Unit tests (in `*/tests/`) LOCK them.
|
||||
Manual demo tool. NOT test.
|
||||
|
||||
## replay-combat.js
|
||||
|
||||
Drives full combat through live backend via ws adapter (same contract as App).
|
||||
Player display live-updates. Use to watch UI react to state changes.
|
||||
Live backend demo. Drives full combat via ws adapter (same contract as App).
|
||||
Player display live-updates. Watch UI react to state changes.
|
||||
|
||||
```bash
|
||||
# start backend + frontend first (see docs/DEVELOPMENT.md)
|
||||
@@ -20,33 +18,8 @@ removeParticipant, addParticipant (reinforcements), updateParticipant,
|
||||
pause/resume, reorderParticipants, endEncounter. Revives dead each round
|
||||
to sustain full round count.
|
||||
|
||||
## audit-rotation.js
|
||||
## See also
|
||||
|
||||
Pure turn.js simulation of replay op sequence. Detects rotation violations
|
||||
(skip/dupe per round). Pinpointed BUG-1 (addParticipant + pause corrupts).
|
||||
|
||||
```bash
|
||||
node scripts/audit-rotation.js
|
||||
```
|
||||
|
||||
Bisect: comment/uncomment op blocks to isolate triggering combo.
|
||||
|
||||
## audit-state.js
|
||||
|
||||
Expanded invariant bug-finder. 9 check classes per round:
|
||||
|
||||
1. rotation integrity
|
||||
2. HP bounds
|
||||
3. isActive consistency
|
||||
4. turnOrder no dup ids
|
||||
5. turnOrder ids all active
|
||||
6. currentTurn valid + active
|
||||
7. deathSave range + reset on revive
|
||||
8. removeParticipant orphans
|
||||
9. undo support
|
||||
|
||||
```bash
|
||||
node scripts/audit-state.js [rounds] # default 100
|
||||
```
|
||||
|
||||
See TODO.md for bugs found.
|
||||
- `tests/audit/` — exploratory bug-finders (manual run, non-deterministic)
|
||||
- `{shared,server,src}/tests/` — jest unit/integration/characterization
|
||||
- `scratch/` — gitignored throwaway
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
// 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}`));
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
// scripts/audit-state.js
|
||||
// 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.
|
||||
//
|
||||
// Bug classes audited:
|
||||
// 1. Rotation integrity (skip/dupe per round) — BUG-1, BUG-3
|
||||
// 2. HP invariants (0<=hp<=max, no NaN)
|
||||
// 3. Condition toggles (consistent, applied/removed)
|
||||
// 4. isActive consistency (dead=inactive, alive=active after ops)
|
||||
// 5. turnOrderIds (no dup ids, no orphan/dead ids, subset of active)
|
||||
// 6. currentTurn (valid id, in turnOrderIds, isActive)
|
||||
// 7. deathSave counter (0<=saves<=3, reset on revive)
|
||||
// 8. removeParticipant (turnOrderIds updated, currentTurn updated)
|
||||
// 9. Undo (every op.patch has .log.undo; roundtrip restores)
|
||||
//
|
||||
// Run: node scripts/audit-state.js [rounds]
|
||||
|
||||
'use strict';
|
||||
const shared = require('../shared');
|
||||
const {
|
||||
makeParticipant, startEncounter, nextTurn, togglePause,
|
||||
addParticipant, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
toggleCondition, reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
|
||||
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 200, currentHp: 200,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
function enc(ps) {
|
||||
return { name:'a', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||
}
|
||||
|
||||
const ps = [
|
||||
p('c1', 14, { type:'character' }), p('c2', 10, { type:'character' }),
|
||||
p('c3', 15, { type:'character' }), p('m1', 12), p('m2', 12),
|
||||
p('m3', 11, { maxHp:500, currentHp:500 }), p('m4', 13),
|
||||
p('n1', 8, { maxHp:150, currentHp:150, isNpc:true }),
|
||||
];
|
||||
|
||||
let e = enc(ps);
|
||||
const violations = [];
|
||||
|
||||
function check(label, cond, detail) {
|
||||
if (!cond) violations.push({ label, detail, round: e.round, state: snap(e) });
|
||||
}
|
||||
|
||||
function snap(x) {
|
||||
return JSON.stringify({
|
||||
round: x.round, isStarted: x.isStarted, isPaused: x.isPaused,
|
||||
current: x.currentTurnParticipantId,
|
||||
order: x.turnOrderIds,
|
||||
hp: x.participants.map(p => `${p.id}:${p.currentHp}/${p.maxHp}${p.isActive===false?'-': ''}`),
|
||||
dead: x.participants.filter(p => p.currentHp <= 0).map(p => p.id),
|
||||
inactive: x.participants.filter(p => p.isActive === false).map(p => p.id),
|
||||
});
|
||||
}
|
||||
|
||||
// start
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
let totalTurns = 0;
|
||||
|
||||
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||
const startRound = e.round;
|
||||
|
||||
// ops (mirror replay)
|
||||
const actor = e.participants.find(p => p.id === e.currentTurnParticipantId);
|
||||
if (actor) {
|
||||
const foes = e.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);
|
||||
try { e = { ...e, ...applyHpChange(e, tgt.id, 'damage', dmg).patch }; } catch (err) {}
|
||||
}
|
||||
if (actor.name === 'c2' && totalTurns % 2 === 0) {
|
||||
const wounded = e.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) {
|
||||
try { e = { ...e, ...applyHpChange(e, wounded[0].id, 'heal', 2+Math.floor(Math.random()*5)).patch }; } catch (err) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (totalTurns % 4 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = living[Math.floor(Math.random()*living.length)];
|
||||
try { e = { ...e, ...toggleCondition(e, tgt.id, 'stunned').patch }; } catch (err) {}
|
||||
}
|
||||
}
|
||||
if (totalTurns % 9 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = living[Math.floor(Math.random()*living.length)];
|
||||
try { e = { ...e, ...toggleParticipantActive(e, tgt.id).patch }; } catch (err) {}
|
||||
}
|
||||
}
|
||||
if (totalTurns % 5 === 0) {
|
||||
const dead = e.participants.find(p => p.currentHp <= 0);
|
||||
if (dead) { try { e = { ...e, ...removeParticipant(e, dead.id).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) {}
|
||||
}
|
||||
if (totalTurns % 12 === 0) {
|
||||
try { e = { ...e, ...togglePause(e).patch }; } catch (err) {}
|
||||
try { e = { ...e, ...togglePause(e).patch }; } catch (err) {}
|
||||
}
|
||||
|
||||
// advance until round wraps or cap
|
||||
const cap = (e.participants.length + 4) * 2;
|
||||
let guard = 0;
|
||||
const seenThisRound = [];
|
||||
while (e.round === startRound && guard < cap) {
|
||||
if (e.currentTurnParticipantId) seenThisRound.push(e.currentTurnParticipantId);
|
||||
if (e.isPaused) { check('advance-while-paused', false, 'paused at advance'); break; }
|
||||
let t;
|
||||
try { t = nextTurn(e); } catch (err) { check('nextTurn-throws', false, err.message); break; }
|
||||
e = { ...e, ...t.patch };
|
||||
if (e.round !== startRound) break;
|
||||
totalTurns++;
|
||||
guard++;
|
||||
if (!e.isStarted) break;
|
||||
}
|
||||
|
||||
// === audits ===
|
||||
// 1. rotation (this round, before wrap)
|
||||
const uniq = new Set(seenThisRound);
|
||||
check('rotation-dupes', uniq.size >= seenThisRound.length,
|
||||
`seen ${seenThisRound.length} uniq ${uniq.size}: ${JSON.stringify(seenThisRound)}`);
|
||||
|
||||
// 2. HP invariants
|
||||
for (const p of e.participants) {
|
||||
check(`hp-valid:${p.id}`, typeof p.currentHp === 'number' && !isNaN(p.currentHp) && p.currentHp >= 0 && p.currentHp <= p.maxHp,
|
||||
`hp=${p.currentHp} max=${p.maxHp}`);
|
||||
}
|
||||
// 3. isActive consistency: dead should be inactive (after applyHpChange)
|
||||
for (const p of e.participants) {
|
||||
check(`dead-inactive:${p.id}`, p.currentHp > 0 || p.isActive === false,
|
||||
`hp=${p.currentHp} isActive=${p.isActive}`);
|
||||
}
|
||||
// 4. turnOrderIds no dup
|
||||
const orderUniq = new Set(e.turnOrderIds);
|
||||
check('turnOrder-no-dup', orderUniq.size === e.turnOrderIds.length,
|
||||
`order ${JSON.stringify(e.turnOrderIds)}`);
|
||||
// 5. turnOrderIds all active
|
||||
for (const id of e.turnOrderIds) {
|
||||
const p = e.participants.find(x => x.id === id);
|
||||
check(`turnOrder-active:${id}`, p && p.isActive !== false,
|
||||
`isActive=${p && p.isActive}`);
|
||||
}
|
||||
// 6. currentTurn valid
|
||||
if (e.isStarted && e.currentTurnParticipantId) {
|
||||
const ct = e.participants.find(x => x.id === e.currentTurnParticipantId);
|
||||
check('currentTurn-exists', !!ct, `id=${e.currentTurnParticipantId}`);
|
||||
if (ct) check('currentTurn-active', ct.isActive !== false, `isActive=${ct.isActive}`);
|
||||
}
|
||||
// 7. deathSave range
|
||||
for (const p of e.participants) {
|
||||
check(`deathSaves-range:${p.id}`, (p.deathSaves||0) >= 0 && (p.deathSaves||0) <= 3,
|
||||
`saves=${p.deathSaves}`);
|
||||
if (p.currentHp > 0 && !p.isDying) {
|
||||
check(`deathSaves-reset:${p.id}`, (p.deathSaves||0) === 0,
|
||||
`alive but saves=${p.deathSaves}`);
|
||||
}
|
||||
}
|
||||
// 8. remove: turnOrderIds doesn't contain removed ids
|
||||
const ids = new Set(e.participants.map(p => p.id));
|
||||
for (const id of e.turnOrderIds) {
|
||||
check(`turnOrder-present:${id}`, ids.has(id), `orphan id in order`);
|
||||
}
|
||||
|
||||
if (!e.isStarted) { console.log('encounter ended early'); break; }
|
||||
|
||||
// revive dead each round (sustain combat)
|
||||
const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
for (const d of dead) {
|
||||
try {
|
||||
if (d.isActive === false) e = { ...e, ...toggleParticipantActive(e, d.id).patch };
|
||||
e = { ...e, ...applyHpChange(e, d.id, 'heal', d.maxHp).patch };
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. undo: every op returns log.undo
|
||||
const undoOps = ['startEncounter','nextTurn','applyHpChange','toggleCondition','toggleParticipantActive','addParticipant','removeParticipant','togglePause'];
|
||||
console.log('\n=== undo support (static check) ===');
|
||||
console.log('checked via log fields at runtime; this harness discards logs');
|
||||
|
||||
console.log(`\n=== VIOLATIONS: ${violations.length} / ${ROUNDS} rounds ===`);
|
||||
const byLabel = {};
|
||||
for (const v of violations) byLabel[v.label] = (byLabel[v.label]||0) + 1;
|
||||
const sorted = Object.entries(byLabel).sort((a,b)=>b[1]-a[1]);
|
||||
for (const [label, count] of sorted) console.log(` ${count}x ${label}`);
|
||||
console.log('\nfirst 5 examples:');
|
||||
for (const v of violations.slice(0,5)) console.log(` r${v.round} ${v.label}: ${v.detail}`);
|
||||
Reference in New Issue
Block a user