국가 대상 명령 입력에 Ref 권유문·원조 한도·피장파장 전략 대기 복원
- 임관·장수대상임관: 정제된 임관 권유문 목록(크게/작게 보기, 940px 기준 2열)과 임관 대상 국가별 가능 여부(AllowJoinDestNation)를 명령표에 싣는다. - 물자원조: 작위별 원조 한도표를 보이고 현재 작위를 굵은 밑줄로 표시한다. - 피장파장: 전략 선택지를 원본 key 대신 전략 이름으로 보이고, 재사용 대기 중인 전략은 `(불가, N턴)` 붉은색으로 표시한다. 선포·전쟁 국가만 대상 가능으로 둔다. - 이호경식·급습: Ref 외교 조건으로 대상 국가 가능 여부를 계산한다. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 ?? [])
|
||||
|
||||
@@ -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_급습: '급습',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user