diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 2d3393f2..141a54c1 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -43,6 +43,7 @@ export const zWorldStateConfig = z.object({ export type WorldStateConfig = z.infer; export const zWorldStateMeta = z.object({ + serverId: z.string().optional(), starttime: z.string().optional(), opentime: z.string().optional(), preopenAt: z.string().optional(), diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 6dfc5bac..c39cb6e0 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -885,11 +885,16 @@ export const generalRouter = router({ }) : null; const worldMeta = asRecord(worldState.meta); + const serverId = + typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() + ? worldMeta.serverId.trim() + : ctx.profile?.name || 'game'; const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime; const parsedLastExecuted = typeof rawLastExecuted === 'string' || rawLastExecuted instanceof Date ? new Date(rawLastExecuted) : null; return { + serverId, onlineUserCount: onlineGenerals.length, onlineNations, onlineGenerals: myOnlineGenerals, diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index 5cfc213c..2ba7ab5e 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -52,6 +52,7 @@ export const lobbyRouter = router({ } return { + serverId: worldState.meta.serverId?.trim() || ctx.profile?.name || 'game', year: worldState.currentYear, month: worldState.currentMonth, userCnt, diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index a8aa4396..eb411eb6 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -74,6 +74,7 @@ describe('lobby season state', () => { .createCaller( buildContext( { + serverId: 'che_260819_season', preopenAt: '2026-08-19 22:00:00', opentime: '2026-08-19 23:00:00', scenarioMeta: { title: '【가상모드27-b】 아시아 명장전(비급)' }, @@ -101,6 +102,7 @@ describe('lobby season state', () => { .lobby.info(); expect(result).toMatchObject({ + serverId: 'che_260819_season', preopenAt: '2026-08-19 22:00:00', opentime: '2026-08-19 23:00:00', scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)', diff --git a/app/game-api/test/mainFrontStatusRouter.test.ts b/app/game-api/test/mainFrontStatusRouter.test.ts index ee10cff9..b3b2afff 100644 --- a/app/game-api/test/mainFrontStatusRouter.test.ts +++ b/app/game-api/test/mainFrontStatusRouter.test.ts @@ -40,6 +40,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted findFirst: vi.fn(async () => ({ tickSeconds: 3600, meta: { + serverId: 'che_260819_front', lastTurnTime: '2026-07-26T10:00:00.000Z', }, })), @@ -87,6 +88,7 @@ describe('general.getFrontStatus', () => { const result = await caller.general.getFrontStatus(); expect(result).toEqual({ + serverId: 'che_260819_front', onlineUserCount: 3, onlineNations: '【촉】, 【위】', onlineGenerals: '유비, 관우', diff --git a/app/game-api/test/readModelOutboxWorker.test.ts b/app/game-api/test/readModelOutboxWorker.test.ts index a38b22d7..e41b2412 100644 --- a/app/game-api/test/readModelOutboxWorker.test.ts +++ b/app/game-api/test/readModelOutboxWorker.test.ts @@ -6,10 +6,23 @@ import type { ReadModelOutboxDatabase } from '@sammo-ts/infra'; import { ReadModelOutboxWorker } from '../src/realtime/outboxWorker.js'; const payload = ( - domain: 'front.general' | 'access.general' | 'dashboard.global' | 'messages.mailbox' | 'tournament' | 'betting' + domain: + | 'front.global' + | 'front.general' + | 'access.general' + | 'dashboard.global' + | 'messages.mailbox' + | 'tournament' + | 'betting' ) => ({ version: 1, - changes: [[domain, domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0, '1']], + changes: [ + [ + domain, + domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0, + '1', + ], + ], }); const createFixture = (rows: readonly object[]) => { @@ -26,6 +39,23 @@ const createFixture = (rows: readonly object[]) => { }; describe('ReadModelOutboxWorker', () => { + it('publishes a survey-style global front-status invalidation without a turn daemon', async () => { + const fixture = createFixture([{ id: 10n, payload: payload('front.global'), attempts: 1 }]); + const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', { + owner: 'worker-test', + intervalMs: 60_000, + }); + + worker.start(); + await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); + await worker.stop(); + + expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({ + type: 'readModelChanged', + changes: { frontStatusChanged: true }, + }); + }); + it('publishes a legacy internal readModelChanged event and acknowledges the durable row', async () => { const fixture = createFixture([{ id: 11n, payload: payload('front.general'), attempts: 1 }]); const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', { diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index d2c02277..75c2b253 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -241,6 +241,7 @@ describe('vote router actor and permission boundaries', () => { ).resolves.toEqual({ ok: true }); expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.global', entityId: 0 }]); + expect(fixture.requestCommand).not.toHaveBeenCalled(); expect(fixture.redisIncr).not.toHaveBeenCalled(); expect(fixture.redisPublish).not.toHaveBeenCalled(); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 8d3be817..4387beb4 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -50,6 +50,7 @@ type NavigationFixture = { refCommandCategories?: boolean; currentYear?: number; currentMonth?: number; + serverId?: string; scenarioTitle?: string; nationColor?: string; lastExecuted?: string | null; @@ -401,6 +402,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { if (operation === 'lobby.info') { return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, + serverId: state.serverId ?? 'che_fixture_season', year: state.currentYear ?? 185, month: state.currentMonth ?? 1, turnTerm: 10, @@ -547,6 +549,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { } if (operation === 'general.getFrontStatus') { return response({ + serverId: state.serverId ?? 'che_fixture_season', onlineUserCount: 1, onlineNations: '위(1)', onlineGenerals: '메뉴검증장수', @@ -836,6 +839,32 @@ const persistArtifact = async (page: Page, name: string) => { ]); }; +test('scopes the new-survey notice cursor to the reset-specific server ID', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + serverId: 'che_260819_new_season', + latestVote: { id: 1, title: '가오픈 설문', hasVoted: false }, + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + await page.addInitScript(() => { + localStorage.setItem('state.che.lastVote', '99'); + }); + + await waitForMain(page); + + await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.'); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('state.che_260819_new_season.lastVote'))) + .toBe('1'); + expect(await page.evaluate(() => localStorage.getItem('state.che.lastVote'))).toBe('99'); +}); + test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index ad242ffc..2b313082 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -293,7 +293,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { surveyNotice.value = null; return; } - const serverId = session.profile?.split(':', 1)[0] ?? 'game'; + // Ref scopes this cursor with the reset-specific UniqueConst::$serverID. + // A profile name is stable across seasons, while vote IDs restart after reset. + const serverId = nextStatus.serverId; const storageKey = `state.${serverId}.lastVote`; const lastSeenVoteId = Number.parseInt(window.localStorage.getItem(storageKey) ?? '0', 10); if (latestVote.id <= (Number.isFinite(lastSeenVoteId) ? lastSeenVoteId : 0)) { @@ -1079,7 +1081,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const profile = session.profile ?? 'game'; const account = session.user?.id ?? `general-${generalId.value}`; - const scope = `${encodeURIComponent(profile)}:${encodeURIComponent(account)}`; + // Do not let a tab that survived a reset exchange stale read-model patches + // with tabs belonging to the next season of the same profile. + const serverId = frontStatus.value?.serverId ?? profile; + const scope = `${encodeURIComponent(serverId)}:${encodeURIComponent(profile)}:${encodeURIComponent(account)}`; if (realtimeCoordinator && realtimeCoordinatorScope === scope) return; closeRealtimeCoordinator(); @@ -1243,6 +1248,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { session.user?.id, generalId.value, accessLimited.value, + frontStatus.value?.serverId, ], ([active, enabled, ready, hasGeneral, , , , , limited]) => { realtimeStatus.value = !enabled || limited ? 'paused' : realtimeStatus.value;