From c4d6e45467e6a0f79357388fed212d6697e27e17 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 01:01:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=B0=9C=EB=A0=B9=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EC=97=90=EC=84=9C=20=EC=95=84=EA=B5=AD=20=EB=8F=84?= =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=EC=9A=B0=EC=84=A0=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 적국과 공백지를 포함한 다른 도시 선택지는 유지하고 기존 지도 선택 흐름을 보존한다. 발령 전용 정렬 단위 테스트와 데스크톱·모바일 Chromium 회귀를 추가한다. --- .../e2e/commandArguments.spec.ts | 67 +++++++++++++++++-- .../command/commandArgumentOptions.ts | 21 ++++++ .../components/main/CommandArgumentForm.vue | 11 ++- .../test/commandArgumentOptions.test.ts | 57 ++++++++++++++++ 4 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 app/game-frontend/src/components/command/commandArgumentOptions.ts create mode 100644 app/game-frontend/test/commandArgumentOptions.test.ts diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index c1d8afa2..4bb3bc29 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -953,9 +953,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile', await expect(spy).toBeFocused(); await spy.click(); const form = picker.getByTestId('command-argument-form'); - await expect(form.getByTestId('command-argument-guidance')).toContainText( - '선택한 도시에 첩보를 실행합니다.' - ); + await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 첩보를 실행합니다.'); await expect(form.getByTestId('command-argument-guidance')).toContainText( '인접 도시에서는 더 많은 정보를 얻습니다.' ); @@ -979,9 +977,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile', await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') }); }); -test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ - page, -}) => { +test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page }) => { const foundingColors = [ { value: 0, label: '색상 1', color: '#FF0000' }, { value: 15, label: '색상 16', color: '#6495ED' }, @@ -1284,7 +1280,9 @@ test('keeps general and chief command categories after input and across page rel const reloadedChiefPicker = chiefPage.getByTestId('command-picker'); await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/); await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible(); - await expect.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(200); + await expect + .poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height)) + .toBeGreaterThan(200); await reloadedChiefPicker.screenshot({ path: test.info().outputPath('chief-category-after-reload-mobile-500.png'), }); @@ -1825,6 +1823,61 @@ test('shows city or capital maps for every requested chief command', async ({ pa await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true }); }); +test('prioritizes own cities for assignment while retaining other map targets', async ({ page }) => { + const assignmentTable = structuredClone(commandTable); + assignmentTable.inputOptions.cities = [ + { value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' }, + { value: 3, label: '단양 (무주)' }, + { value: 1, label: '업 (아국)' }, + ]; + + await install(page, false, assignmentTable); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/che/chief-center'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click(); + await picker.getByRole('button', { name: /발령/ }).click(); + + const form = picker.getByTestId('command-argument-form'); + const citySelect = form.locator('#command-arg-destCityId'); + await expect(form.getByTestId('command-argument-map')).toBeVisible(); + await expect(citySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']); + await expect(citySelect).toHaveValue('1'); + + await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click(); + await expect(citySelect).toHaveValue('2'); + await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창'); + await expect(page).toHaveURL(/\/che\/chief-center$/); + await form.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority.png') }); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto('/che/chief-center'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const mobilePicker = page.getByTestId('command-picker'); + await mobilePicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click(); + await mobilePicker.getByRole('button', { name: /발령/ }).click(); + + const mobileForm = mobilePicker.getByTestId('command-argument-form'); + const mobileMap = mobileForm.getByTestId('command-argument-map'); + const mobileCitySelect = mobileForm.locator('#command-arg-destCityId'); + await expect(mobileMap).toBeVisible(); + await expect(mobileCitySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']); + const mobileGeometry = await mobilePicker.evaluate((element) => ({ + width: element.getBoundingClientRect().width, + overflow: element.scrollWidth - element.clientWidth, + })); + expect(mobileGeometry).toEqual({ width: 500, overflow: 0 }); + const mapGeometry = await mobileMap.locator('.map-area').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + expect(mapGeometry.width / mapGeometry.height).toBeCloseTo(7 / 5, 2); + await mobileMap.screenshot({ path: test.info().outputPath('chief-assignment-map-mobile.png') }); + await mobileCitySelect.scrollIntoViewIfNeeded(); + await mobilePicker.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority-mobile.png') }); +}); + test('prioritizes current nation targets while preserving every choice', async ({ page }) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); diff --git a/app/game-frontend/src/components/command/commandArgumentOptions.ts b/app/game-frontend/src/components/command/commandArgumentOptions.ts new file mode 100644 index 00000000..e8c84dc7 --- /dev/null +++ b/app/game-frontend/src/components/command/commandArgumentOptions.ts @@ -0,0 +1,21 @@ +import type { CommandMapData, CommandOption } from './types'; + +export const commandCityOptions = ( + commandKey: string, + options: readonly CommandOption[], + mapData?: CommandMapData | null +): CommandOption[] => { + if (commandKey !== 'che_발령' || typeof mapData?.myNation !== 'number') return [...options]; + + const nationByCityId = new Map(mapData.cityList.map(([cityId, , , nationId]) => [cityId, nationId])); + return options + .map((option, index) => ({ option, index })) + .sort((left, right) => { + const leftOwned = + typeof left.option.value === 'number' && nationByCityId.get(left.option.value) === mapData.myNation; + const rightOwned = + typeof right.option.value === 'number' && nationByCityId.get(right.option.value) === mapData.myNation; + return Number(rightOwned) - Number(leftOwned) || left.index - right.index; + }) + .map(({ option }) => option); +}; diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index c6b7fc67..57e266d4 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -2,6 +2,7 @@ import { computed, reactive, watch, type CSSProperties } from 'vue'; import MapViewer from './MapViewer.vue'; import { commandArgumentPresentation } from '../command/commandArgumentPresentation'; +import { commandCityOptions } from '../command/commandArgumentOptions'; import { commandArgumentFieldContract, shouldPreserveCommandArgumentValue, @@ -64,6 +65,9 @@ const optionsFor = (field: CommandInputField): CommandOption[] => { if (field.optionSource === 'nations') { return props.options.nationTargets?.[props.commandKey] ?? props.options.nations; } + if (field.optionSource === 'cities') { + return commandCityOptions(props.commandKey, props.options.cities, props.mapData); + } if (field.optionSource === 'items') { return props.options.items[String(values.itemType ?? '')] ?? []; } @@ -104,12 +108,7 @@ const synchronizeValues = () => { for (const field of props.fields) { const preserve = !commandChanged && - shouldPreserveCommandArgumentValue( - field, - previousFieldContracts.get(field.key), - values, - optionsFor(field) - ); + shouldPreserveCommandArgumentValue(field, previousFieldContracts.get(field.key), values, optionsFor(field)); if (!preserve) values[field.key] = defaultValue(field); } const itemCodeField = props.fields.find((field) => field.key === 'itemCode'); diff --git a/app/game-frontend/test/commandArgumentOptions.test.ts b/app/game-frontend/test/commandArgumentOptions.test.ts new file mode 100644 index 00000000..2c8635e2 --- /dev/null +++ b/app/game-frontend/test/commandArgumentOptions.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { commandCityOptions } from '../src/components/command/commandArgumentOptions.ts'; +import type { CommandMapData, CommandOption } from '../src/components/command/types.ts'; + +const cities: CommandOption[] = [ + { value: 20, label: '적국 도시' }, + { value: 30, label: '아국 도시 둘' }, + { value: 40, label: '공백지' }, + { value: 10, label: '아국 도시 하나' }, +]; +const mapData: CommandMapData = { + year: 200, + month: 1, + startYear: 180, + cityList: [ + [10, 8, 0, 1, 1, 1], + [20, 7, 0, 2, 2, 1], + [30, 6, 0, 1, 3, 1], + [40, 5, 0, 0, 4, 1], + ], + nationList: [ + [1, '아국', '#008000', 10], + [2, '적국', '#800000', 20], + ], + myCity: 10, + myNation: 1, +}; + +void test('발령은 아국 도시를 먼저 두고 적국과 공백지를 원래 순서로 보존한다', () => { + const sorted = commandCityOptions('che_발령', cities, mapData); + + assert.deepEqual( + sorted.map((option) => option.value), + [30, 10, 20, 40] + ); + assert.deepEqual( + cities.map((option) => option.value), + [20, 30, 40, 10], + '공용 입력 option은 변경하지 않는다' + ); +}); + +void test('다른 도시 대상 명령의 순서는 바꾸지 않는다', () => { + assert.deepEqual( + commandCityOptions('che_출병', cities, mapData).map((option) => option.value), + [20, 30, 40, 10] + ); +}); + +void test('지도 국가 정보가 아직 없으면 발령의 기존 option 순서를 유지한다', () => { + assert.deepEqual( + commandCityOptions('che_발령', cities, null).map((option) => option.value), + [20, 30, 40, 10] + ); +}); From b0ab73e08b3ee28ccacfd6936f136dfa966692d3 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 01:06:06 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(game-ui):=20=EC=82=AC=EB=A0=B9?= =?UTF-8?q?=ED=84=B4=20=EB=8C=80=EC=83=81=20=EC=A7=80=EB=8F=84=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=9E=90=20=EA=B3=84=EC=95=BD=EC=97=90=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도시·국가 선택 필드에서 지도 대상을 자동 판별하고 발령을 제외한 공개 사령턴 13종의 지도 선택과 보조 정보를 Chromium 회귀로 고정한다. --- app/game-api/test/commandInput.test.ts | 40 ++++++-- .../e2e/commandArguments.spec.ts | 97 ++++++++++++++----- .../command/commandArgumentPresentation.ts | 20 +++- .../components/main/CommandArgumentForm.vue | 54 ++++------- .../test/commandArgumentPresentation.test.ts | 23 +++++ 5 files changed, 167 insertions(+), 67 deletions(-) diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index 97d5c0c1..ad464973 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -6,10 +6,7 @@ import { } from '@sammo-ts/logic'; import { describe, expect, it } from 'vitest'; -import { - buildTurnCommandInputFields, - parseReservedTurnArgs, -} from '../src/turns/commandInput.js'; +import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js'; describe('turn command argument input', () => { it('builds supported fields for every argument-bearing command module', async () => { @@ -22,6 +19,9 @@ describe('turn command argument input', () => { key: spec.key, fields: buildTurnCommandInputFields(spec), })); + const nationFields = nation + .filter((spec) => spec.reqArg) + .map((spec) => ({ key: spec.key, fields: buildTurnCommandInputFields(spec) })); expect(argumentSpecs).toHaveLength(44); expect(fields.every((entry) => entry.fields.length > 0)).toBe(true); @@ -32,6 +32,34 @@ describe('turn command argument input', () => { { key: 'destNationId', kind: 'select', optionSource: 'nations' }, { key: 'amountList', kind: 'numberTuple' }, ]); + + expect( + nationFields + .filter((entry) => entry.key !== 'che_발령') + .flatMap((entry) => + entry.fields + .filter((field) => field.optionSource === 'cities' || field.optionSource === 'nations') + .map((field) => `${entry.key}:${field.optionSource}`) + ) + .sort() + ).toEqual( + [ + 'che_급습:nations', + 'che_물자원조:nations', + 'che_백성동원:cities', + 'che_불가침제의:nations', + 'che_불가침파기제의:nations', + 'che_선전포고:nations', + 'che_수몰:cities', + 'che_이호경식:nations', + 'che_종전제의:nations', + 'che_천도:cities', + 'che_초토화:cities', + 'che_피장파장:nations', + 'che_허보:cities', + 'cr_인구이동:cities', + ].sort() + ); }); it('normalizes valid arguments and rejects malformed or wrong-scope commands', async () => { @@ -50,8 +78,6 @@ describe('turn command argument input', () => { amount: 200, destGeneralId: 7, }); - await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow( - 'Unknown general turn command' - ); + await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command'); }); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index c1d8afa2..5eedd0d3 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -522,6 +522,21 @@ const commandTable = { buildCityCommand('che_백성동원', '백성동원'), buildCityCommand('che_수몰', '수몰'), buildCityCommand('che_허보', '허보'), + buildNationCommand('che_이호경식', '이호경식'), + buildNationCommand('che_급습', '급습'), + { + ...buildNationCommand('che_피장파장', '피장파장'), + inputFields: [ + ...buildNationCommand('che_피장파장', '피장파장').inputFields, + { + key: 'commandType', + label: '대응 명령', + kind: 'select', + required: true, + options: [{ value: 'che_수몰', label: '수몰' }], + }, + ], + }, ], }, ], @@ -815,7 +830,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: { id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] }, ], regionMap: { 1: '하북', 2: '예주' }, - levelMap: { 8: '특' }, + levelMap: { 8: '특', 7: '대' }, }); if (name === 'auth.status') return response({ ok: true }); if (name === 'lobby.info') @@ -953,9 +968,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile', await expect(spy).toBeFocused(); await spy.click(); const form = picker.getByTestId('command-argument-form'); - await expect(form.getByTestId('command-argument-guidance')).toContainText( - '선택한 도시에 첩보를 실행합니다.' - ); + await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 첩보를 실행합니다.'); await expect(form.getByTestId('command-argument-guidance')).toContainText( '인접 도시에서는 더 많은 정보를 얻습니다.' ); @@ -979,9 +992,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile', await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') }); }); -test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ - page, -}) => { +test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page }) => { const foundingColors = [ { value: 0, label: '색상 1', color: '#FF0000' }, { value: 15, label: '색상 16', color: '#6495ED' }, @@ -1284,7 +1295,9 @@ test('keeps general and chief command categories after input and across page rel const reloadedChiefPicker = chiefPage.getByTestId('command-picker'); await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/); await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible(); - await expect.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(200); + await expect + .poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height)) + .toBeGreaterThan(200); await reloadedChiefPicker.screenshot({ path: test.info().outputPath('chief-category-after-reload-mobile-500.png'), }); @@ -1780,18 +1793,25 @@ test('uses the map to choose a nation target in the chief command window', async await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true }); }); -test('shows city or capital maps for every requested chief command', async ({ page }) => { +test('shows a map and target details for every city or nation argument chief command except assignment', async ({ + page, +}) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); const cases = [ - { category: '인사', action: '발령', mode: 'city' }, { category: '특수', action: '초토화', mode: 'city' }, { category: '특수', action: '천도', mode: 'city' }, - { category: '특수', action: '증축', mode: 'capital' }, - { category: '특수', action: '감축', mode: 'capital' }, { category: '전략', action: '수몰', mode: 'city' }, { category: '전략', action: '허보', mode: 'city' }, { category: '전략', action: '백성동원', mode: 'city' }, + { category: '외교', action: '원조', mode: 'nation' }, + { category: '외교', action: '불가침 제의', mode: 'nation' }, + { category: '외교', action: '선전포고', mode: 'nation' }, + { category: '외교', action: '종전 제의', mode: 'nation' }, + { category: '외교', action: '불가침 파기 제의', mode: 'nation' }, + { category: '전략', action: '이호경식', mode: 'nation' }, + { category: '전략', action: '급습', mode: 'nation' }, + { category: '전략', action: '피장파장', mode: 'nation' }, ]; for (const entry of cases) { @@ -1805,24 +1825,51 @@ test('shows city or capital maps for every requested chief command', async ({ pa await expect(map, `${entry.action} 지도`).toBeVisible(); await expect(form.getByTestId('command-argument-guidance')).toBeVisible(); - if (entry.mode === 'capital') { - await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 수도업'); - await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 수도'); - await expect(map.locator('.city-base').first()).toHaveJSProperty('tagName', 'DIV'); - expect( - await map - .locator('.city-base') - .first() - .evaluate((node) => getComputedStyle(node).cursor) - ).toBe('default'); - } else { - await map.locator('.city-base').nth(1).click(); + await map.locator('.city-base').nth(1).click(); + if (entry.mode === 'city') { await expect(form.locator('#command-arg-destCityId')).toHaveValue('2'); await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창'); + await expect(form.getByTestId('command-map-target-summary')).toContainText( + '허창 · 적국 · 예주 · 대 · 현재 도시에서 1칸' + ); + } else { + await expect(form.locator('#command-arg-destNationId')).toHaveValue('2'); + await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국'); + await expect(form.getByTestId('command-map-target-summary')).toContainText('적국 · 수도 허창 · 도시 1개'); } + await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업'); } - await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true }); + await page.screenshot({ path: test.info().outputPath('chief-command-target-map-details.png'), fullPage: true }); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto('/che/chief-center'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click(); + await picker.getByRole('button', { name: /피장파장/ }).click(); + const form = picker.getByTestId('command-argument-form'); + const map = form.getByTestId('command-argument-map'); + const targetCity = map.locator('.city-base').nth(1); + await targetCity.hover(); + expect(await targetCity.evaluate((node) => getComputedStyle(node).cursor)).toBe('pointer'); + await targetCity.focus(); + await expect(targetCity).toBeFocused(); + await targetCity.click(); + await expect(form.locator('#command-arg-destNationId')).toHaveValue('2'); + const geometry = await picker.evaluate((element) => ({ + pickerWidth: element.getBoundingClientRect().width, + pickerOverflow: element.scrollWidth - element.clientWidth, + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + mapInsidePicker: + element.querySelector('[data-testid="command-argument-map"]')!.getBoundingClientRect().right <= + element.getBoundingClientRect().right, + })); + expect(geometry).toEqual({ pickerWidth: 500, pickerOverflow: 0, documentOverflow: 0, mapInsidePicker: true }); + await page.screenshot({ + path: test.info().outputPath('chief-command-target-map-details-mobile.png'), + fullPage: true, + }); }); test('prioritizes current nation targets while preserving every choice', async ({ page }) => { diff --git a/app/game-frontend/src/components/command/commandArgumentPresentation.ts b/app/game-frontend/src/components/command/commandArgumentPresentation.ts index c5e80015..0e98a3a2 100644 --- a/app/game-frontend/src/components/command/commandArgumentPresentation.ts +++ b/app/game-frontend/src/components/command/commandArgumentPresentation.ts @@ -1,6 +1,10 @@ +import type { CommandInputField } from './types'; + +export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital'; + export type CommandArgumentPresentation = { lines: string[]; - mapTarget?: 'city' | 'nation' | 'capital'; + mapTarget?: CommandArgumentMapTarget; }; const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' }); @@ -98,4 +102,18 @@ const PRESENTATIONS: Record = { export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation => PRESENTATIONS[commandKey] ?? { lines: [] }; +/** + * 대상 지도는 명령명 목록이 아니라 API가 내린 실제 인자 계약을 우선한다. + * 인자가 없는 증축·감축의 수도 확인 지도만 presentation의 명시적 target을 사용한다. + */ +export const resolveCommandArgumentMapTarget = ( + commandKey: string, + fields: readonly CommandInputField[] +): CommandArgumentMapTarget | undefined => { + const selectableTargets = fields.filter((field) => field.kind === 'select'); + if (selectableTargets.some((field) => field.optionSource === 'cities')) return 'city'; + if (selectableTargets.some((field) => field.optionSource === 'nations')) return 'nation'; + return commandArgumentPresentation(commandKey).mapTarget; +}; + export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS); diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index c6b7fc67..8dd420d2 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -1,7 +1,7 @@