등용·장수대상임관 대상의 타국 장수 정보를 Ref 공개 범위로 제한하고 국가색 묶음 복원

- 명령표의 등용·장수대상임관·공통 장수 fallback에서 타국·재야 장수의 금·쌀·병력·훈련·사기·부대·위치 도시를 제외
- Ref exportJSVars 범위(이름·국가·NPC·통/무/지)만 싣고 국가 묶음용 nationId 추가
- 재야 actor에게는 같은 국가 명령 후보를 보내지 않음
- 등용·장수대상임관 후보를 Ref SelectGeneral groupByNation처럼 국가색 머리와 optgroup으로 묶음
- 증여·포상·몰수·부대탈퇴지시·선양 후보에 Ref의 통/무/지 표시

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 14:55:39 +00:00
co-authored by Claude Opus 5.5
parent 0a9c47eddf
commit 4a50c354fe
10 changed files with 461 additions and 60 deletions
@@ -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}');
});
}
@@ -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<string> = 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<number, GeneralOptionGroup>();
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()];
};
@@ -17,6 +17,7 @@ export type CommandOption = {
crew?: number;
troopId?: number;
npcState?: number;
nationId?: number;
};
export type CommandAmountPreset = {
@@ -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)"
>
<option
v-for="option in optionsFor(field)"
:key="String(option.value)"
:value="String(option.value)"
:style="colorOptionStyle(field, option)"
>
{{ option.label }}
</option>
<template v-if="isNationGroupedField(field)">
<optgroup
v-for="group in nationGroupsOf(field, optionsFor(field))"
:key="group.nationId"
:label="group.name"
>
<option
v-for="option in group.options"
:key="String(option.value)"
:value="String(option.value)"
:style="colorOptionStyle(field, option)"
>
{{ option.label }}
</option>
</optgroup>
</template>
<template v-else>
<option
v-for="option in optionsFor(field)"
:key="String(option.value)"
:value="String(option.value)"
:style="colorOptionStyle(field, option)"
>
{{ option.label }}
</option>
</template>
</select>
<div v-else-if="field.kind === 'boolean'" class="boolean-options">
<button
@@ -718,25 +753,37 @@ watch(
<span v-if="!visibleOptionsFor(field).length" class="target-search-empty"
>검색 결과가 없습니다.</span
>
<button
v-for="option in visibleOptionsFor(field)"
:key="String(option.value)"
type="button"
class="target-option"
:class="{
selected: option.value === values[field.key],
unavailable: option.availableNow === false,
}"
:aria-pressed="option.value === values[field.key]"
@click="setSelectValue(field, String(option.value))"
>
<span v-if="option.color" class="option-color" :style="{ backgroundColor: option.color }" />
<strong :style="colorOptionStyle(field, option)">{{ option.label }}</strong>
<span class="target-state">{{
option.availableNow === false ? '현재 불가' : option.availableNow ? '우선 대상' : '대상'
}}</span>
<small>{{ commandTargetDescription(commandKey, option) }}</small>
</button>
<template v-for="group in targetCardGroupsFor(field)" :key="group.key">
<div
v-if="group.header"
class="target-group-header"
data-testid="general-nation-group"
:style="{ backgroundColor: group.header.color, color: group.header.textColor }"
>
{{ group.header.name }}
</div>
<button
v-for="option in group.options"
:key="String(option.value)"
type="button"
class="target-option"
:class="{
selected: option.value === values[field.key],
unavailable: option.availableNow === false,
}"
:aria-pressed="option.value === values[field.key]"
@click="setSelectValue(field, String(option.value))"
>
<span v-if="option.color" class="option-color" :style="{ backgroundColor: option.color }" />
<strong :style="colorOptionStyle(field, option)">{{
group.header ? (option.targetNames?.name ?? option.label) : option.label
}}</strong>
<span class="target-state">{{
option.availableNow === false ? '현재 불가' : option.availableNow ? '우선 대상' : '대상'
}}</span>
<small>{{ commandTargetDescription(commandKey, option) }}</small>
</button>
</template>
</div>
</div>
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
@@ -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;
@@ -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}` : '없음');
@@ -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_장수대상임관']);
});
@@ -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_증여', {