Files
david raistrick fd03ae48c8 Character writeback on encounter end
Campaign-level toggle (CharacterManager, live flip). When ON, ending an
encounter writes each character participant's current HP, Max HP, and AC back
to the campaign roster so they carry into the next encounter.

- shared: buildCharacterParticipant uses defaultCurrentHp (fallback maxHp)
- endEncounter: syncs maxHp/ac/currentHp to campaign players if syncCharacters,
  snapshots old values into undo payload for restore. Works retroactive
  (ctx.campaignId fallback for encounters lacking campaignId field).
- New encounters store campaignId field.
- Undo end-encounter restores character values client-side.
- CharacterManager: styled sync toggle (checkbox badge), stat badges in list
  (HP/Current HP/AC/Init), edit box supports Current HP field.

Tests: 5 writeback cases (off/on/skip monsters/undo snapshot/missing char).
2026-07-08 16:33:21 -04:00

158 lines
5.9 KiB
JavaScript

// 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();
});
});