fix: guard player display from cross-encounter conflicts
- Start encounter only claims display if slot empty or already ours (prevents stealing display from another live encounter) - End encounter only clears display if THIS encounter is the one showing (prevents killing display for a different live encounter) - Use unwrapped activeDisplayData (not snapshot wrapper activeDisplayInfo) - Tests: 4 display guard cases (claim empty, no-steal busy, clear own, no-clear other)
This commit is contained in:
+14
-2
@@ -2165,7 +2165,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
|
||||
try {
|
||||
await startEncounter(encounter, buildCtx(encounterPath));
|
||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
|
||||
// Only claim player display if slot is empty or already showing this encounter.
|
||||
// Don't steal display from another live encounter.
|
||||
const displayEmpty = !activeDisplayData || !activeDisplayData.activeEncounterId;
|
||||
const displayIsOurs = activeDisplayData && activeDisplayData.activeCampaignId === campaignId && activeDisplayData.activeEncounterId === encounter.id;
|
||||
if (displayEmpty || displayIsOurs) {
|
||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message || "Failed to start encounter. Please try again."); }
|
||||
};
|
||||
@@ -2207,7 +2213,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
} catch {}
|
||||
}
|
||||
await endEncounter(encounter, ctx);
|
||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
||||
// Only clear display if THIS encounter is the one showing.
|
||||
const displayIsThis = activeDisplayData &&
|
||||
activeDisplayData.activeCampaignId === campaignId &&
|
||||
activeDisplayData.activeEncounterId === encounter.id;
|
||||
if (displayIsThis) {
|
||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to end encounter. Please try again."); }
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Display guard: starting encounter does NOT steal display from another live encounter.
|
||||
// Ending encounter does NOT clear display if different encounter showing.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
|
||||
|
||||
const DISPLAY_PATH = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
|
||||
|
||||
function findCallActiveDisplay(fn) {
|
||||
return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status'));
|
||||
}
|
||||
|
||||
describe('Display guard', () => {
|
||||
test('startEncounter: claims display when slot empty', async () => {
|
||||
await setupReady('GuardCamp1', 'GuardEnc1');
|
||||
await addMonsterViaUI('Goblin', 10, 5);
|
||||
await startCombatViaUI();
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last).toBeTruthy();
|
||||
expect(last.data.activeEncounterId).toBeTruthy();
|
||||
});
|
||||
|
||||
test('startEncounter: does NOT steal display from another live encounter', async () => {
|
||||
// Pre-seed display as busy with a DIFFERENT encounter
|
||||
MOCK_DB.set(DISPLAY_PATH, {
|
||||
activeCampaignId: 'other-camp',
|
||||
activeEncounterId: 'other-enc',
|
||||
});
|
||||
|
||||
await setupReady('GuardCamp2', 'GuardEnc2');
|
||||
await addMonsterViaUI('Orc', 15, 8);
|
||||
await startCombatViaUI();
|
||||
|
||||
// Combat started on encounter doc
|
||||
const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
expect(encCalls.some(c => c.data.isStarted === true)).toBe(true);
|
||||
|
||||
// But display setDoc should NOT have been called to claim this encounter
|
||||
const adClaims = findCallActiveDisplay('setDoc').filter(
|
||||
c => c.data && c.data.activeEncounterId && c.data.activeEncounterId !== 'other-enc'
|
||||
);
|
||||
expect(adClaims).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('endEncounter: clears display when ending the displayed encounter', async () => {
|
||||
await setupReady('GuardCamp3', 'GuardEnc3');
|
||||
await addMonsterViaUI('Wolf', 8, 3);
|
||||
await startCombatViaUI();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
return last && last.data && last.data.activeCampaignId === null;
|
||||
});
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||
});
|
||||
|
||||
test('endEncounter: does NOT clear display when different encounter is live', async () => {
|
||||
await setupReady('GuardCamp4', 'GuardEnc4');
|
||||
await addMonsterViaUI('Bat', 4, 2);
|
||||
await startCombatViaUI();
|
||||
|
||||
// Now hijack display to a different encounter (simulating eyeball toggle elsewhere)
|
||||
MOCK_DB.set(DISPLAY_PATH, {
|
||||
activeCampaignId: 'different-camp',
|
||||
activeEncounterId: 'different-enc',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
// Wait for encounter to end
|
||||
await waitFor(() => {
|
||||
const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const last = encCalls[encCalls.length - 1];
|
||||
return last && last.data.isStarted === false;
|
||||
});
|
||||
|
||||
// Display should NOT have been cleared
|
||||
const clearCalls = findCallActiveDisplay('setDoc').filter(
|
||||
c => c.data && c.data.activeCampaignId === null
|
||||
);
|
||||
expect(clearCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user