From 7522f5100eb2abe8070eb875bed4df5d2218c623 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 14 Sep 2026 14:24:59 +0000 Subject: [PATCH] =?UTF-8?q?=ED=84=B4=20=EB=8C=80=EC=83=81=EC=9D=98=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=EC=9D=B4=EB=A6=84=EA=B3=BC=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=EA=B0=92=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=B4=20=EA=B2=80=EC=83=89=20=EC=A0=95=ED=99=95?= =?UTF-8?q?=EB=8F=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/turns/index.ts | 3 +- app/game-api/src/turns/commandInput.ts | 8 +++ app/game-api/src/turns/commandTargets.ts | 23 ++++----- app/game-api/test/commandTargets.test.ts | 51 ++++++++++++++++--- .../e2e/commandArguments.spec.ts | 51 ++++++++++++++++++- .../src/components/command/types.ts | 8 +++ .../components/main/CommandArgumentForm.vue | 15 +++--- .../src/utils/commandTargetDescription.ts | 16 ++++++ .../src/utils/commandTargetSearch.ts | 8 +++ .../test/commandTargetDescription.test.ts | 29 +++++++++++ .../test/commandTargetSearch.test.ts | 37 +++++++++++++- 11 files changed, 220 insertions(+), 29 deletions(-) create mode 100644 app/game-frontend/src/utils/commandTargetDescription.ts create mode 100644 app/game-frontend/test/commandTargetDescription.test.ts diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 37f60fed..ed842b57 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -365,7 +365,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number id: entry.id, name: entry.name, color: entry.color, - capitalName: entry.capitalCityId ? (cityById.get(entry.capitalCityId)?.name ?? '-') : '-', + capitalName: entry.capitalCityId ? cityById.get(entry.capitalCityId)?.name : undefined, level: entry.level, power: readGeneralMetaNumber(entry.meta, 'power') ?? 0, generalCount: generalCountByNation.get(entry.id) ?? 0, @@ -402,6 +402,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number cities: cities.map((entry) => ({ value: entry.id, label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`, + targetNames: { name: entry.name, nationName: nationById.get(entry.nationId)?.name ?? null }, })), nations: nationTargetOptions.nations, nationTargets: nationTargetOptions.nationTargets, diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 68eb7598..3dd31af4 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -20,6 +20,14 @@ export type TurnCommandOptionValue = string | number; export interface TurnCommandOption { value: TurnCommandOptionValue; label: string; + /** 원본 이름. 없는 소속/부대/수도는 null이며 대체 문구는 화면에서 표시한다. */ + targetNames?: { + name: string; + nationName?: string | null; + cityName?: string | null; + troopName?: string | null; + capitalName?: string | null; + }; color?: string; description?: string; availableNow?: boolean; diff --git a/app/game-api/src/turns/commandTargets.ts b/app/game-api/src/turns/commandTargets.ts index a88ceec5..f1bf4095 100644 --- a/app/game-api/src/turns/commandTargets.ts +++ b/app/game-api/src/turns/commandTargets.ts @@ -24,7 +24,7 @@ export interface NationTargetSource { id: number; name: string; color: string; - capitalName: string; + capitalName?: string; level: number; power: number; generalCount: number; @@ -60,9 +60,6 @@ export const buildRefGeneralTargetOptions = (options: { const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => { const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined; const cityName = options.cityNames.get(entry.cityId) ?? '재야'; - const troopLabel = entry.troopId - ? `${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}` - : '부대 없음'; const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id); const isTroopExit = action === 'che_부대탈퇴지시'; const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined; @@ -77,18 +74,12 @@ export const buildRefGeneralTargetOptions = (options: { entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`, entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`, entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`, - action === 'che_발령' || action === 'che_포상' || action === 'che_몰수' - ? null - : entry.troopId - ? `탑승 부대 ${troopLabel}` - : '탑승 부대 없음', ].filter((value): value is string => Boolean(value)); // 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다. // 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다. const assignmentDetails = action === 'che_발령' ? [ - entry.troopId ? `탑승 부대 ${troopLabel}` : '탑승 부대 없음', [ entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`, entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`, @@ -119,6 +110,12 @@ export const buildRefGeneralTargetOptions = (options: { return { value: entry.id, label, + targetNames: { + name: entry.name, + nationName: options.nationNames.get(entry.nationId) ?? null, + cityName: options.cityNames.get(entry.cityId) ?? null, + troopName: troopName ?? null, + }, description: assignmentDetails ?? details.join(' · '), ...(availableNow === undefined ? {} : { availableNow }), ...(entry.gold === undefined ? {} : { gold: entry.gold }), @@ -212,8 +209,9 @@ export const buildRefNationTargetOptions = (options: { const baseOptions = options.nations.map((entry) => ({ value: entry.id, label: entry.name, + targetNames: { name: entry.name, capitalName: entry.capitalName ?? null }, color: entry.color, - description: `수도 ${entry.capitalName} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`, + description: `수도 ${entry.capitalName ?? '-'} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`, })); const nationTargets: Record = {}; for (const action of NATION_TARGET_COMMANDS) { @@ -225,9 +223,10 @@ export const buildRefNationTargetOptions = (options: { return { value: entry.id, label: entry.name, + targetNames: { name: entry.name, capitalName: entry.capitalName ?? null }, color: entry.color, availableNow: availability.available, - description: `${availability.reason} · ${relation}${term} · 수도 ${entry.capitalName} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`, + description: `${availability.reason} · ${relation}${term} · 수도 ${entry.capitalName ?? '-'} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`, power: entry.power, } as TurnCommandOption & { power: number }; }) diff --git a/app/game-api/test/commandTargets.test.ts b/app/game-api/test/commandTargets.test.ts index faac2888..6a91ba5d 100644 --- a/app/game-api/test/commandTargets.test.ts +++ b/app/game-api/test/commandTargets.test.ts @@ -46,6 +46,24 @@ describe('Ref command general targets', () => { } }); + it('indexes genuine names even when named 없음, and excludes missing-name placeholders', () => { + const options = buildRefGeneralTargetOptions({ + actorId: 1, + actorNationId: 0, + generals: [general({ name: '없음', nationId: 0, cityId: 0, troopId: 99 })], + nationNames: new Map(), + cityNames: new Map(), + troopNames: new Map(), + }); + expect(options.generalTargets.che_증여?.[0]?.targetNames).toEqual({ + name: '없음', + nationName: null, + cityName: null, + troopName: null, + }); + expect(options.generalTargets.che_증여?.[0]?.description).not.toContain('없음'); + }); + it('preserves the distinct Ref filters for gift, abdication, recruitment, and target-based joining', () => { expect(ids('che_증여')).toEqual([1, 2, 3]); expect(ids('che_선양')).toEqual([2, 3]); @@ -88,7 +106,7 @@ describe('Ref command general targets', () => { rice: 200, crew: 900, troopId: 3, - description: expect.stringContaining('탑승 부대 청룡대'), + targetNames: { name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' }, }); expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([ '본인 (업)', @@ -96,13 +114,16 @@ describe('Ref command general targets', () => { '부대장 (업)', ]); expect(detailed.generalTargets.che_발령?.[1]?.description).toBe( - '탑승 부대 청룡대\n통솔 100 · 무력 95 · 지력 0\n병력 900 · 훈련 80 · 사기 70\n금 100 · 쌀 200' + '통솔 100 · 무력 95 · 지력 0\n병력 900 · 훈련 80 · 사기 70\n금 100 · 쌀 200' ); - expect(detailed.generalTargets.che_발령?.[0]?.description).toBe( - '탑승 부대 없음\n병력 1,000\n금 5,000 · 쌀 4,000' - ); - expect(detailed.generalTargets.che_발령?.[2]?.description).toContain('청룡대 (부대장)'); + expect(detailed.generalTargets.che_발령?.[0]?.description).toBe('병력 1,000\n금 5,000 · 쌀 4,000'); + expect(detailed.generalTargets.che_발령?.[2]?.targetNames?.troopName).toBe('청룡대'); expect(detailed.generalTargets.che_증여?.[1]?.description).not.toContain('통솔'); + expect(detailed.generalTargets.che_증여?.map((entry) => entry.targetNames)).toEqual([ + { name: '본인', nationName: '아국', cityName: '업', troopName: null }, + { name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' }, + { name: '부대장', nationName: '아국', cityName: '업', troopName: '청룡대' }, + ]); expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([ '본인 (업)', '부대원 (업)', @@ -170,6 +191,24 @@ describe('Ref nation target guidance', () => { }, ]; + it('indexes real nation and capital names without missing-capital or diplomacy copy', () => { + const result = buildRefNationTargetOptions({ + actorNationId: 1, + nations: nations.map((entry) => ({ ...entry, capitalName: undefined })), + }); + expect(result.nations.map((entry) => entry.targetNames)).toEqual( + nations.map((entry) => ({ name: entry.name, capitalName: null })) + ); + for (const options of Object.values(result.nationTargets)) { + for (const option of options) expect(option.targetNames).toEqual({ name: option.label, capitalName: null }); + } + const populated = buildRefNationTargetOptions({ actorNationId: 1, nations }); + expect(populated.nations[0]?.targetNames).toEqual({ + name: nations[0]!.name, + capitalName: nations[0]!.capitalName, + }); + }); + it('sorts the currently relevant relation first for each diplomacy command', () => { const result = buildRefNationTargetOptions({ actorNationId: 1, nations }); expect(result.nationTargets.che_선전포고?.map((entry) => entry.value)).toEqual([2, 1, 3, 4]); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index ad5bf58a..333efafb 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -2892,7 +2892,8 @@ for (const width of [1200, 500]) { ? { ...option, label: `${longName} (업)`, - description: option.description?.replace('청룡대', longTroop), + description: option.description, + targetNames: { ...option.targetNames!, name: longName, troopName: longTroop }, } : option ); @@ -3660,6 +3661,10 @@ for (const width of [1200, 390]) { await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); const generalSearch = form.locator('#command-search-destGeneralId'); const citySearch = form.locator('#command-search-destCityId'); + for (const query of ['ㅇㅇ', '없음', '통솔', '1,200']) { + await generalSearch.fill(query); + await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(0); + } await generalSearch.fill('ㅊㄹㄷ'); await expect(form.getByTestId('general-target-list')).toContainText('청룡대'); await generalSearch.fill('ㄱㅇ'); @@ -3722,3 +3727,47 @@ for (const width of [1200, 390]) { }); }); } + +test('gift search indexes nullable names without matching the displayed no-troop fallback', async ({ + page, +}, testInfo) => { + const targets = buildRefGeneralTargetOptions({ + actorId: 1, + actorNationId: 1, + generals: [ + { id: 1, name: '관우', nationId: 1, cityId: 1, troopId: 0, npcState: 0, officerLevel: 5 }, + { id: 2, name: '원우', nationId: 1, cityId: 1, troopId: 0, npcState: 0, officerLevel: 5 }, + { id: 3, name: '조조', nationId: 1, cityId: 1, troopId: 3, npcState: 0, officerLevel: 5 }, + ], + nationNames: new Map([[1, '피곤']]), + cityNames: new Map([[1, '업']]), + troopNames: new Map([[3, '원위대']]), + }); + await install(page, false, { + ...commandTable, + general: [{ category: '인사', values: [buildGeneralCommand('che_증여', '증여')] }], + inputOptions: { ...inputOptions, ...targets }, + }); + await page.goto(gamePath('/')); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: /증여/ }).click(); + const form = picker.getByTestId('command-argument-form'); + const results = form.getByTestId('general-target-list'); + await expect(results.locator('button')).toHaveCount(3); + await expect(results.getByRole('button', { name: /관우/ })).toContainText('탑승 부대 없음'); + await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); + const search = form.locator('input[type=search]'); + await search.fill('ㅇㅇ'); + await expect(results.locator('button strong')).toHaveText(['원우 (피곤 · 업)', '조조 (피곤 · 업)']); + await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('1'); + await form.screenshot({ path: testInfo.outputPath('gift-real-name-search.png') }); + await writeFile( + testInfo.outputPath('gift-real-name-search.html'), + await form.evaluate((element) => element.outerHTML) + ); + await search.fill('없음'); + await expect(results.locator('button')).toHaveCount(0); + await search.fill('피곤'); + await expect(results.locator('button')).toHaveCount(3); +}); diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index e3ddbe1b..959b2524 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -1,6 +1,14 @@ export type CommandOption = { value: string | number; label: string; + /** 원본 이름. 없는 소속/부대/수도는 null이며 대체 문구는 화면에서 표시한다. */ + targetNames?: { + name: string; + nationName?: string | null; + cityName?: string | null; + troopName?: string | null; + capitalName?: string | null; + }; color?: string; description?: string; availableNow?: boolean; diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index 504cbfc8..41f95cdf 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -1,7 +1,8 @@