import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { buildScenarioBootstrap, type City, type TurnSchedule } from '@sammo-ts/logic'; import { describe, expect, it } from 'vitest'; import { loadMapDefinitionByName } from '../src/scenario/mapLoader.js'; import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js'; import { loadUnitSetDefinitionByName } from '../src/scenario/unitSetLoader.js'; import { applyInitialChangeCityEvents } from '../src/turn/monthlyChangeCityAction.js'; import { createUnificationHandler } from '../src/turn/unificationHandler.js'; import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js'; import { NpcUnificationTimingProfiler } from './helpers/npcUnificationTimingProfiler.js'; const benchmarkEnabled = process.env.NPC_UNIFICATION_BENCHMARK === '1'; const benchmarkDescribe = describe.runIf(benchmarkEnabled); const SCENARIO_ID = 2601; const HIDDEN_SEED = 'scenario-2601-npc-unification-benchmark-v1'; const TURN_MINUTES = 10; const createGameDate = (year: number, month: number): Date => { const date = new Date(0); date.setUTCFullYear(year, month - 1, 1); date.setUTCHours(0, 0, 0, 0); return date; }; const buildTurnGeneral = ( domainGeneral: ReturnType['snapshot']['generals'][number], seedGeneral: ReturnType['seed']['generals'][number], startTime: Date, startYear: number, startMonth: number ): TurnGeneral => { const deathMonthRaw = seedGeneral.meta.deathMonth; const deathMonth = typeof deathMonthRaw === 'number' && Number.isInteger(deathMonthRaw) ? deathMonthRaw : startMonth; const killturn = Math.max(0, (seedGeneral.deathYear - startYear) * 12 + (deathMonth - 1) + startMonth - 1); const initialTurnOffsetMicros = typeof seedGeneral.meta.initialTurnOffsetMicros === 'number' ? seedGeneral.meta.initialTurnOffsetMicros : 0; return { ...domainGeneral, userId: null, bornYear: seedGeneral.birthYear, deadYear: seedGeneral.deathYear, affinity: seedGeneral.affinity, picture: seedGeneral.picture === null ? null : String(seedGeneral.picture), startAge: 20, turnTime: new Date(startTime.getTime() + Math.floor(initialTurnOffsetMicros / 1_000)), recentWarTime: null, lastTurn: { command: '휴식' }, penalty: {}, inheritancePoints: {}, meta: { ...domainGeneral.meta, ...seedGeneral.meta, killturn, npcType: seedGeneral.npcType, crewTypeId: seedGeneral.crewTypeId, }, }; }; const buildTurnCity = (seed: ReturnType['seed']['cities'][number]): City => ({ id: seed.id, name: seed.name, nationId: seed.nationId, level: seed.level, state: seed.state, population: seed.population, populationMax: seed.populationMax, agriculture: seed.agriculture, agricultureMax: seed.agricultureMax, commerce: seed.commerce, commerceMax: seed.commerceMax, security: seed.security, securityMax: seed.securityMax, supplyState: seed.supplyState, frontState: seed.frontState, defence: seed.defence, defenceMax: seed.defenceMax, wall: seed.wall, wallMax: seed.wallMax, meta: { ...seed.meta, region: seed.region, trust: seed.trust, trade: seed.trade, positionX: seed.position.x, positionY: seed.position.y, }, }); const applyConvergenceAssist = (world: InMemoryTurnWorld, mode: string): void => { if (mode !== 'nation-1-max-city') return; for (const city of world.listCities()) { if (city.nationId !== 1) continue; world.updateCity(city.id, { population: city.populationMax, agriculture: city.agricultureMax, commerce: city.commerceMax, security: city.securityMax, defence: city.defenceMax, wall: city.wallMax, meta: { ...city.meta, trust: 100 }, }); } }; benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', () => { it('DB와 Redis 없이 시작 상태부터 통일까지 자동 실행하고 상세 시간을 기록한다', async () => { const startedAtNs = process.hrtime.bigint(); const scenario = await loadScenarioDefinitionById(SCENARIO_ID); const map = await loadMapDefinitionByName(scenario.config.environment.mapName); const unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet); const startYear = scenario.startYear ?? 180; const startMonth = 1; const startTime = createGameDate(startYear, startMonth); const bootstrap = buildScenarioBootstrap({ scenario, map, unitSet, options: { hiddenSeed: HIDDEN_SEED, initialYear: startYear, initialMonth: startMonth, turnTermMinutes: TURN_MINUTES, includeNeutralNationInSeed: true, }, }); expect(bootstrap.warnings).toEqual([]); const cities = applyInitialChangeCityEvents(bootstrap.seed.cities, bootstrap.seed.initialEvents).map( buildTurnCity ); const domainGeneralById = new Map(bootstrap.snapshot.generals.map((general) => [general.id, general])); const generals = bootstrap.seed.generals.map((seedGeneral) => { const domainGeneral = domainGeneralById.get(seedGeneral.id); if (!domainGeneral) throw new Error(`missing domain general ${seedGeneral.id}`); return buildTurnGeneral(domainGeneral, seedGeneral, startTime, startYear, startMonth); }); const snapshot: TurnWorldSnapshot = { scenarioConfig: bootstrap.snapshot.scenarioConfig, scenarioMeta: bootstrap.snapshot.scenarioMeta, worldConfig: { fiction: scenario.fiction, npcMode: 2, turnTermMinutes: TURN_MINUTES, tournamentTrig: false, }, map, unitSet, generals, cities, nations: bootstrap.snapshot.nations, troops: bootstrap.snapshot.troops, diplomacy: bootstrap.snapshot.diplomacy.map((entry) => ({ fromNationId: entry.fromNationId, toNationId: entry.toNationId, state: entry.state, term: entry.durationMonths, dead: 0, meta: {}, })), // This benchmark isolates general/nation commands and core monthly // handlers. Scenario event actions are excluded explicitly below. events: [], initialEvents: [], }; const state: TurnWorldState = { id: 1, currentYear: startYear, currentMonth: startMonth, tickSeconds: TURN_MINUTES * 60, lastTurnTime: startTime, clockBaseTime: startTime, clockTick: 0, clockMode: 'manual', clockWallAnchor: startTime, lastTurnTick: 0, meta: { scenarioId: SCENARIO_ID, scenarioMeta: bootstrap.seed.scenarioMeta, hiddenSeed: HIDDEN_SEED, seed: HIDDEN_SEED, initYear: startYear, initMonth: startMonth, fiction: scenario.fiction, killturn: 4800 / TURN_MINUTES, develcost: 20, isUnited: 0, isunited: 0, lastGeneralId: Math.max(0, ...generals.map((general) => general.id)), lastNationId: 0, serverId: 'benchmark-scenario-2601', }, }; const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: TURN_MINUTES }] }; const worldRef = { current: null as InMemoryTurnWorld | null }; const profiler = new NpcUnificationTimingProfiler(); const turnStartedAt = new Map(); const convergenceAssist = process.env.NPC_UNIFICATION_BENCHMARK_CONVERGENCE_ASSIST ?? 'none'; let foundedNationCount = 0; const discardedDrafts = { logs: 0, messages: 0, neutralAuctions: 0 }; const unification = createUnificationHandler({ profileName: 'benchmark-scenario-2601', getWorld: () => worldRef.current, dispatchUnitedEvents: async () => {}, }); const harness = await createTurnTestHarness({ snapshot, state, schedule, map, worldRef, extraCalendarHandlers: [unification.handler], onActionProfiled: (payload) => profiler.observeAction(payload), turnProcessorOptions: { tickMinutes: TURN_MINUTES, beforeExecuteGeneral: async (general) => { turnStartedAt.set(general.id, process.hrtime.bigint()); }, afterExecuteGeneral: async (general) => { const generalStartedAt = turnStartedAt.get(general.id); if (generalStartedAt === undefined) throw new Error(`missing turn timer ${general.id}`); const current = worldRef.current?.getState(); if (!current) throw new Error('world not initialized'); profiler.observeGeneralTurn({ year: current.currentYear, month: current.currentMonth, officerLevel: general.officerLevel, durationNs: process.hrtime.bigint() - generalStartedAt, }); turnStartedAt.delete(general.id); }, }, }); const maximumYear = Number(process.env.NPC_UNIFICATION_BENCHMARK_MAX_YEAR ?? 300); while (true) { const before = harness.world.getState(); const monthStartedAtNs = process.hrtime.bigint(); await harness.runOneTick({ budgetMs: 600_000, maxGenerals: 100_000, catchUpCap: 1 }); profiler.observeMonth({ year: before.currentYear, month: before.currentMonth, wallDurationMs: Number(process.hrtime.bigint() - monthStartedAtNs) / 1_000_000, activeNationCount: harness.world.listNations().filter((nation) => nation.level > 0).length, generalCount: harness.world.listGenerals().length, }); applyConvergenceAssist(harness.world, convergenceAssist); const activeNationCount = harness.world.listNations().filter((nation) => nation.level > 0).length; foundedNationCount = Math.max(foundedNationCount, activeNationCount); const changes = harness.world.consumeDirtyState(); discardedDrafts.logs += changes.logs.length; discardedDrafts.messages += changes.messages.length; discardedDrafts.neutralAuctions += changes.pendingNeutralAuctions.length; const reservedChanges = harness.reservedTurnStore.peekDirtyState(); harness.reservedTurnStore.acknowledgeDirtyState(reservedChanges); const current = harness.world.getState(); const meta = current.meta as Record; if ((meta.isUnited ?? meta.isunited ?? 0) !== 0) break; if (current.currentYear >= maximumYear) break; } const finalState = harness.world.getState(); const finalMeta = finalState.meta as Record; const unificationReached = (finalMeta.isUnited ?? finalMeta.isunited ?? 0) !== 0; const finalNationCount = harness.world.listNations().filter((nation) => nation.level > 0).length; const report = profiler.buildReport({ startedAtNs, scenarioId: SCENARIO_ID, scenarioTitle: scenario.title, hiddenSeed: HIDDEN_SEED, initialGeneralCount: generals.length, initialCityCount: cities.length, startYear, startMonth, finalYear: finalState.currentYear, finalMonth: finalState.currentMonth, finalGeneralCount: harness.world.listGenerals().length, foundedNationCount, finalNationCount, unificationReached, convergenceAssist, discardedDrafts, }); const reportPath = resolve( process.env.NPC_UNIFICATION_BENCHMARK_REPORT_PATH ?? 'test-results/npc-scenario-unification-benchmark.json' ); mkdirSync(dirname(reportPath), { recursive: true }); writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); console.log( `[NPC_UNIFICATION_BENCHMARK_REPORT]${JSON.stringify({ reportPath, scenario: report.scenario, result: report.result, npcDecisionByOfficerGroup: report.npcDecisionByOfficerGroup, generalTurnByOfficerGroup: report.generalTurnByOfficerGroup, memory: report.memory, })}` ); expect(generals.length).toBeGreaterThanOrEqual(600); expect(cities.length).toBeGreaterThanOrEqual(90); expect(unificationReached).toBe(true); }, 1_800_000); });