diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..09f93e9 --- /dev/null +++ b/TODO.md @@ -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 diff --git a/scripts/replay-combat.js b/scripts/replay-combat.js new file mode 100644 index 0000000..629ef20 --- /dev/null +++ b/scripts/replay-combat.js @@ -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); });