원조 기반 제의의 서신 만료 tick을 영속화하고 거절 상대를 영구 제외한다. 불가침 월별 감소와 만료 전후 선전포고 경계를 통합 회귀로 검증한다.
278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
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 });
|
|
});
|
|
});
|