From 7ce1be0bb94e2fe5ad40cb718d2f2b011b3b5f33 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 18:07:26 +0000 Subject: [PATCH] =?UTF-8?q?fix(game-ui):=20=EB=A9=94=EC=9D=B8=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=ED=84=B4=20=EB=B6=84=EB=A5=98=EB=A5=BC=20Ref=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=EB=A1=9C=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref GameConst의 장수 명령 분류와 명령 순서를 예약턴 API 표시 계약으로 투영한다.\n\nAPI 회귀 테스트와 desktop/mobile production Chromium 상호작 검증을 추가한다. --- app/game-api/src/turns/commandTable.ts | 100 ++++++++++++++- app/game-api/test/commandTable.test.ts | 41 ++++++ app/game-frontend/e2e/mainNavigation.spec.ts | 126 +++++++++++++++---- 3 files changed, 239 insertions(+), 28 deletions(-) diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 05457ba9..d70174c1 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -7,6 +7,7 @@ import type { GeneralItemSlots, GeneralActionDefinition, GeneralTurnCommandSpec, + GeneralTurnCommandKey, MapDefinition, Nation, NationTurnCommandSpec, @@ -72,6 +73,83 @@ interface CommandEntry { evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore; } +const REF_GENERAL_COMMAND_GROUPS = [ + { + category: '개인', + commands: [ + '휴식', + 'che_요양', + 'che_단련', + 'che_숙련전환', + 'che_견문', + 'che_은퇴', + 'che_장비매매', + 'che_군량매매', + 'che_내정특기초기화', + 'che_전투특기초기화', + ], + }, + { + category: '내정', + commands: [ + 'che_농지개간', + 'che_상업투자', + 'che_기술연구', + 'che_수비강화', + 'che_성벽보수', + 'che_치안강화', + 'che_정착장려', + 'che_주민선정', + ], + }, + { + category: '군사', + commands: [ + 'che_징병', + 'che_모병', + 'che_훈련', + 'che_사기진작', + 'che_출병', + 'che_집합', + 'che_소집해제', + 'che_첩보', + ], + }, + { + category: '인사', + commands: [ + 'che_이동', + 'che_강행', + 'che_인재탐색', + 'che_등용', + 'che_귀환', + 'che_임관', + 'che_랜덤임관', + 'che_장수대상임관', + ], + }, + { + category: '계략', + commands: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'], + }, + { + category: '국가', + commands: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], + }, +] as const satisfies ReadonlyArray<{ + category: string; + commands: ReadonlyArray; +}>; + +const REF_GENERAL_CATEGORY_ORDER = new Map( + REF_GENERAL_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const) +); +const REF_GENERAL_COMMAND_POSITION = new Map( + REF_GENERAL_COMMAND_GROUPS.flatMap(({ category, commands }) => + commands.map((command, index) => [command, { category, index }] as const) + ) +); + const INPUT_REQUIREMENT_KINDS = new Set([ 'destGeneral', 'destCity', @@ -567,6 +645,26 @@ const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: Stat })); }; +const projectRefGeneralCommandGroups = (entries: CommandEntry[]): CommandEntry[] => + entries + .map((entry, profileIndex) => { + const refPosition = REF_GENERAL_COMMAND_POSITION.get(entry.definition.key as GeneralTurnCommandKey); + return { + entry: refPosition ? { ...entry, category: refPosition.category } : entry, + categoryIndex: + REF_GENERAL_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER, + commandIndex: refPosition?.index ?? profileIndex, + profileIndex, + }; + }) + .sort( + (left, right) => + left.categoryIndex - right.categoryIndex || + left.commandIndex - right.commandIndex || + left.profileIndex - right.profileIndex + ) + .map(({ entry }) => entry); + export const buildTurnCommandTable = async (options: { worldState: WorldStateRow; general: GeneralRow; @@ -598,7 +696,7 @@ export const buildTurnCommandTable = async (options: { const nationEntries = buildEntries(env, nationSpecs); return { - general: buildGroups(generalEntries, ctx, view), + general: buildGroups(projectRefGeneralCommandGroups(generalEntries), ctx, view), nation: buildGroups(nationEntries, ctx, view), inputOptions: options.inputOptions ?? { cities: [], diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 185e1bed..9741acc8 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -100,6 +100,47 @@ const buildNation = (): NationRow => }) as unknown as NationRow; describe('buildTurnCommandTable', () => { + it('projects the main reserved-turn categories and command order from Ref', async () => { + const table = await buildTurnCommandTable({ + worldState: buildWorldState(), + general: buildGeneral(), + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + + expect(table.general.map(({ category }) => category)).toEqual(['개인', '내정', '군사', '인사', '계략', '국가']); + expect( + Object.fromEntries(table.general.map(({ category, values }) => [category, values.map(({ key }) => key)])) + ).toEqual({ + 개인: [ + '휴식', + 'che_요양', + 'che_단련', + 'che_숙련전환', + 'che_견문', + 'che_장비매매', + 'che_군량매매', + 'che_내정특기초기화', + 'che_전투특기초기화', + ], + 내정: [ + 'che_농지개간', + 'che_상업투자', + 'che_기술연구', + 'che_수비강화', + 'che_성벽보수', + 'che_치안강화', + 'che_정착장려', + 'che_주민선정', + ], + 군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'], + 인사: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], + 계략: ['che_화계'], + 국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], + }); + }); + it('uses min-condition constraints for availability', async () => { const table = await buildTurnCommandTable({ worldState: buildWorldState(), diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 6100a9a6..c83872fa 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -32,6 +32,7 @@ type NavigationFixture = { forceSnapshotCalls?: number; refreshDelayMs?: number; largeCommandTable?: boolean; + refCommandCategories?: boolean; currentYear?: number; currentMonth?: number; scenarioTitle?: string; @@ -108,32 +109,48 @@ const emitReadModelInvalidation = (page: Page, invalidation: ReturnType ({ - general: large - ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ - category, - values: Array.from({ length: 16 }, (_, localIndex) => { - const index = categoryIndex * 16 + localIndex; - return { - key: `command-${index}`, - name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, - reqArg: index % 2 === 0, - possible: index >= blockedCount, - status: index >= blockedCount ? 'available' : 'blocked', - inputFields: [ - { - key: 'amount', - label: '수량', - kind: 'number', - required: true, - min: 1, - max: 10_000, - }, - ], - }; - }), - })) - : [], +const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계략', '국가'].map((category, index) => ({ + category, + values: [ + { + key: `ref-command-${index}`, + name: category === '계략' ? '화계' : `${category} 명령`, + reqArg: false, + possible: true, + status: 'available' as const, + inputFields: [], + }, + ], +})); + +const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false) => ({ + general: refCategories + ? refCommandCategoryFixture + : large + ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ + category, + values: Array.from({ length: 16 }, (_, localIndex) => { + const index = categoryIndex * 16 + localIndex; + return { + key: `command-${index}`, + name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, + reqArg: index % 2 === 0, + possible: index >= blockedCount, + status: index >= blockedCount ? 'available' : 'blocked', + inputFields: [ + { + key: 'amount', + label: '수량', + kind: 'number', + required: true, + min: 1, + max: 10_000, + }, + ], + }; + }), + })) + : [], nation: [], inputOptions: { cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })), @@ -371,7 +388,11 @@ const installFixture = async (page: Page, state: NavigationFixture) => { ? { kind: 'snapshot' as const, revision: currentCommandTableRevision, - data: commandTableFixture(state.largeCommandTable === true, state.commandBlockedCount), + data: commandTableFixture( + state.largeCommandTable === true, + state.commandBlockedCount, + state.refCommandCategories === true + ), } : input.known.commandTable === currentCommandTableRevision ? { kind: 'unchanged' as const, revision: currentCommandTableRevision } @@ -953,6 +974,57 @@ test('pure NPC message senders are not rendered as reply targets', async ({ page await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`); }); +test('main reserved-turn picker renders the Ref general category order', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 1, + permission: 0, + nationLevel: 1, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + refCommandCategories: true, + reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })), + }; + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + const categoryButtons = picker.locator('.category-btn'); + await expect(categoryButtons).toHaveText(['개인', '내정', '군사', '인사', '계략', '국가']); + + const desktopGeometry = await picker.evaluate((element) => { + const categories = element.querySelector('.category-list'); + if (!categories) throw new Error('command category list is missing'); + const buttons = [...categories.querySelectorAll('.category-btn')]; + return { + columns: getComputedStyle(categories).gridTemplateColumns, + rows: new Set(buttons.map((button) => button.getBoundingClientRect().y)).size, + horizontalOverflow: element.scrollWidth - element.clientWidth, + }; + }); + expect(desktopGeometry.columns.split(' ')).toHaveLength(3); + expect(desktopGeometry.rows).toBe(2); + expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0); + + const strategyCategory = picker.getByRole('button', { name: '계략', exact: true }); + await strategyCategory.hover(); + await strategyCategory.focus(); + await expect(strategyCategory).toBeFocused(); + await strategyCategory.click(); + await expect(strategyCategory).toHaveClass(/active/); + await expect(picker.locator('.command-item')).toHaveText(['화계']); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const mobilePicker = page.getByTestId('command-picker'); + await expect(mobilePicker.locator('.category-btn')).toHaveText(['개인', '내정', '군사', '인사', '계략', '국가']); + expect(await mobilePicker.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0); + await persistArtifact(page, `${basePath.slice(1)}-main-reserved-ref-categories-mobile-500`); +}); + test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1,