Complete instant diplomacy response parity

This commit is contained in:
2026-07-26 03:10:41 +00:00
parent b3f4734e80
commit 9885bf6905
23 changed files with 2072 additions and 305 deletions
@@ -1,4 +1,4 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
beChief,
@@ -7,15 +7,15 @@ import {
existsDestNation,
notBeNeutral,
occupiedCity,
reqDestNationValue,
suppliedCity,
} from '@sammo-ts/logic/constraints/presets.js';
import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createDiplomacyPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common';
import {
destGeneralBelongsToDestNation,
resolveInstantDiplomacyResponse,
} from '@sammo-ts/logic/diplomacy/instantResponse.js';
export interface NonAggressionAcceptArgs {
destNationId: number;
@@ -29,11 +29,11 @@ export interface NonAggressionAcceptContext<
> extends GeneralActionResolveContext<TriggerState> {
currentYear: number;
currentMonth: number;
destNation: Nation;
destGeneral: General<TriggerState>;
}
const ACTION_NAME = '불가침 수락';
const DIPLOMACY_NON_AGGRESSION = 7;
const parseNationId = (raw: unknown): number | null => {
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
@@ -147,7 +147,7 @@ export class ActionDefinition<
suppliedCity(),
existsDestNation(),
existsDestGeneral(),
reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'),
destGeneralBelongsToDestNation(),
reqFutureTreatyTerm(),
disallowDiplomacyBetweenStatus({
0: '아국과 이미 교전중입니다.',
@@ -160,44 +160,29 @@ export class ActionDefinition<
context: NonAggressionAcceptContext<TriggerState>,
args: NonAggressionAcceptArgs
): GeneralActionOutcome<TriggerState> {
const nationId = context.nation?.id;
if (nationId === undefined || nationId <= 0) {
const nation = context.nation;
if (!nation) {
return {
effects: [
createLogEffect(`${ACTION_NAME}을 준비했지만 국가 정보가 없습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
effects: [],
};
}
const currentMonth = resolveMonthIndex(context.currentYear, context.currentMonth);
const targetMonth = args.year * 12 + args.month;
const term = Math.max(0, targetMonth - currentMonth);
const destNationName = String(args.destNationId);
const josaWa = JosaUtil.pick(destNationName, '와');
const resolution = resolveInstantDiplomacyResponse<TriggerState>(
{
actor: context.general,
actorNation: nation,
proposer: context.destGeneral,
proposerNation: context.destNation,
currentYear: context.currentYear,
currentMonth: context.currentMonth,
},
{
action: 'noAggression',
treatyYear: args.year,
treatyMonth: args.month,
}
);
return {
effects: [
createDiplomacyPatchEffect(nationId, args.destNationId, {
state: DIPLOMACY_NON_AGGRESSION,
term,
}),
createDiplomacyPatchEffect(args.destNationId, nationId, {
state: DIPLOMACY_NON_AGGRESSION,
term,
}),
createLogEffect(
`<D><b>${destNationName}</b></>${josaWa} <C>${args.year}</>년 <C>${args.month}</>월까지 불가침에 성공했습니다.`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}
),
],
effects: resolution.effects,
};
}
}
@@ -1,4 +1,4 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowDiplomacyBetweenStatus,
@@ -6,21 +6,29 @@ import {
existsDestGeneral,
existsDestNation,
notBeNeutral,
reqDestNationValue,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createDiplomacyPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common';
import {
destGeneralBelongsToDestNation,
resolveInstantDiplomacyResponse,
} from '@sammo-ts/logic/diplomacy/instantResponse.js';
export interface NonAggressionCancelAcceptArgs {
destNationId: number;
destGeneralId: number;
}
export interface NonAggressionCancelAcceptContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destNation: Nation;
destGeneral: General<TriggerState>;
currentYear: number;
currentMonth: number;
}
const ACTION_NAME = '불가침 파기 수락';
const DIPLOMACY_NEUTRAL = 2;
const DIPLOMACY_NON_AGGRESSION = 7;
const parseNationId = (raw: unknown): number | null => {
@@ -40,7 +48,11 @@ const parseGeneralId = (raw: unknown): number | null => {
// 불가침 파기 수락은 메시지와 연결되는 즉시 국가 커맨드로 사용한다.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, NonAggressionCancelAcceptArgs> {
> implements GeneralActionDefinition<
TriggerState,
NonAggressionCancelAcceptArgs,
NonAggressionCancelAcceptContext<TriggerState>
> {
public readonly key = 'che_불가침파기수락';
public readonly name = ACTION_NAME;
@@ -60,47 +72,32 @@ export class ActionDefinition<
notBeNeutral(),
existsDestNation(),
existsDestGeneral(),
reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'),
destGeneralBelongsToDestNation(),
allowDiplomacyBetweenStatus([DIPLOMACY_NON_AGGRESSION], '불가침 중인 상대국에게만 가능합니다.'),
];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
args: NonAggressionCancelAcceptArgs
context: NonAggressionCancelAcceptContext<TriggerState>,
_args: NonAggressionCancelAcceptArgs
): GeneralActionOutcome<TriggerState> {
const nationId = context.nation?.id;
if (nationId === undefined || nationId <= 0) {
return {
effects: [
createLogEffect(`${ACTION_NAME}을 준비했지만 국가 정보가 없습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
const nation = context.nation;
if (!nation) {
return { effects: [] };
}
const destNationName = String(args.destNationId);
const josaWa = JosaUtil.pick(destNationName, '와');
const resolution = resolveInstantDiplomacyResponse<TriggerState>(
{
actor: context.general,
actorNation: nation,
proposer: context.destGeneral,
proposerNation: context.destNation,
currentYear: context.currentYear,
currentMonth: context.currentMonth,
},
{ action: 'cancelNA' }
);
return {
effects: [
createDiplomacyPatchEffect(nationId, args.destNationId, {
state: DIPLOMACY_NEUTRAL,
term: 0,
}),
createDiplomacyPatchEffect(args.destNationId, nationId, {
state: DIPLOMACY_NEUTRAL,
term: 0,
}),
createLogEffect(`<D><b>${destNationName}</b></>${josaWa}의 불가침을 파기했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
effects: resolution.effects,
};
}
}
@@ -1,4 +1,4 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowDiplomacyBetweenStatus,
@@ -6,22 +6,29 @@ import {
existsDestGeneral,
existsDestNation,
notBeNeutral,
reqDestNationValue,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createDiplomacyPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common';
import {
destGeneralBelongsToDestNation,
resolveInstantDiplomacyResponse,
} from '@sammo-ts/logic/diplomacy/instantResponse.js';
export interface StopWarAcceptArgs {
destNationId: number;
destGeneralId: number;
}
const ACTION_NAME = '종전 수락';
const DIPLOMACY_NEUTRAL = 2;
export interface StopWarAcceptContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destNation: Nation;
destGeneral: General<TriggerState>;
currentYear: number;
currentMonth: number;
}
const ACTION_NAME = '종전 수락';
const parseNationId = (raw: unknown): number | null => {
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
@@ -39,7 +46,7 @@ const parseGeneralId = (raw: unknown): number | null => {
// 종전 수락은 메시지와 연결되는 즉시 국가 커맨드로 사용한다.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, StopWarAcceptArgs> {
> implements GeneralActionDefinition<TriggerState, StopWarAcceptArgs, StopWarAcceptContext<TriggerState>> {
public readonly key = 'che_종전수락';
public readonly name = ACTION_NAME;
@@ -59,97 +66,29 @@ export class ActionDefinition<
notBeNeutral(),
existsDestNation(),
existsDestGeneral(),
reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'),
destGeneralBelongsToDestNation(),
allowDiplomacyBetweenStatus([0, 1], '상대국과 선포, 전쟁중이지 않습니다.'),
];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
args: StopWarAcceptArgs
): GeneralActionOutcome<TriggerState> {
const nationId = context.nation?.id;
if (nationId === undefined || nationId <= 0) {
return {
effects: [
createLogEffect(`${ACTION_NAME}을 준비했지만 국가 정보가 없습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
resolve(context: StopWarAcceptContext<TriggerState>, _args: StopWarAcceptArgs): GeneralActionOutcome<TriggerState> {
const nation = context.nation;
if (!nation) {
return { effects: [] };
}
const nationName = context.nation?.name ?? `국가${nationId}`;
const destNationName =
(context as { destNation?: { name?: string } }).destNation?.name ?? `국가${args.destNationId}`;
const generalName = context.general.name;
const josaYiGeneral = JosaUtil.pick(generalName, '이');
const josaYiNation = JosaUtil.pick(nationName, '이');
const josaWa = JosaUtil.pick(destNationName, '와');
const resolution = resolveInstantDiplomacyResponse<TriggerState>(
{
actor: context.general,
actorNation: nation,
proposer: context.destGeneral,
proposerNation: context.destNation,
currentYear: context.currentYear,
currentMonth: context.currentMonth,
},
{ action: 'stopWar' }
);
return {
effects: [
createDiplomacyPatchEffect(nationId, args.destNationId, {
state: DIPLOMACY_NEUTRAL,
term: 0,
}),
createDiplomacyPatchEffect(args.destNationId, nationId, {
state: DIPLOMACY_NEUTRAL,
term: 0,
}),
createLogEffect(`<D><b>${destNationName}</b></>${josaWa} 종전에 합의했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
}),
createLogEffect(`<D><b>${destNationName}</b></>${josaWa} 종전 수락`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
createLogEffect(
`<Y>${generalName}</>${josaYiGeneral} <D><b>${destNationName}</b></>${josaWa} <M>종전 합의</> 하였습니다.`,
{
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
}
),
createLogEffect(
`<Y><b>【종전】</b></><D><b>${nationName}</b></>${josaYiNation} <D><b>${destNationName}</b></>${josaWa} <M>종전 합의</> 하였습니다.`,
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(`<D><b>${destNationName}</b></>${josaWa} 종전`, {
scope: LogScope.NATION,
nationId,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
createLogEffect(`<D><b>${nationName}</b></>${josaWa} 종전에 성공했습니다.`, {
scope: LogScope.GENERAL,
generalId: args.destGeneralId,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
}),
createLogEffect(`<D><b>${nationName}</b></>${josaWa} 종전 성공`, {
scope: LogScope.GENERAL,
generalId: args.destGeneralId,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
createLogEffect(`<D><b>${nationName}</b></>${josaWa} 종전`, {
scope: LogScope.NATION,
nationId: args.destNationId,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
],
effects: resolution.effects,
};
}
}
@@ -6,8 +6,10 @@ export {
export {
ActionDefinition as NonAggressionCancelAcceptActionDefinition,
type NonAggressionCancelAcceptArgs,
type NonAggressionCancelAcceptContext,
} from './che_불가침파기수락.js';
export {
ActionDefinition as StopWarAcceptActionDefinition,
type StopWarAcceptArgs,
type StopWarAcceptContext,
} from './che_종전수락.js';
@@ -0,0 +1,7 @@
// Legacy diplomacy state codes.
export const DIPLOMACY_STATE = {
WAR: 0,
DECLARATION: 1,
TRADE: 2,
NON_AGGRESSION: 7,
} as const;
@@ -0,0 +1,84 @@
export interface FrontStateCity {
id: number;
nationId: number;
frontState: number;
}
export interface FrontStateDiplomacy {
fromNationId: number;
toNationId: number;
state: number;
term: number;
}
const collectAdjacentCities = (
cityIds: number[],
connections: ReadonlyMap<number, readonly number[]>,
bucket: Set<number>
): void => {
for (const cityId of cityIds) {
for (const neighborId of connections.get(cityId) ?? []) {
bucket.add(neighborId);
}
}
};
export const buildNationFrontStatePatches = (options: {
cities: FrontStateCity[];
diplomacy: FrontStateDiplomacy[];
connections: ReadonlyMap<number, readonly number[]>;
nationIds: number[];
}): Array<{ id: number; frontState: number }> => {
const cityIdsByNation = new Map<number, number[]>();
for (const city of options.cities) {
const list = cityIdsByNation.get(city.nationId) ?? [];
list.push(city.id);
cityIdsByNation.set(city.nationId, list);
}
const cityById = new Map(options.cities.map((city) => [city.id, city]));
const patches: Array<{ id: number; frontState: number }> = [];
for (const nationId of new Set(options.nationIds.filter((id) => id > 0))) {
const adjWar = new Set<number>();
const adjNeutral = new Set<number>();
const adjDeclaration = new Set<number>();
for (const entry of options.diplomacy) {
if (entry.fromNationId !== nationId) {
continue;
}
const targetCities = cityIdsByNation.get(entry.toNationId) ?? [];
if (entry.state === 0) {
collectAdjacentCities(targetCities, options.connections, adjWar);
} else if (entry.state === 1 && entry.term <= 5) {
collectAdjacentCities(targetCities, options.connections, adjDeclaration);
}
}
if (adjWar.size === 0 && adjDeclaration.size === 0) {
collectAdjacentCities(cityIdsByNation.get(0) ?? [], options.connections, adjNeutral);
}
for (const cityId of cityIdsByNation.get(nationId) ?? []) {
const city = cityById.get(cityId);
if (!city) {
continue;
}
let frontState = 0;
if (adjDeclaration.has(cityId)) {
frontState = 1;
}
if (adjNeutral.has(cityId)) {
frontState = 2;
}
if (adjWar.has(cityId)) {
frontState = 3;
}
if (city.frontState !== frontState) {
patches.push({ id: cityId, frontState });
}
}
}
return patches;
};
+6 -8
View File
@@ -1,17 +1,12 @@
import { clamp } from 'es-toolkit';
// 외교 상태 코드 (legacy 기준).
export const DIPLOMACY_STATE = {
WAR: 0,
DECLARATION: 1,
TRADE: 2,
NON_AGGRESSION: 7,
} as const;
import { DIPLOMACY_STATE } from './constants.js';
export { DIPLOMACY_STATE } from './constants.js';
export const DEFAULT_DECLARE_WAR_TERM = 24;
export const DEFAULT_WAR_TERM = 6;
// TODO: 불가침 제의/수락 등 외교 커맨드 전환 규칙을 이 모듈에 추가 예정.
const MAX_WAR_TERM = 13;
export interface DiplomacyEntry {
@@ -141,3 +136,6 @@ export const processDiplomacyMonth = (
return next;
};
export * from './frontState.js';
export * from './instantResponse.js';
@@ -0,0 +1,354 @@
import { JosaUtil } from '@sammo-ts/common';
import {
createDiplomacyPatchEffect,
createLogEffect,
createNationPatchEffect,
type GeneralActionEffect,
} from '../actions/engine.js';
import { allow, unknownOrDeny } from '../constraints/helpers.js';
import type { Constraint } from '../constraints/types.js';
import type { GeneralTriggerState, Nation, TriggerValue } from '../domain/entities.js';
import { LogCategory, LogFormat, LogScope } from '../logging/types.js';
import { DIPLOMACY_STATE } from './constants.js';
export type InstantDiplomacyResponseAction = 'noAggression' | 'cancelNA' | 'stopWar';
export interface InstantDiplomacyResponseContext {
actor: {
id: number;
name: string;
nationId: number;
};
actorNation: Nation;
proposer: {
id: number;
name: string;
nationId: number;
};
proposerNation: Nation;
currentYear: number;
currentMonth: number;
}
export interface InstantDiplomacyResponseArgs {
action: InstantDiplomacyResponseAction;
treatyYear?: number;
treatyMonth?: number;
}
export const destGeneralBelongsToDestNation = (): Constraint => ({
name: 'destGeneralBelongsToDestNation',
requires: (ctx) => {
const requirements = [];
if (ctx.destGeneralId !== undefined) {
requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId } as const);
}
if (ctx.destNationId !== undefined) {
requirements.push({ kind: 'destNation', id: ctx.destNationId } as const);
}
return requirements;
},
test: (ctx, view) => {
if (ctx.destGeneralId === undefined || ctx.destNationId === undefined) {
return unknownOrDeny(ctx, [], '제의 장수가 국가 소속이 아닙니다');
}
const generalRequirement = { kind: 'destGeneral', id: ctx.destGeneralId } as const;
const nationRequirement = { kind: 'destNation', id: ctx.destNationId } as const;
if (!view.has(generalRequirement) || !view.has(nationRequirement)) {
return unknownOrDeny(
ctx,
[generalRequirement, nationRequirement].filter((requirement) => !view.has(requirement)),
'제의 장수가 국가 소속이 아닙니다'
);
}
const destGeneral = view.get(generalRequirement) as { nationId?: unknown } | null;
return destGeneral?.nationId === ctx.destNationId
? allow()
: { kind: 'deny', reason: '제의 장수가 국가 소속이 아닙니다' };
},
});
export interface InstantDiplomacyResponseResolution<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
actionKey: 'che_불가침수락' | 'che_불가침파기수락' | 'che_종전수락';
diplomacyDetail: string;
effects: GeneralActionEffect<TriggerState>[];
refreshFront: boolean;
}
const resolveMonthIndex = (year: number, month: number): number => year * 12 + month - 1;
const buildNonAggressionEffects = <TriggerState extends GeneralTriggerState>(
context: InstantDiplomacyResponseContext,
treatyYear: number,
treatyMonth: number
): GeneralActionEffect<TriggerState>[] => {
const actorNation = context.actorNation;
const proposerNation = context.proposerNation;
const actorNationName = actorNation.name;
const proposerNationName = proposerNation.name;
const actorJosaWa = JosaUtil.pick(actorNationName, '와');
const proposerJosaWa = JosaUtil.pick(proposerNationName, '와');
const proposerMeta = { ...proposerNation.meta };
const recvAssist =
proposerMeta.recv_assist &&
typeof proposerMeta.recv_assist === 'object' &&
!Array.isArray(proposerMeta.recv_assist)
? (proposerMeta.recv_assist as Record<string, TriggerValue>)
: {};
const respAssist =
proposerMeta.resp_assist &&
typeof proposerMeta.resp_assist === 'object' &&
!Array.isArray(proposerMeta.resp_assist)
? { ...(proposerMeta.resp_assist as Record<string, TriggerValue>) }
: ({} as Record<string, TriggerValue>);
const assistKey = `n${actorNation.id}`;
const recvEntry = recvAssist[assistKey];
const recvAmount =
Array.isArray(recvEntry) && typeof recvEntry[1] === 'number' && Number.isFinite(recvEntry[1])
? recvEntry[1]
: 0;
respAssist[assistKey] = [actorNation.id, recvAmount];
const currentMonth = resolveMonthIndex(context.currentYear, context.currentMonth);
const targetMonth = treatyYear * 12 + treatyMonth;
const term = targetMonth - currentMonth;
return [
createDiplomacyPatchEffect(actorNation.id, proposerNation.id, {
state: DIPLOMACY_STATE.NON_AGGRESSION,
term,
}),
createDiplomacyPatchEffect(proposerNation.id, actorNation.id, {
state: DIPLOMACY_STATE.NON_AGGRESSION,
term,
}),
createNationPatchEffect(
{
meta: {
...proposerMeta,
resp_assist: respAssist,
},
},
proposerNation.id
),
createLogEffect(
`<D><b>${proposerNationName}</b></>${proposerJosaWa} ${treatyYear}${treatyMonth}월까지 불가침 성공`,
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: context.actor.id,
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(
`<D><b>${proposerNationName}</b></>${proposerJosaWa} <C>${treatyYear}</>년 <C>${treatyMonth}</>월까지 불가침에 성공했습니다.`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: context.actor.id,
format: LogFormat.PLAIN,
}
),
createLogEffect(
`<D><b>${actorNationName}</b></>${actorJosaWa} ${treatyYear}${treatyMonth}월까지 불가침 성공`,
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: context.proposer.id,
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(
`<D><b>${actorNationName}</b></>${actorJosaWa} <C>${treatyYear}</>년 <C>${treatyMonth}</>월까지 불가침에 성공했습니다.`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: context.proposer.id,
format: LogFormat.PLAIN,
}
),
];
};
const buildCancelNonAggressionEffects = <TriggerState extends GeneralTriggerState>(
context: InstantDiplomacyResponseContext
): GeneralActionEffect<TriggerState>[] => {
const actorNation = context.actorNation;
const proposerNation = context.proposerNation;
const actorNationName = actorNation.name;
const proposerNationName = proposerNation.name;
const actorName = context.actor.name;
const actorJosaYi = JosaUtil.pick(actorName, '이');
const actorNationJosaYi = JosaUtil.pick(actorNationName, '이');
const actorNationJosaWa = JosaUtil.pick(actorNationName, '와');
const proposerJosaWa = JosaUtil.pick(proposerNationName, '와');
return [
createDiplomacyPatchEffect(actorNation.id, proposerNation.id, {
state: DIPLOMACY_STATE.TRADE,
term: 0,
}),
createDiplomacyPatchEffect(proposerNation.id, actorNation.id, {
state: DIPLOMACY_STATE.TRADE,
term: 0,
}),
createLogEffect(`<D><b>${proposerNationName}</b></>${proposerJosaWa}의 불가침 파기 수락`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: context.actor.id,
format: LogFormat.YEAR_MONTH,
}),
createLogEffect(`<D><b>${proposerNationName}</b></>${proposerJosaWa}의 불가침을 파기했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: context.actor.id,
format: LogFormat.PLAIN,
}),
createLogEffect(
`<Y><b>【파기】</b></><D><b>${actorNationName}</b></>${actorNationJosaYi} <D><b>${proposerNationName}</b></>${proposerJosaWa}의 불가침 조약을 <M>파기</> 하였습니다.`,
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(
`<Y>${actorName}</>${actorJosaYi} <D><b>${proposerNationName}</b></>${proposerJosaWa}의 불가침 조약을 <M>파기</> 하였습니다.`,
{
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
}
),
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa}의 불가침 파기 성공`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: context.proposer.id,
format: LogFormat.YEAR_MONTH,
}),
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa}의 불가침 파기에 성공했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: context.proposer.id,
format: LogFormat.PLAIN,
}),
];
};
const buildStopWarEffects = <TriggerState extends GeneralTriggerState>(
context: InstantDiplomacyResponseContext
): GeneralActionEffect<TriggerState>[] => {
const actorNation = context.actorNation;
const proposerNation = context.proposerNation;
const actorNationName = actorNation.name;
const proposerNationName = proposerNation.name;
const actorName = context.actor.name;
const actorJosaYi = JosaUtil.pick(actorName, '이');
const actorNationJosaYi = JosaUtil.pick(actorNationName, '이');
const actorNationJosaWa = JosaUtil.pick(actorNationName, '와');
const proposerJosaWa = JosaUtil.pick(proposerNationName, '와');
return [
createDiplomacyPatchEffect(actorNation.id, proposerNation.id, {
state: DIPLOMACY_STATE.TRADE,
term: 0,
}),
createDiplomacyPatchEffect(proposerNation.id, actorNation.id, {
state: DIPLOMACY_STATE.TRADE,
term: 0,
}),
createLogEffect(`<D><b>${proposerNationName}</b></>${proposerJosaWa} 종전 수락`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: context.actor.id,
format: LogFormat.YEAR_MONTH,
}),
createLogEffect(`<D><b>${proposerNationName}</b></>${proposerJosaWa} 종전에 합의했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: context.actor.id,
format: LogFormat.PLAIN,
}),
createLogEffect(`<D><b>${proposerNationName}</b></>${proposerJosaWa} 종전`, {
scope: LogScope.NATION,
nationId: actorNation.id,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
createLogEffect(
`<Y><b>【종전】</b></><D><b>${actorNationName}</b></>${actorNationJosaYi} <D><b>${proposerNationName}</b></>${proposerJosaWa} <M>종전 합의</> 하였습니다.`,
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(
`<Y>${actorName}</>${actorJosaYi} <D><b>${proposerNationName}</b></>${proposerJosaWa} <M>종전 합의</> 하였습니다.`,
{
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
}
),
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전 성공`, {
scope: LogScope.GENERAL,
generalId: context.proposer.id,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전에 성공했습니다.`, {
scope: LogScope.GENERAL,
generalId: context.proposer.id,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
}),
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전`, {
scope: LogScope.NATION,
nationId: proposerNation.id,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
];
};
export const resolveInstantDiplomacyResponse = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
context: InstantDiplomacyResponseContext,
args: InstantDiplomacyResponseArgs
): InstantDiplomacyResponseResolution<TriggerState> => {
if (args.action === 'noAggression') {
if (
typeof args.treatyYear !== 'number' ||
!Number.isInteger(args.treatyYear) ||
typeof args.treatyMonth !== 'number' ||
!Number.isInteger(args.treatyMonth) ||
args.treatyMonth < 1 ||
args.treatyMonth > 12
) {
throw new Error('Invalid non-aggression treaty term.');
}
return {
actionKey: 'che_불가침수락',
diplomacyDetail: `${args.treatyYear}${args.treatyMonth}월까지 불가침 합의`,
effects: buildNonAggressionEffects<TriggerState>(context, args.treatyYear, args.treatyMonth),
refreshFront: false,
};
}
if (args.action === 'cancelNA') {
return {
actionKey: 'che_불가침파기수락',
diplomacyDetail: `${context.proposerNation.name}국과 불가침 파기 합의`,
effects: buildCancelNonAggressionEffects<TriggerState>(context),
refreshFront: false,
};
}
return {
actionKey: 'che_종전수락',
diplomacyDetail: `${context.proposerNation.name}국과 종전 합의`,
effects: buildStopWarEffects<TriggerState>(context),
refreshFront: true,
};
};
@@ -181,6 +181,10 @@ describe('Nation Instant Actions', () => {
general,
city,
nation,
destNation,
destGeneral: buildGeneral(2, 2, 2, 'Dest'),
currentYear: 200,
currentMonth: 1,
rng: {} as any,
addLog: () => {},
} as any,
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import type { Nation } from '../src/domain/entities.js';
import { buildNationFrontStatePatches, resolveInstantDiplomacyResponse } from '../src/diplomacy/index.js';
import { LogCategory, LogScope } from '../src/logging/types.js';
const nation = (id: number, name: string, meta: Nation['meta'] = {}): Nation => ({
id,
name,
color: '#000000',
capitalCityId: id,
chiefGeneralId: id,
gold: 1000,
rice: 1000,
power: 0,
level: 1,
typeCode: 'test',
meta,
});
const context = {
actor: { id: 11, name: '수락자', nationId: 1 },
actorNation: nation(1, '위'),
proposer: { id: 22, name: '제안자', nationId: 2 },
proposerNation: nation(2, '촉', {
recv_assist: { n1: [1, 345] },
resp_assist: { n3: [3, 100] },
}),
currentYear: 200,
currentMonth: 3,
};
describe('instant diplomatic response parity', () => {
it('creates the legacy non-aggression term, assistance response, and both general logs', () => {
const result = resolveInstantDiplomacyResponse(context, {
action: 'noAggression',
treatyYear: 201,
treatyMonth: 2,
});
expect(result.actionKey).toBe('che_불가침수락');
expect(result.diplomacyDetail).toBe('201년 2월까지 불가침 합의');
expect(result.effects.filter((effect) => effect.type === 'diplomacy:patch')).toEqual([
{
type: 'diplomacy:patch',
srcNationId: 1,
destNationId: 2,
patch: { state: 7, term: 12 },
},
{
type: 'diplomacy:patch',
srcNationId: 2,
destNationId: 1,
patch: { state: 7, term: 12 },
},
]);
expect(result.effects).toContainEqual({
type: 'nation:patch',
targetId: 2,
patch: {
meta: {
recv_assist: { n1: [1, 345] },
resp_assist: { n3: [3, 100], n1: [1, 345] },
},
},
});
const logs = result.effects.filter((effect) => effect.type === 'log');
expect(logs).toHaveLength(4);
expect(logs.map((effect) => effect.entry.generalId)).toEqual([11, 11, 22, 22]);
});
it('creates the full legacy cancellation logs without refreshing fronts', () => {
const result = resolveInstantDiplomacyResponse(context, { action: 'cancelNA' });
const logs = result.effects.filter((effect) => effect.type === 'log');
expect(result.actionKey).toBe('che_불가침파기수락');
expect(result.refreshFront).toBe(false);
expect(logs).toHaveLength(6);
expect(logs).toContainEqual(
expect.objectContaining({
entry: expect.objectContaining({
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
}),
})
);
});
it('creates both nation histories and requests front refresh for stop-war', () => {
const result = resolveInstantDiplomacyResponse(context, { action: 'stopWar' });
const nationLogIds = result.effects.flatMap((effect) =>
effect.type === 'log' && effect.entry.scope === LogScope.NATION ? [effect.entry.nationId] : []
);
expect(result.actionKey).toBe('che_종전수락');
expect(result.refreshFront).toBe(true);
expect(nationLogIds).toEqual([1, 2]);
});
it('recomputes only requested nation fronts with legacy priority', () => {
const patches = buildNationFrontStatePatches({
cities: [
{ id: 1, nationId: 1, frontState: 3 },
{ id: 2, nationId: 2, frontState: 3 },
{ id: 3, nationId: 0, frontState: 0 },
],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: 2, term: 0 },
{ fromNationId: 2, toNationId: 1, state: 2, term: 0 },
],
connections: new Map([
[1, [2, 3]],
[2, [1]],
[3, [1]],
]),
nationIds: [1, 2],
});
expect(patches).toEqual([
{ id: 1, frontState: 2 },
{ id: 2, frontState: 0 },
]);
});
});