From 985f627499ef08d76e607724e84a699cbd27d7dd Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 22 Aug 2026 06:00:22 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EA=B5=B0=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=EC=9D=84=20=EB=B6=84=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토너먼트 전용 SSE scope와 slice invalidation을 추가하고 같은 계정의 visible 탭이 조회를 공유하게 한다. 서버 증명 snapshot 조회는 접속 점수를 더하지 않되 기존 hard limit은 유지한다. --- app/game-api/src/realtime/publicEvent.ts | 20 ++ app/game-api/src/server.ts | 10 +- app/game-api/src/services/generalAccess.ts | 6 + app/game-api/src/trpc.ts | 5 +- .../test/generalAccessTracking.test.ts | 28 +- app/game-api/test/publicRealtimeEvent.test.ts | 39 +++ ...ournamentStoreRevision.integration.test.ts | 45 ++- .../test/tournamentStoreRevision.test.ts | 39 ++- app/game-engine/src/turn/runtimeClockShift.ts | 3 + .../src/turn/runtimeGameSettings.ts | 3 + .../src/turn/tournamentAutoStart.ts | 20 +- .../e2e/tournamentBracket.spec.ts | 180 ++++++++++- .../src/stores/tournamentPages.ts | 301 ++++++++++++++++++ app/game-frontend/src/views/BettingView.vue | 35 +- .../src/views/TournamentView.vue | 29 +- docs/architecture/realtime-change-journal.md | 22 +- packages/common/src/realtime/types.ts | 29 +- .../common/src/tournament/sourceRevision.ts | 41 ++- .../test/tournamentSourceRevision.test.ts | 153 ++++----- 19 files changed, 845 insertions(+), 163 deletions(-) create mode 100644 app/game-frontend/src/stores/tournamentPages.ts diff --git a/app/game-api/src/realtime/publicEvent.ts b/app/game-api/src/realtime/publicEvent.ts index 00b06ba2..f358c04d 100644 --- a/app/game-api/src/realtime/publicEvent.ts +++ b/app/game-api/src/realtime/publicEvent.ts @@ -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', diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index c0e51c34..1fab7480 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -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; diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index 60a94b1f..97681bf1 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -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(['nation-list', 'npc-control']); export const generalAccessLimitEndpoints = new Set([ diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 7867dfd6..34dd502d 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -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) { diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index 57310638..15d017f0 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -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 diff --git a/app/game-api/test/publicRealtimeEvent.test.ts b/app/game-api/test/publicRealtimeEvent.test.ts index 3ebfd96b..a382b985 100644 --- a/app/game-api/test/publicRealtimeEvent.test.ts +++ b/app/game-api/test/publicRealtimeEvent.test.ts @@ -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', diff --git a/app/game-api/test/tournamentStoreRevision.integration.test.ts b/app/game-api/test/tournamentStoreRevision.integration.test.ts index ffa119be..5acf6dd6 100644 --- a/app/game-api/test/tournamentStoreRevision.integration.test.ts +++ b/app/game-api/test/tournamentStoreRevision.integration.test.ts @@ -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((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 }), + }), ]); }); }); diff --git a/app/game-api/test/tournamentStoreRevision.test.ts b/app/game-api/test/tournamentStoreRevision.test.ts index 9269c0c3..eaa9bbb4 100644 --- a/app/game-api/test/tournamentStoreRevision.test.ts +++ b/app/game-api/test/tournamentStoreRevision.test.ts @@ -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); }); }); diff --git a/app/game-engine/src/turn/runtimeClockShift.ts b/app/game-engine/src/turn/runtimeClockShift.ts index ae532fb7..5b730e81 100644 --- a/app/game-engine/src/turn/runtimeClockShift.ts +++ b/app/game-engine/src/turn/runtimeClockShift.ts @@ -80,6 +80,9 @@ const shiftTournamentClock = async ( const stateKey = `sammo:${profileName}:tournament:state`; const sourceKeys = { stateKey, + participantsKey: `sammo:${profileName}:tournament:participants`, + matchesKey: `sammo:${profileName}:tournament:matches`, + bettingKey: `sammo:${profileName}:tournament:betting`, sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, realtimeEventChannel: buildGameEventChannel(profileName), diff --git a/app/game-engine/src/turn/runtimeGameSettings.ts b/app/game-engine/src/turn/runtimeGameSettings.ts index 08bee2d1..43a186e9 100644 --- a/app/game-engine/src/turn/runtimeGameSettings.ts +++ b/app/game-engine/src/turn/runtimeGameSettings.ts @@ -142,6 +142,9 @@ const reprojectTournamentClock = async ( const stateKey = `sammo:${profileName}:tournament:state`; const sourceKeys = { stateKey, + participantsKey: `sammo:${profileName}:tournament:participants`, + matchesKey: `sammo:${profileName}:tournament:matches`, + bettingKey: `sammo:${profileName}:tournament:betting`, sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, realtimeEventChannel: buildGameEventChannel(profileName), diff --git a/app/game-engine/src/turn/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts index a3172728..8da5370f 100644 --- a/app/game-engine/src/turn/tournamentAutoStart.ts +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -1,10 +1,4 @@ -import { - asRecord, - buildGameEventChannel, - LiteHashDRBG, - RandUtil, - writeTournamentProjection, -} from '@sammo-ts/common'; +import { asRecord, buildGameEventChannel, LiteHashDRBG, RandUtil, writeTournamentProjection } from '@sammo-ts/common'; import type { RedisConnector } from '@sammo-ts/infra'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; @@ -76,9 +70,9 @@ export const createTournamentAutoStartHandler = (options: { }): TurnCalendarHandler => { const keys = { stateKey: `sammo:${options.profileName}:tournament:state`, - participants: `sammo:${options.profileName}:tournament:participants`, - matches: `sammo:${options.profileName}:tournament:matches`, - betting: `sammo:${options.profileName}:tournament:betting`, + participantsKey: `sammo:${options.profileName}:tournament:participants`, + matchesKey: `sammo:${options.profileName}:tournament:matches`, + bettingKey: `sammo:${options.profileName}:tournament:betting`, sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`, sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`, realtimeEventChannel: buildGameEventChannel(options.profileName), @@ -149,9 +143,9 @@ export const createTournamentAutoStartHandler = (options: { lastErrorAt: undefined, }; await writeTournamentProjection(redis, keys, [ - { key: keys.participants, value: [] }, - { key: keys.matches, value: [] }, - { key: keys.betting, value: [] }, + { key: keys.participantsKey, value: [] }, + { key: keys.matchesKey, value: [] }, + { key: keys.bettingKey, value: [] }, { key: keys.stateKey, value: nextState }, ]); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 9221dd9f..19fbaa4c 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -195,6 +195,8 @@ const installFixture = async ( tournamentStage?: number; joinedGroupId?: number; emptyFinalGroups?: boolean; + realtimeState?: { tournamentStage: number; totalAmount: number }; + onOperation?: (operation: string, headers: Record) => 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) => { + 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'); diff --git a/app/game-frontend/src/stores/tournamentPages.ts b/app/game-frontend/src/stores/tournamentPages.ts new file mode 100644 index 00000000..4ec3afae --- /dev/null +++ b/app/game-frontend/src/stores/tournamentPages.ts @@ -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>; +type BettingSummary = Awaited>; +type Rankings = Awaited>; + +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(null); + const betting = ref(null); + const rankings = ref([]); + const loading = ref(false); + const refreshing = ref(false); + const error = ref(null); + const realtimeStatus = ref<'idle' | 'connected'>('idle'); + + let activeConsumers = 0; + let realtimeSource: EventSource | null = null; + let realtimeToken: string | null = null; + let realtimeCoordinator: BroadcastTabCoordinator | 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 => { + 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 => + refreshProjection({ snapshot: true, betting: true, rankings: false }, null, true); + const loadBettingPage = (): Promise => refreshProjection(FULL_INVALIDATION(), null, true); + + const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_')); + const ensureAccessToken = async (): Promise => { + 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 => { + 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(`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, + }; +}); diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue index 6d1a47fd..8a5bcc42 100644 --- a/app/game-frontend/src/views/BettingView.vue +++ b/app/game-frontend/src/views/BettingView.vue @@ -1,20 +1,17 @@