diff --git a/README.md b/README.md index f63086ed..991a14b8 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ pnpm docs:preview - [아키텍처 개요](docs/architecture/overview.md) - [런타임 아키텍처](docs/architecture/runtime.md) - [릴리스 운영 매뉴얼](docs/release-operations.md) +- [Gateway와 게임 공통 메뉴 설정](docs/runtime-navigation.md) - [차등 검증](docs/architecture/turn-state-differential-testing.md) - [Caddy prefix 계약](docs/e2e-caddy-routing.md) - [레거시 DB 이관](docs/legacy-db-migration.md) diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index 97e800c2..5cfc213c 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; -import { asRecord } from '@sammo-ts/common'; +import { asNumber, asRecord } from '@sammo-ts/common'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js'; @@ -26,7 +26,14 @@ export const lobbyRouter = router({ const userCnt = await ctx.db.general.count({ where: { npcState: { lt: 2 } } }); const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } }); const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } }); + const rawConfig = asRecord(rawWorldState.config); const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title; + const autorunUser = worldState.meta.autorun_user; + const autorunOptions = autorunUser?.options + ? Object.entries(autorunUser.options) + .filter(([, enabled]) => enabled) + .map(([option]) => option) + : []; const gameTime = await loadCurrentGameTime(ctx.db); let myGeneral = null; @@ -55,10 +62,20 @@ export const lobbyRouter = router({ fictionMode: worldState.config.fictionMode ?? '사실', starttime: worldState.meta.starttime ?? '', opentime: worldState.meta.opentime ?? '', + preopenAt: worldState.meta.preopenAt ?? '', turntime: worldState.meta.turntime ?? '', serverTime: gameTime.now.toISOString(), clockMode: gameTime.mode ?? 'realtime', otherTextInfo: worldState.meta.otherTextInfo ?? '', + npcMode: worldState.config.npcMode ?? 0, + defaultStatTotal: asNumber(asRecord(rawConfig.stat).total, 165), + autorunUser: + autorunUser?.limit_minutes && autorunUser.limit_minutes > 0 && autorunOptions.length > 0 + ? { + limitMinutes: autorunUser.limit_minutes, + options: autorunOptions, + } + : null, isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0, selectionPoolEnabled: isSelectionPoolWorld(rawWorldState), npcPossessionEnabled: worldState.config.npcMode === 1, diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index ecd9de5a..a8aa4396 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -10,7 +10,8 @@ const buildContext = ( tick?: bigint; mode?: string; wallAnchor?: Date; - } = {} + } = {}, + config: Record = {} ): GameApiContext => ({ auth: null, @@ -22,7 +23,7 @@ const buildContext = ( currentYear: 200, currentMonth: 1, tickSeconds: 3_600, - config: {}, + config, meta, clockBaseTime: clock.baseTime ?? null, clockTick: clock.tick ?? null, @@ -67,4 +68,48 @@ describe('lobby season state', () => { expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z'); expect(result.clockMode).toBe('manual'); }); + + it('projects the Ref-compatible opening announcement settings without exposing disabled autorun options', async () => { + const result = await appRouter + .createCaller( + buildContext( + { + preopenAt: '2026-08-19 22:00:00', + opentime: '2026-08-19 23:00:00', + scenarioMeta: { title: '【가상모드27-b】 아시아 명장전(비급)' }, + autorun_user: { + limit_minutes: 1_440, + options: { + develop: true, + warp: true, + recruit: false, + recruit_high: true, + train: true, + battle: true, + chief: true, + }, + }, + }, + {}, + { + fictionMode: '가상', + npcMode: 0, + stat: { total: 310, min: 10, max: 110 }, + } + ) + ) + .lobby.info(); + + expect(result).toMatchObject({ + preopenAt: '2026-08-19 22:00:00', + opentime: '2026-08-19 23:00:00', + scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)', + npcMode: 0, + defaultStatTotal: 310, + autorunUser: { + limitMinutes: 1_440, + options: ['develop', 'warp', 'recruit_high', 'train', 'battle', 'chief'], + }, + }); + }); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index afb2a0ad..8d3be817 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1,8 +1,11 @@ -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); +const runtimeNavigation = JSON.parse( + await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8') +) as unknown; const errorResponse = (path: string, message: string) => ({ error: { message, @@ -359,11 +362,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => { await page.route('**/events**', async (route) => { await route.abort(); }); + await page.route('**/gateway/api/navigation', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(runtimeNavigation) }); + }); await page.route('**/gateway/api/trpc/**', async (route) => { const operations = operationNames(route); const results = operations.map((operation) => operation === 'me' ? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' }) + : operation === 'navigation.get' + ? response(runtimeNavigation) : response({ ok: true }) ); await route.fulfill({ @@ -924,7 +932,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro ); await expect(global.locator('[data-navigation-id="nation-list"]')).toHaveAttribute('target', '_blank'); await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community'); - await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('aria-disabled', 'true'); + await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('target', '_blank'); await expect(global.locator('[data-navigation-id="survey"]')).toHaveClass(/highlight/); await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/); @@ -971,6 +979,14 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click(); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); + await gameInfoButton.click(); + await global.locator('[data-navigation-id="version"]').click(); + const versionDialog = page.getByRole('dialog', { name: '게임 정보' }); + await expect(versionDialog).toBeVisible(); + await expect(versionDialog).toContainText('메인 화면 검증 시나리오'); + await versionDialog.getByRole('button', { name: '닫기' }).click(); + await expect(versionDialog).toBeHidden(); + const bottomGlobal = page.locator('[data-menu-position="bottom"]'); const bottomGameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]'); await bottomGameInfoButton.click(); @@ -995,7 +1011,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); -test('split buttons keep square inner corners and a single divider in every interaction state', async ({ +test('nation split buttons keep square inner corners and a single divider in every interaction state', async ({ page, }, testInfo) => { const state: NavigationFixture = { @@ -1062,14 +1078,8 @@ test('split buttons keep square inner corners and a single divider in every inte for (const width of [1200, 500]) { await page.setViewportSize({ width, height: 900 }); await waitForMain(page); - const globalSplit = page.locator('.main-global-menu:visible .main-menu-split').first(); const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first(); const pairs: Array<[string, Locator, Locator]> = [ - [ - 'global', - globalSplit.locator('[data-navigation-id="board-community"]'), - globalSplit.locator('[data-menu-id="boards"]'), - ], [ 'nation', nationSplit.locator('[data-navigation-id="auction-resource"]'), @@ -1129,7 +1139,8 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn const bottomGlobal = page.locator('[data-menu-position="bottom"]'); const gameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]'); - await gameInfoButton.click(); + await gameInfoButton.scrollIntoViewIfNeeded(); + await gameInfoButton.evaluate((button) => (button as HTMLElement).click()); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible(); const geometry = await gameInfoButton.evaluate((button) => { @@ -2418,9 +2429,9 @@ test('all main Lumen button families share the rounded pressed geometry', async page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'), ], [ - '게임정보', + '게임 정보', page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', { - name: '게임정보', + name: '게임 정보', exact: true, }), ], @@ -2566,7 +2577,7 @@ test('mobile main Lumen button families keep the same state geometry without ove const controls = [ page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'), page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', { - name: '게임정보', + name: '게임 정보', exact: true, }), page.locator('.layout-mobile [data-navigation-id="meeting"]'), diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 6c766ef7..8fe08af6 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -12,7 +12,8 @@ const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/'; const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; const frontendEnv = `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` + - `VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl}`; + `VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} ` + + 'VITE_GATEWAY_API_URL=/gateway/api/trpc'; export default defineConfig({ testDir: '.', diff --git a/app/game-frontend/src/components/main/MainGlobalMenu.vue b/app/game-frontend/src/components/main/MainGlobalMenu.vue index 5a331a20..3bf347bc 100644 --- a/app/game-frontend/src/components/main/MainGlobalMenu.vue +++ b/app/game-frontend/src/components/main/MainGlobalMenu.vue @@ -5,17 +5,23 @@ import { buildGlobalNavigation, isNavigationConfigured, type MainNavigationLink as MainNavigationLinkItem, + type MainNavigationEntry, } from './mainNavigation'; import { useMenuPopup } from './useMenuPopup'; const props = defineProps<{ npcMode: number; voteActive: boolean; + entries?: MainNavigationEntry[]; }>(); -const entries = computed(() => buildGlobalNavigation(props.npcMode)); +const emit = defineEmits<{ + action: [action: NonNullable]; +}>(); + +const entries = computed(() => buildGlobalNavigation(props.npcMode, props.entries)); const { setRoot, openId, close, toggle } = useMenuPopup(); -const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props.voteActive; +const isActive = (link: MainNavigationLinkItem) => link.highlightWhen === 'vote' && props.voteActive; @@ -65,6 +73,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props :enabled="isNavigationConfigured(entry.main)" :active="isActive(entry.main)" lumen-variant="navigation" + @action="emit('action', $event)" />