Merge branch 'fix/end-combat-writeback-path': fix end combat failing when character sync enabled

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:30:58 -04:00
co-authored by Claude Fable 5
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 });
+11 -3
View File
@@ -1129,11 +1129,15 @@ async function endEncounter(encounter, ctx) {
};
// Character writeback: sync maxHp/ac/currentHp to campaign players if enabled.
// ctx.campaignPath: adapter-specific doc path (firebase needs the full
// artifacts/{APP_ID}/public/data prefix); bare campaigns/{id} fallback for
// server/test adapters. Writeback failure must not block ending combat.
const wbCampaignId = encounter.campaignId || ctx.campaignId;
if ((wbCampaignId || ctx.campaign) && ctx.storage) {
const campaignPath = ctx.campaignPath || `campaigns/${wbCampaignId}`;
let campaign;
try {
campaign = ctx.campaign || await ctx.storage.getDoc(`campaigns/${wbCampaignId}`);
campaign = ctx.campaign || await ctx.storage.getDoc(campaignPath);
} catch { campaign = null; }
if (campaign && campaign.syncCharacters && Array.isArray(campaign.players)) {
const charParticipants = (encounter.participants || []).filter(p => p.originalCharacterId);
@@ -1153,8 +1157,12 @@ async function endEncounter(encounter, ctx) {
return newPlayer;
});
if (changed) {
await ctx.storage.updateDoc(`campaigns/${wbCampaignId}`, { players: updatedPlayers });
log.undo.characterWriteback = oldValues;
try {
await ctx.storage.updateDoc(campaignPath, { players: updatedPlayers });
log.undo.characterWriteback = oldValues;
} catch (err) {
console.error(`endEncounter: character writeback failed (${campaignPath}):`, err);
}
}
}
}
+2 -1
View File
@@ -2281,8 +2281,9 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
try {
const ctx = { ...buildCtx(encounterPath), campaignId };
if (campaignId) {
ctx.campaignPath = getPath.campaign(campaignId);
try {
ctx.campaign = await storage.getDoc(getPath.campaign(campaignId));
ctx.campaign = await storage.getDoc(ctx.campaignPath);
} catch {}
}
await endEncounter(encounter, ctx);