feat: 토너먼트 페이지군 자동 갱신을 분리한다
토너먼트 전용 SSE scope와 slice invalidation을 추가하고 같은 계정의 visible 탭이 조회를 공유하게 한다. 서버 증명 snapshot 조회는 접속 점수를 더하지 않되 기존 hard limit은 유지한다.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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]))
|
||||
|
||||
Reference in New Issue
Block a user