diff --git a/src/App.js b/src/App.js index 8ea85e0..50292ea 100644 --- a/src/App.js +++ b/src/App.js @@ -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."); } diff --git a/src/tests/DisplayGuard.test.js b/src/tests/DisplayGuard.test.js new file mode 100644 index 0000000..d13fae6 --- /dev/null +++ b/src/tests/DisplayGuard.test.js @@ -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); + }); +});