diff --git a/app/game-api/src/messages/diplomaticResponse.ts b/app/game-api/src/messages/diplomaticResponse.ts index 8c6517b0..ec3a6172 100644 --- a/app/game-api/src/messages/diplomaticResponse.ts +++ b/app/game-api/src/messages/diplomaticResponse.ts @@ -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: [], }; } diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 993eeb79..bb6f4562 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -745,6 +745,7 @@ describe('messages router missing-flow compatibility', () => { mailboxNationId?: number; proposerNationId?: number; proposerCurrentNationId?: number; + proposerNationMeta?: Record; 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], diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index 41da9fb8..a1ffd79f 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -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): 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; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts index 2bebe544..93e85d09 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts @@ -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 = {}; 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; }; diff --git a/app/game-engine/test/npcAidNonAggressionLifecycle.test.ts b/app/game-engine/test/npcAidNonAggressionLifecycle.test.ts new file mode 100644 index 00000000..232f269a --- /dev/null +++ b/app/game-engine/test/npcAidNonAggressionLifecycle.test.ts @@ -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; 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).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 }); + }); +}); diff --git a/packages/logic/src/actions/turn/nation/che_불가침제의.ts b/packages/logic/src/actions/turn/nation/che_불가침제의.ts index 39f8e897..5c46ee30 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침제의.ts @@ -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 + 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; +}; diff --git a/packages/logic/test/diplomacy.test.ts b/packages/logic/test/diplomacy.test.ts index f1e44936..0655ec52 100644 --- a/packages/logic/test/diplomacy.test.ts +++ b/packages/logic/test/diplomacy.test.ts @@ -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); + }); }); diff --git a/packages/logic/test/diplomacyInstantResponse.test.ts b/packages/logic/test/diplomacyInstantResponse.test.ts index f202d479..e8ac917c 100644 --- a/packages/logic/test/diplomacyInstantResponse.test.ts +++ b/packages/logic/test/diplomacyInstantResponse.test.ts @@ -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');