diff --git a/app/game-api/src/realtime/publicEvent.ts b/app/game-api/src/realtime/publicEvent.ts index 3417ccfb..20b086cb 100644 --- a/app/game-api/src/realtime/publicEvent.ts +++ b/app/game-api/src/realtime/publicEvent.ts @@ -69,6 +69,24 @@ export const toPublicRealtimeEvent = ( : null; } + if (event.type === 'tournamentChanged') { + return { + type: 'readModelInvalidated', + invalidation: { + context: false, + lobby: false, + map: false, + commands: false, + contacts: false, + boardAccess: false, + reservedTurns: false, + records: false, + frontStatus: false, + tournament: true, + }, + }; + } + if (event.type === 'turnCompleted' && !event.changes) { return { type: 'readModelInvalidated', diff --git a/app/game-api/src/tournament/keys.ts b/app/game-api/src/tournament/keys.ts index f0fefcef..e560483d 100644 --- a/app/game-api/src/tournament/keys.ts +++ b/app/game-api/src/tournament/keys.ts @@ -1,3 +1,5 @@ +import { buildGameEventChannel } from '@sammo-ts/common'; + export interface TournamentKeys { stateKey: string; participantsKey: string; @@ -5,6 +7,7 @@ export interface TournamentKeys { bettingKey: string; sourceRevisionKey: string; sourceRevisionChannel: string; + realtimeEventChannel: string; } export const buildTournamentKeys = (profileName: string): TournamentKeys => ({ @@ -14,4 +17,5 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({ 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-api/test/publicRealtimeEvent.test.ts b/app/game-api/test/publicRealtimeEvent.test.ts index db0a00a4..b409761d 100644 --- a/app/game-api/test/publicRealtimeEvent.test.ts +++ b/app/game-api/test/publicRealtimeEvent.test.ts @@ -52,6 +52,7 @@ describe('public realtime event privacy boundary', () => { reservedTurns: true, records: true, frontStatus: false, + tournament: false, }, }); const serialized = JSON.stringify(publicEvent); @@ -108,10 +109,33 @@ describe('public realtime event privacy boundary', () => { reservedTurns: true, records: true, frontStatus: true, + tournament: true, }, }); }); + it('redacts tournament state changes to one global boolean invalidation', () => { + const publicEvent = toPublicRealtimeEvent({ type: 'tournamentChanged' }, [viewer]); + + expect(publicEvent).toEqual({ + type: 'readModelInvalidated', + invalidation: { + context: false, + lobby: false, + map: false, + commands: false, + contacts: false, + boardAccess: false, + reservedTurns: false, + records: false, + frontStatus: false, + tournament: true, + }, + }); + expect(JSON.stringify(publicEvent)).not.toMatch(/revision|source|channel|time|generalId/u); + expect(shouldReloadRealtimeViewerIdentity({ type: 'tournamentChanged' }, viewer)).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/realtimeSse.test.ts b/app/game-api/test/realtimeSse.test.ts index ce214a02..accefabe 100644 --- a/app/game-api/test/realtimeSse.test.ts +++ b/app/game-api/test/realtimeSse.test.ts @@ -42,6 +42,12 @@ describe('parseRealtimeEvent', () => { expect(parseRealtimeEvent('not-json')).toBeNull(); expect(parseRealtimeEvent(JSON.stringify({}))).toBeNull(); }); + + it('accepts the minimal tournament state wake-up', () => { + expect(parseRealtimeEvent(JSON.stringify({ type: 'tournamentChanged' }))).toEqual({ + type: 'tournamentChanged', + }); + }); }); describe('buildGameEventChannel', () => { diff --git a/app/game-api/test/tournamentStoreRevision.integration.test.ts b/app/game-api/test/tournamentStoreRevision.integration.test.ts index cca74c1b..ffa119be 100644 --- a/app/game-api/test/tournamentStoreRevision.integration.test.ts +++ b/app/game-api/test/tournamentStoreRevision.integration.test.ts @@ -10,12 +10,27 @@ const integration = describe.skipIf(!process.env.REDIS_URL); integration('TournamentStore Redis source revision', () => { let connector: RedisConnector; + let subscriber: RedisConnector; const profile = `test:tournament-revision:${randomUUID()}`; const keys = buildTournamentKeys(profile); + const sourceMessages: string[] = []; + const realtimeMessages: string[] = []; + + const waitForLength = async (values: readonly string[], length: number): Promise => { + const deadline = Date.now() + 1_000; + while (values.length < length && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(values).toHaveLength(length); + }; beforeAll(async () => { connector = createRedisConnector(resolveRedisConfigFromEnv()); + subscriber = createRedisConnector(resolveRedisConfigFromEnv()); await connector.connect(); + await subscriber.connect(); + await subscriber.client.subscribe(keys.sourceRevisionChannel, (message) => sourceMessages.push(message)); + await subscriber.client.subscribe(keys.realtimeEventChannel, (message) => realtimeMessages.push(message)); }); afterAll(async () => { @@ -27,6 +42,11 @@ integration('TournamentStore Redis source revision', () => { keys.bettingKey, keys.sourceRevisionKey, ]); + if (subscriber) { + await subscriber.client.unsubscribe(keys.sourceRevisionChannel); + await subscriber.client.unsubscribe(keys.realtimeEventChannel); + await subscriber.disconnect(); + } await connector.disconnect(); }); @@ -44,4 +64,37 @@ integration('TournamentStore Redis source revision', () => { await expect(store.getSourceRevision()).resolves.toBe('20'); await expect(store.getMatches()).resolves.toHaveLength(1); }); + + it('publishes the main wake-up only when the atomic state write changes stage', async () => { + const store = new TournamentStore(connector.client, keys); + const sourceBefore = sourceMessages.length; + const realtimeBefore = realtimeMessages.length; + const baseState = { + stage: 1, + phase: 0, + type: 0 as const, + auto: true, + openYear: 185, + openMonth: 2, + termSeconds: 10, + nextAt: '2026-08-17T00:00:00.000Z', + }; + + await store.setState(baseState); + await waitForLength(sourceMessages, sourceBefore + 1); + await waitForLength(realtimeMessages, realtimeBefore + 1); + + await store.setState({ ...baseState, phase: 1 }); + await waitForLength(sourceMessages, sourceBefore + 2); + await new Promise((resolve) => setImmediate(resolve)); + expect(realtimeMessages).toHaveLength(realtimeBefore + 1); + + await store.setState({ ...baseState, stage: 2, phase: 0 }); + await waitForLength(sourceMessages, sourceBefore + 3); + await waitForLength(realtimeMessages, realtimeBefore + 2); + expect(realtimeMessages.slice(realtimeBefore)).toEqual([ + JSON.stringify({ type: 'tournamentChanged' }), + JSON.stringify({ type: 'tournamentChanged' }), + ]); + }); }); diff --git a/app/game-api/test/tournamentStoreRevision.test.ts b/app/game-api/test/tournamentStoreRevision.test.ts index cea7a1dd..9269c0c3 100644 --- a/app/game-api/test/tournamentStoreRevision.test.ts +++ b/app/game-api/test/tournamentStoreRevision.test.ts @@ -35,7 +35,8 @@ class AtomicMemoryRedis { } async publish(channel: string, message: string): Promise { - this.events.push(`publish:${JSON.parse(message).sourceRevision as string}`); + const payload = JSON.parse(message) as { sourceRevision?: string; type?: string }; + this.events.push(`publish:${payload.sourceRevision ?? payload.type ?? 'unknown'}`); this.published.push({ channel, message }); return 1; } @@ -71,6 +72,29 @@ describe('TournamentStore source revision', () => { expect(redis.published).toEqual([]); }); + it('wakes the main realtime channel only for shared state writes', async () => { + const redis = new AtomicMemoryRedis(); + const keys = buildTournamentKeys('che:default'); + const store = new TournamentStore(redis, keys); + + await store.setState({ + stage: 1, + phase: 0, + type: 0, + auto: true, + openYear: 185, + openMonth: 2, + termSeconds: 10, + nextAt: '2026-08-17T00:00:00.000Z', + }); + + expect(redis.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentChanged']); + expect(redis.published).toEqual([ + { channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) }, + { channel: keys.realtimeEventChannel, message: JSON.stringify({ type: 'tournamentChanged' }) }, + ]); + }); + it('serializes concurrent writes into monotonic per-profile revisions', async () => { const redis = new AtomicMemoryRedis(); const store = new TournamentStore(redis, buildTournamentKeys('pwe:default')); diff --git a/app/game-engine/src/turn/runtimeClockShift.ts b/app/game-engine/src/turn/runtimeClockShift.ts index 84f8b5ed..ae532fb7 100644 --- a/app/game-engine/src/turn/runtimeClockShift.ts +++ b/app/game-engine/src/turn/runtimeClockShift.ts @@ -1,7 +1,12 @@ import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra'; import { randomUUID } from 'node:crypto'; -import { writeTournamentProjection, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common'; +import { + buildGameEventChannel, + writeTournamentProjection, + type TurnDaemonCommand, + type TurnDaemonCommandResult, +} from '@sammo-ts/common'; import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js'; @@ -74,8 +79,10 @@ const shiftTournamentClock = async ( ): Promise => { const stateKey = `sammo:${profileName}:tournament:state`; const sourceKeys = { + stateKey, sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, + realtimeEventChannel: buildGameEventChannel(profileName), }; const lockKey = `${stateKey}:mutation-lock`; const token = randomUUID(); diff --git a/app/game-engine/src/turn/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts index a80f2d2c..a3172728 100644 --- a/app/game-engine/src/turn/tournamentAutoStart.ts +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -1,4 +1,10 @@ -import { asRecord, 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'; @@ -69,12 +75,13 @@ export const createTournamentAutoStartHandler = (options: { now?: () => Date; }): TurnCalendarHandler => { const keys = { - state: `sammo:${options.profileName}:tournament:state`, + stateKey: `sammo:${options.profileName}:tournament:state`, participants: `sammo:${options.profileName}:tournament:participants`, matches: `sammo:${options.profileName}:tournament:matches`, betting: `sammo:${options.profileName}:tournament:betting`, sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`, sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`, + realtimeEventChannel: buildGameEventChannel(options.profileName), }; return { onMonthChanged: async (context) => { @@ -85,7 +92,7 @@ export const createTournamentAutoStartHandler = (options: { if (!world || !redis || config.tournamentTrig !== true) { return; } - const previousState = safeJsonParse(await redis.get(keys.state)); + const previousState = safeJsonParse(await redis.get(keys.stateKey)); if (previousState && previousState.stage > 0) { return; } @@ -145,7 +152,7 @@ export const createTournamentAutoStartHandler = (options: { { key: keys.participants, value: [] }, { key: keys.matches, value: [] }, { key: keys.betting, value: [] }, - { key: keys.state, value: nextState }, + { key: keys.stateKey, value: nextState }, ]); const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0]; diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index c30f466e..eac9a843 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -97,6 +97,7 @@ const readModelInvalidation = ( reservedTurns: boolean; records: boolean; frontStatus: boolean; + tournament: boolean; }> ) => ({ context: false, @@ -108,6 +109,7 @@ const readModelInvalidation = ( reservedTurns: false, records: false, frontStatus: false, + tournament: false, ...overrides, }); @@ -2300,6 +2302,22 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); await expect(page.locator('.general-title')).toContainText('메뉴검증장수'); + await expect + .poll(() => + page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) + ) + .toBe(true); + await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음'); + await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u); + + const operationsBeforeTournament = state.operations.length; + state.stage = 1; + await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true })); + await expect + .poll(() => state.operations.slice(operationsBeforeTournament), { timeout: 3_000 }) + .toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']); + await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중'); + await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u); await page.evaluate(() => { const general = document.querySelector('[data-main-target="general"]'); @@ -2352,6 +2370,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl reservedTurns: false, records: false, frontStatus: false, + tournament: false, }, }); } @@ -2420,6 +2439,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl reservedTurns: false, records: false, frontStatus: true, + tournament: false, }, } ); @@ -2577,6 +2597,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl reservedTurns: false, records: false, frontStatus: false, + tournament: false, }, } ); @@ -2751,6 +2772,23 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn const followerPage = pages[followerIndex]; if (!leaderPage || !followerPage) throw new Error('realtime leader election failed'); + const operationsBeforeTournament = state.operations.length; + state.stage = 1; + await emitReadModelInvalidation(leaderPage, readModelInvalidation({ tournament: true })); + await expect + .poll(() => state.operations.slice(operationsBeforeTournament), { timeout: 3_000 }) + .toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']); + await Promise.all( + pages.map((currentPage) => + expect(currentPage.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중') + ) + ); + await Promise.all( + pages.map((currentPage) => + expect(currentPage.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u) + ) + ); + const callsBeforeSharedRefresh = state.generalMeCalls; state.generalName = '탭공유갱신장수'; await leaderPage.evaluate(() => { @@ -2767,6 +2805,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn reservedTurns: false, records: false, frontStatus: false, + tournament: false, }, } ); @@ -2794,6 +2833,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn reservedTurns: false, records: false, frontStatus: false, + tournament: false, }, } ); diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 35979a96..30ca2e36 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -46,6 +46,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type ReservedTurnView = Awaited>['turns'][number]; type RecentRecord = Awaited>['global'][number]; type FrontStatus = Awaited>; + type TournamentState = Awaited>; type ContextBundleDelta = Awaited>; type DashboardReadModelPatch = { contextSnapshot?: GeneralContext; @@ -72,6 +73,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { generalRecords?: RecentRecord[]; worldHistory?: RecentRecord[]; frontStatus?: FrontStatus | null; + tournamentStage?: number; }; type DashboardTabMessage = { kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' }; @@ -112,6 +114,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const generalRecords = ref([]); const worldHistory = ref([]); const frontStatus = ref(null); + const tournamentStage = ref(0); const surveyNotice = ref | null>(null); let lastGeneralRecordId = 0; let lastWorldHistoryId = 0; @@ -430,6 +433,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } else if (patch.frontStatus !== undefined) { updateFrontStatus(patch.frontStatus); } + if (patch.tournamentStage !== undefined) { + tournamentStage.value = patch.tournamentStage; + } if (patch.contextRevision !== undefined) { contextRevision = patch.contextRevision; contextSourceRevision = patch.contextSourceRevision ?? null; @@ -476,6 +482,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { patch.generalRecords = toRaw(generalRecords.value); patch.worldHistory = toRaw(worldHistory.value); patch.frontStatus = toRaw(frontStatus.value); + patch.tournamentStage = tournamentStage.value; return patch; }; @@ -585,7 +592,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { frontStatusError.value = resolveErrorMessage(err); return null; }); - const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus] = + const tournamentPromise = trpc.tournament.getState.query().catch(() => undefined); + const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([ layoutPromise, trpc.lobby.info.query(), @@ -595,6 +603,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { generalTurnsPromise, recordsPromise, frontStatusPromise, + tournamentPromise, ]); general.value = structurallyShare(general.value, context.general); @@ -617,6 +626,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (nextFrontStatus) { updateFrontStatus(nextFrontStatus); } + if (tournamentState !== undefined) { + tournamentStage.value = tournamentState?.stage ?? 0; + } if (initializedMailboxGeneralId !== id) { targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId; initializedMailboxGeneralId = id; @@ -705,14 +717,18 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return null; }) : Promise.resolve(undefined); + const tournamentPromise: Promise = plan.tournament + ? trpc.tournament.getState.query().catch(() => undefined) + : Promise.resolve(undefined); - const [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([ + const [lobby, map, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([ lobbyPromise, mapPromise, contactsPromise, reservedPromise, recordsPromise, frontPromise, + tournamentPromise, ]); const patch: DashboardReadModelPatch = { ...contextPatch }; @@ -733,6 +749,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { patch.worldHistory = nextWorldHistory; } if (nextFrontStatus) patch.frontStatus = nextFrontStatus; + if (tournamentState !== undefined) patch.tournamentStage = tournamentState?.stage ?? 0; applyDashboardPatch(patch); publishDashboardPatch(patch); } catch (err) { @@ -1241,6 +1258,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { generalRecords, worldHistory, frontStatus, + tournamentStage, surveyNotice, messageDraftText, targetMailbox, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 234067b2..a325dfe3 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -27,7 +27,6 @@ const session = useSessionStore(); const dashboard = useMainDashboardStore(); const isMobile = useMediaQuery('(max-width: 939.98px)'); -const tournamentStage = ref(0); const npcMode = ref(0); const { @@ -52,6 +51,7 @@ const { generalRecords, worldHistory, frontStatus, + tournamentStage, surveyNotice, messageDraftText, targetMailbox, @@ -107,12 +107,10 @@ const repeatGeneralTurns = (amount: number) => { }; const loadMainData = async () => { - const [, state, worldState] = await Promise.all([ + const [, worldState] = await Promise.all([ dashboard.loadMainData(), - trpc.tournament.getState.query().catch(() => null), trpc.world.getState.query().catch(() => null), ]); - tournamentStage.value = state?.stage ?? 0; npcMode.value = worldState?.config.npcMode ?? 0; }; diff --git a/app/game-frontend/test/dashboardReadModel.test.ts b/app/game-frontend/test/dashboardReadModel.test.ts index bc8f819c..651c15f3 100644 --- a/app/game-frontend/test/dashboardReadModel.test.ts +++ b/app/game-frontend/test/dashboardReadModel.test.ts @@ -25,6 +25,7 @@ void test('last-turn-time-only events do not schedule any dashboard query', () = reservedTurns: false, records: false, frontStatus: false, + tournament: false, }); }); @@ -46,6 +47,7 @@ void test('selects only the read models affected by the current identity', () => reservedTurns: true, records: true, frontStatus: false, + tournament: false, }); }); @@ -79,6 +81,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac reservedTurns: false, records: false, frontStatus: false, + tournament: false, }); const taxRate = resolveDashboardRefreshPlan( @@ -100,6 +103,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac reservedTurns: false, records: false, frontStatus: false, + tournament: false, }); const cityState = resolveDashboardRefreshPlan( @@ -161,6 +165,7 @@ void test('refreshes only front status for a global survey projection change', ( reservedTurns: false, records: false, frontStatus: true, + tournament: false, }); }); @@ -174,8 +179,8 @@ void test('targets a submitted survey projection to its own general', () => { assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false); }); -void test('keeps the access bundle projection-free for map, records, and front-status-only plans', () => { - for (const slice of ['map', 'records', 'frontStatus'] as const) { +void test('keeps the access bundle projection-free for independent read-model plans', () => { + for (const slice of ['map', 'records', 'frontStatus', 'tournament'] as const) { const plan = { ...createEmptyRealtimeReadModelInvalidation(), [slice]: true }; assert.deepEqual(resolveDashboardContextBundleInclude(plan), { context: false, diff --git a/docs/architecture/realtime-change-journal.md b/docs/architecture/realtime-change-journal.md index e57b4b52..9685b581 100644 --- a/docs/architecture/realtime-change-journal.md +++ b/docs/architecture/realtime-change-journal.md @@ -272,9 +272,14 @@ transaction을 잡은 채 `turnDaemon.requestCommand()`의 별도 ENGINE transac 토너먼트 state/participants/matches/bets는 현재 Redis가 원본이므로 PostgreSQL revision과 원자적으로 묶을 수 없다. `TournamentStore`가 state write와 Redis domain -revision 증가를 같은 Redis transaction 또는 Lua script로 수행한다. 저장 뒤 별도 -`publish()` 두 호출로 끝내지 않는다. 장기 durability 요구가 생기면 tournament state -자체를 PostgreSQL 소유로 옮기는 별도 migration으로 다룬다. +revision 증가를 같은 Lua script로 수행한다. source revision은 참가자·대진·베팅을 +포함한 모든 projection write에서 증가하지만 메인 화면 wake-up은 Lua 안에서 이전/다음 +`state.stage`를 비교해 실제 단계가 바뀔 때만 결정한다. commit 뒤 +`tournamentChanged`를 공용 game event channel에 best-effort publish하며, API는 이를 +식별자·revision 없는 public `tournament: true` invalidation으로 바꾼다. 참가 등록, +대진 결과, 같은 stage 안의 phase/timer/정산 flag write는 300 viewer를 깨우지 않는다. +장기 durability 요구가 생기면 tournament state 자체를 PostgreSQL 소유로 옮기는 별도 +migration으로 다룬다. ### 국가 베팅과 direct writer @@ -352,7 +357,9 @@ test가 완료되어 post-deploy one-off로 안전하게 활성화할 수 있다 meta/head 누락, DB/Redis 오류에는 shared cache를 완전히 우회해 full compute한다. - 개인 `spyList`, `shownByGeneralList`, `myCity`, `myNation`은 request에서 계속 조합한다. - tournament는 API store, 월 자동 개막과 runtime clock shift 모두 payload와 profile source - revision을 같은 Lua invocation으로 갱신하고 commit 뒤에만 best-effort publish한다. + revision을 같은 Lua invocation으로 갱신한다. stage transition만 main realtime channel에 + best-effort publish하고, 같은 stage의 phase/participant/match/bet write는 source revision만 + 진행한다. - records는 기존 `lastGeneralRecordId`/`lastWorldHistoryId` 증분 조회를 유지하되 해당 domain이 선택되지 않으면 query하지 않는다. @@ -374,6 +381,13 @@ manual refresh, visible 복귀와 realtime 재활성화는 cadence를 기다리 snapshot을 한 번 읽는다. 숫자는 실제 혼합 부하 결과에 따라 조정하며, 부하만 낮추기 위해 5초를 초과하지 않는다. +메인 dashboard의 `tournamentStage`는 store가 소유한다. visible leader 탭이 +`tournament: true`를 받으면 기존 access-only gate 뒤 `tournament.getState`만 읽어 stage +patch를 만들고, 같은 profile/account의 follower 탭은 BroadcastChannel patch를 적용한다. +따라서 상단 `토너먼트:` 문구, 국가 메뉴와 모바일 메뉴의 stage 강조가 한 값으로 함께 +갱신된다. Redis/API 오류에는 현재 stage를 거짓 0으로 덮지 않고 다음 event, 사용자 +`갱 신`, visible 복귀 snapshot으로 복구한다. + ## 구현 단계와 commit 경계 ### Phase A: 저위험 read 절감 @@ -414,13 +428,13 @@ snapshot을 한 번 읽는다. 숫자는 실제 혼합 부하 결과에 따라 5. 결과에 따라 pool, cadence, cache와 worker concurrency를 조정하고 전체 benchmark를 다시 실행한다. -### 2026-08-16 구현 상태 +### 2026-08-17 구현 상태 | Phase | 상태 | 현재 근거 | | --- | --- | --- | | A | 완료 | all-false access gate, frontend 강제 context 제거, Chromium realtime trace | | B | 완료 | typed journal, PostgreSQL revision/outbox/meta, engine/API 원자 writer, retry dispatcher, 86 mutation inventory | -| C | 완료 | dashboard revision-first, auth/global dependency, durable map cache, 모든 tournament Redis writer 원자화, coverage v1 activation/rollback integration | +| C | 완료 | dashboard revision-first, auth/global dependency, durable map cache, 모든 tournament Redis writer 원자화, stage-only main realtime invalidation, coverage v1 activation/rollback integration | | D | 부분 완료 | E1 1,200장수 1개월 deterministic profile과 300 SSE/HTTP 짧은 calibration 완료. E2 actual daemon DB flush 및 30분 M1/R1은 미실행 | `부분 완료`는 capacity 합격을 뜻하지 않는다. 이 작업의 수용 추산은 아래 실제 짧은 diff --git a/packages/common/src/realtime/types.ts b/packages/common/src/realtime/types.ts index ade4f4f2..dd420019 100644 --- a/packages/common/src/realtime/types.ts +++ b/packages/common/src/realtime/types.ts @@ -51,6 +51,8 @@ export interface RealtimeReadModelInvalidation { reservedTurns: boolean; records: boolean; frontStatus: boolean; + /** Shared tournament stage shown by the main dashboard changed. */ + tournament: boolean; } export interface RealtimeViewerIdentity { @@ -69,6 +71,7 @@ export const createEmptyRealtimeReadModelInvalidation = (): RealtimeReadModelInv reservedTurns: false, records: false, frontStatus: false, + tournament: false, }); export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({ @@ -81,6 +84,7 @@ export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInva reservedTurns: true, records: true, frontStatus: true, + tournament: true, }); export const mergeRealtimeReadModelInvalidations = ( @@ -96,6 +100,7 @@ export const mergeRealtimeReadModelInvalidations = ( reservedTurns: left.reservedTurns || right.reservedTurns, records: left.records || right.records, frontStatus: left.frontStatus || right.frontStatus, + tournament: left.tournament || right.tournament, }); export const hasRealtimeReadModelInvalidation = (invalidation: RealtimeReadModelInvalidation): boolean => @@ -141,6 +146,7 @@ export const resolveRealtimeReadModelInvalidation = ( frontStatusGeneralChanged || ownFrontStatusNationChanged || ownFrontStatusActorChanged, + tournament: false, }; }; @@ -212,6 +218,11 @@ export interface MessagesChangedEvent { mailboxes: number[]; } +/** Redis-owned tournament stage changed after its atomic source revision commit. */ +export interface TournamentChangedEvent { + type: 'tournamentChanged'; +} + export interface ReadModelInvalidatedEvent { type: 'readModelInvalidated'; invalidation: RealtimeReadModelInvalidation; @@ -224,4 +235,9 @@ export interface MessagesInvalidatedEvent { /** Events safe to expose to an authenticated browser over SSE. */ export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent; -export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent; +export type RealtimeEvent = + | TurnCompletedEvent + | ReadModelChangedEvent + | MessageCreatedEvent + | MessagesChangedEvent + | TournamentChangedEvent; diff --git a/packages/common/src/tournament/autoStart.ts b/packages/common/src/tournament/autoStart.ts index 1225da8d..0190442e 100644 --- a/packages/common/src/tournament/autoStart.ts +++ b/packages/common/src/tournament/autoStart.ts @@ -1,4 +1,5 @@ import { asRecord } from '../util/parse.js'; +import { buildGameEventChannel } from '../realtime/keys.js'; import { writeTournamentProjection } from './sourceRevision.js'; interface TournamentState { @@ -44,6 +45,7 @@ const buildTournamentKeys = (profileName: string) => ({ bettingKey: `sammo:${profileName}:tournament:betting`, sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, + realtimeEventChannel: buildGameEventChannel(profileName), }); const resolveTermSeconds = (tickSeconds: number): number => { diff --git a/packages/common/src/tournament/sourceRevision.ts b/packages/common/src/tournament/sourceRevision.ts index dbfd74a8..95ad1d5e 100644 --- a/packages/common/src/tournament/sourceRevision.ts +++ b/packages/common/src/tournament/sourceRevision.ts @@ -1,6 +1,8 @@ export interface TournamentSourceKeys { + stateKey: string; sourceRevisionKey: string; sourceRevisionChannel: string; + realtimeEventChannel: string; } export interface TournamentProjectionRedis { @@ -24,11 +26,25 @@ if current then return redis.error_reply('tournament source revision exhausted') end end +local stage_changed = false for index = 1, #KEYS - 1 do + local next_ok, next_value = pcall(cjson.decode, ARGV[index]) + if next_ok and type(next_value) == 'table' and next_value['stage'] ~= nil then + local previous = redis.call('GET', KEYS[index]) + local previous_stage = nil + if previous then + local previous_ok, previous_value = pcall(cjson.decode, previous) + if previous_ok and type(previous_value) == 'table' then + previous_stage = previous_value['stage'] + end + end + local next_stage = next_value['stage'] + stage_changed = (not previous) or previous_stage ~= next_stage + end redis.call('SET', KEYS[index], ARGV[index]) end local revision = redis.call('INCR', revision_key) -return tostring(revision) +return tostring(revision) .. ':' .. (stage_changed and '1' or '0') `; export const parseTournamentSourceRevision = (value: unknown): string | null => { @@ -54,11 +70,16 @@ export const writeTournamentProjection = async ( throw new Error('Tournament projection write keys must be unique.'); } + const writesState = writes.some(({ key }) => key === keys.stateKey); const result = await redis.eval(WRITE_TOURNAMENT_PROJECTION_SCRIPT, { keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey], arguments: writes.map(({ value }) => JSON.stringify(value)), }); - const sourceRevision = parseTournamentSourceRevision(result); + const scriptResult = typeof result === 'string' ? /^(\d+):([01])$/u.exec(result) : null; + const sourceRevision = parseTournamentSourceRevision(scriptResult?.[1] ?? result); + // Plain revision results remain accepted for rolling deployments and small + // Redis fakes; only the current Lua contract can suppress same-stage writes. + const stageChanged = writesState && (scriptResult ? scriptResult[2] === '1' : true); if (sourceRevision === null) { throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.'); } @@ -69,6 +90,13 @@ export const writeTournamentProjection = async ( } catch { // Payload and revision are committed; publication remains best effort. } + if (stageChanged) { + try { + await redis.publish(keys.realtimeEventChannel, JSON.stringify({ type: 'tournamentChanged' })); + } catch { + // The source-revision wake-up and main SSE wake-up are independent best-effort fan-out. + } + } } return sourceRevision; }; diff --git a/packages/common/test/tournamentSourceRevision.test.ts b/packages/common/test/tournamentSourceRevision.test.ts index 9ae61ee3..819e30ec 100644 --- a/packages/common/test/tournamentSourceRevision.test.ts +++ b/packages/common/test/tournamentSourceRevision.test.ts @@ -5,14 +5,14 @@ import { writeTournamentProjection } from '../src/tournament/sourceRevision.js'; describe('tournament source revision', () => { it('passes every payload and one profile revision key to a single atomic script', async () => { const calls: Array<{ keys: string[]; arguments: string[] }> = []; - const published: string[] = []; + const published: Array<{ channel: string; message: string }> = []; const redis = { eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => { calls.push(options); - return '7'; + return '7:1'; }, - publish: async (_channel: string, message: string) => { - published.push(message); + publish: async (channel: string, message: string) => { + published.push({ channel, message }); return 1; }, }; @@ -20,7 +20,12 @@ describe('tournament source revision', () => { await expect( writeTournamentProjection( redis, - { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, + { + stateKey: 'state', + sourceRevisionKey: 'revision', + sourceRevisionChannel: 'changed', + realtimeEventChannel: 'realtime', + }, [ { key: 'state', value: { stage: 1 } }, { key: 'matches', value: [] }, @@ -34,18 +39,35 @@ describe('tournament source revision', () => { arguments: [JSON.stringify({ stage: 1 }), '[]'], }, ]); - expect(published).toEqual([JSON.stringify({ sourceRevision: '7' })]); + expect(published).toEqual([ + { channel: 'changed', message: JSON.stringify({ sourceRevision: '7' }) }, + { channel: 'realtime', message: JSON.stringify({ type: 'tournamentChanged' }) }, + ]); }); it('rejects empty or duplicate writes before evaluating Redis', async () => { const redis = { eval: async () => '1' }; await expect( - writeTournamentProjection(redis, { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, []) + writeTournamentProjection( + redis, + { + stateKey: 'state', + sourceRevisionKey: 'revision', + sourceRevisionChannel: 'changed', + realtimeEventChannel: 'realtime', + }, + [] + ) ).rejects.toThrow('at least one'); await expect( writeTournamentProjection( redis, - { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, + { + stateKey: 'state', + sourceRevisionKey: 'revision', + sourceRevisionChannel: 'changed', + realtimeEventChannel: 'realtime', + }, [ { key: 'state', value: 1 }, { key: 'state', value: 2 }, @@ -53,4 +75,79 @@ describe('tournament source revision', () => { ) ).rejects.toThrow('unique'); }); + + it('does not wake the main dashboard for participant-only writes', async () => { + const published: string[] = []; + const redis = { + eval: async () => '8', + publish: async (channel: string) => { + published.push(channel); + return 1; + }, + }; + + await writeTournamentProjection( + redis, + { + stateKey: 'state', + sourceRevisionKey: 'revision', + sourceRevisionChannel: 'changed', + realtimeEventChannel: 'realtime', + }, + [{ key: 'participants', value: [{ id: 7 }] }] + ); + + expect(published).toEqual(['changed']); + }); + + it('does not wake the main dashboard when tournament state keeps the same stage', async () => { + const published: string[] = []; + const redis = { + eval: async () => '10:0', + publish: async (channel: string) => { + published.push(channel); + return 1; + }, + }; + + await expect( + writeTournamentProjection( + redis, + { + stateKey: 'state', + sourceRevisionKey: 'revision', + sourceRevisionChannel: 'changed', + realtimeEventChannel: 'realtime', + }, + [{ key: 'state', value: { stage: 1, phase: 2 } }] + ) + ).resolves.toBe('10'); + expect(published).toEqual(['changed']); + }); + + it('keeps both post-commit wake-up channels independently best effort', async () => { + const published: string[] = []; + const redis = { + eval: async () => '9', + publish: async (channel: string) => { + published.push(channel); + if (channel === 'changed') throw new Error('source subscriber unavailable'); + return 1; + }, + }; + + await expect( + writeTournamentProjection( + redis, + { + stateKey: 'state', + sourceRevisionKey: 'revision', + sourceRevisionChannel: 'changed', + realtimeEventChannel: 'realtime', + }, + [{ key: 'state', value: { stage: 1 } }] + ) + ).resolves.toBe('9'); + expect(published).toEqual(['changed', 'realtime']); + }); });