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
+20
View File
@@ -31,6 +31,14 @@ const eventChanges = (event: RealtimeEvent): RealtimeReadModelChanges | null =>
return null;
};
export type RealtimeSubscriptionScope = 'dashboard' | 'tournament';
/** Keeps page-family projection traffic off the dashboard subscription and vice versa. */
export const shouldForwardRealtimeEvent = (event: RealtimeEvent, scope: RealtimeSubscriptionScope): boolean =>
scope === 'tournament'
? event.type === 'tournamentProjectionChanged'
: event.type !== 'tournamentProjectionChanged';
export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identity: RealtimeViewerIdentity): boolean => {
if (identity.generalId === null) return false;
const changes = eventChanges(event);
@@ -86,6 +94,18 @@ export const toPublicRealtimeEvent = (
};
}
if (event.type === 'tournamentProjectionChanged') {
return {
type: 'tournamentViewInvalidated',
refreshGrant: createRefreshGrant(),
invalidation: {
snapshot: event.invalidation.snapshot === true,
betting: event.invalidation.betting === true,
rankings: event.invalidation.rankings === true,
},
};
}
if (event.type === 'turnCompleted' && !event.changes) {
return {
type: 'readModelInvalidated',
+8 -2
View File
@@ -33,7 +33,11 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
import { formatSseFrame } from './realtime/sse.js';
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js';
import {
shouldForwardRealtimeEvent,
shouldReloadRealtimeViewerIdentity,
toPublicRealtimeEvent,
} from './realtime/publicEvent.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
@@ -267,7 +271,8 @@ export const createGameApiServer = async () => {
});
app.get(config.eventsPath, async (request, reply) => {
const query = request.query as { token?: string };
const query = request.query as { token?: string; scope?: string };
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
const tokenFromHeader = extractBearerToken(request.headers.authorization);
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
@@ -322,6 +327,7 @@ export const createGameApiServer = async () => {
let closed = false;
let eventQueue = Promise.resolve();
const unsubscribe = realtimeHub.subscribe((event) => {
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
eventQueue = eventQueue
.then(async () => {
if (closed) return;
@@ -69,6 +69,12 @@ export const generalAccessEndpointWeights = {
export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights;
/** Server-proven realtime refreshes retain the limit gate but do not add refresh score. */
export const shouldRecordGeneralAccessEndpoint = (
endpoint: GeneralAccessEndpoint,
realtimeAccessGranted: boolean | undefined
): boolean => !(realtimeAccessGranted === true && endpoint === 'tournament.getSnapshot');
export const generalAccessLimitPages = new Set<AccessPage>(['nation-list', 'npc-control']);
export const generalAccessLimitEndpoints = new Set<GeneralAccessEndpoint>([
+4 -1
View File
@@ -13,6 +13,7 @@ import {
getGeneralAccessState,
recordGeneralAccessWeight,
resolveGeneralAccessEndpointWeight,
shouldRecordGeneralAccessEndpoint,
type GeneralAccessEndpoint,
} from './services/generalAccess.js';
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
@@ -119,7 +120,9 @@ const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input,
return next();
}
const endpoint = path as GeneralAccessEndpoint;
await recordGeneralAccessWeight(ctx, weight);
if (shouldRecordGeneralAccessEndpoint(endpoint, ctx.realtimeAccessGranted)) {
await recordGeneralAccessWeight(ctx, weight);
}
if (generalAccessLimitEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {