fix: NPC 불가침 제의 수명주기를 보정

원조 기반 제의의 서신 만료 tick을 영속화하고 거절 상대를 영구 제외한다. 불가침 월별 감소와 만료 전후 선전포고 경계를 통합 회귀로 검증한다.
This commit is contained in:
2026-08-21 13:42:35 +00:00
parent 36762b8958
commit 64f70258ca
10 changed files with 514 additions and 12 deletions
@@ -253,6 +253,37 @@ export const respondToDiplomaticMessage = async (options: {
}
if (!response) {
let declinedAidProposalNationId: number | null = null;
if (action === 'noAggression') {
await db.$queryRaw`
SELECT id
FROM nation
WHERE id = ${proposerNationId}
FOR UPDATE
`;
const proposerNation = await db.nation.findUnique({ where: { id: proposerNationId } });
if (proposerNation) {
const proposerMeta = asRecord(proposerNation.meta);
const respAssistTry = asRecord(proposerMeta.resp_assist_try);
const assistKey = `n${actorNationId}`;
if (Object.prototype.hasOwnProperty.call(respAssistTry, assistKey)) {
const respAssistDeclined = asRecord(proposerMeta.resp_assist_declined);
await db.nation.update({
where: { id: proposerNationId },
data: {
meta: {
...proposerMeta,
resp_assist_declined: {
...respAssistDeclined,
[assistKey]: [actorNationId, world.currentYear * 12 + world.currentMonth - 1],
},
} as InputJsonValue,
},
});
declinedAidProposalNationId = proposerNationId;
}
}
}
const actorLogger = new ActionLogger({ generalId: actor.id });
const proposerLogger = new ActionLogger({ generalId: proposerGeneralId });
const receiverNationName = message.payload.dest.nationName;
@@ -278,7 +309,7 @@ export const respondToDiplomaticMessage = async (options: {
reason: 'success',
affectedMailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId],
affectedGeneralRecordIds: [actor.id, proposerGeneralId],
affectedNationIds: [],
affectedNationIds: declinedAidProposalNationId === null ? [] : [declinedAidProposalNationId],
affectedCityIds: [],
};
}
+47 -1
View File
@@ -745,6 +745,7 @@ describe('messages router missing-flow compatibility', () => {
mailboxNationId?: number;
proposerNationId?: number;
proposerCurrentNationId?: number;
proposerNationMeta?: Record<string, unknown>;
diplomacyState?: number;
response?: boolean;
cities?: Array<{ id: number; nationId: number; frontState: number }>;
@@ -903,7 +904,9 @@ describe('messages router missing-flow compatibility', () => {
tech: 0,
level: 1,
typeCode: 'test',
meta: { recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] } },
meta: options?.proposerNationMeta ?? {
recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] },
},
}
: null
),
@@ -1006,6 +1009,49 @@ describe('messages router missing-flow compatibility', () => {
expect(setup.logCreateMany).toHaveBeenCalledOnce();
});
it('permanently records rejection of an NPC aid-based non-aggression proposal', async () => {
const setup = buildDiplomaticContext({
proposerNationMeta: {
recv_assist: { n1: [1, 50] },
resp_assist_try: { n1: [1, 2402, 108_000_000] },
},
});
const result = await setup.caller.messages.respond({
generalId: setup.actor.id,
messageId: 31,
response: false,
});
expect(result).toEqual({ result: true, reason: 'success' });
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
expect(setup.nationUpdate).toHaveBeenCalledWith({
where: { id: 2 },
data: {
meta: {
recv_assist: { n1: [1, 50] },
resp_assist_try: { n1: [1, 2402, 108_000_000] },
resp_assist_declined: { n1: [1, 2402] },
},
},
});
expect(setup.messageUpdateMany).toHaveBeenCalledOnce();
});
it('does not record a permanent aid rejection for a manual non-aggression proposal', async () => {
const setup = buildDiplomaticContext({ proposerNationMeta: {} });
const result = await setup.caller.messages.respond({
generalId: setup.actor.id,
messageId: 31,
response: false,
});
expect(result).toEqual({ result: true, reason: 'success' });
expect(setup.nationUpdate).not.toHaveBeenCalled();
expect(setup.messageUpdateMany).toHaveBeenCalledOnce();
});
it.each([
['cancelNA' as const, 7],
['stopWar' as const, 0],
@@ -658,9 +658,16 @@ export class GeneralAI {
markCapitalMoveTrial(): void {
if (!this.nation || this.general.turnTick === undefined) return;
this.patchPersistentNationMeta({
lastCapitalMoveTrial: [this.general.officerLevel, this.general.turnTick],
});
}
patchPersistentNationMeta(patch: Record<string, unknown>): void {
if (!this.nation) return;
const nextMeta = {
...(this.promotionNationMeta ?? this.nation.meta),
lastCapitalMoveTrial: [this.general.officerLevel, this.general.turnTick],
...patch,
};
this.nation = { ...this.nation, meta: nextMeta as Nation['meta'] };
this.promotionNationMeta = nextMeta;
@@ -1,8 +1,16 @@
import type { GeneralAI } from '../core.js';
import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.js';
import { resolveDiplomacyMessageValidUntilTick } from '@sammo-ts/logic';
import { isNeighbor } from '@sammo-ts/logic/world/distance.js';
import { resolveNationIncome } from './helpers.js';
const LEGACY_RETRY_COOLDOWN_MONTHS = 8;
const readIndexedNumber = (value: unknown, index: number, fallback = 0): number => {
const raw = Array.isArray(value) ? value[index] : asRecord(value)[String(index)];
return typeof raw === 'number' && Number.isFinite(raw) ? raw : fallback;
};
const isTechLimited = (ai: GeneralAI, tech: number): boolean => {
const relativeYear = Math.max(0, ai.world.currentYear - ai.startYear);
const levelIncreaseYears = ai.commandEnv.techLevelIncYear ?? 5;
@@ -26,7 +34,9 @@ export const do불가침제의 = (ai: GeneralAI) => {
const recvAssist = Array.isArray(meta.recv_assist) ? meta.recv_assist : Object.values(asRecord(meta.recv_assist));
const respAssist = asRecord(meta.resp_assist);
const respAssistTry = asRecord(meta.resp_assist_try);
const respAssistDeclined = asRecord(meta.resp_assist_declined);
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
const currentTurnTick = ai.general.turnTick;
const candidateList: Record<number, number> = {};
for (const entry of recvAssist) {
@@ -36,8 +46,8 @@ export const do불가침제의 = (ai: GeneralAI) => {
if (!Number.isFinite(destNationId) || !Number.isFinite(amount)) {
continue;
}
const respEntry = asRecord(respAssist[`n${destNationId}`]);
const respAmount = readMetaNumber(respEntry, '1', 0);
const respEntry = respAssist[`n${destNationId}`];
const respAmount = readIndexedNumber(respEntry, 1);
const remain = amount - respAmount;
if (remain <= 0) {
continue;
@@ -45,10 +55,22 @@ export const do불가침제의 = (ai: GeneralAI) => {
if (ai.warTargetNation[destNationId]) {
continue;
}
const lastTry = readMetaNumber(asRecord(respAssistTry[`n${destNationId}`]), '1', 0);
if (lastTry >= yearMonth - 8) {
const assistKey = `n${destNationId}`;
if (Object.prototype.hasOwnProperty.call(respAssistDeclined, assistKey)) {
continue;
}
const lastTryEntry = respAssistTry[assistKey];
const proposalValidUntilTick = readIndexedNumber(lastTryEntry, 2);
if (typeof currentTurnTick === 'number' && Number.isFinite(currentTurnTick) && proposalValidUntilTick > 0) {
if (currentTurnTick < proposalValidUntilTick) {
continue;
}
} else {
const lastTry = readIndexedNumber(lastTryEntry, 1);
if (lastTry >= yearMonth - LEGACY_RETRY_COOLDOWN_MONTHS) {
continue;
}
}
candidateList[destNationId] = remain;
}
@@ -84,8 +106,15 @@ export const do불가침제의 = (ai: GeneralAI) => {
'불가침제의'
);
if (result) {
const nextTry = { ...respAssistTry, [`n${destNationId}`]: [destNationId, yearMonth] };
asRecord(ai.nation.meta).resp_assist_try = nextTry;
const validUntilTick =
typeof currentTurnTick === 'number'
? resolveDiplomacyMessageValidUntilTick(currentTurnTick, ai.world.tickSeconds)
: null;
const nextTry = {
...respAssistTry,
[`n${destNationId}`]: [destNationId, yearMonth, ...(validUntilTick === null ? [] : [validUntilTick])],
};
ai.patchPersistentNationMeta({ resp_assist_try: nextTry });
}
return result;
};
@@ -0,0 +1,277 @@
import { describe, expect, it } from 'vitest';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { DIPLOMACY_STATE, type TriggerValue, type TurnSchedule } from '@sammo-ts/logic';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createMonthlyDiplomacyHandler } from '../src/turn/monthlyNationStatsHandler.js';
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
const mockDate = new Date('0190-01-01T00:00:00.000Z');
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const createChief = (npcState: number, userId: string | null): TurnGeneral => ({
id: 1,
userId,
name: npcState >= 2 ? 'NPC군주' : '유저군주',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 90, strength: 80, intelligence: 70 },
turnTime: mockDate,
turnTick: 0,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 800 },
officerLevel: 12,
experience: 0,
dedication: 0,
injury: 0,
gold: 100_000,
rice: 100_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState,
});
const createFixture = (options: { npcState: number; nationMeta?: Record<string, TriggerValue>; term?: number }) => {
const cities = buildLargeTestCities();
for (const city of cities) {
city.nationId = city.id === 1 ? 1 : city.id === 2 ? 2 : 0;
if (city.id === 1 || city.id === 2) {
city.supplyState = 1;
}
}
const diplomacyState = options.term === undefined ? DIPLOMACY_STATE.TRADE : DIPLOMACY_STATE.NON_AGGRESSION;
const diplomacyTerm = options.term ?? 0;
const snapshot: TurnWorldSnapshot = {
generals: [createChief(options.npcState, options.npcState >= 2 ? null : 'user-1')],
cities,
nations: [
{
id: 1,
name: '제안국',
color: '#aa0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 1_000_000,
rice: 1_000_000,
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: options.nationMeta ?? {},
},
{
id: 2,
name: '원조국',
color: '#0000aa',
capitalCityId: 2,
chiefGeneralId: null,
gold: 1_000_000,
rice: 1_000_000,
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: {},
},
],
troops: [],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: diplomacyState, term: diplomacyTerm, dead: 0, meta: {} },
{ fromNationId: 2, toNationId: 1, state: diplomacyState, term: diplomacyTerm, dead: 0, meta: {} },
],
events: [],
initialEvents: [],
map: LARGE_TEST_MAP,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
openingPartYear: 3,
develCost: 10,
baseGold: 1000,
baseRice: 1000,
maxResourceActionAmount: 10_000,
maxTechLevel: 12_000,
},
environment: { mapName: 'large_test_map', unitSet: 'default' },
},
scenarioMeta: { startYear: 180 } as never,
unitSet: {} as never,
};
const state: TurnWorldState = {
id: 1,
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: mockDate,
clockBaseTime: mockDate,
clockTick: 0,
lastTurnTick: 0,
meta: { seed: 1 },
};
return { snapshot, state };
};
describe('NPC 원조 기반 불가침 제의 lifecycle', () => {
it('persists the exact message expiry and does not propose again while the previous letter is valid', async () => {
const fixture = createFixture({
npcState: 2,
nationMeta: {
recv_assist: { n2: [2, 1_000_000] },
npc_nation_policy: { priority: ['불가침제의'] },
},
});
const { world, runOneTick } = await createTurnTestHarness({
...fixture,
schedule,
map: LARGE_TEST_MAP,
reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 },
});
await runOneTick();
const firstMessages = world
.peekDirtyState()
.messages.filter((message) => message.msgType === 'diplomacy' && message.option?.action === 'noAggression');
expect(firstMessages).toHaveLength(1);
const tryEntry = (world.getNationById(1)!.meta.resp_assist_try as Record<string, number[]>).n2!;
expect(tryEntry).toHaveLength(3);
const validUntilTick = tryEntry[2]!;
expect(validUntilTick - world.dateToGameTick(firstMessages[0]!.time)).toBe(GAME_TICKS_PER_TURN * 3);
expect(world.dateToGameTick(firstMessages[0]!.validUntil)).toBe(validUntilTick);
await runOneTick();
await runOneTick();
expect(
world
.peekDirtyState()
.messages.filter(
(message) => message.msgType === 'diplomacy' && message.option?.action === 'noAggression'
)
).toHaveLength(1);
await runOneTick();
expect(
world
.peekDirtyState()
.messages.filter(
(message) => message.msgType === 'diplomacy' && message.option?.action === 'noAggression'
)
).toHaveLength(2);
});
it('never proposes again to a nation that rejected the aid-based proposal', async () => {
const fixture = createFixture({
npcState: 2,
nationMeta: {
recv_assist: { n2: [2, 1_000_000] },
resp_assist_try: { n2: [2, 2280, 0] },
resp_assist_declined: { n2: [2, 2280] },
npc_nation_policy: { priority: ['불가침제의'] },
},
});
const { world, runOneTick } = await createTurnTestHarness({
...fixture,
schedule,
map: LARGE_TEST_MAP,
reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 },
});
for (let turn = 0; turn < 8; turn += 1) {
await runOneTick();
}
expect(
world
.peekDirtyState()
.messages.filter(
(message) => message.msgType === 'diplomacy' && message.option?.action === 'noAggression'
)
).toHaveLength(0);
});
it('does not propose again after the accepted pact accounts for all received aid', async () => {
const fixture = createFixture({
npcState: 2,
nationMeta: {
recv_assist: { n2: [2, 1_000_000] },
resp_assist: { n2: [2, 1_000_000] },
npc_nation_policy: { priority: ['불가침제의'] },
},
});
const { world, runOneTick } = await createTurnTestHarness({
...fixture,
schedule,
map: LARGE_TEST_MAP,
reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 },
});
for (let turn = 0; turn < 8; turn += 1) {
await runOneTick();
}
expect(
world
.peekDirtyState()
.messages.filter(
(message) => message.msgType === 'diplomacy' && message.option?.action === 'noAggression'
)
).toHaveLength(0);
});
});
describe('불가침 만료와 선전포고 lifecycle', () => {
it('blocks declaration during the pact, decrements monthly, then allows declaration after trade resumes', async () => {
const fixture = createFixture({ npcState: 0, term: 2 });
const resolved: Array<{ requestedAction: string; actionKey: string; blockedReason?: string }> = [];
const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({
...fixture,
schedule,
map: LARGE_TEST_MAP,
reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 },
onActionResolved: (event) => {
if (event.kind === 'nation') resolved.push(event);
},
});
const setDeclaration = () => {
reservedTurnStore.getNationTurns(1, 12)[0] = {
action: 'che_선전포고',
args: { destNationId: 2 },
};
};
const getForward = () =>
world.listDiplomacy().find((entry) => entry.fromNationId === 1 && entry.toNationId === 2)!;
setDeclaration();
await runOneTick();
expect(resolved.at(-1)).toMatchObject({
requestedAction: 'che_선전포고',
blockedReason: '불가침국입니다.',
});
expect(getForward()).toMatchObject({ state: DIPLOMACY_STATE.NON_AGGRESSION, term: 1 });
const monthlyDiplomacy = createMonthlyDiplomacyHandler({ getWorld: () => world });
await monthlyDiplomacy.onMonthChanged?.({} as never);
expect(getForward()).toMatchObject({ state: DIPLOMACY_STATE.TRADE, term: 0 });
setDeclaration();
await runOneTick();
expect(resolved.at(-1)).toMatchObject({
requestedAction: 'che_선전포고',
actionKey: 'che_선전포고',
});
expect(resolved.at(-1)?.blockedReason).toBeUndefined();
// runOneTick은 명령 실행 뒤 같은 월의 외교 월말 처리까지 완료한다.
expect(getForward()).toMatchObject({ state: DIPLOMACY_STATE.DECLARATION, term: 23 });
});
});
@@ -18,6 +18,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
import { resolveDiplomacyMessageValidMinutes } from '../../../diplomacy/messageValidity.js';
const ARGS_SCHEMA = z.object({
destNationId: z.number().int().positive(),
@@ -195,7 +196,7 @@ export const actionContextBuilder: ActionContextBuilder<NonAggressionProposalArg
...base,
destNation,
messageTime: base.general.turnTime,
messageValidMinutes: Math.max(30, Math.floor((options.world.tickSeconds / 60) * 3)),
messageValidMinutes: resolveDiplomacyMessageValidMinutes(options.world.tickSeconds),
};
};
+1
View File
@@ -138,3 +138,4 @@ export const processDiplomacyMonth = (
export * from './frontState.js';
export * from './instantResponse.js';
export * from './messageValidity.js';
@@ -0,0 +1,18 @@
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
const MIN_DIPLOMACY_MESSAGE_VALID_MINUTES = 30;
const DIPLOMACY_MESSAGE_VALID_TURNS = 3;
export const resolveDiplomacyMessageValidMinutes = (tickSeconds: number): number =>
Math.max(MIN_DIPLOMACY_MESSAGE_VALID_MINUTES, Math.floor((tickSeconds / 60) * DIPLOMACY_MESSAGE_VALID_TURNS));
export const resolveDiplomacyMessageValidUntilTick = (turnTick: number, tickSeconds: number): number | null => {
if (!Number.isFinite(turnTick) || !Number.isInteger(tickSeconds) || tickSeconds <= 0) {
return null;
}
const ticksPerSecond = GAME_TICKS_PER_TURN / tickSeconds;
if (!Number.isInteger(ticksPerSecond)) {
return null;
}
return turnTick + resolveDiplomacyMessageValidMinutes(tickSeconds) * 60 * ticksPerSecond;
};
+46 -1
View File
@@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { DEFAULT_WAR_TERM, DIPLOMACY_STATE, processDiplomacyMonth, type DiplomacyEntry } from '@sammo-ts/logic';
import {
DEFAULT_WAR_TERM,
DIPLOMACY_STATE,
processDiplomacyMonth,
resolveDiplomacyMessageValidMinutes,
resolveDiplomacyMessageValidUntilTick,
type DiplomacyEntry,
} from '@sammo-ts/logic';
const buildEntry = (
fromNationId: number,
@@ -88,4 +96,41 @@ describe('diplomacy month processing', () => {
expect(entry.term).toBe(0);
}
});
it('decrements non-aggression exactly once per month and allows declaration only after the final month', () => {
let entries = [
buildEntry(1, 2, DIPLOMACY_STATE.NON_AGGRESSION, 3),
buildEntry(2, 1, DIPLOMACY_STATE.NON_AGGRESSION, 3),
];
const generalCounts = new Map([
[1, 1],
[2, 1],
]);
entries = processDiplomacyMonth(entries, generalCounts);
expect(entries.map(({ state, term }) => ({ state, term }))).toEqual([
{ state: DIPLOMACY_STATE.NON_AGGRESSION, term: 2 },
{ state: DIPLOMACY_STATE.NON_AGGRESSION, term: 2 },
]);
entries = processDiplomacyMonth(entries, generalCounts);
expect(entries.map(({ state, term }) => ({ state, term }))).toEqual([
{ state: DIPLOMACY_STATE.NON_AGGRESSION, term: 1 },
{ state: DIPLOMACY_STATE.NON_AGGRESSION, term: 1 },
]);
entries = processDiplomacyMonth(entries, generalCounts);
expect(entries.map(({ state, term }) => ({ state, term }))).toEqual([
{ state: DIPLOMACY_STATE.TRADE, term: 0 },
{ state: DIPLOMACY_STATE.TRADE, term: 0 },
]);
});
});
describe('diplomacy message validity', () => {
it('uses the same three-turn or 30-minute logical expiry for messages and NPC retry gates', () => {
expect(resolveDiplomacyMessageValidMinutes(600)).toBe(30);
expect(resolveDiplomacyMessageValidUntilTick(100, 600)).toBe(100 + GAME_TICKS_PER_TURN * 3);
expect(resolveDiplomacyMessageValidMinutes(300)).toBe(30);
expect(resolveDiplomacyMessageValidUntilTick(100, 300)).toBe(100 + GAME_TICKS_PER_TURN * 6);
});
});
@@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest';
import type { Nation } from '../src/domain/entities.js';
import { buildNationFrontStatePatches, resolveInstantDiplomacyResponse } from '../src/diplomacy/index.js';
import {
buildNationFrontStatePatches,
DIPLOMACY_STATE,
processDiplomacyMonth,
resolveInstantDiplomacyResponse,
type DiplomacyEntry,
} from '../src/diplomacy/index.js';
import { LogCategory, LogScope } from '../src/logging/types.js';
const nation = (id: number, name: string, meta: Nation['meta'] = {}): Nation => ({
@@ -69,6 +75,47 @@ describe('instant diplomatic response parity', () => {
expect(logs.map((effect) => effect.entry.generalId)).toEqual([11, 11, 22, 22]);
});
it('counts an accepted pact down through every month and returns both directions to trade', () => {
const result = resolveInstantDiplomacyResponse(context, {
action: 'noAggression',
treatyYear: 200,
treatyMonth: 5,
});
let diplomacy = result.effects.flatMap((effect) =>
effect.type === 'diplomacy:patch'
? [
{
fromNationId: effect.srcNationId,
toNationId: effect.destNationId,
state: effect.patch.state!,
term: effect.patch.term!,
dead: 0,
meta: {},
} satisfies DiplomacyEntry,
]
: []
);
expect(diplomacy.map(({ state, term }) => ({ state, term }))).toEqual([
{ state: DIPLOMACY_STATE.NON_AGGRESSION, term: 3 },
{ state: DIPLOMACY_STATE.NON_AGGRESSION, term: 3 },
]);
const generalCounts = new Map([
[1, 1],
[2, 1],
]);
for (const expectedTerm of [2, 1]) {
diplomacy = processDiplomacyMonth(diplomacy, generalCounts);
expect(diplomacy.every((entry) => entry.state === DIPLOMACY_STATE.NON_AGGRESSION)).toBe(true);
expect(diplomacy.map((entry) => entry.term)).toEqual([expectedTerm, expectedTerm]);
}
diplomacy = processDiplomacyMonth(diplomacy, generalCounts);
expect(diplomacy.map(({ state, term }) => ({ state, term }))).toEqual([
{ state: DIPLOMACY_STATE.TRADE, term: 0 },
{ state: DIPLOMACY_STATE.TRADE, term: 0 },
]);
});
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');