merge: 최신 main을 ETag 최적화 브랜치에 통합한다

This commit is contained in:
2026-08-22 06:04:33 +00:00
19 changed files with 845 additions and 163 deletions
+178 -2
View File
@@ -195,6 +195,8 @@ const installFixture = async (
tournamentStage?: number;
joinedGroupId?: number;
emptyFinalGroups?: boolean;
realtimeState?: { tournamentStage: number; totalAmount: number };
onOperation?: (operation: string, headers: Record<string, string>) => void;
} = {}
) => {
let joined = false;
@@ -213,13 +215,17 @@ const installFixture = async (
});
await page.route(gameTrpcRoute, async (route) => {
const results = operationNames(route).map((operation) => {
options.onOperation?.(operation, route.request().headers());
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: names[0] } });
if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } });
if (operation === 'tournament.getAdminStatus') return response({ ok: false });
if (operation === 'tournament.getSnapshot') {
const tournamentStage = options.tournamentStage ?? (options.applicationOpen ? 1 : 0);
const tournamentStage =
options.realtimeState?.tournamentStage ??
options.tournamentStage ??
(options.applicationOpen ? 1 : 0);
const joinedGroupId = options.joinedGroupId ?? 0;
return response({
state: {
@@ -272,7 +278,7 @@ const installFixture = async (
participants.slice(0, 16).map((participant, index) => [participant.id, 100 + index * 10])
),
myTotals: { 1: 120, 2: 40 },
totalAmount: 2800,
totalAmount: options.realtimeState?.totalAmount ?? 2800,
myAmount: 160,
});
}
@@ -318,6 +324,44 @@ const installFixture = async (
return { placedBets };
};
const installFakeEventSource = async (page: Page) => {
await page.addInitScript(() => {
class FakeEventSource extends EventTarget {
static instances: FakeEventSource[] = [];
readonly url: string;
closed = false;
constructor(url: string | URL) {
super();
this.url = String(url);
FakeEventSource.instances.push(this);
queueMicrotask(() => {
if (!this.closed) this.dispatchEvent(new Event('open'));
});
}
close() {
this.closed = true;
}
emit(type: string, payload: unknown) {
if (this.closed) return;
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
}
}
Object.defineProperty(window, 'EventSource', { configurable: true, value: FakeEventSource });
Object.assign(window, {
__tournamentEventSourceCount: () => FakeEventSource.instances.filter((source) => !source.closed).length,
__tournamentEventSourceUrls: () =>
FakeEventSource.instances.filter((source) => !source.closed).map((source) => source.url),
__emitTournamentEvent: (type: string, payload: unknown) => {
for (const source of FakeEventSource.instances) source.emit(type, payload);
},
});
});
};
const openTournament = async (page: Page) => {
await installFixture(page);
await page.goto('tournament');
@@ -732,6 +776,138 @@ test('tournament and betting pages expose same-row navigation tabs beside close'
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
});
test('betting realtime refresh is shared across tabs and preserves local interaction state', async ({
page,
context,
}) => {
test.setTimeout(40_000);
const follower = await context.newPage();
await Promise.all([
page.setViewportSize({ width: 390, height: 844 }),
follower.setViewportSize({ width: 390, height: 844 }),
]);
const state = { tournamentStage: 6, totalAmount: 2800 };
const operations: Array<{ operation: string; grant: string | undefined }> = [];
const fixtureOptions = {
realtimeState: state,
onOperation: (operation: string, headers: Record<string, string>) => {
operations.push({ operation, grant: headers['x-sammo-realtime-access-grant'] });
},
};
await Promise.all([installFakeEventSource(page), installFakeEventSource(follower)]);
await Promise.all([installFixture(page, fixtureOptions), installFixture(follower, fixtureOptions)]);
await Promise.all([page.goto('betting'), follower.goto('betting')]);
await Promise.all([
expect(page.getByRole('tab', { name: '전력전' })).toBeVisible(),
expect(follower.getByRole('tab', { name: '전력전' })).toBeVisible(),
]);
await follower.getByRole('tab', { name: '통솔전' }).click();
await follower.getByRole('button', { name: '관우에게 베팅하기' }).click();
await follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액').selectOption('50');
await expect
.poll(async () => {
const counts = await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate(() =>
(
window as unknown as {
__tournamentEventSourceCount: () => number;
}
).__tournamentEventSourceCount()
)
)
);
return counts.reduce((sum, count) => sum + count, 0);
})
.toBe(1);
const activeSourceUrls = (
await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate(() =>
(
window as unknown as {
__tournamentEventSourceUrls: () => string[];
}
).__tournamentEventSourceUrls()
)
)
)
).flat();
expect(activeSourceUrls).toHaveLength(1);
expect(new URL(activeSourceUrls[0]!).searchParams.get('scope')).toBe('tournament');
const before = {
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
betting: operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length,
rankings: operations.filter(({ operation }) => operation === 'tournament.getRankings').length,
};
state.totalAmount = 3333;
const payload = {
type: 'tournamentViewInvalidated',
refreshGrant: 'opaque-e2e-grant',
invalidation: { snapshot: false, betting: true, rankings: false },
};
await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate((event) => {
(
window as unknown as {
__emitTournamentEvent: (type: string, payload: unknown) => void;
}
).__emitTournamentEvent('tournamentViewInvalidated', event);
}, payload)
)
);
await expect
.poll(() => operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length)
.toBe(before.betting + 1);
await expect(page.locator('.section-title small')).toContainText('전체 금액 : 3333');
await expect(follower.locator('.section-title small')).toContainText('전체 금액 : 3333');
await expect(follower.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
await expect(follower.getByRole('dialog', { name: '베팅하기' })).toBeVisible();
await expect(follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액')).toHaveValue('50');
expect(operations.filter(({ operation }) => operation === 'tournament.getSnapshot')).toHaveLength(before.snapshot);
expect(operations.filter(({ operation }) => operation === 'tournament.getRankings')).toHaveLength(before.rankings);
expect(
operations.filter(
({ operation, grant }) => operation === 'tournament.getBettingSummary' && grant === 'opaque-e2e-grant'
)
).toHaveLength(1);
const recoveryBefore = {
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
betting: operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length,
rankings: operations.filter(({ operation }) => operation === 'tournament.getRankings').length,
};
for (const visibilityState of ['hidden', 'visible'] as const) {
await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate((nextVisibilityState) => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: nextVisibilityState,
});
document.dispatchEvent(new Event('visibilitychange'));
}, visibilityState)
)
);
}
await expect
.poll(() => operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length)
.toBe(recoveryBefore.snapshot + 1);
expect(operations.filter(({ operation }) => operation === 'tournament.getBettingSummary')).toHaveLength(
recoveryBefore.betting + 1
);
expect(operations.filter(({ operation }) => operation === 'tournament.getRankings')).toHaveLength(
recoveryBefore.rankings + 1
);
await expect(follower.getByRole('dialog', { name: '베팅하기' })).toBeVisible();
await expect(follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액')).toHaveValue('50');
});
test('tournament and betting close only their script-opened popup window', async ({ page }, testInfo) => {
const baseURL = testInfo.project.use.baseURL;
expect(typeof baseURL).toBe('string');
@@ -0,0 +1,301 @@
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
import type { PublicRealtimeEvent, TournamentViewInvalidation } from '@sammo-ts/common';
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue';
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
import { structurallyShare } from '../utils/structuralShare';
import { trpc } from '../utils/trpc';
import { useSessionStore } from './session';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
type BettingSummary = Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>>;
type Rankings = Awaited<ReturnType<typeof trpc.tournament.getRankings.query>>;
type TournamentPatch = {
snapshot?: Snapshot;
betting?: BettingSummary;
rankings?: Rankings;
};
type TournamentTabMessage =
{ kind: 'patch'; patch: TournamentPatch } | { kind: 'status'; status: 'idle' | 'connected' };
const EMPTY_INVALIDATION = (): TournamentViewInvalidation => ({ snapshot: false, betting: false, rankings: false });
const FULL_INVALIDATION = (): TournamentViewInvalidation => ({ snapshot: true, betting: true, rankings: true });
const hasInvalidation = (value: TournamentViewInvalidation): boolean =>
value.snapshot || value.betting || value.rankings;
const mergeInvalidation = (
left: TournamentViewInvalidation,
right: TournamentViewInvalidation
): TournamentViewInvalidation => ({
snapshot: left.snapshot || right.snapshot,
betting: left.betting || right.betting,
rankings: left.rankings || right.rankings,
});
const resolveErrorMessage = (value: unknown): string => (value instanceof Error ? value.message : String(value));
export const useTournamentPagesStore = defineStore('tournamentPages', () => {
const session = useSessionStore();
const snapshot = ref<Snapshot | null>(null);
const betting = ref<BettingSummary | null>(null);
const rankings = ref<Rankings>([]);
const loading = ref(false);
const refreshing = ref(false);
const error = ref<string | null>(null);
const realtimeStatus = ref<'idle' | 'connected'>('idle');
let activeConsumers = 0;
let realtimeSource: EventSource | null = null;
let realtimeToken: string | null = null;
let realtimeCoordinator: BroadcastTabCoordinator<TournamentTabMessage> | null = null;
let realtimeCoordinatorScope: string | null = null;
let visibilityListenerInstalled = false;
let needsRecovery = false;
let pendingInvalidation = EMPTY_INVALIDATION();
let pendingRefreshGrant: string | null = null;
const applyPatch = (patch: TournamentPatch): void => {
if (patch.snapshot !== undefined) {
snapshot.value =
snapshot.value === null ? patch.snapshot : structurallyShare(snapshot.value, patch.snapshot);
}
if (patch.betting !== undefined) {
betting.value = betting.value === null ? patch.betting : structurallyShare(betting.value, patch.betting);
}
if (patch.rankings !== undefined) {
rankings.value = structurallyShare(rankings.value, patch.rankings);
}
};
const refreshProjection = async (
invalidation: TournamentViewInvalidation,
refreshGrant?: string | null,
foreground = false
): Promise<TournamentPatch> => {
if (!hasInvalidation(invalidation)) return {};
if (foreground) {
loading.value = true;
error.value = null;
} else {
refreshing.value = true;
}
const queryOptions = createRealtimeRequestOptions(refreshGrant);
try {
const [nextSnapshot, nextBetting, nextRankings] = await Promise.all([
invalidation.snapshot ? trpc.tournament.getSnapshot.query(undefined, queryOptions) : undefined,
invalidation.betting ? trpc.tournament.getBettingSummary.query(undefined, queryOptions) : undefined,
invalidation.rankings ? trpc.tournament.getRankings.query(undefined, queryOptions) : undefined,
]);
const patch: TournamentPatch = {};
if (nextSnapshot !== undefined) patch.snapshot = nextSnapshot;
if (nextBetting !== undefined) patch.betting = nextBetting;
if (nextRankings !== undefined) patch.rankings = nextRankings;
applyPatch(patch);
return patch;
} catch (value) {
if (foreground) error.value = resolveErrorMessage(value);
return {};
} finally {
if (foreground) loading.value = false;
else refreshing.value = false;
}
};
const backgroundRefreshQueue = createRateLimitedRefreshQueue(
async () => {
const invalidation = pendingInvalidation;
const refreshGrant = pendingRefreshGrant;
pendingInvalidation = EMPTY_INVALIDATION();
pendingRefreshGrant = null;
const patch = await refreshProjection(invalidation, refreshGrant);
if (Object.keys(patch).length > 0) {
realtimeCoordinator?.postFromLeader({ kind: 'patch', patch });
}
},
{ minIntervalMs: 5_000 }
);
const requestBackgroundRefresh = (invalidation: TournamentViewInvalidation, refreshGrant?: string | null): void => {
pendingInvalidation = mergeInvalidation(pendingInvalidation, invalidation);
if (refreshGrant) pendingRefreshGrant = refreshGrant;
backgroundRefreshQueue.request();
};
const loadTournamentPage = (): Promise<TournamentPatch> =>
refreshProjection({ snapshot: true, betting: true, rankings: false }, null, true);
const loadBettingPage = (): Promise<TournamentPatch> => refreshProjection(FULL_INVALIDATION(), null, true);
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
const ensureAccessToken = async (): Promise<string | null> => {
if (!session.gameToken) return null;
if (isAccessToken(session.gameToken)) return session.gameToken;
if (!(await session.exchangeGatewayToken())) return null;
return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null;
};
const buildRealtimeUrl = (token: string): string => {
const base = import.meta.env.VITE_GAME_SSE_URL ?? '/events';
const url = new URL(base, window.location.origin);
url.searchParams.set('token', token);
url.searchParams.set('scope', 'tournament');
return url.toString();
};
const parseRealtimePayload = (raw: MessageEvent): PublicRealtimeEvent | null => {
if (!raw.data || typeof raw.data !== 'string') return null;
try {
const parsed = JSON.parse(raw.data) as PublicRealtimeEvent;
return parsed && typeof parsed === 'object' && typeof parsed.type === 'string' ? parsed : null;
} catch {
return null;
}
};
const closeRealtimeSource = (): void => {
realtimeSource?.close();
realtimeSource = null;
realtimeToken = null;
};
const isRealtimeParticipant = (): boolean =>
activeConsumers > 0 && document.visibilityState !== 'hidden' && session.isReady && session.hasGeneral;
const closeRealtimeCoordinator = (): void => {
const coordinator = realtimeCoordinator;
realtimeCoordinator = null;
realtimeCoordinatorScope = null;
coordinator?.stop();
closeRealtimeSource();
};
const connectRealtime = async (): Promise<void> => {
if (
typeof window === 'undefined' ||
!isRealtimeParticipant() ||
(realtimeCoordinator !== null && !realtimeCoordinator.isLeader())
) {
return;
}
const token = await ensureAccessToken();
if (!token || !isRealtimeParticipant()) return;
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
if (realtimeSource && realtimeToken === token) return;
closeRealtimeSource();
realtimeToken = token;
const source = new EventSource(buildRealtimeUrl(token));
realtimeSource = source;
source.addEventListener('open', () => {
realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
if (needsRecovery) {
needsRecovery = false;
backgroundRefreshQueue.beginCooldown();
void refreshProjection(FULL_INVALIDATION()).then((patch) => {
if (Object.keys(patch).length > 0) {
realtimeCoordinator?.postFromLeader({ kind: 'patch', patch });
}
});
}
});
source.addEventListener('error', () => {
needsRecovery = true;
realtimeStatus.value = 'idle';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
});
source.addEventListener('tournamentViewInvalidated', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
const payload = parseRealtimePayload(event);
if (!payload || payload.type !== 'tournamentViewInvalidated') return;
requestBackgroundRefresh(payload.invalidation, payload.refreshGrant);
});
source.addEventListener('ping', () => {
realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
});
};
const reconcileRealtimeCoordinator = (): void => {
if (typeof window === 'undefined') return;
if (!isRealtimeParticipant()) {
closeRealtimeCoordinator();
return;
}
if (typeof BroadcastChannel === 'undefined') {
void connectRealtime();
return;
}
const profile = session.profile ?? 'game';
const account = session.user?.id ?? 'general';
const scope = `${encodeURIComponent(profile)}:${encodeURIComponent(account)}`;
if (realtimeCoordinator && realtimeCoordinatorScope === scope) return;
closeRealtimeCoordinator();
realtimeCoordinatorScope = scope;
realtimeCoordinator = createBroadcastTabCoordinator<TournamentTabMessage>(`sammo:tournament-pages:${scope}`, {
onLeadershipChange: (leader) => {
if (leader) void connectRealtime();
else closeRealtimeSource();
},
onPayload: (message) => {
if (!isRealtimeParticipant()) return;
if (message.kind === 'patch') applyPatch(message.patch);
else realtimeStatus.value = message.status;
},
});
realtimeCoordinator.start();
};
const handleVisibilityChange = (): void => {
if (activeConsumers === 0) return;
if (document.visibilityState === 'hidden') {
backgroundRefreshQueue.cancelPending();
pendingInvalidation = EMPTY_INVALIDATION();
pendingRefreshGrant = null;
closeRealtimeCoordinator();
realtimeStatus.value = 'idle';
return;
}
backgroundRefreshQueue.beginCooldown();
needsRecovery = true;
reconcileRealtimeCoordinator();
};
const startRealtime = (): void => {
if (typeof window === 'undefined') return;
activeConsumers += 1;
if (activeConsumers > 1) return;
backgroundRefreshQueue.beginCooldown();
document.addEventListener('visibilitychange', handleVisibilityChange);
visibilityListenerInstalled = true;
reconcileRealtimeCoordinator();
};
const stopRealtime = (): void => {
activeConsumers = Math.max(0, activeConsumers - 1);
if (activeConsumers > 0) return;
backgroundRefreshQueue.cancelPending();
pendingInvalidation = EMPTY_INVALIDATION();
pendingRefreshGrant = null;
closeRealtimeCoordinator();
if (visibilityListenerInstalled) {
document.removeEventListener('visibilitychange', handleVisibilityChange);
visibilityListenerInstalled = false;
}
realtimeStatus.value = 'idle';
};
watch(
() => [session.isReady, session.hasGeneral, session.gameToken, session.profile, session.user?.id],
() => reconcileRealtimeCoordinator()
);
return {
snapshot,
betting,
rankings,
loading,
refreshing,
error,
realtimeStatus,
loadTournamentPage,
loadBettingPage,
startRealtime,
stopRealtime,
};
});
+11 -24
View File
@@ -1,20 +1,17 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, nextTick, onMounted, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { useTournamentPagesStore } from '../stores/tournamentPages';
import type { TournamentBracketSlot } from '../utils/tournamentBracket';
import { trpc } from '../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
const snapshot = ref<Snapshot | null>(null);
const summary = ref<Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>> | null>(null);
const rankings = ref<Awaited<ReturnType<typeof trpc.tournament.getRankings.query>>>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const tournamentPages = useTournamentPagesStore();
const { snapshot, betting: summary, rankings, loading, error } = storeToRefs(tournamentPages);
const amounts = ref<Record<number, number>>({});
const selectedTarget = ref<TournamentBracketSlot | null>(null);
const betDialog = ref<HTMLDialogElement | null>(null);
@@ -39,22 +36,12 @@ const stageNames = [
];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => {
loading.value = true;
error.value = null;
try {
[snapshot.value, summary.value, rankings.value] = await Promise.all([
trpc.tournament.getSnapshot.query(),
trpc.tournament.getBettingSummary.query(),
trpc.tournament.getRankings.query(),
]);
} catch (value) {
error.value = errorText(value);
} finally {
loading.value = false;
}
};
onMounted(() => void load());
const load = () => tournamentPages.loadBettingPage();
onMounted(() => {
tournamentPages.startRealtime();
void load();
});
onUnmounted(() => tournamentPages.stopRealtime());
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
const myAmount = computed(() => summary.value?.myAmount ?? 0);
+13 -16
View File
@@ -1,21 +1,20 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, nextTick, onMounted, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentGroupCard from '../components/tournament/TournamentGroupCard.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { useTournamentPagesStore } from '../stores/tournamentPages';
import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../utils/tournamentStatus';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
const snapshot = ref<Snapshot | null>(null);
const betting = ref<Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>> | null>(null);
const tournamentPages = useTournamentPagesStore();
const { snapshot, betting, loading, error } = storeToRefs(tournamentPages);
type Snapshot = NonNullable<typeof snapshot.value>;
const myGeneralId = ref(0);
const loading = ref(false);
const error = ref<string | null>(null);
const adminEnabled = ref(false);
const activeFinalGroup = ref(0);
const activePreliminaryGroup = ref(0);
@@ -27,27 +26,25 @@ const typeStatNames = ['종합', '통솔', '무력', '지력'];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => {
loading.value = true;
error.value = null;
try {
const [nextSnapshot, nextBetting, me, admin] = await Promise.all([
trpc.tournament.getSnapshot.query(),
trpc.tournament.getBettingSummary.query(),
const [, me, admin] = await Promise.all([
tournamentPages.loadTournamentPage(),
trpc.general.me.query(),
trpc.tournament.getAdminStatus.query().catch(() => null),
]);
snapshot.value = nextSnapshot;
betting.value = nextBetting;
myGeneralId.value = me?.general?.id ?? 0;
adminEnabled.value = !!admin?.ok;
} catch (value) {
error.value = errorText(value);
} finally {
loading.value = false;
}
};
onMounted(() => void load());
onMounted(() => {
tournamentPages.startRealtime();
void load();
});
onUnmounted(() => tournamentPages.stopRealtime());
const participantsById = computed(
() => new Map((snapshot.value?.participants ?? []).map((participant) => [participant.id, participant]))