From b33b2f454bd368c0c44ad5033254e622209a1d36 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:16:43 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EC=9C=A0=EC=82=B0=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=88=ED=81=AC=20=EA=B2=BD=EB=A7=A4=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=A2=85=EB=A5=98=EB=B3=84=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref의 명마, 무기, 서적, 도구 순서와 종류 내부 선언 순서를 보존한다. 선택 목록은 종류별 optgroup으로 구분하고 API·Chromium 회귀를 추가한다. --- app/game-api/src/router/inherit/index.ts | 9 ++++-- app/game-api/test/inheritRouter.test.ts | 26 +++++++++++++++ app/game-frontend/src/views/InheritView.vue | 26 +++++++++++++-- .../inheritance-management.spec.ts | 32 ++++++++++++++++++- 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index edbc8bbc..955ceb2e 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -12,6 +12,7 @@ import { isWarTraitKey, } from '@sammo-ts/logic'; import type { InheritBuffType } from '@sammo-ts/logic'; +import type { ItemSlot } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { @@ -39,6 +40,8 @@ const BUFF_KEYS: InheritBuffType[] = [ 'warMagicTrialProbOppose', ]; +const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item']; + const BUFF_LABELS: Record = { warAvoidRatio: '회피 확률 증가', warCriticalRatio: '필살 확률 증가', @@ -79,7 +82,8 @@ const loadAvailableUniqueItems = async (worldState: WorldStateRow) => { const loader = new ItemLoader(); const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader); const enabledKeys: Array[0]> = []; - for (const entries of Object.values(allItems)) { + for (const slot of UNIQUE_ITEM_SLOT_ORDER) { + const entries = allItems[slot] ?? {}; for (const [key, amount] of Object.entries(asRecord(entries))) { if (asNumber(amount, 0) !== 0 && isItemKey(key)) { enabledKeys.push(key); @@ -94,10 +98,11 @@ const loadAvailableUniqueItems = async (worldState: WorldStateRow) => { name: item.name, rawName: item.rawName, info: item.info ?? '', + slot: item.slot, }; }) ); - return items.sort((left, right) => left.name.localeCompare(right.name, 'ko')); + return items; }; const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise } } }) => { diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index ae39c870..72d28db3 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -218,6 +218,32 @@ describe('inherit router actor and permission boundaries', () => { } ); + it('orders unique auction candidates by Ref slot order and preserves order within each slot', async () => { + const fixture = buildContext({ + configConst: { + allItems: { + item: { che_보물_도기: 1 }, + book: { che_서적_07_논어: 1 }, + weapon: { che_무기_12_칠성검: 1 }, + horse: { + che_명마_07_백마: 1, + che_명마_07_기주마: 1, + }, + }, + }, + }); + + const status = await appRouter.createCaller(fixture.context).inherit.getStatus(); + + expect(status.availableUnique.map(({ key, slot }) => ({ key, slot }))).toEqual([ + { key: 'che_명마_07_백마', slot: 'horse' }, + { key: 'che_명마_07_기주마', slot: 'horse' }, + { key: 'che_무기_12_칠성검', slot: 'weapon' }, + { key: 'che_서적_07_논어', slot: 'book' }, + { key: 'che_보물_도기', slot: 'item' }, + ]); + }); + it('loads the first inheritance-log page without an out-of-range integer cursor', async () => { const createdAt = new Date('2026-07-26T00:00:00Z'); const fixture = buildContext({ diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index 4aeb761d..4167d6ac 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -6,6 +6,7 @@ import { trpc } from '../utils/trpc'; type InheritStatus = Awaited>; type InheritLog = Awaited>[number]; type JoinConfig = Awaited>; +type UniqueItemSlot = InheritStatus['availableUnique'][number]['slot']; type BuffKey = | 'warAvoidRatio' @@ -67,6 +68,14 @@ const pointOrder = [ 'betting', ] as const; +const uniqueItemSlotOrder: readonly UniqueItemSlot[] = ['horse', 'weapon', 'book', 'item']; +const uniqueItemSlotLabels: Record = { + horse: '명마', + weapon: '무기', + book: '서적', + item: '도구', +}; + const pointHelp: Record = { previous: '이전에 물려받은 포인트입니다.', lived_month: '살아남은 기간입니다. (1개월 단위)', @@ -196,6 +205,15 @@ const specialNameMap = computed(() => { const selectedSpecialWarInfo = computed( () => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? '' ); +const availableUniqueGroups = computed(() => + uniqueItemSlotOrder + .map((slot) => ({ + slot, + label: uniqueItemSlotLabels[slot], + items: status.value?.availableUnique.filter((item) => item.slot === slot) ?? [], + })) + .filter((group) => group.items.length > 0) +); const buffCost = (key: BuffKey, target: number): number => { const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0]; @@ -518,9 +536,11 @@ onMounted(() => {
diff --git a/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts index 2546e890..7b50b62a 100644 --- a/tools/frontend-legacy-parity/inheritance-management.spec.ts +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -81,17 +81,33 @@ const statusFixture = { resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 }, availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }], availableUnique: [ + { + key: 'che_명마_07_백마', + name: '백마(+7)', + rawName: '백마', + info: '기동력을 올려주는 유니크 명마입니다.', + slot: 'horse', + }, { key: 'che_무기_12_칠성검', name: '칠성검(+12)', rawName: '칠성검', info: '무력을 올려주는 유니크 무기입니다.', + slot: 'weapon', }, { key: 'che_서적_07_논어', name: '논어(+7)', rawName: '논어', info: '지력을 올려주는 유니크 서적입니다.', + slot: 'book', + }, + { + key: 'che_보물_도기', + name: '도기', + rawName: '도기', + info: '전투를 돕는 유니크 도구입니다.', + slot: 'item', }, ], availableTargetGenerals: [{ id: 8, name: '조조' }], @@ -197,7 +213,21 @@ test.describe('inheritance management legacy parity', () => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto(gameUrl); await expect(page.locator('#container')).toBeVisible(); - await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검'); + await expect(page.locator('#specific-unique')).toHaveValue('che_명마_07_백마'); + await expect(page.locator('#specific-unique optgroup')).toHaveCount(4); + expect( + await page.locator('#specific-unique optgroup').evaluateAll((groups) => + groups.map((group) => ({ + label: group.getAttribute('label'), + values: [...group.querySelectorAll('option')].map((option) => option.value), + })) + ) + ).toEqual([ + { label: '명마', values: ['che_명마_07_백마'] }, + { label: '무기', values: ['che_무기_12_칠성검'] }, + { label: '서적', values: ['che_서적_07_논어'] }, + { label: '도구', values: ['che_보물_도기'] }, + ]); const desktop = await page.evaluate(() => { const rect = (selector: string) => { From daa96b79aa516ae07c6a4d0ccd59138e38330070 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:08:04 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=EC=A0=95=EC=8B=9D=20=EA=B8=B0=EC=88=98=20?= =?UTF-8?q?=ED=91=9C=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 }}