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) {
@@ -6,6 +6,7 @@ import type { GameApiContext } from '../src/context.js';
import type { DatabaseClient } from '../src/context.js';
import {
accessAuthedProcedure,
accessAuthedInputProcedure,
accessLimitAuthedProcedure,
deferredAccessLimitAuthedProcedure,
@@ -20,6 +21,7 @@ import {
resolveGeneralAccessEndpointWeight,
resolveAccessWindows,
resolveGeneralScoreStartedAt,
shouldRecordGeneralAccessEndpoint,
} from '../src/services/generalAccess.js';
const profile = { id: 'che', name: 'che:default', scenario: 'default' };
@@ -144,6 +146,27 @@ describe('general access tracking', () => {
expect(generalAccessEndpointWeights['world.getGeneralDirectory']).toBe(2);
});
it('waives score only for a server-proven tournament snapshot refresh', () => {
expect(shouldRecordGeneralAccessEndpoint('tournament.getSnapshot', true)).toBe(false);
expect(shouldRecordGeneralAccessEndpoint('tournament.getSnapshot', false)).toBe(true);
expect(shouldRecordGeneralAccessEndpoint('world.getGeneralDirectory', true)).toBe(true);
});
it('keeps the existing hard limit gate on a granted tournament refresh', async () => {
const now = new Date();
const fixture = buildDb({ lastTurnTime: now.toISOString() }, { lastRefresh: now, refreshScore: 999 });
const tournamentBoundary = router({
tournament: router({ getSnapshot: accessAuthedProcedure.query(() => ({ ok: true })) }),
});
const caller = tournamentBoundary.createCaller({
...accessContext(fixture.db),
realtimeAccessGranted: true,
} as unknown as GameApiContext);
await expect(caller.tournament.getSnapshot()).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
expect(fixture.executeRaw).not.toHaveBeenCalled();
});
it('uses the latest processed game turn as the traffic period and score window', () => {
expect(
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
@@ -197,7 +220,6 @@ describe('general access tracking', () => {
expect(accessStatement.values).toContain(2);
expect(accessStatement.values).toContain(now);
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
});
it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => {
@@ -248,9 +270,7 @@ describe('general access tracking', () => {
const fixture = buildDb();
const resolver = vi.fn(() => ({ ok: true }));
const limitedRouter = router({ read: deferredAccessLimitAuthedProcedure.query(resolver) });
const get = vi.fn(async () =>
JSON.stringify({ nextAccessAt: '2099-07-26T03:10:00.000Z' })
);
const get = vi.fn(async () => JSON.stringify({ nextAccessAt: '2099-07-26T03:10:00.000Z' }));
await expect(
limitedRouter
@@ -4,6 +4,7 @@ import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
import {
shouldForwardRealtimeEvent,
shouldReloadRealtimeViewerIdentity,
toPublicRealtimeEvent as convertPublicRealtimeEvent,
} from '../src/realtime/publicEvent.js';
@@ -145,6 +146,44 @@ describe('public realtime event privacy boundary', () => {
expect(shouldReloadRealtimeViewerIdentity({ type: 'tournamentChanged' }, viewer)).toBe(false);
});
it('exposes tournament page refresh selection without projection identity or revision', () => {
const invalidation = { snapshot: true, betting: false, rankings: false, generalId: 7 };
const publicEvent = toPublicRealtimeEvent(
{
type: 'tournamentProjectionChanged',
invalidation,
},
[viewer]
);
expect(publicEvent).toEqual({
type: 'tournamentViewInvalidated',
refreshGrant,
invalidation: { snapshot: true, betting: false, rankings: false },
});
expect(JSON.stringify(publicEvent)).not.toMatch(/revision|source|channel|time|generalId|matchId/u);
expect(
shouldReloadRealtimeViewerIdentity(
{
type: 'tournamentProjectionChanged',
invalidation: { snapshot: false, betting: true, rankings: false },
},
viewer
)
).toBe(false);
});
it('routes tournament projection traffic only to the dedicated page-family subscription', () => {
const projectionEvent: RealtimeEvent = {
type: 'tournamentProjectionChanged',
invalidation: { snapshot: true, betting: false, rankings: false },
};
expect(shouldForwardRealtimeEvent(projectionEvent, 'dashboard')).toBe(false);
expect(shouldForwardRealtimeEvent(projectionEvent, 'tournament')).toBe(true);
expect(shouldForwardRealtimeEvent({ type: 'tournamentChanged' }, 'dashboard')).toBe(true);
expect(shouldForwardRealtimeEvent({ type: 'tournamentChanged' }, 'tournament')).toBe(false);
});
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
const event: RealtimeEvent = {
type: 'messageCreated',
@@ -54,9 +54,7 @@ integration('TournamentStore Redis source revision', () => {
const store = new TournamentStore(connector.client, keys);
const revisions = await Promise.all(
Array.from({ length: 20 }, (_, index) =>
store.setMatches([
{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 },
])
store.setMatches([{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 }])
)
);
@@ -82,19 +80,48 @@ integration('TournamentStore Redis source revision', () => {
await store.setState(baseState);
await waitForLength(sourceMessages, sourceBefore + 1);
await waitForLength(realtimeMessages, realtimeBefore + 1);
await waitForLength(realtimeMessages, realtimeBefore + 2);
await store.setState({ ...baseState, phase: 1 });
await waitForLength(sourceMessages, sourceBefore + 2);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(realtimeMessages).toHaveLength(realtimeBefore + 1);
await waitForLength(realtimeMessages, realtimeBefore + 3);
await store.setState({ ...baseState, stage: 2, phase: 0 });
await waitForLength(sourceMessages, sourceBefore + 3);
await waitForLength(realtimeMessages, realtimeBefore + 5);
expect(
realtimeMessages
.slice(realtimeBefore)
.filter((message) => (JSON.parse(message) as { type?: string }).type === 'tournamentChanged')
).toEqual([JSON.stringify({ type: 'tournamentChanged' }), JSON.stringify({ type: 'tournamentChanged' })]);
});
it('selects rankings only on the first committed reward settlement', async () => {
const store = new TournamentStore(connector.client, keys);
const realtimeBefore = realtimeMessages.length;
const state = await store.getState();
expect(state).not.toBeNull();
await store.setState({ ...state!, rewardSettled: true, bettingSettled: false });
await store.setState({ ...state!, rewardSettled: true, bettingSettled: true });
await waitForLength(realtimeMessages, realtimeBefore + 2);
expect(realtimeMessages.slice(realtimeBefore)).toEqual([
JSON.stringify({ type: 'tournamentChanged' }),
JSON.stringify({ type: 'tournamentChanged' }),
const pageEvents = realtimeMessages.slice(realtimeBefore).map(
(message) =>
JSON.parse(message) as {
type: string;
invalidation?: { rankings?: boolean };
}
);
expect(pageEvents).toEqual([
expect.objectContaining({
type: 'tournamentProjectionChanged',
invalidation: expect.objectContaining({ rankings: true }),
}),
expect.objectContaining({
type: 'tournamentProjectionChanged',
invalidation: expect.objectContaining({ rankings: false }),
}),
]);
});
});
@@ -48,14 +48,22 @@ describe('TournamentStore source revision', () => {
const keys = buildTournamentKeys('che:default');
const store = new TournamentStore(redis, keys);
await expect(store.setParticipants([{ id: 7, name: '관우', leadership: 90, strength: 97, intel: 75, level: 5 }]))
.resolves.toBe('1');
await expect(
store.setParticipants([{ id: 7, name: '관우', leadership: 90, strength: 97, intel: 75, level: 5 }])
).resolves.toBe('1');
await expect(store.getSourceRevision()).resolves.toBe('1');
await expect(store.getParticipants()).resolves.toHaveLength(1);
expect(redis.events).toEqual(['commit:1', 'publish:1']);
expect(redis.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentProjectionChanged']);
expect(redis.published).toEqual([
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
{
channel: keys.realtimeEventChannel,
message: JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: { snapshot: true, betting: false, rankings: false },
}),
},
]);
});
@@ -64,8 +72,9 @@ describe('TournamentStore source revision', () => {
const store = new TournamentStore(redis, buildTournamentKeys('hwe:default'));
redis.failNextEval = true;
await expect(store.setParticipants([{ id: 1, name: '실패', leadership: 1, strength: 1, intel: 1, level: 1 }]))
.rejects.toThrow('injected Redis write failure');
await expect(
store.setParticipants([{ id: 1, name: '실패', leadership: 1, strength: 1, intel: 1, level: 1 }])
).rejects.toThrow('injected Redis write failure');
await expect(store.getParticipants()).resolves.toEqual([]);
await expect(store.getSourceRevision()).resolves.toBeNull();
@@ -88,9 +97,21 @@ describe('TournamentStore source revision', () => {
nextAt: '2026-08-17T00:00:00.000Z',
});
expect(redis.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentChanged']);
expect(redis.events).toEqual([
'commit:1',
'publish:1',
'publish:tournamentProjectionChanged',
'publish:tournamentChanged',
]);
expect(redis.published).toEqual([
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
{
channel: keys.realtimeEventChannel,
message: JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: { snapshot: true, betting: true, rankings: false },
}),
},
{ channel: keys.realtimeEventChannel, message: JSON.stringify({ type: 'tournamentChanged' }) },
]);
});
@@ -101,14 +122,12 @@ describe('TournamentStore source revision', () => {
const revisions = await Promise.all(
Array.from({ length: 50 }, (_, index) =>
store.setMatches([
{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 },
])
store.setMatches([{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 }])
)
);
expect(new Set(revisions).size).toBe(50);
await expect(store.getSourceRevision()).resolves.toBe('50');
expect(redis.published).toHaveLength(50);
expect(redis.published).toHaveLength(100);
});
});