// Logging contract: every mutating combat op writes a log entry to ctx.logPath // via ctx.storage.addDoc. No-op (no state change) performs NO writes and // returns the same encounter reference. Display lifecycle ops write the display // doc, no combat log. // // Contract = every combat mutation produces a log entry. // New shape: mutating funcs are async, take ctx, write encounter (updateDoc) + // log (addDoc) internally, return newEnc. Logs are read back via storage.logs(). // // undo payloads now live on the written log entry's `undo_payload.updates`. 'use strict'; const shared = require('@ttrpg/shared'); const { mockCtx } = require('./_helpers'); const { makeParticipant, startEncounter, nextTurn, togglePause, addParticipant, addParticipants, updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange, deathSave, toggleCondition, reorderParticipants, endEncounter, } = shared; function p(id, init, extra = {}) { return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 100, currentHp: 100, ...extra }); } function enc(ps, extra = {}) { return { name:'t', participants:ps, isStarted:false, isPaused:false, round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra }; } // Assert last written log entry is a well-formed mutation log. function expectLastLogged(storage) { const logs = storage.logs(); expect(logs.length).toBeGreaterThan(0); const last = logs[logs.length - 1]; expect(typeof last.message).toBe('string'); expect(last.message.length).toBeGreaterThan(0); return last; } describe('Logging contract: mutating ops', () => { let storage, ctx; beforeEach(() => { ({ storage, ctx } = mockCtx()); }); test('startEncounter logs', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); await startEncounter(e, ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('nextTurn logs', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); const started = await startEncounter(e, ctx); // 1 log await nextTurn(started, ctx); // 2 logs expect(storage.logs()).toHaveLength(2); expectLastLogged(storage); }); test('togglePause logs', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); const started = await startEncounter(e, ctx); await togglePause(started, ctx); expect(storage.logs()).toHaveLength(2); expectLastLogged(storage); }); test('addParticipant logs', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); await addParticipant(e, p('d', 5), ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('removeParticipant logs', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); await removeParticipant(e, 'b', ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('toggleParticipantActive logs', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); await toggleParticipantActive(e, 'b', ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('applyHpChange (damage) logs', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); await applyHpChange(e, 'b', 'damage', 10, ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('applyHpChange (heal) logs', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); await applyHpChange(e, 'b', 'heal', 5, ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('deathSave logs', async () => { const e = enc([p('a', 10), p('b', 7, { type: 'character' }), p('c', 3)]); const started = await startEncounter(e, ctx); // 1 log const dying = await applyHpChange(started, 'b', 'damage', 100, ctx); // 2 logs const r = await deathSave(dying, 'b', 'fail', ctx); // 3 logs expect(r.status).toBe('dying'); expect(storage.logs()).toHaveLength(3); expectLastLogged(storage); }); test('toggleCondition logs', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); await toggleCondition(e, 'b', 'poisoned', ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('reorderParticipants logs (BUG-7)', async () => { // same-init tie (both 10) for valid reorder (unstarted: no pointer check) const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]); await reorderParticipants(e2, 'x', 'a', ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('endEncounter logs', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); const started = await startEncounter(e, ctx); await endEncounter(started, ctx); expect(storage.logs()).toHaveLength(2); expectLastLogged(storage); }); }); describe('Logging contract: no-ops', () => { let storage, ctx; beforeEach(() => { ({ storage, ctx } = mockCtx()); }); test('reorder same-id = no-op', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); const newEnc = await reorderParticipants(e, 'a', 'a', ctx); expect(newEnc).toBe(e); // same ref = no write expect(storage.logs()).toHaveLength(0); }); test('reorder cross-init = no-op', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); const newEnc = await reorderParticipants(e, 'a', 'b', ctx); expect(newEnc).toBe(e); // same ref = no write expect(storage.logs()).toHaveLength(0); }); }); describe('Logging undo payloads', () => { let storage, ctx; beforeEach(() => { ({ storage, ctx } = mockCtx()); }); test('startEncounter undo restores pre-combat state', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); await startEncounter(e, ctx); const log = expectLastLogged(storage); expect(log.undo).toBeDefined(); expect(log.undo.isStarted).toBe(false); }); test('endEncounter undo restores combat state', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); const started = await startEncounter(e, ctx); await endEncounter(started, ctx); const log = expectLastLogged(storage); expect(log.undo).toBeDefined(); expect(log.undo.isStarted).toBe(true); }); test('applyHpChange undo restores prior hp', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); const newEnc = await applyHpChange(e, 'b', 'damage', 10, ctx); const log = expectLastLogged(storage); expect(log.undo).toBeDefined(); const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates }; expect(restored.participants.find(x => x.id === 'b').currentHp).toBe(100); }); test('reorder undo restores prior order (BUG-7)', async () => { const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]); const orig = e2.participants.map(p => p.id); const newEnc = await reorderParticipants(e2, 'x', 'a', ctx); const log = expectLastLogged(storage); expect(log.undo).toBeDefined(); const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates }; expect(restored.participants.map(p => p.id)).toEqual(orig); }); }); describe('Logging: addParticipants + updateParticipant', () => { let storage, ctx; beforeEach(() => { ({ storage, ctx } = mockCtx()); }); test('addParticipants logs', async () => { const e = enc([p('a', 10), p('b', 7)]); await addParticipants(e, [p('c', 3)], ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('updateParticipant (same slot) logs', async () => { const e = enc([p('a', 10), p('b', 7)]); await updateParticipant(e, 'b', { name: 'B' }, ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('updateParticipant (init change) logs', async () => { const e = enc([p('a', 10), p('b', 7)]); await updateParticipant(e, 'b', { initiative: 5 }, ctx); expect(storage.logs()).toHaveLength(1); expectLastLogged(storage); }); test('addParticipants undo restores prior list', async () => { const e = enc([p('a', 10), p('b', 7)]); const orig = e.participants.map(p => p.id); const newEnc = await addParticipants(e, [p('c', 3)], ctx); const log = expectLastLogged(storage); const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates }; expect(restored.participants.map(p => p.id)).toEqual(orig); }); test('updateParticipant undo restores prior participant', async () => { const e = enc([p('a', 10), p('b', 7)]); const orig = e.participants.map(p => p.id); const newEnc = await updateParticipant(e, 'b', { name: 'B' }, ctx); const log = expectLastLogged(storage); const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates }; expect(restored.participants.map(p => p.id)).toEqual(orig); }); });