feat: 토너먼트 페이지군 자동 갱신을 분리한다

토너먼트 전용 SSE scope와 slice invalidation을 추가하고 같은 계정의 visible 탭이 조회를 공유하게 한다. 서버 증명 snapshot 조회는 접속 점수를 더하지 않되 기존 hard limit은 유지한다.
This commit is contained in:
2026-08-22 06:00:22 +00:00
parent 3b2b98dc88
commit 985f627499
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]))