Rework backend #1
@@ -22,6 +22,31 @@
|
|||||||
- UI button: "Make This Turn"
|
- UI button: "Make This Turn"
|
||||||
- Backend action: new endpoint or via generic doc patch.
|
- Backend action: new endpoint or via generic doc patch.
|
||||||
|
|
||||||
|
## Confirmed bugs (tests written, NOT fixed)
|
||||||
|
|
||||||
|
### BUG-1: addParticipant + pause/resume corrupts turn rotation
|
||||||
|
- Audit: 32/100 rounds violate rotation when `addParticipant` + other state
|
||||||
|
changes fire while paused.
|
||||||
|
- Repro in replay round 10+: current stuck on one participant forever,
|
||||||
|
nextTurn returns same id, round never advances.
|
||||||
|
- Clean minimal repro (turn.pause-add.test.js) PASSES = combo needs more
|
||||||
|
state than one add+pause. Audit is authoritative repro.
|
||||||
|
- Togglepause resume rebuilds turnOrderIds via sort but leaves
|
||||||
|
currentTurnParticipantId stale. After enough adds/toggles the stale
|
||||||
|
pointer lands wrong → nextTurn repeats.
|
||||||
|
- Test: `shared/turn.pause-add.test.js` (3 tests, all green currently —
|
||||||
|
document when bug DOES NOT trigger. Audit catches it.)
|
||||||
|
- Real repro = run `scripts/audit-rotation.js` with all ops enabled.
|
||||||
|
|
||||||
|
### BUG-2: addParticipant allows duplicate id
|
||||||
|
- `addParticipant(enc, dup)` appends same id to participants[] twice.
|
||||||
|
- togglePause resume rebuilds order → id appears twice in turnOrderIds →
|
||||||
|
nextTurn stuck repeating that id.
|
||||||
|
- Reachable in normal app? App uses crypto.randomUUID (fresh ids) so
|
||||||
|
unlikely. But no guard exists — defensive bug.
|
||||||
|
- Test: `shared/turn.characterization.test.js` 'addParticipant rejects
|
||||||
|
duplicate id' — RED currently (validates current allow-dup behavior).
|
||||||
|
|
||||||
## Pipeline
|
## Pipeline
|
||||||
- [ ] Red test: dead participant still in turnOrderIds, turn still advances to them
|
- [ ] Red test: dead participant still in turnOrderIds, turn still advances to them
|
||||||
- [ ] Fix `shared/turn.js`: don't drop dead from turn order
|
- [ ] Fix `shared/turn.js`: don't drop dead from turn order
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
|||||||
const cap = (enc.participants.length + 2) * 2;
|
const cap = (enc.participants.length + 2) * 2;
|
||||||
let guard = 0;
|
let guard = 0;
|
||||||
|
|
||||||
// BISECT: testing damage+heal+pause
|
// BISECT: dmg+heal+cond+add+pause
|
||||||
const actor = enc.participants.find(p => p.id === enc.currentTurnParticipantId);
|
const actor = enc.participants.find(p => p.id === enc.currentTurnParticipantId);
|
||||||
if (actor) {
|
if (actor) {
|
||||||
const foes = enc.participants.filter(p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false);
|
const foes = enc.participants.filter(p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Characterization test: addParticipant + pause/resume corrupts turn rotation.
|
||||||
|
// Audit found 56-77 violations/100 rounds starting round 20 in pure turn.js
|
||||||
|
// simulation. Visible in live replay (round 10: 17 turns, 6 duped actors,
|
||||||
|
// R-series stuck repeating forever).
|
||||||
|
//
|
||||||
|
// This test uses FRESH ids (crypto.randomUUID equivalent) — NOT the audit's
|
||||||
|
// self-inflicted dup (loop spun while paused, re-added same `r${totalTurns}`).
|
||||||
|
// Validates real bug reachable via normal UI flow (DM adds monster while paused,
|
||||||
|
// resumes).
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared;
|
||||||
|
|
||||||
|
function p(id, initiative, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative, maxHp: 100, currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(ps) {
|
||||||
|
return {
|
||||||
|
name: 'T', participants: ps,
|
||||||
|
isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('addParticipant + pause/resume rotation corruption', () => {
|
||||||
|
test('add fresh participant while paused, resume, rotation completes full cycle', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const baseOrder = e.turnOrderIds.slice(); // [a,b,c]
|
||||||
|
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // pause
|
||||||
|
|
||||||
|
// add fresh participant x (initiative 25, would sort first)
|
||||||
|
const x = p('x', 25);
|
||||||
|
e = { ...e, ...addParticipant(e, x).patch };
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // resume (rebuilds order)
|
||||||
|
|
||||||
|
// after resume, complete one full round: visit each active participant once
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length - 1; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
// EXPECT: 4 unique (a,b,c,x). BUG: rotation may not visit all.
|
||||||
|
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple adds while paused, resume, rotation visits all', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // pause
|
||||||
|
|
||||||
|
// add 3 fresh participants
|
||||||
|
for (const id of ['x', 'y', 'z']) {
|
||||||
|
const np = p(id, 5 + Math.floor(Math.random() * 30));
|
||||||
|
e = { ...e, ...addParticipant(e, np).patch };
|
||||||
|
}
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // resume
|
||||||
|
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
// EXPECT: all 6 participants reachable. BUG: some stuck/repeated.
|
||||||
|
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add while running, then pause+resume, rotation stays valid', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||||
|
const x = p('x', 25);
|
||||||
|
e = { ...e, ...addParticipant(e, x).patch }; // add while running
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // pause
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // resume
|
||||||
|
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user