From 4a50c354fe054ee4c01667d5ae7ad96d7fcdf3ca Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 23 Sep 2026 14:55:39 +0000 Subject: [PATCH] =?UTF-8?q?=EB=93=B1=EC=9A=A9=C2=B7=EC=9E=A5=EC=88=98?= =?UTF-8?q?=EB=8C=80=EC=83=81=EC=9E=84=EA=B4=80=20=EB=8C=80=EC=83=81?= =?UTF-8?q?=EC=9D=98=20=ED=83=80=EA=B5=AD=20=EC=9E=A5=EC=88=98=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20Ref=20=EA=B3=B5=EA=B0=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=EC=A0=9C=ED=95=9C=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EA=B5=AD=EA=B0=80=EC=83=89=20=EB=AC=B6=EC=9D=8C=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 명령표의 등용·장수대상임관·공통 장수 fallback에서 타국·재야 장수의 금·쌀·병력·훈련·사기·부대·위치 도시를 제외 - Ref exportJSVars 범위(이름·국가·NPC·통/무/지)만 싣고 국가 묶음용 nationId 추가 - 재야 actor에게는 같은 국가 명령 후보를 보내지 않음 - 등용·장수대상임관 후보를 Ref SelectGeneral groupByNation처럼 국가색 머리와 optgroup으로 묶음 - 증여·포상·몰수·부대탈퇴지시·선양 후보에 Ref의 통/무/지 표시 Co-Authored-By: Claude Opus 5.5 (1M context) --- app/game-api/src/turns/commandInput.ts | 2 + app/game-api/src/turns/commandTargets.ts | 86 +++++++++----- app/game-api/test/commandTargets.test.ts | 110 +++++++++++++++++- .../e2e/commandArguments.spec.ts | 109 +++++++++++++++++ .../command/commandGeneralGroups.ts | 50 ++++++++ .../src/components/command/types.ts | 1 + .../components/main/CommandArgumentForm.vue | 109 ++++++++++++----- .../src/utils/commandTargetDescription.ts | 8 +- .../test/commandGeneralGroups.test.ts | 45 +++++++ .../test/commandTargetDescription.test.ts | 1 + 10 files changed, 461 insertions(+), 60 deletions(-) create mode 100644 app/game-frontend/src/components/command/commandGeneralGroups.ts create mode 100644 app/game-frontend/test/commandGeneralGroups.test.ts diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index da49afaa..a85ca121 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -36,6 +36,8 @@ export interface TurnCommandOption { crew?: number; troopId?: number; npcState?: number; + /** 등용·장수대상임관처럼 여러 국가 장수를 고르는 목록의 국가별 묶음 기준. */ + nationId?: number; } export interface TurnCommandAmountPreset { diff --git a/app/game-api/src/turns/commandTargets.ts b/app/game-api/src/turns/commandTargets.ts index f1bf4095..9737a0a0 100644 --- a/app/game-api/src/turns/commandTargets.ts +++ b/app/game-api/src/turns/commandTargets.ts @@ -48,6 +48,15 @@ export interface RefNationTargetOptions { const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const; const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const; +const statsText = (entry: GeneralTargetSource): string => + [ + entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`, + entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`, + entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`, + ] + .filter(Boolean) + .join(' · '); + /** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */ export const buildRefGeneralTargetOptions = (options: { actorId: number; @@ -64,29 +73,30 @@ export const buildRefGeneralTargetOptions = (options: { const isTroopExit = action === 'che_부대탈퇴지시'; const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined; const label = (() => { - if (action === 'che_발령') return `${entry.name} (${cityName})`; - if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`; + // 같은 국가 후보만 있는 명령은 국명을 반복하지 않는다. + if (['che_발령', 'che_포상', 'che_몰수', 'che_선양'].includes(action ?? '')) { + return `${entry.name} (${cityName})`; + } return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`; })(); - const details = [ - entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`, - entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`, - entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`, - entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`, - entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`, - ].filter((value): value is string => Boolean(value)); + // Ref che_선양.vue는 후보에 (통/무/지)만 보인다. + const details = ( + action === 'che_선양' + ? [] + : [ + entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`, + entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`, + entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`, + entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`, + entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`, + ] + ).filter((value): value is string => Boolean(value)); // 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다. // 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다. const assignmentDetails = action === 'che_발령' ? [ - [ - entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`, - entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`, - entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`, - ] - .filter(Boolean) - .join(' · '), + statsText(entry), [ entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`, entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`, @@ -104,6 +114,9 @@ export const buildRefGeneralTargetOptions = (options: { .filter(Boolean) .join('\n') : undefined; + // Ref ProcessGeneralAmount·ProcessGeneral은 같은 국가 후보에 (통/무/지)를 함께 보인다. + const stats = statsText(entry); + if (stats && action !== 'che_발령') details.push(stats); if (isTroopExit) { details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가'); } @@ -125,33 +138,48 @@ export const buildRefGeneralTargetOptions = (options: { npcState: entry.npcState, }; }; - const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] => - options.generals.filter(predicate).map((entry) => toOption(entry)); + // Ref che_등용·che_장수대상임관 exportJSVars()는 타국·재야 장수의 + // no,name,nation,officer_level,npc,leadership,strength,intel만 내보낸다. + // 명령표는 모든 장수에게 전달되므로 금·쌀·병력·훈련·사기·부대·위치 도시를 싣지 않는다. + const toPublicOption = (entry: GeneralTargetSource): TurnCommandOption => { + const nationName = options.nationNames.get(entry.nationId) ?? null; + return { + value: entry.id, + label: `${entry.name} (${nationName ?? '재야'})`, + targetNames: { name: entry.name, nationName }, + description: statsText(entry), + npcState: entry.npcState, + nationId: entry.nationId, + }; + }; + const projectPublic = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] => + options.generals.filter(predicate).map(toPublicOption); + // 재야는 국가가 아니므로 같은 국가 명령 후보가 없다. 해당 명령도 NotBeNeutral로 막힌다. + const isSameNation = (entry: GeneralTargetSource): boolean => + options.actorNationId !== 0 && entry.nationId === options.actorNationId; const generalTargets: Record = {}; for (const action of SAME_NATION_GENERAL_COMMANDS) { - generalTargets[action] = options.generals - .filter((entry) => entry.nationId === options.actorNationId) - .map((entry) => toOption(entry, action)); + generalTargets[action] = options.generals.filter(isSameNation).map((entry) => toOption(entry, action)); } for (const action of SAME_NATION_NATION_COMMANDS) { generalTargets[action] = options.generals - .filter((entry) => entry.nationId === options.actorNationId) + .filter(isSameNation) .map((entry) => toOption(entry, action)) .sort((left, right) => Number(right.availableNow) - Number(left.availableNow)); } - generalTargets.che_선양 = project( - (entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId - ); - generalTargets.che_등용 = project( + generalTargets.che_선양 = options.generals + .filter((entry) => isSameNation(entry) && entry.id !== options.actorId) + .map((entry) => toOption(entry, 'che_선양')); + generalTargets.che_등용 = projectPublic( (entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId ); - generalTargets.che_장수대상임관 = project((entry) => entry.id !== options.actorId); + generalTargets.che_장수대상임관 = projectPublic((entry) => entry.id !== options.actorId); return { - // 기존 profile의 공통 fallback은 유저장 목록을 유지한다. - generals: project((entry) => entry.npcState < 2), + // 기존 profile의 공통 fallback은 유저장 목록을 유지한다. 타국 장수가 섞이므로 공개 필드만 둔다. + generals: projectPublic((entry) => entry.npcState < 2), generalTargets, }; }; diff --git a/app/game-api/test/commandTargets.test.ts b/app/game-api/test/commandTargets.test.ts index 6a91ba5d..3881b3a9 100644 --- a/app/game-api/test/commandTargets.test.ts +++ b/app/game-api/test/commandTargets.test.ts @@ -49,8 +49,9 @@ 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 })], + // 이름·국가·도시·부대 원본이 없는 경우. 재야 actor는 같은 국가 후보가 없으므로 국가 ID만 둔다. + actorNationId: 5, + generals: [general({ name: '없음', nationId: 5, cityId: 0, troopId: 99 })], nationNames: new Map(), cityNames: new Map(), troopNames: new Map(), @@ -118,7 +119,10 @@ describe('Ref command general targets', () => { ); 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('통솔'); + // Ref ProcessGeneralAmount.vue처럼 증여 후보도 (통/무/지)를 함께 보인다. 발령의 줄바꿈 형식은 쓰지 않는다. + expect(detailed.generalTargets.che_증여?.[1]?.description).toBe( + '금 100 · 쌀 200 · 병력 900 · 훈련 80 · 사기 70 · 통솔 100 · 무력 95 · 지력 0' + ); expect(detailed.generalTargets.che_증여?.map((entry) => entry.targetNames)).toEqual([ { name: '본인', nationName: '아국', cityName: '업', troopName: null }, { name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' }, @@ -132,6 +136,106 @@ describe('Ref command general targets', () => { expect(detailed.generalTargets.che_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000'); expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대'); }); + + it('limits cross-nation recruitment and joining targets to the fields Ref exports', () => { + const foreign = general({ + id: 7, + name: '타국장수', + nationId: 2, + cityId: 20, + officerLevel: 1, + gold: 9000, + rice: 8000, + crew: 7000, + train: 90, + atmos: 80, + troopId: 7, + leadership: 75, + strength: 60, + intel: 45, + }); + const wanderer = general({ id: 8, name: '재야장수', nationId: 0, cityId: 30, gold: 500, rice: 400 }); + const options = buildRefGeneralTargetOptions({ + actorId: 1, + actorNationId: 1, + generals: [general({}), foreign, wanderer], + nationNames: new Map([ + [1, '아국'], + [2, '타국'], + ]), + cityNames: new Map([ + [10, '업'], + [20, '허창'], + [30, '단양'], + ]), + troopNames: new Map([[7, '비밀부대']]), + }); + for (const list of [ + options.generalTargets.che_등용, + options.generalTargets.che_장수대상임관, + options.generals, + ]) { + const target = list?.find((entry) => entry.value === 7); + // Ref: no,name,nation,officer_level,npc,leadership,strength,intel + expect(target).toEqual({ + value: 7, + label: '타국장수 (타국)', + targetNames: { name: '타국장수', nationName: '타국' }, + description: '통솔 75 · 무력 60 · 지력 45', + npcState: 0, + nationId: 2, + }); + const serialized = JSON.stringify(list); + for (const hidden of ['9,000', '9000', '8000', '7000', '허창', '단양', '비밀부대', '훈련', '사기']) { + expect(serialized).not.toContain(hidden); + } + expect(list?.find((entry) => entry.value === 8)).toMatchObject({ + label: '재야장수 (재야)', + targetNames: { name: '재야장수', nationName: null }, + nationId: 0, + }); + } + }); + + it('gives a wandering actor no same-nation general candidates', () => { + const options = buildRefGeneralTargetOptions({ + actorId: 1, + actorNationId: 0, + generals: [general({ nationId: 0 }), general({ id: 2, name: '다른재야', nationId: 0, gold: 900 })], + nationNames: new Map(), + cityNames: new Map([[10, '업']]), + }); + for (const action of ['che_증여', 'che_선양', 'che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시']) { + expect(options.generalTargets[action]).toEqual([]); + } + expect(options.generalTargets.che_장수대상임관?.map((entry) => entry.value)).toEqual([2]); + expect(JSON.stringify(options.generalTargets.che_장수대상임관)).not.toContain('900'); + }); + + it('shows only city and Ref stats for abdication candidates', () => { + const options = buildRefGeneralTargetOptions({ + actorId: 1, + actorNationId: 1, + generals: [ + general({}), + general({ + id: 2, + name: '후계', + gold: 100, + rice: 200, + crew: 300, + leadership: 80, + strength: 70, + intel: 60, + }), + ], + nationNames: new Map([[1, '아국']]), + cityNames: new Map([[10, '업']]), + }); + expect(options.generalTargets.che_선양).toMatchObject([ + { value: 2, label: '후계 (업)', description: '통솔 80 · 무력 70 · 지력 60' }, + ]); + }); }); describe('Ref nation target guidance', () => { diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index a8fd6951..e9239d53 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -4222,3 +4222,112 @@ for (const viewport of [ await overlay.getByRole('button', { name: '명령 취소', exact: true }).click(); }); } + +for (const width of [1200, 390]) { + test(`groups recruitment targets by nation color without cross-nation resources at ${width}px`, async ({ + page, + }, testInfo) => { + const targets = buildRefGeneralTargetOptions({ + actorId: 1, + actorNationId: 1, + generals: [ + { id: 1, name: '장수', nationId: 1, cityId: 1, npcState: 0, officerLevel: 5 }, + { + id: 4, + name: '적장', + nationId: 2, + cityId: 2, + npcState: 0, + officerLevel: 1, + gold: 9100, + rice: 8200, + crew: 7300, + train: 90, + atmos: 80, + troopId: 4, + leadership: 75, + strength: 60, + intel: 45, + }, + { id: 5, name: '떠돌이', nationId: 0, cityId: 3, npcState: 0, officerLevel: 0, gold: 600 }, + { id: 6, name: '빙의장', nationId: 2, cityId: 2, npcState: 1, officerLevel: 1 }, + { id: 7, name: '아군', nationId: 1, cityId: 1, npcState: 0, officerLevel: 1 }, + ], + nationNames: new Map([ + [1, '아국'], + [2, '적국'], + ]), + cityNames: new Map([ + [1, '업'], + [2, '허창'], + [3, '단양'], + ]), + troopNames: new Map([[4, '비밀부대']]), + }); + const requests = await install(page, false, { + ...commandTable, + general: [{ category: '인사', values: [buildGeneralCommand('che_등용', '등용')] }], + inputOptions: { ...inputOptions, ...targets }, + }); + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); + 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 list = form.getByTestId('general-target-list'); + const headers = list.getByTestId('general-nation-group'); + // Ref ORDER BY npc,name 순서에서 국가가 처음 나타난 순서로 묶는다. + await expect(headers).toHaveText(['적국', '재야', '아국']); + // 같은 국가 장수는 한 묶음에 모이므로 빙의장(npc=1)이 적국 묶음 안에 들어간다. + await expect(list.locator('.target-option strong')).toHaveText(['적장', '빙의장', '떠돌이', '아군']); + await expect(form.locator('#command-arg-destGeneralId optgroup')).toHaveCount(3); + await expect(form.locator('#command-arg-destGeneralId optgroup').first()).toHaveAttribute('label', '적국'); + await expect(form.locator('#command-arg-destGeneralId option').first()).toHaveText('적장 (적국)'); + const text = await list.innerText(); + for (const hidden of ['9,100', '8,200', '7,300', '허창', '단양', '비밀부대', '훈련', '사기']) { + expect(text).not.toContain(hidden); + } + await expect(list.locator('.target-option').first()).toContainText('통솔 75 · 무력 60 · 지력 45'); + const style = await headers.evaluateAll((nodes) => + nodes.map((node) => { + const computed = getComputedStyle(node); + return [computed.backgroundColor, computed.color, computed.position]; + }) + ); + expect(style).toEqual([ + ['rgb(128, 0, 0)', 'rgb(255, 255, 255)', 'sticky'], + ['rgb(0, 0, 0)', 'rgb(255, 255, 255)', 'sticky'], + ['rgb(0, 128, 0)', 'rgb(255, 255, 255)', 'sticky'], + ]); + // 빙의장(npc=1)은 Ref getNPCColor처럼 skyblue다. + await expect(list.locator('.target-option').nth(1).locator('strong')).toHaveCSS('color', 'rgb(135, 206, 235)'); + await picker.screenshot({ path: testInfo.outputPath(`recruit-groups-all-${width}.png`) }); + + await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); + await form.locator('#command-search-destGeneralId').fill('ㄸㄷ'); + await expect(headers).toHaveText(['재야']); + await expect(list.locator('.target-option')).toHaveCount(1); + await list.locator('.target-option').click(); + await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('5'); + + await page.evaluate(() => document.fonts.ready); + const geometry = await picker.evaluate((element) => ({ + url: location.href, + viewport: [innerWidth, innerHeight], + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + overlayOverflow: element.scrollWidth - element.clientWidth, + list: element.querySelector('[data-testid=general-target-list]')?.getBoundingClientRect().toJSON(), + headers: [...element.querySelectorAll('[data-testid=general-nation-group]')].map((node) => + node.getBoundingClientRect().toJSON() + ), + })); + // 메인 화면 자체는 Ref처럼 최소 500px 폭이라 390px에서 문서가 넓다. 전체화면 overlay 안만 확인한다. + expect(geometry.overlayOverflow).toBe(0); + await writeFile(testInfo.outputPath(`recruit-groups-${width}.json`), JSON.stringify(geometry, null, 2)); + await picker.screenshot({ path: testInfo.outputPath(`recruit-groups-${width}.png`) }); + + await picker.getByRole('button', { name: / 입력$/ }).click(); + await expect.poll(() => JSON.stringify(requests)).toContain('"action":"che_등용","args":{"destGeneralId":5}'); + }); +} diff --git a/app/game-frontend/src/components/command/commandGeneralGroups.ts b/app/game-frontend/src/components/command/commandGeneralGroups.ts new file mode 100644 index 00000000..6399a3a2 --- /dev/null +++ b/app/game-frontend/src/components/command/commandGeneralGroups.ts @@ -0,0 +1,50 @@ +import { legacyLuminanceTextColor } from '../../utils/legacyNationColor.ts'; +import type { CommandOption } from './types'; + +/** Ref `SelectGeneral.vue`에 `groupByNation`을 넘기는 명령. */ +export const NATION_GROUPED_GENERAL_COMMANDS: ReadonlySet = new Set(['che_등용', 'che_장수대상임관']); + +export type GeneralOptionGroup = { + nationId: number; + name: string; + color: string; + textColor: '#000000' | '#FFFFFF'; + options: CommandOption[]; +}; + +/** Ref `getNationStaticInfo(0)`의 재야 표시값. */ +const WANDERER = { name: '재야', color: '#000000' } as const; + +/** + * Ref처럼 정렬된 장수 목록에서 국가가 처음 나타난 순서대로 묶는다. + * 머리 글자색은 Ref `isBrightColor()` 기준이다. + */ +export const groupGeneralOptionsByNation = ( + options: readonly CommandOption[], + nations: readonly CommandOption[] +): GeneralOptionGroup[] => { + const nationById = new Map(nations.map((nation) => [nation.value, nation])); + const groups = new Map(); + for (const option of options) { + const nationId = option.nationId ?? 0; + let group = groups.get(nationId); + if (!group) { + const nation = nationId === 0 ? undefined : nationById.get(nationId); + const name = + nationId === 0 + ? WANDERER.name + : (nation?.targetNames?.name ?? nation?.label ?? option.targetNames?.nationName ?? WANDERER.name); + const color = nationId === 0 ? WANDERER.color : (nation?.color ?? WANDERER.color); + group = { + nationId, + name, + color, + textColor: legacyLuminanceTextColor(color), + options: [], + }; + groups.set(nationId, group); + } + group.options.push(option); + } + return [...groups.values()]; +}; diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index 959b2524..515f8d82 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -17,6 +17,7 @@ export type CommandOption = { crew?: number; troopId?: number; npcState?: number; + nationId?: number; }; export type CommandAmountPreset = { diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index 25b85410..fd4f4463 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -12,6 +12,11 @@ import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '.. import { commandCityOptions } from '../command/commandArgumentOptions'; import { COMMAND_CITY_DISTANCE_RANGE, citiesBasedOnDistance } from '../command/commandCityDistance'; import { sortCommandGeneralOptions } from '../command/commandGeneralOptions'; +import { + groupGeneralOptionsByNation, + NATION_GROUPED_GENERAL_COMMANDS, + type GeneralOptionGroup, +} from '../command/commandGeneralGroups'; import { commandArgumentFieldContract, shouldPreserveCommandArgumentValue, @@ -141,6 +146,18 @@ const filteredTargets = computed( ); const visibleOptionsFor = (field: CommandInputField): CommandOption[] => filteredTargets.value.get(field.key) ?? optionsFor(field); +// 등용·장수대상임관은 Ref SelectGeneral groupByNation처럼 국가색 머리로 후보를 묶는다. +const isNationGroupedField = (field: CommandInputField): boolean => + field.optionSource === 'generals' && NATION_GROUPED_GENERAL_COMMANDS.has(props.commandKey); +const nationGroupsOf = (field: CommandInputField, options: CommandOption[]): GeneralOptionGroup[] | null => + isNationGroupedField(field) ? groupGeneralOptionsByNation(options, props.options.nations) : null; +type TargetCardGroup = { key: string; header: GeneralOptionGroup | null; options: CommandOption[] }; +const targetCardGroupsFor = (field: CommandInputField): TargetCardGroup[] => { + const options = visibleOptionsFor(field); + const groups = nationGroupsOf(field, options); + if (!groups) return [{ key: 'all', header: null, options }]; + return groups.map((group) => ({ key: `nation-${group.nationId}`, header: group, options: group.options })); +}; const clearSearchQueries = () => { for (const key of searchTimers.keys()) cancelSearchTimer(key); for (const key of Object.keys(searchDrafts)) delete searchDrafts[key]; @@ -574,14 +591,32 @@ watch( :style="colorOptionStyle(field, selectedOptionFor(field))" @change="setSelectValue(field, ($event.target as HTMLSelectElement).value)" > - + +
+
@@ -980,6 +1027,14 @@ small { grid-column: 1 / 3; } +.target-group-header { + position: sticky; + top: 0; + z-index: 1; + padding: 4px 8px; + font-weight: bold; +} + .target-state { grid-column: 3; color: #aee6a7; diff --git a/app/game-frontend/src/utils/commandTargetDescription.ts b/app/game-frontend/src/utils/commandTargetDescription.ts index f636fe81..d9c039c3 100644 --- a/app/game-frontend/src/utils/commandTargetDescription.ts +++ b/app/game-frontend/src/utils/commandTargetDescription.ts @@ -4,7 +4,13 @@ export const commandTargetDescription = (commandKey: string, option?: CommandOpt if (!option) return ''; const description = option.description ?? ''; // 이전 API는 완성된 description을 주므로 새 원본 필드가 없으면 그대로 표시한다. - if (option.targetNames?.troopName === undefined || commandKey === 'che_포상' || commandKey === 'che_몰수') { + // Ref ProcessGeneralAmount·che_선양.vue는 부대를 표시하지 않는다. + if ( + option.targetNames?.troopName === undefined || + commandKey === 'che_포상' || + commandKey === 'che_몰수' || + commandKey === 'che_선양' + ) { return description; } const troopName = option.targetNames.troopName ?? (option.troopId ? `#${option.troopId}` : '없음'); diff --git a/app/game-frontend/test/commandGeneralGroups.test.ts b/app/game-frontend/test/commandGeneralGroups.test.ts new file mode 100644 index 00000000..e3ec67c6 --- /dev/null +++ b/app/game-frontend/test/commandGeneralGroups.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + groupGeneralOptionsByNation, + NATION_GROUPED_GENERAL_COMMANDS, +} from '../src/components/command/commandGeneralGroups.ts'; + +const nations = [ + { value: 1, label: '위', color: '#FFD700' }, + { value: 2, label: '오', color: '#000080' }, +]; + +void test('groups general options by nation in first-appearance order like Ref SelectGeneral', () => { + const groups = groupGeneralOptionsByNation( + [ + { value: 11, label: '가 (오)', nationId: 2 }, + { value: 12, label: '나 (재야)', nationId: 0 }, + { value: 13, label: '다 (위)', nationId: 1 }, + { value: 14, label: '라 (오)', nationId: 2 }, + { value: 15, label: '마 (위)', nationId: 1, npcState: 1 }, + ], + nations + ); + assert.deepEqual( + groups.map((group) => [group.name, group.color, group.textColor, group.options.map((option) => option.value)]), + [ + ['오', '#000080', '#FFFFFF', [11, 14]], + ['재야', '#000000', '#FFFFFF', [12]], + ['위', '#FFD700', '#000000', [13, 15]], + ] + ); +}); + +void test('falls back to the option nation name and the wanderer color for unknown nations', () => { + const [group] = groupGeneralOptionsByNation( + [{ value: 1, label: '가', nationId: 9, targetNames: { name: '가', nationName: '멸망국' } }], + nations + ); + assert.deepEqual([group?.name, group?.color], ['멸망국', '#000000']); + assert.deepEqual(groupGeneralOptionsByNation([], nations), []); +}); + +void test('only Ref groupByNation commands are grouped', () => { + assert.deepEqual([...NATION_GROUPED_GENERAL_COMMANDS], ['che_등용', 'che_장수대상임관']); +}); diff --git a/app/game-frontend/test/commandTargetDescription.test.ts b/app/game-frontend/test/commandTargetDescription.test.ts index 3779460e..39270674 100644 --- a/app/game-frontend/test/commandTargetDescription.test.ts +++ b/app/game-frontend/test/commandTargetDescription.test.ts @@ -8,6 +8,7 @@ void test('nullable troop names receive display-only fallbacks and preserve lead assert.equal(commandTargetDescription('che_발령', base), '탑승 부대 없음\n금 100'); assert.equal(commandTargetDescription('che_포상', base), '금 100'); assert.equal(commandTargetDescription('che_몰수', base), '금 100'); + assert.equal(commandTargetDescription('che_선양', base), '금 100'); assert.equal(commandTargetDescription('che_증여', { ...base, troopId: 99 }), '금 100 · 탑승 부대 #99'); assert.equal( commandTargetDescription('che_증여', {