feat: preserve legacy nation turn execution

This commit is contained in:
2026-07-25 06:17:32 +00:00
parent 5040691d7c
commit 85f134fd12
36 changed files with 1346 additions and 283 deletions
@@ -11,18 +11,16 @@ export const do불가침제의 = (ai: GeneralAI) => {
return null;
}
const meta = asRecord(ai.nation.meta);
const recvAssist = Array.isArray(meta.recv_assist) ? 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 respAssistTry = asRecord(meta.resp_assist_try);
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
const candidateList: Record<number, number> = {};
for (const entry of recvAssist) {
if (!Array.isArray(entry) || entry.length < 2) {
continue;
}
const destNationId = Number(entry[0]);
const amount = Number(entry[1]);
const entryRecord = asRecord(entry);
const destNationId = Number(Array.isArray(entry) ? entry[0] : entryRecord['0']);
const amount = Number(Array.isArray(entry) ? entry[1] : entryRecord['1']);
if (!Number.isFinite(destNationId) || !Number.isFinite(amount)) {
continue;
}
+36 -1
View File
@@ -13,7 +13,14 @@ import {
type TurnEngineTroopUpdateInput,
type TurnEngineWorldStateUpdateInput,
} from '@sammo-ts/infra';
import { finalizeLogEntry, LogCategory, LogScope, type LogEntryDraft } from '@sammo-ts/logic';
import {
finalizeLogEntry,
LogCategory,
LogScope,
sendMessage,
type LogEntryDraft,
type MessageRecordDraft,
} from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common';
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
@@ -323,6 +330,7 @@ export const createDatabaseTurnHooks = async (
deletedNationSnapshots,
diplomacy,
logs,
messages,
createdGenerals,
createdNations,
createdTroops,
@@ -566,6 +574,33 @@ export const createDatabaseTurnHooks = async (
});
}
}
for (const message of messages) {
await sendMessage(
{
insertMessage: async (draft: MessageRecordDraft) => {
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${draft.validUntil},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) {
throw new Error('Failed to persist turn message.');
}
return id;
},
},
message
);
}
if (options?.reservedTurns && reservedTurnChanges) {
await options.reservedTurns.persistChanges(prisma, reservedTurnChanges);
}
+10 -1
View File
@@ -1,4 +1,4 @@
import type { City, LogEntryDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic';
import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic';
import { getNextTurnAt } from '@sammo-ts/logic';
import type { TurnCheckpoint } from '../lifecycle/types.js';
@@ -25,6 +25,7 @@ export interface GeneralTurnResult {
nation?: Nation | null;
nextTurnAt?: Date;
logs?: LogEntryDraft[];
messages?: MessageDraft[];
patches?: {
generals: Array<{ id: number; patch: Partial<TurnGeneral> }>;
cities: Array<{ id: number; patch: Partial<City> }>;
@@ -79,6 +80,7 @@ export interface TurnWorldChanges {
deletedNationSnapshots: Array<{ nation: Nation; generalIds: number[]; removedAt: Date }>;
diplomacy: TurnDiplomacy[];
logs: LogEntryDraft[];
messages: MessageDraft[];
createdGenerals: TurnGeneral[];
createdNations: Nation[];
createdTroops: Troop[];
@@ -247,6 +249,7 @@ export class InMemoryTurnWorld {
removedAt: Date;
}> = [];
private readonly logs: LogEntryDraft[] = [];
private readonly messages: MessageDraft[] = [];
private readonly scenarioConfig: ScenarioConfig;
private checkpoint?: TurnCheckpoint;
private state: TurnWorldState;
@@ -587,6 +590,9 @@ export class InMemoryTurnWorld {
if (result.logs && result.logs.length > 0) {
this.logs.push(...result.logs);
}
if (result.messages && result.messages.length > 0) {
this.messages.push(...result.messages);
}
if (result.patches) {
for (const patch of result.patches.generals) {
const target = this.generals.get(patch.id);
@@ -744,6 +750,7 @@ export class InMemoryTurnWorld {
const deletedNations = Array.from(this.deletedNationIds);
const deletedNationSnapshots = this.deletedNationSnapshots.slice();
const logs = this.logs.slice();
const messages = this.messages.slice();
return {
generals,
@@ -756,6 +763,7 @@ export class InMemoryTurnWorld {
deletedNationSnapshots,
diplomacy,
logs,
messages,
createdGenerals,
createdNations,
createdTroops,
@@ -782,6 +790,7 @@ export class InMemoryTurnWorld {
for (const id of changes.deletedNations) this.deletedNationIds.delete(id);
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
this.logs.splice(0, changes.logs.length);
this.messages.splice(0, changes.messages.length);
}
consumeDirtyState(): TurnWorldChanges {
@@ -0,0 +1,38 @@
import { asRecord } from '@sammo-ts/common';
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
const decrementLimit = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(0, Math.floor(value) - 1);
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.max(0, Math.floor(parsed) - 1);
}
}
return 0;
};
// ref preUpdateMonthly(): 전략 제한과 외교 제한은 매 월턴마다 1씩 감소한다.
export const createNationTurnMonthlyHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
}): TurnCalendarHandler => ({
onMonthChanged: () => {
const world = options.getWorld();
if (!world) {
return;
}
for (const nation of world.listNations()) {
const meta = asRecord(nation.meta);
world.updateNation(nation.id, {
meta: {
...nation.meta,
strategic_cmd_limit: decrementLimit(meta.strategic_cmd_limit),
surlimit: decrementLimit(meta.surlimit),
},
});
}
},
});
+143 -12
View File
@@ -5,6 +5,7 @@ import type {
GeneralActionDefinition,
LogEntryDraft,
MapDefinition,
MessageDraft,
Nation,
ScenarioConfig,
ScenarioMeta,
@@ -100,6 +101,40 @@ const serializeSeed = (...values: Array<string | number>): string =>
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
type NationLastTurn = {
command: string;
arg?: Record<string, unknown>;
term?: number;
seq?: number;
};
const nationLastTurnKey = (officerLevel: number): string => `turn_last_${officerLevel}`;
const normalizeLastTurn = (value: unknown): NationLastTurn => {
const raw = asRecord(value);
return {
command: typeof raw.command === 'string' ? raw.command : '휴식',
...(asRecord(raw.arg) && Object.keys(asRecord(raw.arg)).length > 0 ? { arg: asRecord(raw.arg) } : undefined),
...(typeof raw.term === 'number' && Number.isFinite(raw.term) ? { term: Math.floor(raw.term) } : undefined),
...(typeof raw.seq === 'number' && Number.isFinite(raw.seq) ? { seq: Math.floor(raw.seq) } : undefined),
};
};
const sameArgs = (left: Record<string, unknown> | undefined, right: Record<string, unknown>): boolean =>
JSON.stringify(left ?? {}) === JSON.stringify(right);
const readNextAvailableTurn = (nation: Nation, actionName: string): number | null => {
const raw = asRecord(nation.meta)[`next_execute_${actionName}`];
if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.floor(raw);
}
if (typeof raw === 'string') {
const parsed = Number(raw);
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
}
return null;
};
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -214,7 +249,6 @@ const buildUniqueLotteryRunner = (options: {
};
};
type WorldView = {
getGeneralById(id: number): TurnGeneral | null;
getCityById(id: number): City | null;
@@ -573,6 +607,7 @@ export const createReservedTurnHandler = async (options: {
...(options.unitSet ? { unitSet: options.unitSet } : {}),
};
const logs: LogEntryDraft[] = [];
const messages: MessageDraft[] = [];
const patches = {
generals: [] as Array<{ id: number; patch: Partial<TurnGeneral> }>,
cities: [] as Array<{ id: number; patch: Partial<City> }>,
@@ -592,6 +627,7 @@ export const createReservedTurnHandler = async (options: {
let currentNation = context.nation ?? null;
const runAction = (
kind: 'nation' | 'general',
definitionMap: Map<string, GeneralActionDefinition>,
fallbackDefinition: GeneralActionDefinition,
command: ReservedTurnEntry,
@@ -644,6 +680,19 @@ export const createReservedTurnHandler = async (options: {
const meta = result.kind === 'deny' ? { constraintName: result.constraintName } : undefined;
logs.push(createActionLog(reason, meta));
}
if (kind === 'nation' && !usedFallback && currentNation) {
const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth);
const nextAvailableTurn = readNextAvailableTurn(currentNation, definition.name);
if (nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn) {
const remainTurn = nextAvailableTurn - currentYearMonth;
definition = fallbackDefinition;
actionArgs = definition.parseArgs({}) ?? {};
actionKey = definition.key;
usedFallback = true;
blockedReason = `${remainTurn}턴 더 기다려야 합니다`;
logs.push(createActionLog(blockedReason));
}
}
const seedBase = buildSeedBase(context.world);
const buildRng = (key: string) => {
@@ -694,6 +743,8 @@ export const createReservedTurnHandler = async (options: {
definition = fallbackDefinition;
actionArgs = definition.parseArgs({}) ?? {};
actionKey = definition.key;
usedFallback = true;
blockedReason = '예약된 명령을 실행하지 못했습니다.';
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
baseContext = {
general: currentGeneral,
@@ -704,6 +755,52 @@ export const createReservedTurnHandler = async (options: {
specificContext = baseContext;
}
const actionContext = specificContext ?? baseContext;
const executionDefinition = definition as unknown as {
getPreReqTurn?: (context: ActionContextBase, args: unknown) => number;
getPostReqTurn?: (context: ActionContextBase, args: unknown) => number;
getStackSequence?: (context: ActionContextBase, args: unknown) => number | null;
};
const preReqTurn =
kind === 'nation' && !usedFallback
? Math.max(0, Math.floor(executionDefinition.getPreReqTurn?.(actionContext, actionArgs) ?? 0))
: 0;
const postReqTurn =
kind === 'nation' && !usedFallback
? Math.max(0, Math.floor(executionDefinition.getPostReqTurn?.(actionContext, actionArgs) ?? 0))
: 0;
if (kind === 'nation' && !usedFallback && currentNation && preReqTurn > 0) {
const metaKey = nationLastTurnKey(currentGeneral.officerLevel);
const lastTurn = normalizeLastTurn(asRecord(currentNation.meta)[metaKey]);
const stackSequence = executionDefinition.getStackSequence?.(actionContext, actionArgs) ?? null;
const sequenceChanged =
stackSequence !== null && (lastTurn.seq === undefined || lastTurn.seq < stackSequence);
const continuing =
lastTurn.command === definition.name &&
sameArgs(lastTurn.arg, actionArgsRecord) &&
!sequenceChanged;
const nextTerm = continuing ? (lastTurn.term ?? 0) + 1 : 1;
if (!continuing || (lastTurn.term ?? 0) < preReqTurn) {
const nextLastTurn: NationLastTurn = {
command: definition.name,
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
term: nextTerm,
...(stackSequence !== null ? { seq: stackSequence } : undefined),
};
const nextNation: Nation = {
...currentNation,
meta: {
...currentNation.meta,
[metaKey]: nextLastTurn,
} as Nation['meta'],
};
currentNation = nextNation;
worldOverlay?.syncNation(nextNation);
logs.push(createActionLog(`${definition.name} 수행중... (${nextTerm}/${preReqTurn + 1})`));
return { actionKey, usedFallback, blockedReason };
}
}
const resolution = resolveGeneralAction(
definition,
@@ -718,12 +815,39 @@ export const createReservedTurnHandler = async (options: {
currentGeneral = resolution.general as TurnGeneral;
currentCity = resolution.city ?? currentCity;
currentNation = resolution.nation ?? currentNation;
if (kind === 'nation' && !usedFallback && definition.countsAsInheritanceActiveAction) {
const meta = { ...currentGeneral.meta };
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
meta.inherit_active_action = active + 1;
currentGeneral = { ...currentGeneral, meta };
}
if (!currentNation && resolution.created?.nations) {
currentNation =
(resolution.created.nations as Nation[]).find((n) => n.id === currentGeneral.nationId) ??
currentNation;
}
if (kind === 'nation' && !usedFallback && currentNation) {
const metaKey = nationLastTurnKey(currentGeneral.officerLevel);
const nextMeta: Record<string, unknown> = {
...currentNation.meta,
[metaKey]: {
command: definition.name,
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
term: 0,
} satisfies NationLastTurn,
};
if (postReqTurn > 0) {
nextMeta[`next_execute_${definition.name}`] =
joinYearMonth(context.world.currentYear, context.world.currentMonth) +
postReqTurn -
preReqTurn;
}
currentNation = {
...currentNation,
meta: nextMeta as Nation['meta'],
};
}
logs.push(...resolution.logs);
if (worldOverlay) {
@@ -738,15 +862,16 @@ export const createReservedTurnHandler = async (options: {
if (resolution.effects.length > 0) {
for (const effect of resolution.effects) {
if (effect.type !== 'diplomacy:patch') {
continue;
if (effect.type === 'message:add') {
messages.push(effect.draft);
} else if (effect.type === 'diplomacy:patch') {
diplomacyPatches.push({
srcNationId: effect.srcNationId,
destNationId: effect.destNationId,
patch: effect.patch,
});
worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch);
}
diplomacyPatches.push({
srcNationId: effect.srcNationId,
destNationId: effect.destNationId,
patch: effect.patch,
});
worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch);
}
}
@@ -826,7 +951,12 @@ export const createReservedTurnHandler = async (options: {
}
}
return { nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined, actionKey, usedFallback, blockedReason };
return {
nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined,
actionKey,
usedFallback,
blockedReason,
};
};
if (currentNation && currentGeneral.officerLevel >= 5) {
@@ -860,7 +990,7 @@ export const createReservedTurnHandler = async (options: {
}
nationAiState = ai.getDebugState();
}
const nationResult = runAction(nationDefinitions, nationFallback, nationCommand, false);
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
options.onActionResolved?.({
kind: 'nation',
generalId: currentGeneral.id,
@@ -900,7 +1030,7 @@ export const createReservedTurnHandler = async (options: {
}
generalAiState = ai.getDebugState();
}
const generalResult = runAction(generalDefinitions, generalFallback, generalCommand, true);
const generalResult = runAction('general', generalDefinitions, generalFallback, generalCommand, true);
options.onActionResolved?.({
kind: 'general',
generalId: currentGeneral.id,
@@ -935,6 +1065,7 @@ export const createReservedTurnHandler = async (options: {
nation: currentNation,
nextTurnAt,
logs,
...(messages.length > 0 ? { messages } : undefined),
patches,
...(diplomacyPatches.length > 0 ? { diplomacyPatches } : undefined),
created:
+5
View File
@@ -19,6 +19,7 @@ import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { composeCalendarHandlers } from './calendarHandlers.js';
import { createIncomeHandler } from './incomeHandler.js';
import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js';
import { createFrontStateHandler } from './frontStateHandler.js';
import { createReservedTurnHandler } from './reservedTurnHandler.js';
import { createReservedTurnStore } from './reservedTurnStore.js';
@@ -124,6 +125,9 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
scenarioConfig: snapshot.scenarioConfig,
nationTraits: nationTraitMap,
});
const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({
getWorld: () => worldRef,
});
const frontStateHandler = createFrontStateHandler({
getWorld: () => worldRef,
map: snapshot.map ?? null,
@@ -141,6 +145,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
});
const calendarHandler = composeCalendarHandlers(
options.calendarHandler ?? unification?.handler,
nationTurnMonthlyHandler,
incomeHandler,
frontStateHandler,
tournamentAutoStartHandler,
@@ -12,6 +12,7 @@ import { composeCalendarHandlers } from '../../src/turn/calendarHandlers.js';
import { createIncomeHandler } from '../../src/turn/incomeHandler.js';
import { createNpcTaxHandler } from '../../src/turn/npcTaxHandler.js';
import { createFrontStateHandler } from '../../src/turn/frontStateHandler.js';
import { createNationTurnMonthlyHandler } from '../../src/turn/nationTurnMonthlyHandler.js';
export const createMockPrisma = (initialGeneralRows: any[] = []) => {
let generalRows = [...initialGeneralRows];
@@ -126,8 +127,12 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
getWorld: () => worldRef.current,
map: options.map,
});
const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({
getWorld: () => worldRef.current,
});
const calendarHandler = composeCalendarHandlers(
nationTurnMonthlyHandler,
incomeHandler,
npcTaxHandler,
frontStateHandler,
@@ -194,8 +199,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
runUntil,
getCollectedLogs: () => [...collectedLogs],
getCollectedLogsCount: () => collectedLogs.length,
getCollectedLogsRange: (start: number, end?: number) =>
collectedLogs.slice(start, end ?? collectedLogs.length),
getCollectedLogsRange: (start: number, end?: number) => collectedLogs.slice(start, end ?? collectedLogs.length),
getAndClearCollectedLogs: () => collectedLogs.splice(0, collectedLogs.length),
};
};
@@ -238,10 +242,7 @@ const formatCity = (city: ReturnType<InMemoryTurnWorld['getCityById']>) => {
};
};
export const createWorldDebugger = (
getWorld: () => InMemoryTurnWorld | null,
watchTargets: DebugWatchTargets = {}
) => {
export const createWorldDebugger = (getWorld: () => InMemoryTurnWorld | null, watchTargets: DebugWatchTargets = {}) => {
const dumpWorldSummary = (label = 'WORLD') => {
const world = getWorld();
if (!world) {
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest';
import type { TurnSchedule } from '@sammo-ts/logic';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createNationTurnMonthlyHandler } from '../src/turn/nationTurnMonthlyHandler.js';
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
const mockDate = new Date('0189-01-01T00:00:00Z');
const createChief = (): TurnGeneral => ({
id: 1,
name: '군주',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 90, strength: 80, intelligence: 70 },
turnTime: mockDate,
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: 0,
});
describe('레거시 사령부 턴 실행 호환성', () => {
it('화시병 연구는 선행 11턴을 누적한 뒤 12번째 턴에만 완료한다', async () => {
const cities = buildLargeTestCities();
for (const city of cities) {
city.nationId = city.id === 1 ? 1 : city.id === 2 ? 2 : 0;
}
const snapshot: TurnWorldSnapshot = {
generals: [createChief()],
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: { can_화시병사용: 0 },
},
{
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: 0, term: 1200, dead: 0, meta: {} },
{ fromNationId: 2, toNationId: 1, state: 0, term: 1200, 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: 10000,
maxTechLevel: 12000,
},
environment: { mapName: 'large_test_map', unitSet: 'default' },
},
scenarioMeta: { startYear: 189 } as never,
unitSet: {} as never,
};
const state: TurnWorldState = {
id: 1,
currentYear: 189,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: mockDate,
meta: { seed: 1 },
};
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const resolvedActions: Array<{ requestedAction: string; actionKey: string; blockedReason?: string }> = [];
const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 },
onActionResolved: (event) => {
if (event.kind === 'nation') {
resolvedActions.push(event);
}
},
});
const turns = reservedTurnStore.getNationTurns(1, 12);
turns.fill({ action: 'event_화시병연구', args: {} });
for (let index = 1; index <= 11; index += 1) {
await runOneTick();
const nation = world.getNationById(1)!;
expect(nation.meta.can_화시병사용 ?? 0).toBe(0);
expect(nation.meta.turn_last_12).toMatchObject({
command: '화시병 연구',
term: index,
});
}
await runOneTick();
const nation = world.getNationById(1)!;
expect(nation.meta.can_화시병사용).toBe(1);
expect(nation.meta.turn_last_12).toMatchObject({
command: '화시병 연구',
term: 0,
});
expect(world.getGeneralById(1)!.meta.inherit_active_action).toBe(1);
expect(world.getGeneralById(1)!.experience).toBe(60);
expect(world.getGeneralById(1)!.dedication).toBe(60);
reservedTurnStore.getNationTurns(1, 12)[0] = {
action: 'che_종전제의',
args: { destNationId: 2 },
};
await runOneTick();
expect(resolvedActions.at(-1)?.blockedReason).toBeUndefined();
expect(resolvedActions.at(-1)).toMatchObject({
requestedAction: 'che_종전제의',
actionKey: 'che_종전제의',
});
expect(world.peekDirtyState().messages).toContainEqual(
expect.objectContaining({
msgType: 'diplomacy',
dest: expect.objectContaining({ nationId: 2 }),
option: { action: 'stopWar', deletable: false },
})
);
});
it('월 경계마다 전략·외교 제한을 1씩 감소시키고 0 아래로 내리지 않는다', () => {
const updates: Array<{ id: number; patch: Record<string, unknown> }> = [];
const nations = [
{ id: 1, meta: { strategic_cmd_limit: 2, surlimit: '1', keep: true } },
{ id: 2, meta: { strategic_cmd_limit: 0, surlimit: 0 } },
];
const handler = createNationTurnMonthlyHandler({
getWorld: () =>
({
listNations: () => nations,
updateNation: (id: number, patch: Record<string, unknown>) => updates.push({ id, patch }),
}) as never,
});
handler.onMonthChanged?.({} as never);
expect(updates).toEqual([
{
id: 1,
patch: { meta: { strategic_cmd_limit: 1, surlimit: 0, keep: true } },
},
{
id: 2,
patch: { meta: { strategic_cmd_limit: 0, surlimit: 0 } },
},
]);
});
});