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).
158 lines
5.9 KiB
JavaScript
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();
|
|
});
|
|
});
|