merge: NPC 불가침 제의 수명주기 보정을 통합
This commit is contained in:
@@ -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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user