등용·장수대상임관 대상의 타국 장수 정보를 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:
@@ -36,6 +36,8 @@ export interface TurnCommandOption {
|
|||||||
crew?: number;
|
crew?: number;
|
||||||
troopId?: number;
|
troopId?: number;
|
||||||
npcState?: number;
|
npcState?: number;
|
||||||
|
/** 등용·장수대상임관처럼 여러 국가 장수를 고르는 목록의 국가별 묶음 기준. */
|
||||||
|
nationId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnCommandAmountPreset {
|
export interface TurnCommandAmountPreset {
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ export interface RefNationTargetOptions {
|
|||||||
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
|
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
|
||||||
const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', '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으로 투영한다. */
|
/** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */
|
||||||
export const buildRefGeneralTargetOptions = (options: {
|
export const buildRefGeneralTargetOptions = (options: {
|
||||||
actorId: number;
|
actorId: number;
|
||||||
@@ -64,29 +73,30 @@ export const buildRefGeneralTargetOptions = (options: {
|
|||||||
const isTroopExit = action === 'che_부대탈퇴지시';
|
const isTroopExit = action === 'che_부대탈퇴지시';
|
||||||
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
||||||
const label = (() => {
|
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})`;
|
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
||||||
})();
|
})();
|
||||||
const details = [
|
// Ref che_선양.vue는 후보에 (통/무/지)만 보인다.
|
||||||
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
const details = (
|
||||||
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
action === 'che_선양'
|
||||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
? []
|
||||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
: [
|
||||||
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
||||||
].filter((value): value is string => Boolean(value));
|
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처럼 능력치와 병력 준비 상태를 함께 비교한다.
|
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
|
||||||
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
|
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
|
||||||
const assignmentDetails =
|
const assignmentDetails =
|
||||||
action === 'che_발령'
|
action === 'che_발령'
|
||||||
? [
|
? [
|
||||||
[
|
statsText(entry),
|
||||||
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
|
|
||||||
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
|
|
||||||
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · '),
|
|
||||||
[
|
[
|
||||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||||
@@ -104,6 +114,9 @@ export const buildRefGeneralTargetOptions = (options: {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// Ref ProcessGeneralAmount·ProcessGeneral은 같은 국가 후보에 (통/무/지)를 함께 보인다.
|
||||||
|
const stats = statsText(entry);
|
||||||
|
if (stats && action !== 'che_발령') details.push(stats);
|
||||||
if (isTroopExit) {
|
if (isTroopExit) {
|
||||||
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
||||||
}
|
}
|
||||||
@@ -125,33 +138,48 @@ export const buildRefGeneralTargetOptions = (options: {
|
|||||||
npcState: entry.npcState,
|
npcState: entry.npcState,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
|
// Ref che_등용·che_장수대상임관 exportJSVars()는 타국·재야 장수의
|
||||||
options.generals.filter(predicate).map((entry) => toOption(entry));
|
// 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<string, TurnCommandOption[]> = {};
|
const generalTargets: Record<string, TurnCommandOption[]> = {};
|
||||||
for (const action of SAME_NATION_GENERAL_COMMANDS) {
|
for (const action of SAME_NATION_GENERAL_COMMANDS) {
|
||||||
generalTargets[action] = options.generals
|
generalTargets[action] = options.generals.filter(isSameNation).map((entry) => toOption(entry, action));
|
||||||
.filter((entry) => entry.nationId === options.actorNationId)
|
|
||||||
.map((entry) => toOption(entry, action));
|
|
||||||
}
|
}
|
||||||
for (const action of SAME_NATION_NATION_COMMANDS) {
|
for (const action of SAME_NATION_NATION_COMMANDS) {
|
||||||
generalTargets[action] = options.generals
|
generalTargets[action] = options.generals
|
||||||
.filter((entry) => entry.nationId === options.actorNationId)
|
.filter(isSameNation)
|
||||||
.map((entry) => toOption(entry, action))
|
.map((entry) => toOption(entry, action))
|
||||||
.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
|
.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
generalTargets.che_선양 = project(
|
generalTargets.che_선양 = options.generals
|
||||||
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId
|
.filter((entry) => isSameNation(entry) && entry.id !== options.actorId)
|
||||||
);
|
.map((entry) => toOption(entry, 'che_선양'));
|
||||||
generalTargets.che_등용 = project(
|
generalTargets.che_등용 = projectPublic(
|
||||||
(entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId
|
(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 {
|
return {
|
||||||
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다.
|
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다. 타국 장수가 섞이므로 공개 필드만 둔다.
|
||||||
generals: project((entry) => entry.npcState < 2),
|
generals: projectPublic((entry) => entry.npcState < 2),
|
||||||
generalTargets,
|
generalTargets,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ describe('Ref command general targets', () => {
|
|||||||
it('indexes genuine names even when named 없음, and excludes missing-name placeholders', () => {
|
it('indexes genuine names even when named 없음, and excludes missing-name placeholders', () => {
|
||||||
const options = buildRefGeneralTargetOptions({
|
const options = buildRefGeneralTargetOptions({
|
||||||
actorId: 1,
|
actorId: 1,
|
||||||
actorNationId: 0,
|
// 이름·국가·도시·부대 원본이 없는 경우. 재야 actor는 같은 국가 후보가 없으므로 국가 ID만 둔다.
|
||||||
generals: [general({ name: '없음', nationId: 0, cityId: 0, troopId: 99 })],
|
actorNationId: 5,
|
||||||
|
generals: [general({ name: '없음', nationId: 5, cityId: 0, troopId: 99 })],
|
||||||
nationNames: new Map(),
|
nationNames: new Map(),
|
||||||
cityNames: new Map(),
|
cityNames: new Map(),
|
||||||
troopNames: 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_발령?.[0]?.description).toBe('병력 1,000\n금 5,000 · 쌀 4,000');
|
||||||
expect(detailed.generalTargets.che_발령?.[2]?.targetNames?.troopName).toBe('청룡대');
|
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([
|
expect(detailed.generalTargets.che_증여?.map((entry) => entry.targetNames)).toEqual([
|
||||||
{ name: '본인', nationName: '아국', cityName: '업', troopName: null },
|
{ name: '본인', nationName: '아국', cityName: '업', troopName: null },
|
||||||
{ name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' },
|
{ 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_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000');
|
||||||
expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대');
|
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', () => {
|
describe('Ref nation target guidance', () => {
|
||||||
|
|||||||
@@ -4222,3 +4222,112 @@ for (const viewport of [
|
|||||||
await overlay.getByRole('button', { name: '명령 취소', exact: true }).click();
|
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;
|
crew?: number;
|
||||||
troopId?: number;
|
troopId?: number;
|
||||||
npcState?: number;
|
npcState?: number;
|
||||||
|
nationId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandAmountPreset = {
|
export type CommandAmountPreset = {
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '..
|
|||||||
import { commandCityOptions } from '../command/commandArgumentOptions';
|
import { commandCityOptions } from '../command/commandArgumentOptions';
|
||||||
import { COMMAND_CITY_DISTANCE_RANGE, citiesBasedOnDistance } from '../command/commandCityDistance';
|
import { COMMAND_CITY_DISTANCE_RANGE, citiesBasedOnDistance } from '../command/commandCityDistance';
|
||||||
import { sortCommandGeneralOptions } from '../command/commandGeneralOptions';
|
import { sortCommandGeneralOptions } from '../command/commandGeneralOptions';
|
||||||
|
import {
|
||||||
|
groupGeneralOptionsByNation,
|
||||||
|
NATION_GROUPED_GENERAL_COMMANDS,
|
||||||
|
type GeneralOptionGroup,
|
||||||
|
} from '../command/commandGeneralGroups';
|
||||||
import {
|
import {
|
||||||
commandArgumentFieldContract,
|
commandArgumentFieldContract,
|
||||||
shouldPreserveCommandArgumentValue,
|
shouldPreserveCommandArgumentValue,
|
||||||
@@ -141,6 +146,18 @@ const filteredTargets = computed(
|
|||||||
);
|
);
|
||||||
const visibleOptionsFor = (field: CommandInputField): CommandOption[] =>
|
const visibleOptionsFor = (field: CommandInputField): CommandOption[] =>
|
||||||
filteredTargets.value.get(field.key) ?? optionsFor(field);
|
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 = () => {
|
const clearSearchQueries = () => {
|
||||||
for (const key of searchTimers.keys()) cancelSearchTimer(key);
|
for (const key of searchTimers.keys()) cancelSearchTimer(key);
|
||||||
for (const key of Object.keys(searchDrafts)) delete searchDrafts[key];
|
for (const key of Object.keys(searchDrafts)) delete searchDrafts[key];
|
||||||
@@ -574,14 +591,32 @@ watch(
|
|||||||
:style="colorOptionStyle(field, selectedOptionFor(field))"
|
:style="colorOptionStyle(field, selectedOptionFor(field))"
|
||||||
@change="setSelectValue(field, ($event.target as HTMLSelectElement).value)"
|
@change="setSelectValue(field, ($event.target as HTMLSelectElement).value)"
|
||||||
>
|
>
|
||||||
<option
|
<template v-if="isNationGroupedField(field)">
|
||||||
v-for="option in optionsFor(field)"
|
<optgroup
|
||||||
:key="String(option.value)"
|
v-for="group in nationGroupsOf(field, optionsFor(field))"
|
||||||
:value="String(option.value)"
|
:key="group.nationId"
|
||||||
:style="colorOptionStyle(field, option)"
|
:label="group.name"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
<option
|
||||||
</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>
|
</select>
|
||||||
<div v-else-if="field.kind === 'boolean'" class="boolean-options">
|
<div v-else-if="field.kind === 'boolean'" class="boolean-options">
|
||||||
<button
|
<button
|
||||||
@@ -718,25 +753,37 @@ watch(
|
|||||||
<span v-if="!visibleOptionsFor(field).length" class="target-search-empty"
|
<span v-if="!visibleOptionsFor(field).length" class="target-search-empty"
|
||||||
>검색 결과가 없습니다.</span
|
>검색 결과가 없습니다.</span
|
||||||
>
|
>
|
||||||
<button
|
<template v-for="group in targetCardGroupsFor(field)" :key="group.key">
|
||||||
v-for="option in visibleOptionsFor(field)"
|
<div
|
||||||
:key="String(option.value)"
|
v-if="group.header"
|
||||||
type="button"
|
class="target-group-header"
|
||||||
class="target-option"
|
data-testid="general-nation-group"
|
||||||
:class="{
|
:style="{ backgroundColor: group.header.color, color: group.header.textColor }"
|
||||||
selected: option.value === values[field.key],
|
>
|
||||||
unavailable: option.availableNow === false,
|
{{ group.header.name }}
|
||||||
}"
|
</div>
|
||||||
:aria-pressed="option.value === values[field.key]"
|
<button
|
||||||
@click="setSelectValue(field, String(option.value))"
|
v-for="option in group.options"
|
||||||
>
|
:key="String(option.value)"
|
||||||
<span v-if="option.color" class="option-color" :style="{ backgroundColor: option.color }" />
|
type="button"
|
||||||
<strong :style="colorOptionStyle(field, option)">{{ option.label }}</strong>
|
class="target-option"
|
||||||
<span class="target-state">{{
|
:class="{
|
||||||
option.availableNow === false ? '현재 불가' : option.availableNow ? '우선 대상' : '대상'
|
selected: option.value === values[field.key],
|
||||||
}}</span>
|
unavailable: option.availableNow === false,
|
||||||
<small>{{ commandTargetDescription(commandKey, option) }}</small>
|
}"
|
||||||
</button>
|
: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>
|
</div>
|
||||||
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
||||||
@@ -980,6 +1027,14 @@ small {
|
|||||||
grid-column: 1 / 3;
|
grid-column: 1 / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.target-group-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.target-state {
|
.target-state {
|
||||||
grid-column: 3;
|
grid-column: 3;
|
||||||
color: #aee6a7;
|
color: #aee6a7;
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ export const commandTargetDescription = (commandKey: string, option?: CommandOpt
|
|||||||
if (!option) return '';
|
if (!option) return '';
|
||||||
const description = option.description ?? '';
|
const description = option.description ?? '';
|
||||||
// 이전 API는 완성된 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;
|
return description;
|
||||||
}
|
}
|
||||||
const troopName = option.targetNames.troopName ?? (option.troopId ? `#${option.troopId}` : '없음');
|
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), '탑승 부대 없음\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), '금 100');
|
||||||
|
assert.equal(commandTargetDescription('che_선양', base), '금 100');
|
||||||
assert.equal(commandTargetDescription('che_증여', { ...base, troopId: 99 }), '금 100 · 탑승 부대 #99');
|
assert.equal(commandTargetDescription('che_증여', { ...base, troopId: 99 }), '금 100 · 탑승 부대 #99');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
commandTargetDescription('che_증여', {
|
commandTargetDescription('che_증여', {
|
||||||
|
|||||||
Reference in New Issue
Block a user