From 673b8d8a5425ccf7cfea5615e47feee41ef2d9a3 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 25 Aug 2026 04:14:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EB=A0=B9=EB=B6=80=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=20=ED=95=84=EC=9A=94=20=ED=84=B4=EC=9D=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref처럼 여러 턴이 필요한 국가 명령의 소요 턴을 명령명 아래에 표시한다. 실행 getPreReqTurn을 기준으로 삼고 천도는 거리 기반 공식을 노출한다. --- app/game-api/src/turns/commandTable.ts | 28 ++++++++++- app/game-api/test/commandTable.test.ts | 29 +++++++++++ .../e2e/commandArguments.spec.ts | 49 ++++++++++++++----- .../src/components/command/types.ts | 1 + .../src/components/main/CommandSelectForm.vue | 13 +++++ packages/logic/src/actions/definition.ts | 9 ++-- .../logic/src/actions/turn/nation/che_천도.ts | 4 ++ 7 files changed, 114 insertions(+), 19 deletions(-) diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index aedf737b..4c27abb6 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -39,6 +39,7 @@ type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown'; export interface TurnCommandAvailability { key: string; name: string; + turnDurationText?: string; reqArg: boolean; possible: boolean; status: AvailabilityStatus; @@ -699,16 +700,34 @@ const buildEntries = ( return entries; }; -const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: StateView): TurnCommandGroup[] => { +const getTurnDurationText = (definition: GeneralActionDefinition): string | undefined => { + const hint = definition.getTurnDurationHint?.(); + if (hint) return hint; + + const getPreReqTurn = definition.getPreReqTurn; + // 인자를 선언한 구현은 천도처럼 대상에 따라 달라지므로 임의 context로 실행하지 않는다. + if (!getPreReqTurn || getPreReqTurn.length > 0) return undefined; + const preReqTurn = Math.max(0, Math.floor(getPreReqTurn.call(definition, undefined as never, {}))); + return preReqTurn > 0 ? `${preReqTurn + 1}턴` : undefined; +}; + +const buildGroups = ( + entries: CommandEntry[], + ctx: ConstraintContext, + view: StateView, + includeTurnDuration = false +): TurnCommandGroup[] => { const groups = new Map(); for (const entry of entries) { const availability = entry.evaluate ? entry.evaluate(ctx, view) : evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs); + const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined; const value: TurnCommandAvailability = { key: entry.definition.key, name: entry.definition.name, + ...(turnDurationText ? { turnDurationText } : {}), reqArg: entry.reqArg, inputFields: entry.inputFields, ...availability, @@ -806,7 +825,12 @@ export const buildTurnCommandTable = async (options: { ctx, view ), - nation: buildGroups(projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), ctx, view), + nation: buildGroups( + projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), + ctx, + view, + true + ), inputOptions: options.inputOptions ?? { cities: [], nations: [], diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 2e940baf..c26aa5d9 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -190,6 +190,27 @@ describe('buildTurnCommandTable', () => { 전략: ['필사즉생', '백성동원', '수몰', '허보', '의병모집', '이호경식', '급습', '피장파장'], 기타: ['국기변경', '국호변경'], }); + expect( + Object.fromEntries( + table.nation + .flatMap(({ values }) => values) + .filter(({ turnDurationText }) => turnDurationText) + .map(({ key, turnDurationText }) => [key, turnDurationText]) + ) + ).toEqual({ + che_초토화: '3턴', + che_천도: '1+거리×2턴', + che_증축: '6턴', + che_감축: '6턴', + che_필사즉생: '3턴', + che_수몰: '3턴', + che_허보: '2턴', + che_의병모집: '3턴', + che_피장파장: '2턴', + }); + expect(table.general.flatMap(({ values }) => values)).not.toContainEqual( + expect.objectContaining({ turnDurationText: expect.any(String) }) + ); }); it('projects scenario-specific command categories instead of the default profile', async () => { @@ -225,6 +246,14 @@ describe('buildTurnCommandTable', () => { 'event_대검병연구', 'event_화륜차연구', ]); + expect( + Object.fromEntries( + table.nation.flatMap(({ values }) => values.map(({ key, turnDurationText }) => [key, turnDurationText])) + ) + ).toMatchObject({ + event_대검병연구: '12턴', + event_화륜차연구: '24턴', + }); }); it('projects the real 904/905/910/912 world command profiles into the API table', async () => { diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 66914b0e..406a7ced 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -320,9 +320,10 @@ const buildGeneralCommand = (key: string, name: string) => ({ { key: 'destGeneralId', label: '대상 장수', kind: 'select', required: true, optionSource: 'generals' }, ], }); -const buildSimpleCommand = (key: string, name: string) => ({ +const buildSimpleCommand = (key: string, name: string, turnDurationText?: string) => ({ key, name, + ...(turnDurationText ? { turnDurationText } : {}), reqArg: false, possible: true, status: 'available', @@ -635,23 +636,23 @@ const refChiefCommandTable = { { category: '특수', values: [ - buildSimpleCommand('che_초토화', '초토화'), - buildSimpleCommand('che_천도', '천도'), - buildSimpleCommand('che_증축', '증축'), - buildSimpleCommand('che_감축', '감축'), + buildSimpleCommand('che_초토화', '초토화', '3턴'), + buildSimpleCommand('che_천도', '천도', '1+거리×2턴'), + buildSimpleCommand('che_증축', '증축', '6턴'), + buildSimpleCommand('che_감축', '감축', '6턴'), ], }, { category: '전략', values: [ - buildSimpleCommand('che_필사즉생', '필사즉생'), + buildSimpleCommand('che_필사즉생', '필사즉생', '3턴'), buildSimpleCommand('che_백성동원', '백성동원'), - buildSimpleCommand('che_수몰', '수몰'), - buildSimpleCommand('che_허보', '허보'), - buildSimpleCommand('che_의병모집', '의병모집'), + buildSimpleCommand('che_수몰', '수몰', '3턴'), + buildSimpleCommand('che_허보', '허보', '2턴'), + buildSimpleCommand('che_의병모집', '의병모집', '3턴'), buildSimpleCommand('che_이호경식', '이호경식'), buildSimpleCommand('che_급습', '급습'), - buildSimpleCommand('che_피장파장', '피장파장'), + buildSimpleCommand('che_피장파장', '피장파장', '2턴'), ], }, { @@ -1220,8 +1221,24 @@ test('shows every Ref chief command in the exact category and command order', as await expect(picker.locator('.category-btn')).toHaveText(Object.keys(expected)); for (const [category, commands] of Object.entries(expected)) { await picker.locator('.category-btn').filter({ hasText: category }).click(); - await expect(picker.locator('.command-grid .command-item')).toHaveText([...commands]); + await expect(picker.locator('.command-grid .command-name')).toHaveText([...commands]); } + await picker.getByRole('button', { name: '특수', exact: true }).click(); + await expect(picker.locator('.command-grid .command-item')).toHaveText([ + '초토화 /3턴', + '천도 /1+거리×2턴', + '증축 /6턴', + '감축 /6턴', + ]); + const durationGeometry = await picker.locator('.command-grid .command-item').evaluateAll((buttons) => + buttons.map((button) => ({ + text: button.textContent?.replace(/\s/g, ''), + height: button.getBoundingClientRect().height, + overflow: button.scrollWidth - button.clientWidth, + })) + ); + expect(durationGeometry.every(({ height, overflow }) => height >= 35 && overflow <= 0)).toBe(true); + await picker.getByRole('button', { name: '기타', exact: true }).click(); const rename = picker.getByRole('button', { name: '국호변경', exact: true }); await rename.hover(); await rename.focus(); @@ -1242,6 +1259,16 @@ test('shows every Ref chief command in the exact category and command order', as expect(mobileGeometry.width).toBeLessThanOrEqual(500); expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0); expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3); + await mobilePicker.getByRole('button', { name: '특수', exact: true }).click(); + const mobileExpand = mobilePicker.getByRole('button', { name: '증축 /6턴', exact: true }); + await expect(mobileExpand).toBeVisible(); + await mobileExpand.hover(); + await mobileExpand.focus(); + await expect(mobileExpand).toBeFocused(); + await mobileExpand.dispatchEvent('pointerdown'); + await expect(mobileExpand.locator('.command-duration')).toHaveText('/6턴'); + await mobileExpand.dispatchEvent('pointerup'); + expect(await mobilePicker.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0); await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') }); }); diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index a0e5a619..2f6615f5 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -63,6 +63,7 @@ export type CommandInputField = { export type CommandAvailability = { key: string; name: string; + turnDurationText?: string; reqArg: boolean; status: 'available' | 'blocked' | 'needsInput' | 'unknown'; possible: boolean; diff --git a/app/game-frontend/src/components/main/CommandSelectForm.vue b/app/game-frontend/src/components/main/CommandSelectForm.vue index f847d786..6691dca0 100644 --- a/app/game-frontend/src/components/main/CommandSelectForm.vue +++ b/app/game-frontend/src/components/main/CommandSelectForm.vue @@ -5,6 +5,7 @@ import SkeletonLines from '../ui/SkeletonLines.vue'; type CommandAvailability = { key: string; name: string; + turnDurationText?: string; reqArg: boolean; status: 'available' | 'blocked' | 'needsInput' | 'unknown'; possible: boolean; @@ -161,6 +162,9 @@ const commandTitle = (command: CommandAvailability) => @click="emit('select', command.key)" > {{ command.name }} + + /{{ command.turnDurationText }} + @@ -210,6 +214,7 @@ const commandTitle = (command: CommandAvailability) => min-height: 0; padding-inline: 5px; display: flex; + flex-direction: column; align-items: center; justify-content: center; text-align: center; @@ -236,6 +241,14 @@ const commandTitle = (command: CommandAvailability) => font-weight: 600; } +.command-duration { + display: block; + font-size: 0.875em; + font-weight: 400; + line-height: 1.1; + white-space: nowrap; +} + .empty { color: rgba(232, 221, 196, 0.6); } diff --git a/packages/logic/src/actions/definition.ts b/packages/logic/src/actions/definition.ts index caa39ed7..5e773675 100644 --- a/packages/logic/src/actions/definition.ts +++ b/packages/logic/src/actions/definition.ts @@ -16,14 +16,11 @@ export interface GeneralActionDefinition< // 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다. buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[]; buildConstraints(ctx: ConstraintContext, args: Args): Constraint[]; - formatConstraintFailure?( - reason: string, - ctx: ConstraintContext, - args: Args, - view: StateView - ): string | null; + formatConstraintFailure?(reason: string, ctx: ConstraintContext, args: Args, view: StateView): string | null; // NationCommand::addTermStack()/setNextAvailable() 호환 실행 메타데이터. getPreReqTurn?(context: Context, args: Args): number; + // 입력 시점에 대상이 정해져야 소요 턴을 계산할 수 있는 명령의 Ref 표시 문구. + getTurnDurationHint?(): string; getPostReqTurn?(context: Context, args: Args): number; getStackSequence?(context: Context, args: Args): number | null; getProgressText?(context: Context, args: Args, term: number, termMax: number): string; diff --git a/packages/logic/src/actions/turn/nation/che_천도.ts b/packages/logic/src/actions/turn/nation/che_천도.ts index 10e2f81a..0ba12b23 100644 --- a/packages/logic/src/actions/turn/nation/che_천도.ts +++ b/packages/logic/src/actions/turn/nation/che_천도.ts @@ -179,6 +179,10 @@ export class ActionDefinition< return (calcDistance(context.nation.capitalCityId, args.destCityID, context.map, allowedCityIds) ?? 0) * 2; } + getTurnDurationHint(): string { + return '1+거리×2턴'; + } + getStackSequence(context: MoveCapitalResolveContext): number { const value = context.nation?.meta.capset; return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;