M3: add combat replay script + TODO for M4 skip/dead fixes
scripts/replay-combat.js: drives full combat via live backend REST, computes
turns through shared/turn.js. Player display (subscribed via WS) live-updates.
Usage: node scripts/replay-combat.js [rounds] [delayMs]
TODO.md: tracks M4 work.
- Dead participants must NOT be skipped (still occupy initiative slot,
death saves resolve on their turn). Saw in game Saturday.
- JUMP_TURN_TO manual turn override.
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## M4 — Initiative skip bug + dead-participant handling
|
||||||
|
|
||||||
|
### Dead participants must NOT be skipped in turn order
|
||||||
|
- Current: dead (HP=0) → `isActive=false` → removed from turn order → skipped
|
||||||
|
- WRONG. Dead participants still occupy initiative slot.
|
||||||
|
- PCs (unconscious): death saves still resolve on their turn
|
||||||
|
- Monsters/NPCs: may still have reaction/reaction-like considerations
|
||||||
|
- Saw this problem in game Saturday.
|
||||||
|
- Fix: keep dead participants in turnOrderIds; their turn still comes up.
|
||||||
|
Damage/death-save UI already gated on HP=0 so row buttons stay usable.
|
||||||
|
- Affects: `shared/turn.js` `nextTurn` (filters `isActive`), `applyHpChange`
|
||||||
|
(sets isActive=false on death), `computeTurnOrderAfterRemoval`.
|
||||||
|
- Characterization tests (`Combat.characterization.test.js`) lock CURRENT
|
||||||
|
(buggy) behavior — those tests must be UPDATED to desired behavior, not
|
||||||
|
preserved. Red desired-test first, then fix.
|
||||||
|
|
||||||
|
### JUMP_TURN_TO(participantId) manual turn override
|
||||||
|
- DM clicks participant → cursor jumps → that participant's turn now.
|
||||||
|
- Future NEXT_TURN continues from jumped position.
|
||||||
|
- UI button: "Make This Turn"
|
||||||
|
- Backend action: new endpoint or via generic doc patch.
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
- [ ] Red test: dead participant still in turnOrderIds, turn still advances to them
|
||||||
|
- [ ] Fix `shared/turn.js`: don't drop dead from turn order
|
||||||
|
- [ ] Update characterization tests to desired (not preserved) behavior
|
||||||
|
- [ ] JUMP_TURN_TO red test
|
||||||
|
- [ ] JUMP_TURN_TO impl (shared + UI button)
|
||||||
|
- [ ] M5 docker-compose
|
||||||
|
- [ ] M6 undo rework (transactional events table)
|
||||||
|
- [ ] M7 Playwright E2E
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// scripts/replay-combat.js
|
||||||
|
// Drive a full combat through the LIVE backend (generic KV REST) so the player
|
||||||
|
// display window (subscribed via WS) live-updates as combat progresses.
|
||||||
|
// Uses shared/turn.js for all turn logic (same model as the UI).
|
||||||
|
//
|
||||||
|
// Run: node scripts/replay-combat.js [rounds] [delayMs]
|
||||||
|
// rounds default 100, delayMs default 800
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const shared = require('../shared');
|
||||||
|
const {
|
||||||
|
buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, nextTurn, applyHpChange, toggleCondition,
|
||||||
|
endEncounter,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
||||||
|
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||||
|
const BASE = `artifacts/${APP_ID}/public/data`;
|
||||||
|
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
||||||
|
const DELAY = parseInt(process.argv[3], 10) || 800;
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function api(method, path, query, body) {
|
||||||
|
let url = `${BACKEND}${path}`;
|
||||||
|
if (query) url += '?' + new URLSearchParams(query).toString();
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text().catch(() => '');
|
||||||
|
throw new Error(`API ${method} ${path} ${res.status}: ${t}`);
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docGet = (p) => api('GET', '/api/doc', { path: p }).then(r => r && r.data);
|
||||||
|
const docSet = (p, data) => api('PUT', '/api/doc', null, { path: p, data });
|
||||||
|
const docPatch = (p, patch) => api('PATCH', '/api/doc', null, { path: p, patch });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`);
|
||||||
|
|
||||||
|
// campaign + encounter
|
||||||
|
const campaignId = crypto.randomUUID();
|
||||||
|
const encounterId = crypto.randomUUID();
|
||||||
|
const campaignPath = `${BASE}/campaigns/${campaignId}`;
|
||||||
|
const encounterPath = `${BASE}/campaigns/${campaignId}/encounters/${encounterId}`;
|
||||||
|
const activeDisplayPath = `${BASE}/activeDisplay/status`;
|
||||||
|
|
||||||
|
await docSet(campaignPath, {
|
||||||
|
name: 'Replay Campaign',
|
||||||
|
playerDisplayBackgroundUrl: '',
|
||||||
|
ownerId: 'replay',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
players: [
|
||||||
|
{ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2 },
|
||||||
|
{ id: 'c2', name: 'Cleric', defaultMaxHp: 24, defaultInitMod: 1 },
|
||||||
|
{ id: 'c3', name: 'Rogue', defaultMaxHp: 22, defaultInitMod: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// build participants (roll initiative via shared)
|
||||||
|
const chars = [
|
||||||
|
{ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2 },
|
||||||
|
{ id: 'c2', name: 'Cleric', defaultMaxHp: 24, defaultInitMod: 1 },
|
||||||
|
{ id: 'c3', name: 'Rogue', defaultMaxHp: 22, defaultInitMod: 3 },
|
||||||
|
];
|
||||||
|
const monsterSpecs = [
|
||||||
|
{ name: 'Goblin1', maxHp: 8, initMod: 2 },
|
||||||
|
{ name: 'Goblin2', maxHp: 8, initMod: 2 },
|
||||||
|
{ name: 'OrcBoss', maxHp: 60, initMod: 1 },
|
||||||
|
{ name: 'Wolf', maxHp: 14, initMod: 3 },
|
||||||
|
{ name: 'Merchant', maxHp: 12, initMod: 0, isNpc: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const participants = [
|
||||||
|
...chars.map(c => buildCharacterParticipant(c).participant),
|
||||||
|
...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
|
||||||
|
];
|
||||||
|
|
||||||
|
await docSet(encounterPath, {
|
||||||
|
name: 'Big Boss Replay',
|
||||||
|
campaignId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
participants,
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
turnOrderIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);
|
||||||
|
|
||||||
|
// point active display so player view shows it
|
||||||
|
await docSet(activeDisplayPath, {
|
||||||
|
activeCampaignId: campaignId,
|
||||||
|
activeEncounterId: encounterId,
|
||||||
|
hidePlayerHp: false,
|
||||||
|
});
|
||||||
|
await sleep(1000); // let player view load
|
||||||
|
|
||||||
|
// start
|
||||||
|
let enc = await docGet(encounterPath);
|
||||||
|
const start = startEncounter(enc);
|
||||||
|
await docPatch(encounterPath, start.patch);
|
||||||
|
enc = { ...enc, ...start.patch };
|
||||||
|
console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`);
|
||||||
|
await sleep(DELAY);
|
||||||
|
|
||||||
|
// main loop
|
||||||
|
for (let r = 1; r <= ROUNDS; r++) {
|
||||||
|
enc = await docGet(encounterPath);
|
||||||
|
const t = nextTurn(enc);
|
||||||
|
await docPatch(encounterPath, t.patch);
|
||||||
|
enc = { ...enc, ...t.patch };
|
||||||
|
|
||||||
|
// damage front monster
|
||||||
|
if (r % 2 === 0) {
|
||||||
|
enc = await docGet(encounterPath);
|
||||||
|
const orc = enc.participants.find(p => p.name === 'OrcBoss' && p.currentHp > 0);
|
||||||
|
if (orc) {
|
||||||
|
const h = applyHpChange(enc, orc.id, 'damage', 5);
|
||||||
|
if (h.patch) { await docPatch(encounterPath, h.patch); enc = { ...enc, ...h.patch }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (r % 3 === 0) {
|
||||||
|
enc = await docGet(encounterPath);
|
||||||
|
const cleric = enc.participants.find(p => p.name === 'Cleric' && p.currentHp > 0);
|
||||||
|
if (cleric) {
|
||||||
|
const h = applyHpChange(enc, cleric.id, 'heal', 3);
|
||||||
|
if (h.patch) { await docPatch(encounterPath, h.patch); enc = { ...enc, ...h.patch }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (r % 5 === 0) {
|
||||||
|
enc = await docGet(encounterPath);
|
||||||
|
const fighter = enc.participants.find(p => p.name === 'Fighter' && p.currentHp > 0);
|
||||||
|
if (fighter) {
|
||||||
|
const c = toggleCondition(enc, fighter.id, 'stunned');
|
||||||
|
if (c.patch) { await docPatch(encounterPath, c.patch); enc = { ...enc, ...c.patch }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enc = await docGet(encounterPath);
|
||||||
|
console.log(`round ${r}: current=${firstActiveName(enc)} | round=${enc.round}`);
|
||||||
|
await sleep(DELAY);
|
||||||
|
|
||||||
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// end
|
||||||
|
enc = await docGet(encounterPath);
|
||||||
|
if (enc.isStarted) {
|
||||||
|
const end = endEncounter(enc);
|
||||||
|
if (end.patch) await docPatch(encounterPath, end.patch);
|
||||||
|
}
|
||||||
|
await docPatch(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
||||||
|
console.log('replay done');
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstActiveName(enc) {
|
||||||
|
if (!enc.currentTurnParticipantId) return '(none)';
|
||||||
|
const p = enc.participants.find(x => x.id === enc.currentTurnParticipantId);
|
||||||
|
return p ? p.name : '(missing)';
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error('replay failed:', err); process.exit(1); });
|
||||||
Reference in New Issue
Block a user