// Character writeback on endEncounter: sync maxHp/ac to campaign players. const shared = require('@ttrpg/shared'); const { endEncounter } = shared; function makeMockStorage() { const docs = new Map(); const writes = []; return { getDoc: jest.fn(async (p) => docs.get(p) || null), setDoc: jest.fn(async (p, d) => { docs.set(p, d); }), updateDoc: jest.fn(async (p, patch) => { writes.push({ path: p, patch }); const cur = docs.get(p) || {}; docs.set(p, { ...cur, ...patch }); }), addDoc: jest.fn(async (p, data) => { writes.push({ path: p, patch: data }); docs.set(p, data); }), deleteDoc: jest.fn(async () => {}), getCollection: jest.fn(async () => []), _docs: docs, _writes: writes, }; } function makeEncounter(overrides = {}) { return { id: 'enc1', name: 'Test', campaignId: 'camp1', isStarted: true, isPaused: false, currentTurnParticipantId: 'p1', round: 3, turnOrderIds: ['p1', 'p2'], participants: [ { id: 'p1', name: 'Fighter', type: 'character', originalCharacterId: 'char1', initiative: 15, maxHp: 28, currentHp: 20, ac: 18 }, { id: 'p2', name: 'Goblin', type: 'monster', originalCharacterId: null, initiative: 12, maxHp: 7, currentHp: 7, ac: 13 }, { id: 'p3', name: 'Cleric', type: 'character', originalCharacterId: 'char2', initiative: 10, maxHp: 22, currentHp: 22, ac: 16 }, ], ...overrides, }; } describe('character writeback on endEncounter', () => { test('no writeback when syncCharacters false/missing', async () => { const storage = makeMockStorage(); storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players: [ { id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 }, ], }); const enc = makeEncounter(); const ctx = { storage, encounterPath: 'campaigns/camp1/encounters/enc1', logPath: 'logs/log1', logCollection: 'logs', displayPath: 'activeDisplay/status', }; await endEncounter(enc, ctx); // no campaign write expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined(); }); test('writeback updates char maxHp + ac + currentHp when syncCharacters true', async () => { const storage = makeMockStorage(); const players = [ { id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 }, { id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 }, ]; storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true }); const enc = makeEncounter(); const ctx = { storage, encounterPath: 'campaigns/camp1/encounters/enc1', logPath: 'logs/log1', logCollection: 'logs', displayPath: 'activeDisplay/status', }; await endEncounter(enc, ctx); const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1'); expect(campWrite).toBeDefined(); const updated = campWrite.patch.players; const fighter = updated.find(p => p.id === 'char1'); expect(fighter.defaultMaxHp).toBe(28); expect(fighter.defaultAc).toBe(18); expect(fighter.defaultCurrentHp).toBe(20); const cleric = updated.find(p => p.id === 'char2'); expect(cleric.defaultMaxHp).toBe(22); expect(cleric.defaultAc).toBe(16); expect(cleric.defaultCurrentHp).toBe(22); }); test('writeback skips monsters (no originalCharacterId)', async () => { const storage = makeMockStorage(); const players = [{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 }]; storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true }); const enc = makeEncounter(); const ctx = { storage, encounterPath: 'campaigns/camp1/encounters/enc1', logPath: 'logs/log1', logCollection: 'logs', displayPath: 'activeDisplay/status', }; await endEncounter(enc, ctx); const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1'); // players array still 1 entry, goblin not added expect(campWrite.patch.players.length).toBe(1); }); test('undo payload includes old char values for restore', async () => { const storage = makeMockStorage(); const players = [ { id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 }, { id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 }, ]; storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true }); storage._docs.set('logs/log1', null); const enc = makeEncounter(); const ctx = { storage, encounterPath: 'campaigns/camp1/encounters/enc1', logPath: 'logs/log1', logCollection: 'logs', displayPath: 'activeDisplay/status', }; await endEncounter(enc, ctx); const logWrite = storage._writes.find(w => w.path === 'logs/log1'); expect(logWrite).toBeDefined(); const log = logWrite.patch; expect(log.undo.characterWriteback).toBeDefined(); expect(log.undo.characterWriteback).toHaveLength(2); const fighterOld = log.undo.characterWriteback.find(c => c.id === 'char1'); expect(fighterOld.defaultMaxHp).toBe(30); expect(fighterOld.defaultAc).toBe(17); expect(fighterOld.defaultCurrentHp).toBe(30); }); test('writeback no-op if character missing from campaign', async () => { const storage = makeMockStorage(); storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players: [], syncCharacters: true }); const enc = makeEncounter(); const ctx = { storage, encounterPath: 'campaigns/camp1/encounters/enc1', logPath: 'logs/log1', logCollection: 'logs', displayPath: 'activeDisplay/status', }; await endEncounter(enc, ctx); // no writeback (no matching chars = no change) expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined(); }); });