diff --git a/app/game-engine/test/helpers/turnTestHarness.ts b/app/game-engine/test/helpers/turnTestHarness.ts new file mode 100644 index 0000000..c79d6a5 --- /dev/null +++ b/app/game-engine/test/helpers/turnTestHarness.ts @@ -0,0 +1,314 @@ +import { vi } from 'vitest'; +import type { MapDefinition, TurnSchedule } from '@sammo-ts/logic'; +import type { TurnWorldSnapshot, TurnWorldState } from '../../src/turn/types.js'; +import type { InMemoryTurnWorld, GeneralTurnHandler } from '../../src/turn/inMemoryWorld.js'; +import { InMemoryTurnWorld as InMemoryTurnWorldClass } from '../../src/turn/inMemoryWorld.js'; +import { InMemoryReservedTurnStore } from '../../src/turn/reservedTurnStore.js'; +import { createReservedTurnHandler } from '../../src/turn/reservedTurnHandler.js'; +import type { InMemoryTurnProcessorOptions } from '../../src/turn/inMemoryTurnProcessor.js'; +import { InMemoryTurnProcessor } from '../../src/turn/inMemoryTurnProcessor.js'; +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'; + +export const createMockPrisma = (initialGeneralRows: any[] = []) => { + let generalRows = [...initialGeneralRows]; + return { + generalTurn: { + findMany: vi.fn(async ({ where } = {}) => { + if (where?.generalId) { + return generalRows + .filter((row) => row.generalId === where.generalId) + .sort((a, b) => a.turnIdx - b.turnIdx); + } + return generalRows; + }), + deleteMany: vi.fn(async ({ where } = {}) => { + if (where?.generalId) { + generalRows = generalRows.filter((row) => row.generalId !== where.generalId); + } + return { count: 0 }; + }), + createMany: vi.fn(async ({ data }) => { + if (Array.isArray(data)) { + generalRows.push(...data); + } + return { count: data.length }; + }), + }, + nationTurn: { + findMany: vi.fn(async () => []), + deleteMany: vi.fn(async () => ({ count: 0 })), + createMany: vi.fn(async () => ({ count: 0 })), + }, + }; +}; + +export const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); + +export type TurnHarnessRunOptions = { + minutes?: number; + budgetMs?: number; + maxGenerals?: number; + catchUpCap?: number; +}; + +export type TurnTestHarnessOptions = { + snapshot: TurnWorldSnapshot; + state: TurnWorldState; + schedule: TurnSchedule; + map?: MapDefinition; + reservedTurnStoreOptions?: { + maxGeneralTurns: number; + maxNationTurns: number; + }; + turnProcessorOptions?: { + tickMinutes: number; + afterExecuteGeneral?: InMemoryTurnProcessorOptions['afterExecuteGeneral']; + }; + worldRef?: { current: InMemoryTurnWorld | null }; + onActionResolved?: Parameters[0]['onActionResolved']; + wrapGeneralTurnHandler?: (handler: GeneralTurnHandler) => GeneralTurnHandler; +}; + +const defaultRunOptions = { + budgetMs: 10000, + maxGenerals: 100000, + catchUpCap: 1, +} satisfies Required>; + +export const createTurnTestHarness = async (options: TurnTestHarnessOptions) => { + const mockPrisma = createMockPrisma(); + const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { + maxGeneralTurns: 10, + maxNationTurns: 10, + ...(options.reservedTurnStoreOptions ?? {}), + }); + await reservedTurnStore.loadAll(); + + const worldRef = options.worldRef ?? { current: null as InMemoryTurnWorld | null }; + + const handler = await createReservedTurnHandler({ + reservedTurns: reservedTurnStore, + scenarioConfig: options.snapshot.scenarioConfig, + scenarioMeta: options.snapshot.scenarioMeta, + map: options.map, + unitSet: options.snapshot.unitSet, + getWorld: () => worldRef.current, + onActionResolved: options.onActionResolved, + }); + + const generalTurnHandler = options.wrapGeneralTurnHandler ? options.wrapGeneralTurnHandler(handler) : handler; + + const incomeHandler = createIncomeHandler({ + getWorld: () => worldRef.current, + scenarioConfig: options.snapshot.scenarioConfig, + nationTraits: new Map(), + }); + + const npcTaxHandler = createNpcTaxHandler({ + getWorld: () => worldRef.current, + }); + + const frontStateHandler = createFrontStateHandler({ + getWorld: () => worldRef.current, + map: options.map, + }); + + const calendarHandler = composeCalendarHandlers(incomeHandler, npcTaxHandler, frontStateHandler); + + const world = new InMemoryTurnWorldClass(options.state, options.snapshot, { + schedule: options.schedule, + generalTurnHandler, + calendarHandler, + }); + worldRef.current = world; + + const processor = new InMemoryTurnProcessor(world, { + tickMinutes: options.turnProcessorOptions?.tickMinutes ?? 10, + afterExecuteGeneral: options.turnProcessorOptions?.afterExecuteGeneral, + }); + + const runOneTick = async (runOptions: TurnHarnessRunOptions = {}) => { + const minutes = runOptions.minutes ?? options.turnProcessorOptions?.tickMinutes ?? 10; + const target = addMinutes(world.getState().lastTurnTime, minutes); + await processor.run(target, { + budgetMs: runOptions.budgetMs ?? defaultRunOptions.budgetMs, + maxGenerals: runOptions.maxGenerals ?? defaultRunOptions.maxGenerals, + catchUpCap: runOptions.catchUpCap ?? defaultRunOptions.catchUpCap, + }); + }; + + const runUntil = async ( + shouldStop: (state: TurnWorldState) => boolean, + runOptions?: TurnHarnessRunOptions, + afterTick?: (state: TurnWorldState, world: InMemoryTurnWorld) => void + ) => { + while (true) { + await runOneTick(runOptions); + const state = world.getState(); + afterTick?.(state, world); + if (shouldStop(state)) { + break; + } + } + }; + + return { + world, + worldRef, + reservedTurnStore, + handler, + processor, + runOneTick, + runUntil, + }; +}; + +export type DebugWatchTargets = { + cityIds?: number[]; + nationIds?: number[]; + includeNationSummary?: boolean; +}; + +const toList = (value?: number[] | number): number[] => { + if (Array.isArray(value)) return value; + if (typeof value === 'number') return [value]; + return []; +}; + +const formatCity = (city: ReturnType) => { + if (!city) return null; + return { + id: city.id, + name: city.name, + nationId: city.nationId, + level: city.level, + state: city.state, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + supplyState: city.supplyState, + frontState: city.frontState, + meta: city.meta, + }; +}; + +export const createWorldDebugger = ( + getWorld: () => InMemoryTurnWorld | null, + watchTargets: DebugWatchTargets = {} +) => { + const dumpWorldSummary = (label = 'WORLD') => { + const world = getWorld(); + if (!world) { + console.log(`[DEBUG] ${label} (no world)`); + return; + } + const nations = world.listNations(); + const cities = world.listCities(); + const generals = world.listGenerals(); + const neutralCities = cities.filter((city) => city.nationId <= 0).length; + const state = world.getState(); + console.log('[DEBUG] world summary', { + label, + year: state.currentYear, + month: state.currentMonth, + nations: nations.length, + cities: cities.length, + generals: generals.length, + neutralCities, + }); + if (watchTargets.includeNationSummary) { + const nationSummaries = nations.map((nation) => { + const cityCount = cities.filter((city) => city.nationId === nation.id).length; + const generalCount = generals.filter((general) => general.nationId === nation.id).length; + return { + id: nation.id, + name: nation.name, + level: nation.level, + typeCode: nation.typeCode, + capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, + gold: nation.gold, + rice: nation.rice, + power: nation.power, + cityCount, + generalCount, + meta: nation.meta, + }; + }); + console.log('[DEBUG] nation summary', nationSummaries); + } + }; + + const dumpCity = (cityId: number, label = 'CITY') => { + const world = getWorld(); + if (!world) { + console.log(`[DEBUG] ${label} (no world)`); + return; + } + const city = world.getCityById(cityId); + console.log('[DEBUG] city detail', { label, cityId, city: formatCity(city) }); + }; + + const dumpNation = (nationId: number, label = 'NATION') => { + const world = getWorld(); + if (!world) { + console.log(`[DEBUG] ${label} (no world)`); + return; + } + const nation = world.getNationById(nationId); + if (!nation) { + console.log('[DEBUG] nation detail', { label, nationId, nation: null }); + return; + } + const cities = world.listCities().filter((city) => city.nationId === nationId); + const generals = world.listGenerals().filter((general) => general.nationId === nationId); + console.log('[DEBUG] nation detail', { + label, + nationId, + nation: { + id: nation.id, + name: nation.name, + level: nation.level, + typeCode: nation.typeCode, + capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, + gold: nation.gold, + rice: nation.rice, + power: nation.power, + meta: nation.meta, + }, + cityCount: cities.length, + generalCount: generals.length, + }); + }; + + const dumpWatched = (label = 'WATCH') => { + const { cityIds, nationIds } = watchTargets; + dumpWorldSummary(label); + for (const id of toList(nationIds)) { + dumpNation(id, label); + } + for (const id of toList(cityIds)) { + dumpCity(id, label); + } + }; + + return { + dumpWorldSummary, + dumpCity, + dumpNation, + dumpWatched, + }; +}; diff --git a/app/game-engine/test/npcNationGrowthScenario.test.ts b/app/game-engine/test/npcNationGrowthScenario.test.ts index 0a38351..d4dd802 100644 --- a/app/game-engine/test/npcNationGrowthScenario.test.ts +++ b/app/game-engine/test/npcNationGrowthScenario.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { ConstraintContext, LogEntryDraft, @@ -10,57 +10,16 @@ import type { import { DEFAULT_TURN_COMMAND_PROFILE, LogCategory, evaluateConstraints } from '@sammo-ts/logic'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { GeneralAiDebugState } from '../src/turn/ai/generalAi.js'; -import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; -import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; -import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js'; -import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; +import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { GeneralAI } from '../src/turn/ai/generalAi.js'; import { do징병 } from '../src/turn/ai/generalAiGeneralActions.js'; -import { createIncomeHandler } from '../src/turn/incomeHandler.js'; -import { createNpcTaxHandler } from '../src/turn/npcTaxHandler.js'; -import { createFrontStateHandler } from '../src/turn/frontStateHandler.js'; -import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js'; import { buildCommandEnv, buildReservedTurnDefinitions } from '../src/turn/reservedTurnCommands.js'; import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; import { round } from 'es-toolkit'; +import { createTurnTestHarness, createWorldDebugger } from './helpers/turnTestHarness.js'; const mockDate = new Date('0179-08-01T00:00:00Z'); -const createMockPrisma = (initialGeneralRows: any[] = []) => { - let generalRows = [...initialGeneralRows]; - return { - generalTurn: { - findMany: vi.fn(async ({ where } = {}) => { - if (where?.generalId) { - return generalRows - .filter((row) => row.generalId === where.generalId) - .sort((a, b) => a.turnIdx - b.turnIdx); - } - return generalRows; - }), - deleteMany: vi.fn(async ({ where } = {}) => { - if (where?.generalId) { - generalRows = generalRows.filter((row) => row.generalId !== where.generalId); - } - return { count: 0 }; - }), - createMany: vi.fn(async ({ data }) => { - if (Array.isArray(data)) { - generalRows.push(...data); - } - return { count: data.length }; - }), - }, - nationTurn: { - findMany: vi.fn(async () => []), - deleteMany: vi.fn(async () => ({ count: 0 })), - createMany: vi.fn(async () => ({ count: 0 })), - }, - }; -}; - -const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); - const createNpcGeneral = ( id: number, cityId: number, @@ -213,14 +172,7 @@ describe('NPC 대형 시뮬레이션', () => { entries: [{ startMinute: 0, tickMinutes: 10 }], }; - const mockPrisma = createMockPrisma(); - const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { - maxGeneralTurns: 10, - maxNationTurns: 10, - }); - await reservedTurnStore.loadAll(); - - const wrapper = { world: null as InMemoryTurnWorld | null }; + const worldRef = { current: null as InMemoryTurnWorld | null }; type TurnTrace = { year: number; @@ -248,13 +200,12 @@ describe('NPC 대형 시뮬레이션', () => { const commandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet); - const handler = await createReservedTurnHandler({ - reservedTurns: reservedTurnStore, - scenarioConfig: snapshot.scenarioConfig, - scenarioMeta: snapshot.scenarioMeta, - map: LARGE_TEST_MAP as any, - unitSet: snapshot.unitSet, - getWorld: () => wrapper.world, + const { world, reservedTurnStore, runUntil } = await createTurnTestHarness({ + snapshot, + state, + schedule, + map: LARGE_TEST_MAP, + worldRef, onActionResolved: (payload) => { if (payload.kind !== 'general') { return; @@ -269,73 +220,47 @@ describe('NPC 대형 시뮬레이션', () => { trace.blockedReason = payload.blockedReason; trace.aiState = payload.aiState; }, - }); - - const tracedHandler = { - execute: (ctx: Parameters[0]) => { - const trace: TurnTrace = { - year: ctx.world.currentYear, - month: ctx.world.currentMonth, - nationId: ctx.general.nationId, - generalId: ctx.general.id, - gold: ctx.general.gold, - rice: ctx.general.rice, - crew: ctx.general.crew, - train: ctx.general.train, - atmos: ctx.general.atmos, - actionKey: 'unknown', - requestedAction: 'unknown', - usedFallback: false, - ok: true, - actionText: 'unknown', - logs: [], - }; - traceByGeneralId.set(ctx.general.id, trace); - const result = handler.execute(ctx); - const actionLog = result.logs?.find((log) => log.category === LogCategory.ACTION); - trace.actionText = actionLog?.text ?? 'unknown'; - trace.logs = result.logs ?? []; - if (ctx.general.nationId === 0) { + wrapGeneralTurnHandler: (handler) => ({ + execute: (ctx) => { + const trace: TurnTrace = { + year: ctx.world.currentYear, + month: ctx.world.currentMonth, + nationId: ctx.general.nationId, + generalId: ctx.general.id, + gold: ctx.general.gold, + rice: ctx.general.rice, + crew: ctx.general.crew, + train: ctx.general.train, + atmos: ctx.general.atmos, + actionKey: 'unknown', + requestedAction: 'unknown', + usedFallback: false, + ok: true, + actionText: 'unknown', + logs: [], + }; + traceByGeneralId.set(ctx.general.id, trace); + const result = handler.execute(ctx); + const actionLog = result.logs?.find((log) => log.category === LogCategory.ACTION); + trace.actionText = actionLog?.text ?? 'unknown'; + trace.logs = result.logs ?? []; + if (ctx.general.nationId === 0) { + return result; + } + turnTraces.push(trace); return result; - } - turnTraces.push(trace); - return result; - }, - }; - - const incomeHandler = createIncomeHandler({ - getWorld: () => wrapper.world, - scenarioConfig: snapshot.scenarioConfig, - nationTraits: new Map(), - }); - - const npcTaxHandler = createNpcTaxHandler({ - getWorld: () => wrapper.world, - }); - - const frontStateHandler = createFrontStateHandler({ - getWorld: () => wrapper.world, - map: LARGE_TEST_MAP, - }); - - const calendarHandler = composeCalendarHandlers(incomeHandler, npcTaxHandler, frontStateHandler); - - const world = new InMemoryTurnWorld(state, snapshot, { - schedule, - generalTurnHandler: tracedHandler, - calendarHandler, - }); - wrapper.world = world; - - const processor = new InMemoryTurnProcessor(world, { - tickMinutes: 10, - afterExecuteGeneral: async (general, result) => { - const trace = traceByGeneralId.get(general.id); - if (!trace) { - return; - } - trace.ok = result.ok; - trace.error = result.error; + }, + }), + turnProcessorOptions: { + tickMinutes: 10, + afterExecuteGeneral: async (general, result) => { + const trace = traceByGeneralId.get(general.id); + if (!trace) { + return; + } + trace.ok = result.ok; + trace.error = result.error; + }, }, }); const checkpointGoldByGeneral = new Map(); @@ -355,15 +280,9 @@ describe('NPC 대형 시뮬레이션', () => { } }; - const runOneMonth = async () => { - const target = addMinutes(world.getState().lastTurnTime, 10); - await processor.run(target, { - budgetMs: 10000, - maxGenerals: 100000, - catchUpCap: 1, - }); - }; - + const debug = createWorldDebugger(() => worldRef.current, { + includeNationSummary: true, + }); const assertUprisingCount = (minCount: number) => { const nations = world.listNations(); expect(nations.length).toBeGreaterThanOrEqual(minCount); @@ -653,18 +572,17 @@ describe('NPC 대형 시뮬레이션', () => { }; try { - while (true) { - await runOneMonth(); - const { currentYear, currentMonth } = world.getState(); - const key = toKey(currentYear, currentMonth); - const checker = targetChecks.get(key); - if (checker) { - checker(); + await runUntil( + (current) => current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 7), + undefined, + (current) => { + const key = toKey(current.currentYear, current.currentMonth); + const checker = targetChecks.get(key); + if (checker) { + checker(); + } } - if (currentYear > 183 || (currentYear === 183 && currentMonth >= 7)) { - break; - } - } + ); } catch (error) { const lastAiTrace = [...turnTraces].reverse().find((trace) => trace.aiState); if (lastAiTrace?.aiState) { @@ -675,10 +593,12 @@ describe('NPC 대형 시뮬레이션', () => { await debugRecruitConstraints(debugGeneralId); } dumpTraceSummary('NPC 대형 시뮬레이션 실패', 200); + debug.dumpWorldSummary('NPC 대형 시뮬레이션 실패'); const sampleNation = world.listNations().find((nation) => nation.level >= 1 && nation.capitalCityId); if (sampleNation) { const policy = (sampleNation.meta as Record)?.npc_nation_policy; console.log('[TRACE] sample npc_nation_policy:', policy); + debug.dumpNation(sampleNation.id, 'NPC 샘플 국가'); const sampleGeneral = world .listGenerals() .find((general) => general.nationId === sampleNation.id && general.cityId > 0); @@ -691,6 +611,7 @@ describe('NPC 대형 시뮬레이션', () => { population: city?.population, populationMax: city?.populationMax, }); + debug.dumpCity(sampleGeneral.cityId, 'NPC 샘플 도시'); } const nationGenerals = world.listGenerals().filter((general) => general.nationId === sampleNation.id); const crewOnly = nationGenerals.filter((general) => general.crew > 0); diff --git a/app/game-engine/test/npcWarPrepTurns.test.ts b/app/game-engine/test/npcWarPrepTurns.test.ts index 7ffc0af..788a28d 100644 --- a/app/game-engine/test/npcWarPrepTurns.test.ts +++ b/app/game-engine/test/npcWarPrepTurns.test.ts @@ -1,53 +1,12 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; -import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; -import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; -import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js'; -import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; -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 type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; +import { createTurnTestHarness } from './helpers/turnTestHarness.js'; const mockDate = new Date('0182-07-01T00:00:00Z'); -const createMockPrisma = (initialGeneralRows: any[] = []) => { - let generalRows = [...initialGeneralRows]; - return { - generalTurn: { - findMany: vi.fn(async ({ where } = {}) => { - if (where?.generalId) { - return generalRows - .filter((row) => row.generalId === where.generalId) - .sort((a, b) => a.turnIdx - b.turnIdx); - } - return generalRows; - }), - deleteMany: vi.fn(async ({ where } = {}) => { - if (where?.generalId) { - generalRows = generalRows.filter((row) => row.generalId !== where.generalId); - } - return { count: 0 }; - }), - createMany: vi.fn(async ({ data }) => { - if (Array.isArray(data)) { - generalRows.push(...data); - } - return { count: data.length }; - }), - }, - nationTurn: { - findMany: vi.fn(async () => []), - deleteMany: vi.fn(async () => ({ count: 0 })), - createMany: vi.fn(async () => ({ count: 0 })), - }, - }; -}; - -const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); - const createNpcGeneral = ( id: number, cityId: number, @@ -213,12 +172,31 @@ describe('NPC 전투준비 턴 검증', () => { entries: [{ startMinute: 0, tickMinutes: 10 }], }; - const mockPrisma = createMockPrisma(); - const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { - maxGeneralTurns: 10, - maxNationTurns: 10, + const worldRef = { current: null as InMemoryTurnWorld | null }; + const trainingActions = new Set(['che_훈련', 'che_사기진작']); + const trainingCounts = new Map(); + const { world, reservedTurnStore, runUntil } = await createTurnTestHarness({ + snapshot, + state, + schedule, + map: LARGE_TEST_MAP, + worldRef, + onActionResolved: (payload) => { + if (payload.kind !== 'general') { + return; + } + if (!trainingActions.has(payload.actionKey)) { + return; + } + const currentWorld = worldRef.current; + if (!currentWorld) { + return; + } + const { currentYear, currentMonth } = currentWorld.getState(); + const key = `${currentYear}-${String(currentMonth).padStart(2, '0')}`; + trainingCounts.set(key, (trainingCounts.get(key) ?? 0) + 1); + }, }); - await reservedTurnStore.loadAll(); for (const general of generals.filter((g) => g.nationId === 1)) { const turns = reservedTurnStore.getGeneralTurns(general.id); @@ -231,84 +209,9 @@ describe('NPC 전투준비 턴 검증', () => { }; } - const wrapper = { world: null as InMemoryTurnWorld | null }; - const trainingActions = new Set(['che_훈련', 'che_사기진작']); - const trainingCounts = new Map(); - - const handler = await createReservedTurnHandler({ - reservedTurns: reservedTurnStore, - scenarioConfig: snapshot.scenarioConfig, - scenarioMeta: snapshot.scenarioMeta, - map: LARGE_TEST_MAP as any, - unitSet: snapshot.unitSet, - getWorld: () => wrapper.world, - onActionResolved: (payload) => { - if (payload.kind !== 'general') { - return; - } - if (!trainingActions.has(payload.actionKey)) { - return; - } - const world = wrapper.world; - if (!world) { - return; - } - const { currentYear, currentMonth } = world.getState(); - const key = `${currentYear}-${String(currentMonth).padStart(2, '0')}`; - trainingCounts.set(key, (trainingCounts.get(key) ?? 0) + 1); - }, - }); - - const tracedHandler = { - execute: (ctx: Parameters[0]) => { - return handler.execute(ctx); - }, - }; - - const incomeHandler = createIncomeHandler({ - getWorld: () => wrapper.world, - scenarioConfig: snapshot.scenarioConfig, - nationTraits: new Map(), - }); - - const npcTaxHandler = createNpcTaxHandler({ - getWorld: () => wrapper.world, - }); - - const frontStateHandler = createFrontStateHandler({ - getWorld: () => wrapper.world, - map: LARGE_TEST_MAP, - }); - - const calendarHandler = composeCalendarHandlers(incomeHandler, npcTaxHandler, frontStateHandler); - - const world = new InMemoryTurnWorld(state, snapshot, { - schedule, - generalTurnHandler: tracedHandler, - calendarHandler, - }); - wrapper.world = world; - - const processor = new InMemoryTurnProcessor(world, { - tickMinutes: 10, - }); - - const runOneMonth = async () => { - const target = addMinutes(world.getState().lastTurnTime, 10); - await processor.run(target, { - budgetMs: 10000, - maxGenerals: 100000, - catchUpCap: 1, - }); - }; - - while (true) { - await runOneMonth(); - const { currentYear, currentMonth } = world.getState(); - if (currentYear > 182 || (currentYear === 182 && currentMonth >= 11)) { - break; - } - } + await runUntil( + (current) => current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 11) + ); expect(trainingCounts.get('182-09') ?? 0).toBeGreaterThan(0); expect(trainingCounts.get('182-10') ?? 0).toBeGreaterThan(0);