fix: end combat failing when character sync enabled

Character writeback in endEncounter wrote to a bare campaigns/{id} path,
which the firebase adapter passes straight to the SDK — the update hit a
nonexistent top-level doc, threw, and aborted the whole end-combat action
(round never reset, combat stayed running).

- endEncounter now uses ctx.campaignPath (full artifacts/... path from
  App.js), falling back to the bare path for server/test adapters
- writeback failures no longer block ending combat
- regression tests: campaignPath routing, writeback-failure resilience,
  and participants never cleared on end

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:30:00 -04:00
co-authored by Claude Fable 5
parent 23aa99ba0a
commit ea17c5e26e
3 changed files with 62 additions and 4 deletions
+49
View File
@@ -139,6 +139,55 @@ describe('character writeback on endEncounter', () => {
expect(fighterOld.defaultCurrentHp).toBe(30);
});
test('writeback uses ctx.campaignPath when provided (firebase prefixed paths)', async () => {
const storage = makeMockStorage();
const players = [{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 }];
const prefixed = 'artifacts/app1/public/data/campaigns/camp1';
storage._docs.set(prefixed, { id: 'camp1', name: 'Camp', players, syncCharacters: true });
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
campaignPath: prefixed,
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
const campWrite = storage._writes.find(w => w.path === prefixed);
expect(campWrite).toBeDefined();
expect(campWrite.patch.players.find(p => p.id === 'char1').defaultMaxHp).toBe(28);
});
test('writeback failure does not block ending combat', async () => {
const storage = makeMockStorage();
const players = [{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 }];
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
storage.updateDoc = jest.fn(async (p, patch) => {
if (p === 'campaigns/camp1') throw new Error('No document to update');
storage._writes.push({ path: p, patch });
});
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
encPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
const newEnc = await endEncounter(enc, ctx);
expect(newEnc.isStarted).toBe(false);
expect(newEnc.currentTurnParticipantId).toBe(null);
// participants are preserved — ending combat never clears the roster
expect(newEnc.participants).toHaveLength(3);
const encWrite = storage._writes.find(w => w.path === 'campaigns/camp1/encounters/enc1');
expect(encWrite).toBeDefined();
expect(encWrite.patch.isStarted).toBe(false);
expect(encWrite.patch.participants).toBeUndefined();
});
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 });