국가 대상 명령 입력에 Ref 권유문·원조 한도·피장파장 전략 대기 복원

- 임관·장수대상임관: 정제된 임관 권유문 목록(크게/작게 보기, 940px 기준 2열)과
  임관 대상 국가별 가능 여부(AllowJoinDestNation)를 명령표에 싣는다.
- 물자원조: 작위별 원조 한도표를 보이고 현재 작위를 굵은 밑줄로 표시한다.
- 피장파장: 전략 선택지를 원본 key 대신 전략 이름으로 보이고, 재사용 대기 중인
  전략은 `(불가, N턴)` 붉은색으로 표시한다. 선포·전쟁 국가만 대상 가능으로 둔다.
- 이호경식·급습: Ref 외교 조건으로 대상 국가 가능 여부를 계산한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 15:24:31 +00:00
co-authored by Claude Opus 5.5
parent 4a50c354fe
commit f74c3322b7
12 changed files with 893 additions and 17 deletions
+19
View File
@@ -11,6 +11,7 @@ import {
buildRecruitmentCommandInfo,
buildTurnCommandTable,
evaluateReservedTurnPermission,
resolveCommandJoinEnv,
} from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import {
@@ -45,7 +46,10 @@ import {
buildRefAmountPresets,
buildRefGeneralTargetOptions,
buildRefNationTargetOptions,
COUNTER_STRATEGY_COMMAND_KEYS,
} from '../../turns/commandTargets.js';
import { resolveImpossibleStrategicCommands } from '../../services/mainNationProjection.js';
import { resolveNationBlockScout, resolveNationScoutMessage } from '../nation/shared.js';
const zPushAmount = z
.number()
@@ -357,10 +361,20 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId);
}
}
// Ref che_피장파장 availableCommandTypeList: 아국 nation_env next_execute_<전략명> 기준 남은 턴.
const strategyCooldowns = Object.fromEntries(
resolveImpossibleStrategicCommands(nation?.meta, worldState.currentYear, worldState.currentMonth).map(
(entry) => [`che_${entry.name}`, entry.remainingTurns]
)
);
const nationTargetOptions = buildRefNationTargetOptions({
actorNationId: general.nationId,
join: { actorNpcState: general.npcState, ...resolveCommandJoinEnv(worldState) },
strategyAvailable: COUNTER_STRATEGY_COMMAND_KEYS.some((key) => strategyCooldowns[key] === undefined),
nations: nations.map((entry) => {
const relation = diplomacyByNation.get(entry.id);
const meta = asRecord(entry.meta);
const gennum = readGeneralMetaNumber(meta, 'gennum');
return {
id: entry.id,
name: entry.name,
@@ -374,6 +388,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
diplomacyTerm: relation?.term ?? 0,
adjacent: adjacentNationIds.has(entry.id),
diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0,
...(gennum === null ? {} : { gennum }),
blockScout: resolveNationBlockScout(meta),
scoutMessage: resolveNationScoutMessage(meta),
};
}),
});
@@ -406,6 +423,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
})),
nations: nationTargetOptions.nations,
nationTargets: nationTargetOptions.nationTargets,
nationScoutMessages: nationTargetOptions.nationScoutMessages,
strategyCooldowns,
generals: generalTargetOptions.generals,
generalTargets: generalTargetOptions.generalTargets,
crewTypes: (environment.unitSet.crewTypes ?? [])
+21 -5
View File
@@ -99,10 +99,21 @@ export interface TurnCommandInputField {
tupleLabels?: string[];
}
/** 임관 권유문. message는 서버에서 purifyNationHtml로 정제한 HTML이다. */
export interface TurnCommandNationScoutMessage {
nationId: number;
name: string;
color: string;
message: string;
}
export interface TurnCommandInputOptions {
cities: TurnCommandOption[];
nations: TurnCommandOption[];
nationTargets?: Record<string, TurnCommandOption[]>;
nationScoutMessages?: TurnCommandNationScoutMessage[];
/** 아국 전략 명령별 남은 재사용 대기 턴. 대기 중인 전략만 담는다. */
strategyCooldowns?: Record<string, number>;
generals: TurnCommandOption[];
generalTargets?: Record<string, TurnCommandOption[]>;
crewTypes: TurnCommandOption[];
@@ -242,7 +253,8 @@ const FIELD_LABELS: Record<string, string> = {
optionText: '행동',
year: '기간(년)',
month: '기간(월)',
commandType: '대응 명령',
// 피장파장 전용. Ref che_피장파장.vue의 '전략 :'.
commandType: '전략',
amountList: '지원 물자',
};
@@ -268,10 +280,14 @@ const STATIC_LABELS: Record<string, Record<string, string>> = {
item: '도구',
},
commandType: {
che_선전포고: '선전포고',
che_불가침제의: '불가침 제의',
che_불가침파기제의: '불가침 파기 제의',
che_종전제의: '종전 제의',
// 피장파장 대상 전략. Ref availableCommandTypeList의 name(getName()).
che_필사즉생: '필사즉생',
che_백성동원: '백성동원',
che_수몰: '수몰',
che_허보: '허보',
che_의병모집: '의병모집',
che_이호경식: '이호경식',
che_급습: '급습',
},
};
+13
View File
@@ -479,6 +479,19 @@ const mapCityRow = (row: CityRow): City => {
};
};
/** 명령표의 임관 대상 표시에 쓰는 Ref AllowJoinDestNation 시점 값. */
export const resolveCommandJoinEnv = (
worldState: WorldStateRow
): { relYear: number; openingPartYear: number; initialNationGenLimit: number } => {
const constValues = asRecord(asRecord(worldState.config).const);
const env = buildConstraintEnv(worldState);
return {
relYear: Number(env.relYear),
openingPartYear: Number(env.openingPartYear),
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
};
};
const mapNationRow = (row: NationRow): Nation => ({
id: row.id,
name: row.name,
+98 -5
View File
@@ -1,6 +1,6 @@
import { DIPLOMACY_STATE } from '@sammo-ts/logic';
import type { TurnCommandAmountPreset, TurnCommandOption } from './commandInput.js';
import type { TurnCommandAmountPreset, TurnCommandNationScoutMessage, TurnCommandOption } from './commandInput.js';
export interface GeneralTargetSource {
id: number;
@@ -33,6 +33,20 @@ export interface NationTargetSource {
diplomacyTerm: number;
adjacent: boolean;
diplomacyRestricted?: boolean;
/** Ref nation.gennum. 없으면 generalCount를 쓴다. */
gennum?: number;
/** 임관 금지(nation.scout). */
blockScout?: boolean;
/** 서버에서 purifyNationHtml로 정제한 임관 권유문. */
scoutMessage?: string;
}
/** Ref AllowJoinDestNation이 쓰는 actor·시점 정보. */
export interface NationJoinContext {
actorNpcState: number;
relYear: number;
openingPartYear: number;
initialNationGenLimit: number;
}
export interface RefGeneralTargetOptions {
@@ -43,6 +57,7 @@ export interface RefGeneralTargetOptions {
export interface RefNationTargetOptions {
nations: TurnCommandOption[];
nationTargets: Record<string, TurnCommandOption[]>;
nationScoutMessages: TurnCommandNationScoutMessage[];
}
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
@@ -197,14 +212,63 @@ const NATION_TARGET_COMMANDS = [
'che_선전포고',
'che_종전제의',
'che_불가침파기제의',
'che_피장파장',
'che_이호경식',
'che_급습',
] as const;
const nationAvailability = (
action: (typeof NATION_TARGET_COMMANDS)[number],
/** 피장파장으로 지정할 수 있는 전략(GameConst availableChiefCommand['전략']에서 자신 제외). */
export const COUNTER_STRATEGY_COMMAND_KEYS = [
'che_필사즉생',
'che_백성동원',
'che_수몰',
'che_허보',
'che_의병모집',
'che_이호경식',
'che_급습',
] as const;
/** Ref che_임관 exportJSVars()의 notAvailable: AllowJoinDestNation의 대상 국가 조건. */
const joinAvailability = (
join: NationJoinContext,
actorNationId: number,
target: NationTargetSource
): { available: boolean; reason: string } => {
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
const gennum = target.gennum ?? target.generalCount;
if (join.relYear < join.openingPartYear && join.initialNationGenLimit > 0 && gennum >= join.initialNationGenLimit) {
return { available: false, reason: '임관이 제한되고 있습니다.' };
}
if (target.blockScout) return { available: false, reason: '임관이 금지되어 있습니다.' };
if (join.actorNpcState < 2 && target.name.startsWith('ⓤ')) {
return { available: false, reason: '유저장은 태수국에 임관할 수 없습니다.' };
}
if (join.actorNpcState !== 9 && target.name.startsWith('ⓞ')) {
return { available: false, reason: '이민족 국가에 임관할 수 없습니다.' };
}
return { available: true, reason: '현재 임관 가능' };
};
const nationAvailability = (
action: (typeof NATION_TARGET_COMMANDS)[number],
actorNationId: number,
target: NationTargetSource,
strategyAvailable: boolean
): { available: boolean; reason: string } => {
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
// Ref 피장파장은 쓸 수 있는 전략이 하나도 없으면 모든 국가를 불가로 표시한다.
if (action === 'che_피장파장' && !strategyAvailable) {
return { available: false, reason: '사용할 수 있는 전략이 없습니다.' };
}
if (action === 'che_피장파장' || action === 'che_이호경식') {
const available = [DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION].includes(target.diplomacyState as 0 | 1);
return { available, reason: available ? '현재 발동 가능' : '선포, 전쟁중인 상대국에게만 가능합니다.' };
}
if (action === 'che_급습') {
// Ref AllowDiplomacyWithTerm(1, 12): 선포 상태이고 남은 기간이 12개월 이상.
const available = target.diplomacyState === DIPLOMACY_STATE.DECLARATION && target.diplomacyTerm >= 12;
return { available, reason: available ? '현재 발동 가능' : '선포 12개월 이상인 상대국에만 가능합니다.' };
}
if (action === 'che_물자원조') {
return target.diplomacyRestricted
? { available: false, reason: '상대국이 외교제한 중입니다.' }
@@ -233,7 +297,12 @@ const nationAvailability = (
export const buildRefNationTargetOptions = (options: {
actorNationId: number;
nations: readonly NationTargetSource[];
/** 없으면 임관 대상 가능 여부를 계산하지 않고 기본 국가 목록을 쓴다. */
join?: NationJoinContext;
/** 피장파장으로 지정할 수 있는 전략이 하나라도 있는지. 기본값 true. */
strategyAvailable?: boolean;
}): RefNationTargetOptions => {
const strategyAvailable = options.strategyAvailable ?? true;
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
value: entry.id,
label: entry.name,
@@ -245,7 +314,7 @@ export const buildRefNationTargetOptions = (options: {
for (const action of NATION_TARGET_COMMANDS) {
nationTargets[action] = options.nations
.map((entry) => {
const availability = nationAvailability(action, options.actorNationId, entry);
const availability = nationAvailability(action, options.actorNationId, entry, strategyAvailable);
const relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`;
const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}` : '';
return {
@@ -266,7 +335,31 @@ export const buildRefNationTargetOptions = (options: {
)
.map(({ power: _power, ...entry }) => entry);
}
return { nations: baseOptions, nationTargets };
const join = options.join;
if (join) {
// Ref 임관 목록은 nation 표 순서 그대로이며 불가 국가도 남긴다. 재야(0)는 국가가 아니다.
nationTargets.che_임관 = options.nations.flatMap((entry, index) => {
if (entry.id <= 0) return [];
const availability = joinAvailability(join, options.actorNationId, entry);
return [
{
...baseOptions[index]!,
availableNow: availability.available,
description: `${availability.reason} · ${baseOptions[index]!.description}`,
},
];
});
}
// Ref che_임관·che_장수대상임관은 모든 국가의 권유문(scout_msg)을 함께 내보낸다.
const nationScoutMessages = options.nations
.filter((entry) => entry.id > 0)
.map((entry) => ({
nationId: entry.id,
name: entry.name,
color: entry.color,
message: entry.scoutMessage ?? '',
}));
return { nations: baseOptions, nationTargets, nationScoutMessages };
};
const RESOURCE_ACTION_GUIDE = [
+18
View File
@@ -53,6 +53,24 @@ describe('turn command argument input', () => {
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
{ key: 'amountList', kind: 'numberTuple' },
]);
// Ref che_피장파장.vue: '전략 :' select에 자신을 뺀 전략 이름을 보인다.
expect(fields.find((entry) => entry.key === 'che_피장파장')?.fields).toMatchObject([
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
{
key: 'commandType',
label: '전략',
kind: 'select',
options: [
{ value: 'che_필사즉생', label: '필사즉생' },
{ value: 'che_백성동원', label: '백성동원' },
{ value: 'che_수몰', label: '수몰' },
{ value: 'che_허보', label: '허보' },
{ value: 'che_의병모집', label: '의병모집' },
{ value: 'che_이호경식', label: '이호경식' },
{ value: 'che_급습', label: '급습' },
],
},
]);
expect(
nationFields
+83
View File
@@ -5,6 +5,7 @@ import {
buildRefGeneralTargetOptions,
buildRefNationTargetOptions,
type GeneralTargetSource,
type NationTargetSource,
} from '../src/turns/commandTargets.js';
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
@@ -323,6 +324,88 @@ describe('Ref nation target guidance', () => {
expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴');
expect(result.nationTargets.che_불가침파기제의?.at(-1)).toMatchObject({ value: 4, availableNow: false });
});
it('marks counter-strategy targets like Ref exportJSVars notAvailable', () => {
const withDeclaration = [
...nations,
{ ...nations[3]!, id: 5, name: '선포국', diplomacyState: 1, diplomacyTerm: 12 },
{ ...nations[3]!, id: 6, name: '신규선포국', diplomacyState: 1, diplomacyTerm: 11 },
];
const result = buildRefNationTargetOptions({ actorNationId: 1, nations: withDeclaration });
const available = (action: string) =>
result.nationTargets[action]?.filter((entry) => entry.availableNow).map((entry) => entry.value);
// 피장파장·이호경식: AllowDiplomacyBetweenStatus([0, 1])
expect(available('che_피장파장')).toEqual([4, 5, 6]);
expect(available('che_이호경식')).toEqual([4, 5, 6]);
// 급습: AllowDiplomacyWithTerm(1, 12)
expect(available('che_급습')).toEqual([5]);
expect(result.nationTargets.che_급습?.find((entry) => entry.value === 6)?.description).toContain(
'선포 12개월 이상인 상대국에만 가능합니다.'
);
expect(result.nationTargets.che_피장파장?.find((entry) => entry.value === 1)).toMatchObject({
availableNow: false,
});
const exhausted = buildRefNationTargetOptions({
actorNationId: 1,
nations: withDeclaration,
strategyAvailable: false,
});
expect(exhausted.nationTargets.che_피장파장?.every((entry) => entry.availableNow === false)).toBe(true);
expect(exhausted.nationTargets.che_피장파장?.find((entry) => entry.value === 4)?.description).toContain(
'사용할 수 있는 전략이 없습니다.'
);
expect(exhausted.nationTargets.che_이호경식?.some((entry) => entry.availableNow)).toBe(true);
});
it('keeps the Ref nation order for appointment targets and exports purified scout messages', () => {
const joinNations: NationTargetSource[] = [
{ ...nations[0]!, scoutMessage: '<p>어서 오시오</p>' },
{ ...nations[1]!, blockScout: true },
{ ...nations[2]!, name: 'ⓤ태수국' },
{ ...nations[3]!, name: 'ⓞ이민족' },
{ ...nations[3]!, id: 5, name: '초반만원국', generalCount: 3, gennum: 10 },
];
const join = { actorNpcState: 0, relYear: 1, openingPartYear: 3, initialNationGenLimit: 10 };
const result = buildRefNationTargetOptions({ actorNationId: 0, nations: joinNations, join });
expect(result.nationTargets.che_임관?.map((entry) => [entry.value, entry.availableNow])).toEqual([
[1, true],
[2, false],
[3, false],
[4, false],
[5, false],
]);
expect(result.nationTargets.che_임관?.map((entry) => entry.description?.split(' · ')[0])).toEqual([
'현재 임관 가능',
'임관이 금지되어 있습니다.',
'유저장은 태수국에 임관할 수 없습니다.',
'이민족 국가에 임관할 수 없습니다.',
'임관이 제한되고 있습니다.',
]);
// 초반 제한이 끝나면 인원 제한은 없고, NPC(9)는 이민족 국가에 임관할 수 있다.
const later = buildRefNationTargetOptions({
actorNationId: 0,
nations: joinNations,
join: { ...join, actorNpcState: 9, relYear: 3 },
});
expect(later.nationTargets.che_임관?.map((entry) => entry.availableNow)).toEqual([
true,
false,
true,
true,
true,
]);
expect(result.nationScoutMessages).toEqual(
joinNations.map((entry) => ({
nationId: entry.id,
name: entry.name,
color: entry.color,
message: entry.scoutMessage ?? '',
}))
);
// join 정보가 없으면 임관은 기존 공통 국가 목록을 그대로 쓴다.
expect(buildRefNationTargetOptions({ actorNationId: 0, nations }).nationTargets.che_임관).toBeUndefined();
});
});
describe('Ref amount presets', () => {