// Logging contract: every mutating combat op returns { patch, log } where // log = { message: string, undo: object|null }. No-op (no state change) // returns { patch: null, log: null }. Display lifecycle ops return { patch } // only (no log field expected). // // logAction handler does: // if (log) logAction(log.message, ctx, { updates: log.undo }) // → null log = skipped = gap in audit trail. // // Contract = every combat mutation MUST produce a log entry. 'use strict'; const shared = require('@ttrpg/shared'); const { makeParticipant, buildMonsterParticipant, startEncounter, nextTurn, togglePause, addParticipant, addParticipants, updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange, deathSave, toggleCondition, reorderParticipants, endEncounter, } = shared; function p(id, init) { return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 100, currentHp: 100 }); } function enc(ps, extra = {}) { return { name:'t', participants:ps, isStarted:false, isPaused:false, round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra }; } const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e; // Helper: assert a result is a logged mutation (patch + valid log). function expectLogged(r) { expect(r.patch).not.toBeNull(); expect(r.log).not.toBeNull(); expect(typeof r.log).toBe('object'); expect(typeof r.log.message).toBe('string'); expect(r.log.message.length).toBeGreaterThan(0); } function expectNoOp(r) { expect(r.patch).toBeNull(); expect(r.log).toBeNull(); } describe('Logging contract: mutating ops', () => { let e; beforeEach(() => { e = enc([p('a', 10), p('b', 7), p('c', 3)]); }); test('startEncounter logs', () => { const r = startEncounter(e); expectLogged(r); }); test('nextTurn logs', () => { const started = apply(e, startEncounter(e)); const r = nextTurn(started); expectLogged(r); }); test('togglePause logs', () => { const r = togglePause(apply(e, startEncounter(e))); expectLogged(r); }); test('addParticipant logs', () => { const r = addParticipant(e, p('d', 5)); expectLogged(r); }); test('removeParticipant logs', () => { const r = removeParticipant(e, 'b'); expectLogged(r); }); test('toggleParticipantActive logs', () => { const r = toggleParticipantActive(e, 'b'); expectLogged(r); }); test('applyHpChange (damage) logs', () => { const r = applyHpChange(e, 'b', 'damage', 10); expectLogged(r); }); test('applyHpChange (heal) logs', () => { const r = applyHpChange(e, 'b', 'heal', 5); expectLogged(r); }); test('deathSave logs', () => { const started = apply(e, startEncounter(e)); const dying = apply(started, applyHpChange(started, 'b', 'damage', 100)); const r = deathSave(dying, 'b', 'fail', 1); expectLogged(r); }); test('toggleCondition logs', () => { const r = toggleCondition(e, 'b', 'poisoned'); expectLogged(r); }); test('reorderParticipants logs (BUG-7)', () => { // same-init tie (both 10) for valid reorder const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]); const r = reorderParticipants(e2, 'x', 'a'); expectLogged(r); }); test('endEncounter logs', () => { const started = apply(e, startEncounter(e)); const r = endEncounter(started); expectLogged(r); }); }); describe('Logging contract: no-ops', () => { let e; beforeEach(() => { e = enc([p('a', 10), p('b', 7), p('c', 3)]); }); test('reorder same-id = no-op', () => { expectNoOp(reorderParticipants(e, 'a', 'a')); }); test('reorder cross-init = no-op', () => { expectNoOp(reorderParticipants(e, 'a', 'b')); }); }); describe('Logging undo payloads', () => { let e; beforeEach(() => { e = enc([p('a', 10), p('b', 7), p('c', 3)]); }); test('startEncounter undo restores pre-combat state', () => { const r = startEncounter(e); expect(r.log.undo).toBeDefined(); expect(r.log.undo.isStarted).toBe(false); }); test('endEncounter undo restores combat state', () => { const started = apply(e, startEncounter(e)); const r = endEncounter(started); expect(r.log.undo).toBeDefined(); expect(r.log.undo.isStarted).toBe(true); }); test('applyHpChange undo restores prior hp', () => { const r = applyHpChange(e, 'b', 'damage', 10); expect(r.log.undo).toBeDefined(); expect(r.log.undo.participants).toBeDefined(); }); test('reorder undo restores prior order (BUG-7)', () => { const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]); const orig = e2.participants.map(p => p.id); const r = reorderParticipants(e2, 'x', 'a'); expect(r.log.undo).toBeDefined(); const restored = { ...e2, ...r.log.undo }; expect(restored.participants.map(p => p.id)).toEqual(orig); }); }); describe('Logging: addParticipants + updateParticipant', () => { let e; beforeEach(() => { e = enc([p('a', 10), p('b', 7)]); }); test('addParticipants logs', () => { const r = addParticipants(e, [p('c', 3)]); expectLogged(r); }); test('updateParticipant (same slot) logs', () => { const r = updateParticipant(e, 'b', { name: 'B' }); expectLogged(r); }); test('updateParticipant (init change) logs', () => { const r = updateParticipant(e, 'b', { initiative: 5 }); expectLogged(r); }); test('addParticipants undo restores prior list', () => { const orig = e.participants.map(p => p.id); const r = addParticipants(e, [p('c', 3)]); const restored = { ...e, ...r.log.undo }; expect(restored.participants.map(p => p.id)).toEqual(orig); }); test('updateParticipant undo restores prior participant', () => { const orig = e.participants.map(p => p.id); const r = updateParticipant(e, 'b', { name: 'B' }); const restored = { ...e, ...r.log.undo }; expect(restored.participants.map(p => p.id)).toEqual(orig); }); });