2026-06-29 13:51:32 -04:00
|
|
|
// Combat.scenario.test.js
|
2026-07-03 18:53:58 -04:00
|
|
|
// Full combat scenario driven through shared/turn.js directly --- NO React.
|
2026-06-29 13:51:32 -04:00
|
|
|
//
|
2026-07-03 18:29:35 -04:00
|
|
|
// Does 100 ROUNDS (not turns). A round = one full pass through initiative
|
|
|
|
|
// (every active participant acts once); round counter increments at wrap.
|
|
|
|
|
//
|
2026-07-06 10:31:46 -04:00
|
|
|
// Exercises ALL combat paths deterministically:
|
2026-07-03 18:29:35 -04:00
|
|
|
// startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
|
|
|
|
|
// updateParticipant, removeParticipant, toggleParticipantActive,
|
2026-07-06 10:31:46 -04:00
|
|
|
// applyHpChange (damage/heal), deathSave, toggleCondition,
|
|
|
|
|
// reorderParticipants, endEncounter.
|
|
|
|
|
//
|
|
|
|
|
// New API: funcs async, take ctx {storage, encPath, logPath, displayPath},
|
|
|
|
|
// write encounter + log internally, return newEnc. Mock storage captures writes.
|
|
|
|
|
// Display lifecycle now async too — but test keeps inline display writes (mirrors App.js).
|
2026-07-03 18:29:35 -04:00
|
|
|
//
|
|
|
|
|
// Failing assertions do NOT abort the run: each phase wrapped in try/catch,
|
|
|
|
|
// failures collected, final expect reports all.
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
const path = require('path');
|
|
|
|
|
const { createMockStorage, mockCtx, readyEnc } = require('../../shared/tests/_helpers');
|
2026-07-03 18:26:02 -04:00
|
|
|
const {
|
|
|
|
|
buildCharacterParticipant,
|
|
|
|
|
buildMonsterParticipant,
|
2026-07-06 10:31:46 -04:00
|
|
|
startEncounter, nextTurn, togglePause, endEncounter,
|
|
|
|
|
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
2026-07-03 18:29:35 -04:00
|
|
|
toggleParticipantActive: combatToggleActive,
|
2026-07-06 10:31:46 -04:00
|
|
|
applyHpChange: combatApplyHpChange,
|
2026-07-03 18:29:35 -04:00
|
|
|
deathSave: combatDeathSave,
|
2026-07-06 17:10:01 -04:00
|
|
|
stabilizeParticipant,
|
|
|
|
|
reviveParticipant,
|
2026-07-06 10:31:46 -04:00
|
|
|
toggleCondition: combatToggleCondition,
|
|
|
|
|
reorderParticipants,
|
2026-07-03 18:29:35 -04:00
|
|
|
} = require('../../shared');
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// ---------- scenario state ----------
|
2026-07-03 18:26:02 -04:00
|
|
|
|
|
|
|
|
const ROUNDS = 100;
|
2026-07-06 10:31:46 -04:00
|
|
|
const ENCOUNTER_ID = 'enc-1';
|
2026-07-03 18:26:02 -04:00
|
|
|
|
|
|
|
|
let enc = {
|
2026-07-06 10:31:46 -04:00
|
|
|
id: ENCOUNTER_ID,
|
2026-07-03 18:26:02 -04:00
|
|
|
name: 'BigBoss',
|
|
|
|
|
participants: [],
|
|
|
|
|
isStarted: false,
|
|
|
|
|
isPaused: false,
|
|
|
|
|
round: 0,
|
|
|
|
|
currentTurnParticipantId: null,
|
|
|
|
|
turnOrderIds: [],
|
|
|
|
|
};
|
2026-07-06 10:31:46 -04:00
|
|
|
// display tracked via mock storage displayPath doc; simple local mirror.
|
2026-07-03 18:26:02 -04:00
|
|
|
let display = {
|
|
|
|
|
activeCampaignId: null,
|
|
|
|
|
activeEncounterId: null,
|
|
|
|
|
hidePlayerHp: true,
|
|
|
|
|
hideNpcHp: false,
|
|
|
|
|
};
|
|
|
|
|
const CAMPAIGN_ID = 'camp-1';
|
2026-07-06 18:48:29 -04:00
|
|
|
const CAMPAIGN_PATH = `campaigns/${CAMPAIGN_ID}`;const roster = [];
|
2026-07-03 18:26:02 -04:00
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
let storage, ctx;
|
|
|
|
|
const DISPLAY_PATH = 'activeDisplay/status';
|
2026-06-29 13:51:32 -04:00
|
|
|
|
|
|
|
|
const RESULTS = [];
|
2026-07-06 10:31:46 -04:00
|
|
|
async function record(phase, fn) {
|
2026-06-29 13:51:32 -04:00
|
|
|
try { await fn(); RESULTS.push({ phase, ok: true }); }
|
|
|
|
|
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// ---------- helpers ----------
|
2026-07-03 18:29:35 -04:00
|
|
|
|
|
|
|
|
const alive = (name) => enc.participants.find(p => p.name === name && p.currentHp > 0);
|
|
|
|
|
const any = (name) => enc.participants.find(p => p.name === name);
|
2026-07-06 10:31:46 -04:00
|
|
|
const anyById = (id) => enc.participants.find(p => p.id === id);
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
function addCharacterViaUI(name, maxHp, initMod) {
|
2026-07-06 18:48:29 -04:00
|
|
|
const character = { id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod };
|
|
|
|
|
roster.push(character);
|
|
|
|
|
// persist to campaign doc (mirrors app: players array)
|
|
|
|
|
if (storage) {
|
|
|
|
|
const existing = (storage.docs.get(CAMPAIGN_PATH) || {}).players || [];
|
|
|
|
|
storage.setDoc(CAMPAIGN_PATH, {
|
|
|
|
|
id: CAMPAIGN_ID, name: 'Scenario Campaign', createdAt: Date.now(),
|
|
|
|
|
players: [...existing, character],
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
2026-07-06 16:39:11 -04:00
|
|
|
async function addMonsterParticipant(name, maxHp, initMod, asNpc = false) {
|
|
|
|
|
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, asNpc });
|
2026-07-06 10:31:46 -04:00
|
|
|
enc = await addParticipant(enc, participant, ctx);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function addCharacterParticipant(charName) {
|
2026-07-03 18:26:02 -04:00
|
|
|
const character = roster.find(c => c.name === charName);
|
|
|
|
|
if (!character) throw new Error(`char not found: ${charName}`);
|
|
|
|
|
if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) {
|
2026-07-03 18:29:35 -04:00
|
|
|
throw new Error(`${charName} already in encounter`);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
const { participant } = buildCharacterParticipant(character);
|
2026-07-06 10:31:46 -04:00
|
|
|
enc = await addParticipant(enc, participant, ctx);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function addAllCharacters() {
|
2026-07-03 18:26:02 -04:00
|
|
|
const toAdd = roster
|
|
|
|
|
.filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id))
|
|
|
|
|
.map(c => buildCharacterParticipant(c).participant);
|
2026-07-06 10:31:46 -04:00
|
|
|
enc = await addParticipants(enc, toAdd, ctx);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function startCombat() {
|
|
|
|
|
enc = await startEncounter(enc, ctx);
|
|
|
|
|
display.activeCampaignId = CAMPAIGN_ID;
|
|
|
|
|
display.activeEncounterId = ENCOUNTER_ID;
|
|
|
|
|
await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: CAMPAIGN_ID, activeEncounterId: ENCOUNTER_ID });
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function endCombat() {
|
|
|
|
|
enc = await endEncounter(enc, ctx);
|
|
|
|
|
display.activeCampaignId = null;
|
|
|
|
|
display.activeEncounterId = null;
|
|
|
|
|
await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: null, activeEncounterId: null });
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function nextTurnAction() { enc = await nextTurn(enc, ctx); }
|
|
|
|
|
async function pauseCombat() { enc = await togglePause(enc, ctx); if (!enc.isPaused) throw new Error('not paused'); }
|
|
|
|
|
async function resumeCombat() { enc = await togglePause(enc, ctx); if (enc.isPaused) throw new Error('not resumed'); }
|
2026-07-03 18:26:02 -04:00
|
|
|
|
2026-07-06 17:10:01 -04:00
|
|
|
async function applyDamage(name, amount, options = null) {
|
2026-07-03 18:29:35 -04:00
|
|
|
const p = any(name);
|
2026-07-06 17:10:01 -04:00
|
|
|
if (!p || (p.currentHp <= 0 && p.status === 'dead')) return;
|
|
|
|
|
enc = options
|
|
|
|
|
? await combatApplyHpChange(enc, p.id, 'damage', amount, options, ctx)
|
|
|
|
|
: await combatApplyHpChange(enc, p.id, 'damage', amount, ctx);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function applyHeal(name, amount) {
|
2026-07-03 18:29:35 -04:00
|
|
|
const p = any(name);
|
2026-07-06 10:31:46 -04:00
|
|
|
if (!p) return;
|
|
|
|
|
enc = await combatApplyHpChange(enc, p.id, 'heal', amount, ctx);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function toggleActive(name) {
|
2026-07-03 18:29:35 -04:00
|
|
|
const p = any(name);
|
2026-07-03 18:26:02 -04:00
|
|
|
if (!p) throw new Error(`no active target: ${name}`);
|
2026-07-06 10:31:46 -04:00
|
|
|
enc = await combatToggleActive(enc, p.id, ctx);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function toggleConditionAction(name, label) {
|
2026-07-03 18:29:35 -04:00
|
|
|
const p = any(name);
|
2026-07-03 18:26:02 -04:00
|
|
|
if (!p) throw new Error(`no condition target: ${name}`);
|
2026-07-06 10:31:46 -04:00
|
|
|
enc = await combatToggleCondition(enc, p.id, label, ctx);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function editParticipant(name, patch) {
|
2026-07-03 18:29:35 -04:00
|
|
|
const p = any(name);
|
2026-07-03 18:26:02 -04:00
|
|
|
if (!p) throw new Error(`no edit target: ${name}`);
|
2026-07-06 10:31:46 -04:00
|
|
|
enc = await updateParticipant(enc, p.id, patch, ctx);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function removeParticipantByName(name) {
|
2026-07-03 18:29:35 -04:00
|
|
|
const p = any(name);
|
2026-07-03 18:26:02 -04:00
|
|
|
if (!p) throw new Error(`no remove target: ${name}`);
|
2026-07-06 10:31:46 -04:00
|
|
|
enc = await removeParticipant(enc, p.id, ctx);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 16:39:11 -04:00
|
|
|
async function deathSaveAction(name, outcome) {
|
2026-07-03 18:29:35 -04:00
|
|
|
const p = any(name);
|
2026-07-03 18:26:02 -04:00
|
|
|
if (!p) throw new Error(`no deathsave target: ${name}`);
|
2026-07-06 16:39:11 -04:00
|
|
|
const r = await combatDeathSave(enc, p.id, outcome, ctx);
|
2026-07-06 10:31:46 -04:00
|
|
|
enc = r.enc;
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-06 17:10:01 -04:00
|
|
|
async function stabilizeAction(name) {
|
|
|
|
|
const p = any(name);
|
|
|
|
|
if (!p) throw new Error(`no stabilize target: ${name}`);
|
|
|
|
|
enc = await stabilizeParticipant(enc, p.id, ctx);
|
|
|
|
|
}
|
|
|
|
|
async function reviveAction(name) {
|
|
|
|
|
const p = any(name);
|
|
|
|
|
if (!p) throw new Error(`no revive target: ${name}`);
|
|
|
|
|
enc = await reviveParticipant(enc, p.id, ctx);
|
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function dragTie(draggedName, targetName) {
|
2026-07-03 18:29:35 -04:00
|
|
|
const d = any(draggedName), t = any(targetName);
|
|
|
|
|
if (!d || !t) throw new Error('drag target missing');
|
2026-07-06 10:31:46 -04:00
|
|
|
enc = await reorderParticipants(enc, d.id, t.id, ctx);
|
2026-07-03 18:29:35 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
async function toggleHidePlayerHpAction() {
|
|
|
|
|
display.hidePlayerHp = !display.hidePlayerHp;
|
|
|
|
|
await storage.updateDoc(DISPLAY_PATH, { hidePlayerHp: display.hidePlayerHp });
|
2026-07-03 18:29:35 -04:00
|
|
|
}
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// ---------- scenario ----------
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
beforeEach(() => {
|
|
|
|
|
({ storage, ctx } = mockCtx());
|
|
|
|
|
enc = {
|
|
|
|
|
id: ENCOUNTER_ID, name: 'BigBoss', participants: [],
|
|
|
|
|
isStarted: false, isPaused: false, round: 0,
|
|
|
|
|
currentTurnParticipantId: null, turnOrderIds: [],
|
|
|
|
|
};
|
|
|
|
|
display = { activeCampaignId: null, activeEncounterId: null, hidePlayerHp: true, hideNpcHp: false };
|
|
|
|
|
roster.length = 0;
|
|
|
|
|
RESULTS.length = 0;
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 17:10:01 -04:00
|
|
|
test('death-save edge cases in combat scenario helpers', async () => {
|
|
|
|
|
await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
|
|
|
|
await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
|
|
|
|
await record('addAllChars', () => addAllCharacters());
|
|
|
|
|
await record('addMonster Goblin', () => addMonsterParticipant('Goblin', 8, 2));
|
|
|
|
|
await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
|
|
|
|
|
await record('startCombat', () => startCombat());
|
|
|
|
|
|
|
|
|
|
await record('monster drops dead inactive', async () => {
|
|
|
|
|
await applyDamage('Goblin', any('Goblin').currentHp);
|
|
|
|
|
const g = any('Goblin');
|
|
|
|
|
expect(g.status).toBe('dead');
|
|
|
|
|
expect(g.isActive).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await record('npc nat20 returns conscious', async () => {
|
|
|
|
|
await applyDamage('Merchant', any('Merchant').currentHp);
|
|
|
|
|
expect(any('Merchant').status).toBe('dying');
|
|
|
|
|
await deathSaveAction('Merchant', 'nat20');
|
|
|
|
|
expect(any('Merchant').status).toBe('conscious');
|
|
|
|
|
expect(any('Merchant').currentHp).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await record('npc nat1 plus damage kills, revive/heal restores', async () => {
|
|
|
|
|
await applyDamage('Merchant', any('Merchant').currentHp);
|
|
|
|
|
await deathSaveAction('Merchant', 'nat1');
|
|
|
|
|
expect(any('Merchant').deathSaveFailures).toBe(2);
|
|
|
|
|
await applyDamage('Merchant', 1);
|
|
|
|
|
expect(any('Merchant').status).toBe('dead');
|
|
|
|
|
expect(any('Merchant').isActive).toBe(true);
|
|
|
|
|
await reviveAction('Merchant');
|
|
|
|
|
expect(any('Merchant').status).toBe('stable');
|
|
|
|
|
await applyHeal('Merchant', 5);
|
|
|
|
|
expect(any('Merchant').status).toBe('conscious');
|
|
|
|
|
expect(any('Merchant').currentHp).toBe(5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await record('character stable then crit damage dies', async () => {
|
|
|
|
|
await applyDamage('Fighter', any('Fighter').currentHp);
|
|
|
|
|
await deathSaveAction('Fighter', 'success');
|
|
|
|
|
await deathSaveAction('Fighter', 'success');
|
|
|
|
|
await deathSaveAction('Fighter', 'success');
|
|
|
|
|
expect(any('Fighter').status).toBe('stable');
|
|
|
|
|
await applyDamage('Fighter', 1);
|
|
|
|
|
expect(any('Fighter').status).toBe('dying');
|
|
|
|
|
expect(any('Fighter').deathSaveFailures).toBe(1);
|
|
|
|
|
await applyDamage('Fighter', 1, { isCriticalHit: true });
|
|
|
|
|
expect(any('Fighter').status).toBe('dead');
|
|
|
|
|
expect(any('Fighter').isActive).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await record('massive damage kills character but does not deactivate', async () => {
|
|
|
|
|
await applyDamage('Cleric', any('Cleric').currentHp + any('Cleric').maxHp);
|
|
|
|
|
expect(any('Cleric').status).toBe('dead');
|
|
|
|
|
expect(any('Cleric').isActive).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const failed = RESULTS.filter(x => !x.ok);
|
|
|
|
|
if (failed.length > 0) {
|
|
|
|
|
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
|
|
|
|
|
}
|
|
|
|
|
expect(failed).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
test('full 100-round combat scenario (shared, no React)', async () => {
|
2026-07-06 10:31:46 -04:00
|
|
|
await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
|
|
|
|
await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
|
|
|
|
await record('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3));
|
|
|
|
|
|
|
|
|
|
await record('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
|
|
|
|
|
await record('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
|
|
|
|
|
await record('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
|
|
|
|
|
await record('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
|
|
|
|
|
await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
|
|
|
|
|
|
|
|
|
|
await record('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
|
2026-07-06 17:10:01 -04:00
|
|
|
await record('addAllChars Cleric/Rogue', () => addAllCharacters());
|
2026-07-06 10:31:46 -04:00
|
|
|
|
|
|
|
|
await record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
|
|
|
|
|
await record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
|
|
|
|
|
|
|
|
|
|
await record('startCombat', () => startCombat());
|
|
|
|
|
|
|
|
|
|
// ---------- 100 ROUNDS ----------
|
2026-07-03 18:29:35 -04:00
|
|
|
let roundsDone = 0;
|
|
|
|
|
let prevRound = enc.round;
|
|
|
|
|
let turnInRound = 0;
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:29:35 -04:00
|
|
|
while (roundsDone < ROUNDS) {
|
|
|
|
|
const r = enc.round;
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
if (r % 2 === 0 && turnInRound === 0) await record(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
|
|
|
|
if (r % 3 === 0 && turnInRound === 1) await record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
|
|
|
|
if (r % 5 === 0 && turnInRound === 2) await record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
|
|
|
|
|
if (r % 7 === 0 && turnInRound === 3) await record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
|
|
|
|
if (r % 13 === 0 && turnInRound === 4) await record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
2026-07-03 18:29:35 -04:00
|
|
|
|
|
|
|
|
if (r % 10 === 0 && turnInRound === 0) {
|
2026-07-06 10:31:46 -04:00
|
|
|
await record(`r${r} pause`, () => pauseCombat());
|
|
|
|
|
await record(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
|
|
|
|
await record(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
|
|
|
|
|
await record(`r${r} resume`, () => resumeCombat());
|
2026-07-03 18:29:35 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (r % 15 === 0 && turnInRound === 5) {
|
|
|
|
|
const g1 = any('Goblin1');
|
|
|
|
|
if (g1 && alive('Goblin2')) {
|
2026-07-06 10:31:46 -04:00
|
|
|
await record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
|
|
|
|
|
await record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
|
2026-07-03 18:29:35 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (r === 25 && turnInRound === 0) {
|
2026-07-06 16:39:11 -04:00
|
|
|
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', any('Rogue').currentHp));
|
|
|
|
|
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail'));
|
2026-07-06 10:31:46 -04:00
|
|
|
await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
2026-07-03 18:29:35 -04:00
|
|
|
}
|
|
|
|
|
if (r === 50 && turnInRound === 0) {
|
2026-07-06 16:39:11 -04:00
|
|
|
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', any('Cleric').currentHp));
|
|
|
|
|
await record(`r${r} deathSave Cleric x3 (dead)`, async () => {
|
|
|
|
|
await deathSaveAction('Cleric', 'fail');
|
|
|
|
|
await deathSaveAction('Cleric', 'fail');
|
|
|
|
|
await deathSaveAction('Cleric', 'fail');
|
2026-07-03 18:29:35 -04:00
|
|
|
});
|
|
|
|
|
const cl = any('Cleric');
|
2026-07-06 16:39:11 -04:00
|
|
|
if (cl) {
|
|
|
|
|
expect(cl.status).toBe('dead');
|
|
|
|
|
expect(enc.participants.map(p => p.name)).toContain('Cleric');
|
|
|
|
|
}
|
2026-07-03 18:29:35 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (r % 30 === 0 && turnInRound === 1) {
|
|
|
|
|
const rein = any(`Reinforce${r - 10}`);
|
2026-07-06 10:31:46 -04:00
|
|
|
if (rein) await record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
|
2026-07-03 18:29:35 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (r % 20 === 0 && turnInRound === 0) {
|
2026-07-06 10:31:46 -04:00
|
|
|
await record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
|
|
|
|
|
await record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
|
2026-07-03 18:29:35 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (turnInRound === 0 && r % 10 === 0) {
|
2026-07-06 10:31:46 -04:00
|
|
|
await record(`r${r} rotation-check`, () => {
|
2026-06-30 14:04:27 -04:00
|
|
|
const order = enc.turnOrderIds || [];
|
|
|
|
|
const uniq = new Set(order);
|
2026-07-03 18:29:35 -04:00
|
|
|
if (uniq.size !== order.length) throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
|
2026-06-30 14:04:27 -04:00
|
|
|
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
|
|
|
|
|
throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
|
|
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
const ids = enc.participants.map(p => p.id);
|
|
|
|
|
if (JSON.stringify(order) !== JSON.stringify(ids)) {
|
|
|
|
|
throw new Error(`turnOrderIds drift: order=${JSON.stringify(order)} ids=${JSON.stringify(ids)}`);
|
|
|
|
|
}
|
2026-06-30 14:04:27 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
await record(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
|
2026-07-03 18:29:35 -04:00
|
|
|
turnInRound++;
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:29:35 -04:00
|
|
|
if (enc.round > prevRound) {
|
|
|
|
|
roundsDone++;
|
|
|
|
|
prevRound = enc.round;
|
|
|
|
|
turnInRound = 0;
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
await record('endCombat', () => endCombat());
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:29:35 -04:00
|
|
|
const failed = RESULTS.filter(x => !x.ok);
|
2026-06-29 13:51:32 -04:00
|
|
|
if (failed.length > 0) {
|
|
|
|
|
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
|
|
|
|
|
}
|
|
|
|
|
expect(failed).toEqual([]);
|
2026-07-03 18:29:35 -04:00
|
|
|
expect(roundsDone).toBe(ROUNDS);
|
2026-07-03 18:26:02 -04:00
|
|
|
|
|
|
|
|
expect(enc.isStarted).toBe(false);
|
|
|
|
|
expect(enc.round).toBe(0);
|
|
|
|
|
expect(enc.currentTurnParticipantId).toBeNull();
|
|
|
|
|
});
|