feat: 사령턴 대상 선택 정보를 보강

도시·수도 지도와 명령별 국가/장수 상세 목록을 추가한다.\nRef 금액 프리셋과 현재 조건 기반 우선 정렬을 예약 입력에 연결한다.
This commit is contained in:
2026-08-18 13:12:24 +00:00
parent e4c4bd946f
commit 7da67da40f
13 changed files with 1292 additions and 129 deletions
+15
View File
@@ -16,6 +16,19 @@ export interface TurnCommandOption {
label: string;
color?: string;
description?: string;
availableNow?: boolean;
gold?: number;
rice?: number;
crew?: number;
troopId?: number;
}
export interface TurnCommandAmountPreset {
values: number[];
defaultValue: number;
min: number;
max: number;
step: number;
}
export interface TurnCommandRecruitmentCrewType {
@@ -70,6 +83,7 @@ export interface TurnCommandInputField {
export interface TurnCommandInputOptions {
cities: TurnCommandOption[];
nations: TurnCommandOption[];
nationTargets?: Record<string, TurnCommandOption[]>;
generals: TurnCommandOption[];
generalTargets?: Record<string, TurnCommandOption[]>;
crewTypes: TurnCommandOption[];
@@ -78,6 +92,7 @@ export interface TurnCommandInputOptions {
colors: TurnCommandOption[];
items: Record<string, TurnCommandOption[]>;
recruitment: TurnCommandRecruitmentInfo | null;
amountPresets?: Record<string, TurnCommandAmountPreset>;
context?: {
actorGold: number;
actorRice: number;
+2
View File
@@ -770,6 +770,7 @@ export const buildTurnCommandTable = async (options: {
inputOptions: options.inputOptions ?? {
cities: [],
nations: [],
nationTargets: {},
generals: [],
generalTargets: {},
crewTypes: [],
@@ -778,6 +779,7 @@ export const buildTurnCommandTable = async (options: {
colors: [],
items: {},
recruitment: null,
amountPresets: {},
},
};
};
+194 -11
View File
@@ -1,4 +1,6 @@
import type { TurnCommandOption } from './commandInput.js';
import { DIPLOMACY_STATE } from '@sammo-ts/logic';
import type { TurnCommandAmountPreset, TurnCommandOption } from './commandInput.js';
export interface GeneralTargetSource {
id: number;
@@ -7,6 +9,27 @@ export interface GeneralTargetSource {
cityId: number;
npcState: number;
officerLevel: number;
gold?: number;
rice?: number;
crew?: number;
train?: number;
atmos?: number;
troopId?: number;
}
export interface NationTargetSource {
id: number;
name: string;
color: string;
capitalName: string;
level: number;
power: number;
generalCount: number;
cityCount: number;
diplomacyState: number;
diplomacyTerm: number;
adjacent: boolean;
diplomacyRestricted?: boolean;
}
export interface RefGeneralTargetOptions {
@@ -14,6 +37,11 @@ export interface RefGeneralTargetOptions {
generalTargets: Record<string, TurnCommandOption[]>;
}
export interface RefNationTargetOptions {
nations: TurnCommandOption[];
nationTargets: Record<string, TurnCommandOption[]>;
}
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const;
@@ -24,20 +52,54 @@ export const buildRefGeneralTargetOptions = (options: {
generals: readonly GeneralTargetSource[];
nationNames: ReadonlyMap<number, string>;
cityNames: ReadonlyMap<number, string>;
troopNames?: ReadonlyMap<number, string>;
}): RefGeneralTargetOptions => {
const toOption = (entry: GeneralTargetSource): TurnCommandOption => ({
value: entry.id,
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
options.cityNames.get(entry.cityId) ?? '재야'
})`,
});
const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => {
const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined;
const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id);
const isTroopExit = action === 'che_부대탈퇴지시';
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
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()}`,
entry.troopId
? `탑승 부대 ${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}`
: '탑승 부대 없음',
].filter((value): value is string => Boolean(value));
if (isTroopExit) {
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
}
return {
value: entry.id,
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
options.cityNames.get(entry.cityId) ?? '재야'
})`,
description: details.join(' · '),
...(availableNow === undefined ? {} : { availableNow }),
...(entry.gold === undefined ? {} : { gold: entry.gold }),
...(entry.rice === undefined ? {} : { rice: entry.rice }),
...(entry.crew === undefined ? {} : { crew: entry.crew }),
...(entry.troopId === undefined ? {} : { troopId: entry.troopId }),
};
};
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
options.generals.filter(predicate).map(toOption);
options.generals.filter(predicate).map((entry) => toOption(entry));
const sameNation = project((entry) => entry.nationId === options.actorNationId);
const generalTargets: Record<string, TurnCommandOption[]> = {};
for (const action of SAME_NATION_GENERAL_COMMANDS) generalTargets[action] = sameNation;
for (const action of SAME_NATION_NATION_COMMANDS) generalTargets[action] = sameNation;
for (const action of SAME_NATION_GENERAL_COMMANDS) {
generalTargets[action] = options.generals
.filter((entry) => entry.nationId === options.actorNationId)
.map((entry) => toOption(entry, action));
}
for (const action of SAME_NATION_NATION_COMMANDS) {
generalTargets[action] = options.generals
.filter((entry) => entry.nationId === options.actorNationId)
.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
@@ -53,3 +115,124 @@ export const buildRefGeneralTargetOptions = (options: {
generalTargets,
};
};
const DIPLOMACY_LABELS: Record<number, string> = {
[DIPLOMACY_STATE.WAR]: '전쟁',
[DIPLOMACY_STATE.DECLARATION]: '선포',
[DIPLOMACY_STATE.TRADE]: '교역',
[DIPLOMACY_STATE.NON_AGGRESSION]: '불가침',
};
const NATION_TARGET_COMMANDS = [
'che_물자원조',
'che_불가침제의',
'che_선전포고',
'che_종전제의',
'che_불가침파기제의',
] as const;
const nationAvailability = (
action: (typeof NATION_TARGET_COMMANDS)[number],
actorNationId: number,
target: NationTargetSource
): { available: boolean; reason: string } => {
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
if (action === 'che_물자원조') {
return target.diplomacyRestricted
? { available: false, reason: '상대국이 외교제한 중입니다.' }
: { available: true, reason: '현재 원조 대상' };
}
if (action === 'che_불가침제의') {
const available = ![DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION].includes(target.diplomacyState as 0 | 1);
return { available, reason: available ? '현재 제의 가능' : '교전·선포 중에는 제의 불가' };
}
if (action === 'che_선전포고') {
if (!target.adjacent) return { available: false, reason: '인접 국가가 아닙니다.' };
const available = ![DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION, DIPLOMACY_STATE.NON_AGGRESSION].includes(
target.diplomacyState as 0 | 1 | 7
);
return { available, reason: available ? '현재 선전포고 가능' : '현재 외교 관계에서는 선전포고 불가' };
}
if (action === 'che_종전제의') {
const available = [DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION].includes(target.diplomacyState as 0 | 1);
return { available, reason: available ? '현재 종전 제의 가능' : '전쟁·선포 중인 국가가 아닙니다.' };
}
const available = target.diplomacyState === DIPLOMACY_STATE.NON_AGGRESSION;
return { available, reason: available ? '현재 불가침 파기 제의 가능' : '불가침 중인 국가가 아닙니다.' };
};
/** 사령턴 외교 대상은 현재 명령에 맞는 국가부터 보이되, 예약 자체는 모든 대상을 유지한다. */
export const buildRefNationTargetOptions = (options: {
actorNationId: number;
nations: readonly NationTargetSource[];
}): RefNationTargetOptions => {
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
value: entry.id,
label: entry.name,
color: entry.color,
description: `수도 ${entry.capitalName} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`,
}));
const nationTargets: Record<string, TurnCommandOption[]> = {};
for (const action of NATION_TARGET_COMMANDS) {
nationTargets[action] = options.nations
.map((entry) => {
const availability = nationAvailability(action, options.actorNationId, entry);
const relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`;
const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}` : '';
return {
value: entry.id,
label: entry.name,
color: entry.color,
availableNow: availability.available,
description: `${availability.reason} · ${relation}${term} · 수도 ${entry.capitalName} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`,
power: entry.power,
} as TurnCommandOption & { power: number };
})
.sort(
(left, right) =>
Number(right.availableNow) - Number(left.availableNow) ||
right.power - left.power ||
Number(left.value) - Number(right.value)
)
.map(({ power: _power, ...entry }) => entry);
}
return { nations: baseOptions, nationTargets };
};
const RESOURCE_ACTION_GUIDE = [
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1200, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7000, 8000, 9000,
10000,
];
/** Ref SelectAmount의 dropdown 값을 공통 예약 입력 DTO로 옮긴다. */
export const buildRefAmountPresets = (
nationLevel: number,
maxResourceActionAmount: number
): Record<string, TurnCommandAmountPreset> => {
const resourceMax = maxResourceActionAmount > 0 ? maxResourceActionAmount : 10_000;
const resourceValues = RESOURCE_ACTION_GUIDE.filter((value) => value <= resourceMax);
if (!resourceValues.includes(resourceMax)) resourceValues.push(resourceMax);
const resourcePreset: TurnCommandAmountPreset = {
values: resourceValues,
defaultValue: Math.min(1000, resourceMax),
min: Math.min(100, resourceMax),
max: resourceMax,
step: 1,
};
const aidMax = Math.max(10_000, Math.max(1, nationLevel) * 10_000);
const aidPreset: TurnCommandAmountPreset = {
values: Array.from({ length: Math.max(1, nationLevel) }, (_, index) => (index + 1) * 10_000),
defaultValue: Math.min(1000, aidMax),
min: 1000,
max: aidMax,
step: 10,
};
return {
che_증여: resourcePreset,
che_헌납: resourcePreset,
che_군량매매: resourcePreset,
che_포상: resourcePreset,
che_몰수: resourcePreset,
che_물자원조: aidPreset,
};
};