From daa96b79aa516ae07c6a4d0ccd59138e38330070 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:08:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=EC=A0=95=EC=8B=9D=20=EA=B8=B0=EC=88=98=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/context.ts | 1 + app/game-api/src/router/lobby/index.ts | 2 + app/game-api/test/lobbyRouter.test.ts | 4 ++ .../src/scenario/scenarioSeeder.ts | 17 +++++- app/game-engine/test/scenarioSeeder.test.ts | 52 ++++++++++++++++ app/game-frontend/e2e/mainNavigation.spec.ts | 60 ++++++++++++++++++- app/game-frontend/src/views/MainView.vue | 23 ++++++- app/gateway-api/test/releaseManifest.test.ts | 2 +- .../migration.sql | 18 ++++++ release-manifest.json | 2 +- 10 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 packages/infra/prisma/migrations/20260820002000_persist_official_game_index/migration.sql diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 141a54c1..0cd9df52 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -44,6 +44,7 @@ export type WorldStateConfig = z.infer; export const zWorldStateMeta = z.object({ serverId: z.string().optional(), + gameIdx: z.number().int().positive().optional(), starttime: z.string().optional(), opentime: z.string().optional(), preopenAt: z.string().optional(), diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index 2ba7ab5e..0b611078 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -53,6 +53,8 @@ export const lobbyRouter = router({ return { serverId: worldState.meta.serverId?.trim() || ctx.profile?.name || 'game', + profile: ctx.profile.id, + gameIdx: worldState.meta.gameIdx ?? 1, 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 eb411eb6..144570c8 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -15,6 +15,7 @@ const buildContext = ( ): GameApiContext => ({ auth: null, + profile: { id: 'che', scenario: 'default', name: 'che:default' }, db: { worldState: { findFirst: vi.fn(async () => ({ @@ -75,6 +76,7 @@ describe('lobby season state', () => { buildContext( { serverId: 'che_260819_season', + gameIdx: 101, preopenAt: '2026-08-19 22:00:00', opentime: '2026-08-19 23:00:00', scenarioMeta: { title: '【가상모드27-b】 아시아 명장전(비급)' }, @@ -103,6 +105,8 @@ describe('lobby season state', () => { expect(result).toMatchObject({ serverId: 'che_260819_season', + profile: 'che', + gameIdx: 101, preopenAt: '2026-08-19 22:00:00', opentime: '2026-08-19 23:00:00', scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)', diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index a1d1c96f..3a8b17b5 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -323,9 +323,6 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom options: install.autorunUser.options, }; } - const archivedWorldMeta = { ...worldMeta }; - delete archivedWorldMeta.hiddenSeed; - await connector.connect(); try { const result: ScenarioSeedResult = { seed, warnings, applied: true }; @@ -383,6 +380,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom await prisma.worldState.deleteMany(); } + const serverId = typeof worldMeta.serverId === 'string' ? worldMeta.serverId : undefined; + const completedGameCount = await prisma.gameHistory.count({ + where: { + status: 'COMPLETED', + ...(serverId ? { serverId: { not: serverId } } : {}), + }, + }); + // Ref fixes server_cnt once during ResetHelper initialization. Keep the + // frequently rendered game index in the same persisted read model and + // exclude abandoned or unfinished rows from the official sequence. + worldMeta.gameIdx = completedGameCount + 1; + const archivedWorldMeta = { ...worldMeta }; + delete archivedWorldMeta.hiddenSeed; + await prisma.worldState.create({ data: { scenarioCode: String(options.scenarioId), diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index d516c825..c6d587c5 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -128,6 +128,58 @@ describeDb('scenario database seed', () => { } }); + test('persists the next official game index without counting cancelled or unfinished games', async () => { + const marker = `scenario-seeder-game-index-${Date.now()}`; + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const completedBefore = await connector.prisma.gameHistory.count({ where: { status: 'COMPLETED' } }); + await connector.prisma.gameHistory.createMany({ + data: [ + { + serverId: `${marker}-completed`, + date: new Date('2026-08-01T00:00:00.000Z'), + season: 1, + scenario: 1010, + scenarioName: '정상 종료 fixture', + status: 'COMPLETED', + }, + { + serverId: `${marker}-abandoned`, + date: new Date('2026-08-02T00:00:00.000Z'), + season: 1, + scenario: 1010, + scenarioName: '취소 fixture', + status: 'ABANDONED', + }, + { + serverId: `${marker}-open`, + date: new Date('2026-08-03T00:00:00.000Z'), + season: 1, + scenario: 1010, + scenarioName: '미완료 fixture', + status: 'OPEN', + }, + ], + }); + + await seedScenarioToDatabase({ + scenarioId: 1010, + databaseUrl, + installOptions: { serverId: marker }, + }); + + const worldState = await connector.prisma.worldState.findFirstOrThrow(); + expect(worldState.meta).toMatchObject({ gameIdx: completedBefore + 2 }); + await expect( + connector.prisma.gameHistory.findUniqueOrThrow({ where: { serverId: marker } }) + ).resolves.toMatchObject({ status: 'OPEN' }); + } finally { + await connector.prisma.gameHistory.deleteMany({ where: { serverId: { startsWith: marker } } }); + await connector.disconnect(); + } + }); + test('writes scenario data into tables', async () => { const { seed } = await seedScenarioToDatabase({ scenarioId, diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 81a3271b..2d6be228 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -52,6 +52,8 @@ type NavigationFixture = { currentYear?: number; currentMonth?: number; serverId?: string; + profile?: string; + gameIdx?: number; scenarioTitle?: string; nationColor?: string; lastExecuted?: string | null; @@ -529,6 +531,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => { return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, serverId: state.serverId ?? 'che_fixture_season', + profile: state.profile ?? 'che', + gameIdx: state.gameIdx ?? 101, year: state.currentYear ?? 185, month: state.currentMonth ?? 1, turnTerm: 10, @@ -1112,7 +1116,9 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-mobile')).toHaveCount(0); - await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1); + await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount( + 1 + ); await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); @@ -1264,6 +1270,56 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); +test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + profile: 'hwe', + gameIdx: 7, + scenarioTitle: '메인 화면 검증 시나리오', + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true }); + + for (const viewport of [ + { width: 1200, height: 900 }, + { width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + if (page.url() === 'about:blank') await waitForMain(page); + + const title = page.getByRole('heading', { name: '메인 화면 검증 시나리오 훼섭 7기', exact: true }); + await expect(title).toBeVisible(); + const geometry = await title.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const mainRect = element.closest('.main-page')?.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: rect.left, + right: rect.right, + mainLeft: mainRect?.left, + mainRight: mainRect?.right, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(geometry.mainLeft ?? 0); + expect(geometry.right).toBeLessThanOrEqual(geometry.mainRight ?? viewport.width); + expect(geometry.documentOverflow).toBeLessThanOrEqual(0); + expect(geometry.fontSize).toBe('25.6px'); + expect(geometry.lineHeight).toBe('38.4px'); + expect(geometry.fontFamily).toContain('Pretendard'); + await persistArtifact(page, `official-game-index-${viewport.width}`); + } +}); + test('nation split buttons keep square inner corners and a single divider in every interaction state', async ({ page, }, testInfo) => { @@ -2239,7 +2295,7 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await page.setViewportSize({ width: 500, height: 900 }); - await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1); + await expect(page.getByRole('heading', { name: '모바일 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1); await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index ef828879..94f08bb8 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -95,6 +95,27 @@ const nationAccess = computed(() => ({ })); const nationColor = computed(() => nation.value?.color ?? '#000000'); const voteActive = computed(() => Boolean(frontStatus.value?.latestVote)); +const profileLabels: Record = { + che: '체', + kwe: '퀘', + pwe: '풰', + twe: '퉤', + nya: '냐', + pya: '퍄', + hwe: '훼', +}; +const gameProfileLabel = computed(() => { + const profile = lobbyInfo.value?.profile?.trim(); + return profile ? (profileLabels[profile] ?? profile) : ''; +}); +const gameTitle = computed(() => { + const scenarioTitle = lobbyInfo.value?.scenarioTitle || '전장 현황'; + const profileLabel = gameProfileLabel.value; + const gameIdx = lobbyInfo.value?.gameIdx; + return profileLabel && typeof gameIdx === 'number' && Number.isInteger(gameIdx) && gameIdx > 0 + ? `${scenarioTitle} ${profileLabel}섭 ${gameIdx}기` + : scenarioTitle; +}); const recordTimeSuffixPattern = /\d{2}:\d{2}(?:<\/>)?\s*$/u; const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => { if (!appendTime || recordTimeSuffixPattern.test(entry.text)) return formatLog(entry.text); @@ -199,7 +220,7 @@ watch(

- {{ lobbyInfo?.scenarioTitle || '전장 현황' }} + {{ gameTitle }}