등용·장수대상임관 대상의 타국 장수 정보를 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
+2
View File
@@ -36,6 +36,8 @@ export interface TurnCommandOption {
crew?: number;
troopId?: number;
npcState?: number;
/** 등용·장수대상임관처럼 여러 국가 장수를 고르는 목록의 국가별 묶음 기준. */
nationId?: number;
}
export interface TurnCommandAmountPreset {
+57 -29
View File
@@ -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<string, TurnCommandOption[]> = {};
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,
};
};
+107 -3
View File
@@ -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', () => {