merge: NPC 불가침 제의 수명주기 보정을 통합

This commit is contained in:
2026-08-21 13:42:41 +00:00
10 changed files with 514 additions and 12 deletions
@@ -253,6 +253,37 @@ export const respondToDiplomaticMessage = async (options: {
} }
if (!response) { 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 actorLogger = new ActionLogger({ generalId: actor.id });
const proposerLogger = new ActionLogger({ generalId: proposerGeneralId }); const proposerLogger = new ActionLogger({ generalId: proposerGeneralId });
const receiverNationName = message.payload.dest.nationName; const receiverNationName = message.payload.dest.nationName;
@@ -278,7 +309,7 @@ export const respondToDiplomaticMessage = async (options: {
reason: 'success', reason: 'success',
affectedMailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId], affectedMailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId],
affectedGeneralRecordIds: [actor.id, proposerGeneralId], affectedGeneralRecordIds: [actor.id, proposerGeneralId],
affectedNationIds: [], affectedNationIds: declinedAidProposalNationId === null ? [] : [declinedAidProposalNationId],
affectedCityIds: [], affectedCityIds: [],
}; };
} }
+47 -1
View File
@@ -745,6 +745,7 @@ describe('messages router missing-flow compatibility', () => {
mailboxNationId?: number; mailboxNationId?: number;
proposerNationId?: number; proposerNationId?: number;
proposerCurrentNationId?: number; proposerCurrentNationId?: number;
proposerNationMeta?: Record<string, unknown>;
diplomacyState?: number; diplomacyState?: number;
response?: boolean; response?: boolean;
cities?: Array<{ id: number; nationId: number; frontState: number }>; cities?: Array<{ id: number; nationId: number; frontState: number }>;
@@ -903,7 +904,9 @@ describe('messages router missing-flow compatibility', () => {
tech: 0, tech: 0,
level: 1, level: 1,
typeCode: 'test', typeCode: 'test',
meta: { recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] } }, meta: options?.proposerNationMeta ?? {
recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] },
},
} }
: null : null
), ),
@@ -1006,6 +1009,49 @@ describe('messages router missing-flow compatibility', () => {
expect(setup.logCreateMany).toHaveBeenCalledOnce(); 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([ it.each([
['cancelNA' as const, 7], ['cancelNA' as const, 7],
['stopWar' as const, 0], ['stopWar' as const, 0],
@@ -658,9 +658,16 @@ export class GeneralAI {
markCapitalMoveTrial(): void { markCapitalMoveTrial(): void {
if (!this.nation || this.general.turnTick === undefined) return; 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 = { const nextMeta = {
...(this.promotionNationMeta ?? this.nation.meta), ...(this.promotionNationMeta ?? this.nation.meta),
lastCapitalMoveTrial: [this.general.officerLevel, this.general.turnTick], ...patch,
}; };
this.nation = { ...this.nation, meta: nextMeta as Nation['meta'] }; this.nation = { ...this.nation, meta: nextMeta as Nation['meta'] };
this.promotionNationMeta = nextMeta; this.promotionNationMeta = nextMeta;
@@ -1,8 +1,16 @@
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.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 { isNeighbor } from '@sammo-ts/logic/world/distance.js';
import { resolveNationIncome } from './helpers.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 isTechLimited = (ai: GeneralAI, tech: number): boolean => {
const relativeYear = Math.max(0, ai.world.currentYear - ai.startYear); const relativeYear = Math.max(0, ai.world.currentYear - ai.startYear);
const levelIncreaseYears = ai.commandEnv.techLevelIncYear ?? 5; 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 recvAssist = Array.isArray(meta.recv_assist) ? meta.recv_assist : Object.values(asRecord(meta.recv_assist));
const respAssist = asRecord(meta.resp_assist); const respAssist = asRecord(meta.resp_assist);
const respAssistTry = asRecord(meta.resp_assist_try); const respAssistTry = asRecord(meta.resp_assist_try);
const respAssistDeclined = asRecord(meta.resp_assist_declined);
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth); const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
const currentTurnTick = ai.general.turnTick;
const candidateList: Record<number, number> = {}; const candidateList: Record<number, number> = {};
for (const entry of recvAssist) { for (const entry of recvAssist) {
@@ -36,8 +46,8 @@ export const do불가침제의 = (ai: GeneralAI) => {
if (!Number.isFinite(destNationId) || !Number.isFinite(amount)) { if (!Number.isFinite(destNationId) || !Number.isFinite(amount)) {
continue; continue;
} }
const respEntry = asRecord(respAssist[`n${destNationId}`]); const respEntry = respAssist[`n${destNationId}`];
const respAmount = readMetaNumber(respEntry, '1', 0); const respAmount = readIndexedNumber(respEntry, 1);
const remain = amount - respAmount; const remain = amount - respAmount;
if (remain <= 0) { if (remain <= 0) {
continue; continue;
@@ -45,10 +55,22 @@ export const do불가침제의 = (ai: GeneralAI) => {
if (ai.warTargetNation[destNationId]) { if (ai.warTargetNation[destNationId]) {
continue; continue;
} }
const lastTry = readMetaNumber(asRecord(respAssistTry[`n${destNationId}`]), '1', 0); const assistKey = `n${destNationId}`;
if (lastTry >= yearMonth - 8) { if (Object.prototype.hasOwnProperty.call(respAssistDeclined, assistKey)) {
continue; 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; candidateList[destNationId] = remain;
} }
@@ -84,8 +106,15 @@ export const do불가침제의 = (ai: GeneralAI) => {
'불가침제의' '불가침제의'
); );
if (result) { if (result) {
const nextTry = { ...respAssistTry, [`n${destNationId}`]: [destNationId, yearMonth] }; const validUntilTick =
asRecord(ai.nation.meta).resp_assist_try = nextTry; 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; 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 type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod'; import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js'; import { parseArgsWithSchema } from '../parseArgs.js';
import { resolveDiplomacyMessageValidMinutes } from '../../../diplomacy/messageValidity.js';
const ARGS_SCHEMA = z.object({ const ARGS_SCHEMA = z.object({
destNationId: z.number().int().positive(), destNationId: z.number().int().positive(),
@@ -195,7 +196,7 @@ export const actionContextBuilder: ActionContextBuilder<NonAggressionProposalArg
...base, ...base,
destNation, destNation,
messageTime: base.general.turnTime, 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 './frontState.js';
export * from './instantResponse.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 { 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 = ( const buildEntry = (
fromNationId: number, fromNationId: number,
@@ -88,4 +96,41 @@ describe('diplomacy month processing', () => {
expect(entry.term).toBe(0); 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 { describe, expect, it } from 'vitest';
import type { Nation } from '../src/domain/entities.js'; 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'; import { LogCategory, LogScope } from '../src/logging/types.js';
const nation = (id: number, name: string, meta: Nation['meta'] = {}): Nation => ({ 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]); 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', () => { it('creates the full legacy cancellation logs without refreshing fronts', () => {
const result = resolveInstantDiplomacyResponse(context, { action: 'cancelNA' }); const result = resolveInstantDiplomacyResponse(context, { action: 'cancelNA' });
const logs = result.effects.filter((effect) => effect.type === 'log'); const logs = result.effects.filter((effect) => effect.type === 'log');