From 7ce1be0bb94e2fe5ad40cb718d2f2b011b3b5f33 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 18:07:26 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(game-ui):=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=ED=84=B4=20=EB=B6=84=EB=A5=98=EB=A5=BC=20Ref?= =?UTF-8?q?=20=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, From f02d433dd00d0c3aae7b986703e33cd0a63387f2 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 18:09:12 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=8F=84=EC=8B=9C=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=9E=A5=EC=88=98=20=ED=91=9C=EC=8B=9C=EC=99=80=20=ED=8F=B0?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=B0=94=EB=A1=9C=EC=9E=A1=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 화면과 같은 Pretendard 14px을 사용하고 장수 이름 목록의 줄바꿈을 허용한다. 실제 예약 턴에만 작은 글씨를 적용해 NPC 장수 표기는 본문 크기로 유지하며 Chromium 회귀 검증을 추가한다. --- app/game-frontend/e2e/inGameInfo.spec.ts | 96 ++++++++++++++++++- .../src/views/CurrentCityView.vue | 15 +-- docs/frontend-legacy-parity.md | 10 +- 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index fbc3bac8..31171634 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -160,7 +160,8 @@ const install = async ( mode: 'member' | 'wanderer' | 'admin' = 'member', trade: number | null = 100, globalNationCount = 2, - mapFixture = map + mapFixture = map, + denseCurrentCity = false ) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_info'); @@ -372,8 +373,32 @@ const install = async ( crew: 500, train: 90, atmos: 90, - turns: ['징병'], + turns: denseCurrentCity ? ['징병', '훈련'] : ['징병'], }, + ...(denseCurrentCity + ? Array.from({ length: 12 }, (_, index) => ({ + id: index + 2, + name: `NPC장수이름이긴${index + 1}`, + npcState: 2, + picture: null, + imageServer: 0, + nationId: 1, + nationName: '아국', + leadership: 60, + strength: 60, + intelligence: 60, + injury: 0, + officerLevel: 1, + leadershipBonus: 0, + defenceTrain: 80, + crewTypeId: 1, + crewTypeName: '보병', + crew: 500, + train: 90, + atmos: 90, + turns: [], + })) + : []), ], forceSummary: { enemyCrew: 0, @@ -407,7 +432,7 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p ['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'], ['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'], ['global-info', '.global-page', '14px', 'Pretendard', 'collapse'], - ['current-city', '.city-page', '16px', 'Times New Roman', 'separate'], + ['current-city', '.city-page', '14px', 'Pretendard', 'separate'], ] as const) { await go(page, path); await expect(page.locator(selector)).toBeVisible(); @@ -774,8 +799,8 @@ test('current-city exposes own general details to a member and admin fixture', a }; }); expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 }); - expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 }); - expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 }); + expect(legacyGeometry.stats).toEqual({ x: 100, y: 165.375, width: 1000, height: 106.9375 }); + expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 290.3125, width: 1024 }); expect(legacyGeometry.titleAlign).toBe('start'); expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 }); if (artifactRoot) { @@ -834,6 +859,67 @@ test('current-city exposes own general details to a member and admin fixture', a } }); +test('current-city wraps dense general names and only shrinks reserved turns', async ({ page }) => { + await install(page, 'member', 100, 2, map, true); + for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await go(page, 'current-city'); + + const pageStyle = await page.locator('.city-page').evaluate((element) => { + const style = getComputedStyle(element); + return { fontFamily: style.fontFamily, fontSize: style.fontSize }; + }); + expect(pageStyle.fontFamily).toContain('Pretendard'); + expect(pageStyle.fontSize).toBe('14px'); + + const names = page.locator('.general-names'); + await expect(names).toHaveCSS('white-space', 'normal'); + const nameLineCount = await names.locator('span').evaluateAll((elements) => { + const tops = elements.map((element) => Math.round(element.getBoundingClientRect().top)); + return new Set(tops).size; + }); + expect(nameLineCount).toBeGreaterThan(1); + + const rows = page.locator('.generals tbody tr'); + const reservedTurns = rows.nth(0).locator('.turns'); + const npcTurns = rows.nth(1).locator('.turns'); + await expect(reservedTurns).toContainText('1 : 징병'); + await expect(reservedTurns).toContainText('2 : 훈련'); + await expect(reservedTurns).toHaveClass(/turns--reserved/); + await expect(npcTurns).toHaveText('NPC 장수'); + await expect(npcTurns).not.toHaveClass(/turns--reserved/); + + const turnFontSizes = await Promise.all([ + reservedTurns.evaluate((element) => getComputedStyle(element).fontSize), + npcTurns.evaluate((element) => getComputedStyle(element).fontSize), + ]); + expect(Number.parseFloat(turnFontSizes[0])).toBeLessThan(Number.parseFloat(pageStyle.fontSize)); + expect(turnFontSizes[1]).toBe(pageStyle.fontSize); + + const reservedLineTops = await reservedTurns + .locator('.turn-line') + .evaluateAll((elements) => elements.map((element) => Math.round(element.getBoundingClientRect().top))); + expect(new Set(reservedLineTops).size).toBe(2); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await writeFile( + resolve(artifactRoot, `core-current-city-dense-${viewport.name}.json`), + `${JSON.stringify({ pageStyle, nameLineCount, turnFontSizes, reservedLineTops }, null, 2)}\n`, + 'utf8' + ); + await page.screenshot({ + path: resolve(artifactRoot, `core-current-city-dense-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + } + } +}); + test('current-city renders a missing merchant rate with the legacy dash and percent text', async ({ page }) => { await install(page, 'member', null); await page.setViewportSize({ width: 1200, height: 900 }); diff --git a/app/game-frontend/src/views/CurrentCityView.vue b/app/game-frontend/src/views/CurrentCityView.vue index f87f0f67..e75b1d28 100644 --- a/app/game-frontend/src/views/CurrentCityView.vue +++ b/app/game-frontend/src/views/CurrentCityView.vue @@ -299,7 +299,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general {{ general.crew ?? '?' }} {{ general.train ?? '?' }} {{ general.atmos ?? '?' }} - +