389 lines
16 KiB
TypeScript
389 lines
16 KiB
TypeScript
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { performance } from 'node:perf_hooks';
|
|
|
|
import { buildScenarioBootstrap, 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 { EngineStateManager } from '../src/turn/engineStateManager.js';
|
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
|
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
|
import {
|
|
buildNpcLifecycleMemoryReport,
|
|
captureLifecycleMemorySample,
|
|
type NpcLifecycleMemorySample,
|
|
type NpcLifecycleMemoryScenario,
|
|
} from './helpers/npcLifecycleMemoryProfiler.js';
|
|
|
|
const profileEnabled = process.env.NPC_LIFECYCLE_MEMORY_PROFILE === '1';
|
|
const profileDescribe = describe.runIf(profileEnabled);
|
|
const SCENARIO_ID = 2601;
|
|
const HIDDEN_SEED = 'scenario-2601-npc-lifecycle-memory-v1';
|
|
const ROLLBACK_SENTINEL = new Error('npc-lifecycle-memory-rollback');
|
|
const VALID_SCENARIOS = new Set<NpcLifecycleMemoryScenario>([
|
|
'steady-state',
|
|
'growth',
|
|
'death-drain',
|
|
'balanced-churn',
|
|
'rollback-churn',
|
|
]);
|
|
|
|
const readPositiveInteger = (name: string, fallback: number): number => {
|
|
const raw = process.env[name];
|
|
if (raw === undefined) {
|
|
return fallback;
|
|
}
|
|
const value = Number(raw);
|
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
throw new Error(`${name} must be a positive integer: ${raw}`);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
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<typeof buildScenarioBootstrap>['snapshot']['generals'][number],
|
|
seedGeneral: ReturnType<typeof buildScenarioBootstrap>['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 - startMonth);
|
|
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),
|
|
recentWarTime: null,
|
|
lastTurn: { command: '휴식' },
|
|
penalty: {},
|
|
inheritancePoints: {},
|
|
meta: {
|
|
...domainGeneral.meta,
|
|
...seedGeneral.meta,
|
|
killturn,
|
|
npcType: seedGeneral.npcType,
|
|
crewTypeId: seedGeneral.crewTypeId,
|
|
},
|
|
};
|
|
};
|
|
|
|
const cloneNpcGeneral = (source: TurnGeneral, id: number): TurnGeneral => {
|
|
const cloned = structuredClone(source);
|
|
return {
|
|
...cloned,
|
|
id,
|
|
name: `${source.name}#M${id}`,
|
|
userId: null,
|
|
npcState: Math.max(2, source.npcState),
|
|
nationId: 0,
|
|
cityId: 0,
|
|
troopId: 0,
|
|
officerLevel: 0,
|
|
meta: {
|
|
...cloned.meta,
|
|
lifecycleMemoryFixture: true,
|
|
},
|
|
};
|
|
};
|
|
|
|
const createProfileWorld = async (initialGeneralCount: number) => {
|
|
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: 10,
|
|
includeNeutralNationInSeed: true,
|
|
},
|
|
});
|
|
if (bootstrap.warnings.length > 0) {
|
|
throw new Error(`scenario bootstrap warnings: ${bootstrap.warnings.join(', ')}`);
|
|
}
|
|
const domainGeneralById = new Map(bootstrap.snapshot.generals.map((general) => [general.id, general]));
|
|
const scenarioGenerals = bootstrap.seed.generals.map((seedGeneral) => {
|
|
const domainGeneral = domainGeneralById.get(seedGeneral.id);
|
|
if (!domainGeneral) {
|
|
throw new Error(`missing scenario general: ${seedGeneral.id}`);
|
|
}
|
|
return buildTurnGeneral(domainGeneral, seedGeneral, startTime, startYear, startMonth);
|
|
});
|
|
const generals = Array.from({ length: initialGeneralCount }, (_, index) =>
|
|
cloneNpcGeneral(scenarioGenerals[index % scenarioGenerals.length]!, index + 1)
|
|
);
|
|
const snapshot: TurnWorldSnapshot = {
|
|
scenarioConfig: bootstrap.snapshot.scenarioConfig,
|
|
scenarioMeta: bootstrap.snapshot.scenarioMeta,
|
|
worldConfig: {
|
|
fiction: scenario.fiction,
|
|
npcMode: 2,
|
|
turnTermMinutes: 10,
|
|
tournamentTrig: false,
|
|
},
|
|
map,
|
|
unitSet,
|
|
generals,
|
|
cities: bootstrap.snapshot.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: {},
|
|
})),
|
|
events: [],
|
|
initialEvents: [],
|
|
};
|
|
const state: TurnWorldState = {
|
|
id: 1,
|
|
currentYear: startYear,
|
|
currentMonth: startMonth,
|
|
tickSeconds: 600,
|
|
lastTurnTime: startTime,
|
|
clockBaseTime: startTime,
|
|
clockTick: 0,
|
|
clockMode: 'manual',
|
|
clockWallAnchor: startTime,
|
|
lastTurnTick: 0,
|
|
meta: {
|
|
scenarioId: SCENARIO_ID,
|
|
hiddenSeed: HIDDEN_SEED,
|
|
killturn: 480,
|
|
lastGeneralId: initialGeneralCount,
|
|
serverId: 'npc-lifecycle-memory-profile',
|
|
},
|
|
};
|
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
|
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
|
const reservedTurns = new InMemoryReservedTurnStore({} as never, {
|
|
maxGeneralTurns: 30,
|
|
maxNationTurns: 12,
|
|
leaseOwner: 'npc-lifecycle-memory-profile',
|
|
});
|
|
for (const general of generals) {
|
|
reservedTurns.getGeneralTurns(general.id);
|
|
}
|
|
return { world, reservedTurns, templateGenerals: scenarioGenerals };
|
|
};
|
|
|
|
profileDescribe('NPC 생성·사망 장기 구동 메모리 프로파일', () => {
|
|
it('격리 시나리오의 GC 안정 heap, rollback snapshot과 예약 큐 보유량을 기록한다', async () => {
|
|
expect(typeof globalThis.gc).toBe('function');
|
|
const rawScenario = process.env.NPC_LIFECYCLE_MEMORY_SCENARIO ?? 'balanced-churn';
|
|
if (!VALID_SCENARIOS.has(rawScenario as NpcLifecycleMemoryScenario)) {
|
|
throw new Error(`unknown NPC lifecycle memory scenario: ${rawScenario}`);
|
|
}
|
|
const scenario = rawScenario as NpcLifecycleMemoryScenario;
|
|
const cycles = readPositiveInteger('NPC_LIFECYCLE_MEMORY_CYCLES', 80);
|
|
const batchSize = readPositiveInteger('NPC_LIFECYCLE_MEMORY_BATCH_SIZE', 100);
|
|
const sampleEvery = readPositiveInteger('NPC_LIFECYCLE_MEMORY_SAMPLE_EVERY', 5);
|
|
const baseGeneralCount = readPositiveInteger('NPC_LIFECYCLE_MEMORY_BASE_GENERALS', 1_200);
|
|
const pruneDeletedQueues = process.env.NPC_LIFECYCLE_MEMORY_PRUNE_DELETED === '1';
|
|
const initialGeneralCount =
|
|
scenario === 'death-drain' ? baseGeneralCount + cycles * batchSize : baseGeneralCount;
|
|
const { world, reservedTurns, templateGenerals } = await createProfileWorld(initialGeneralCount);
|
|
const stateManager = new EngineStateManager();
|
|
stateManager.register('world', {
|
|
capture: () => world.captureState(),
|
|
restore: (snapshot) => world.restoreState(snapshot),
|
|
});
|
|
stateManager.register('reservedTurns', {
|
|
capture: () => reservedTurns.captureTransactionState(),
|
|
restore: (snapshot) => reservedTurns.restoreState(snapshot),
|
|
});
|
|
|
|
const startedAtMs = performance.now();
|
|
const samples: NpcLifecycleMemorySample[] = [
|
|
captureLifecycleMemorySample({
|
|
world,
|
|
reservedTurns,
|
|
startedAtMs,
|
|
cycle: 0,
|
|
phase: 'initial',
|
|
includePending: false,
|
|
includeSnapshot: true,
|
|
}),
|
|
];
|
|
const activeGeneralIds = world
|
|
.listGenerals()
|
|
.map((general) => general.id)
|
|
.sort((left, right) => left - right);
|
|
let nextGeneralId = Math.max(...activeGeneralIds) + 1;
|
|
let createdTotal = 0;
|
|
let deletedTotal = 0;
|
|
let rolledBackCycles = 0;
|
|
|
|
const addGenerals = (count: number): void => {
|
|
for (let index = 0; index < count; index += 1) {
|
|
const generalId = nextGeneralId++;
|
|
const source = templateGenerals[(generalId - 1) % templateGenerals.length]!;
|
|
if (!world.addGeneral(cloneNpcGeneral(source, generalId))) {
|
|
throw new Error(`failed to add profile general ${generalId}`);
|
|
}
|
|
reservedTurns.ensureGeneralTurns(generalId);
|
|
activeGeneralIds.push(generalId);
|
|
createdTotal += 1;
|
|
}
|
|
};
|
|
const deleteGenerals = (count: number): void => {
|
|
const targetIds = activeGeneralIds.splice(0, count);
|
|
for (const generalId of targetIds) {
|
|
if (!world.deleteGeneralWithLifecycle(generalId, 180, 1)) {
|
|
throw new Error(`failed to delete profile general ${generalId}`);
|
|
}
|
|
deletedTotal += 1;
|
|
}
|
|
};
|
|
|
|
for (let cycle = 1; cycle <= cycles; cycle += 1) {
|
|
const sampledCycle = cycle % sampleEvery === 0 || cycle === cycles;
|
|
try {
|
|
await stateManager.transaction(() => {
|
|
if (scenario === 'steady-state') {
|
|
for (let index = 0; index < batchSize; index += 1) {
|
|
const generalId = activeGeneralIds[((cycle - 1) * batchSize + index) % activeGeneralIds.length]!;
|
|
const current = world.getGeneralById(generalId);
|
|
if (!current) {
|
|
throw new Error(`missing steady-state general ${generalId}`);
|
|
}
|
|
world.updateGeneral(generalId, { experience: current.experience + 1 });
|
|
reservedTurns.shiftGeneralTurns(generalId, -1);
|
|
}
|
|
} else if (scenario === 'growth') {
|
|
addGenerals(batchSize);
|
|
} else if (scenario === 'death-drain') {
|
|
deleteGenerals(batchSize);
|
|
} else if (scenario === 'balanced-churn') {
|
|
deleteGenerals(batchSize);
|
|
addGenerals(batchSize);
|
|
} else {
|
|
addGenerals(batchSize);
|
|
deleteGenerals(batchSize);
|
|
}
|
|
|
|
if (sampledCycle) {
|
|
samples.push(
|
|
captureLifecycleMemorySample({
|
|
world,
|
|
reservedTurns,
|
|
startedAtMs,
|
|
cycle,
|
|
phase: 'in-transaction',
|
|
includePending: true,
|
|
includeSnapshot: false,
|
|
})
|
|
);
|
|
}
|
|
if (scenario === 'rollback-churn') {
|
|
throw ROLLBACK_SENTINEL;
|
|
}
|
|
|
|
const worldChanges = world.peekDirtyState();
|
|
const reservedChanges = reservedTurns.peekDirtyState();
|
|
world.acknowledgeDirtyState(worldChanges);
|
|
reservedTurns.acknowledgeDirtyState(reservedChanges);
|
|
if (pruneDeletedQueues) {
|
|
reservedTurns.pruneDeletedEntityQueues(
|
|
worldChanges.deletedGenerals,
|
|
worldChanges.deletedNations
|
|
);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (scenario !== 'rollback-churn' || error !== ROLLBACK_SENTINEL) {
|
|
throw error;
|
|
}
|
|
rolledBackCycles += 1;
|
|
activeGeneralIds.splice(0, activeGeneralIds.length, ...world.listGenerals().map((general) => general.id));
|
|
}
|
|
|
|
if (sampledCycle) {
|
|
samples.push(
|
|
captureLifecycleMemorySample({
|
|
world,
|
|
reservedTurns,
|
|
startedAtMs,
|
|
cycle,
|
|
phase: 'post-flush',
|
|
includePending: true,
|
|
includeSnapshot: true,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
const report = buildNpcLifecycleMemoryReport({
|
|
scenario,
|
|
pruneDeletedQueues,
|
|
initialGeneralCount,
|
|
cycles,
|
|
batchSize,
|
|
sampleEvery,
|
|
createdTotal,
|
|
deletedTotal,
|
|
rolledBackCycles,
|
|
startedAtMs,
|
|
samples,
|
|
});
|
|
const reportPath = resolve(
|
|
process.env.NPC_LIFECYCLE_MEMORY_CHILD_REPORT_PATH ??
|
|
`test-results/npc-lifecycle-memory-${scenario}.json`
|
|
);
|
|
mkdirSync(dirname(reportPath), { recursive: true });
|
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
console.log(
|
|
`[NPC_LIFECYCLE_MEMORY_REPORT]${JSON.stringify({
|
|
reportPath,
|
|
scenario: report.scenario,
|
|
result: report.result,
|
|
memory: report.memory,
|
|
})}`
|
|
);
|
|
|
|
const expectedFinalGeneralCount =
|
|
scenario === 'growth'
|
|
? initialGeneralCount + cycles * batchSize
|
|
: scenario === 'death-drain'
|
|
? baseGeneralCount
|
|
: initialGeneralCount;
|
|
expect(report.result.finalGeneralCount).toBe(expectedFinalGeneralCount);
|
|
expect(report.result.rolledBackCycles).toBe(scenario === 'rollback-churn' ? cycles : 0);
|
|
if (pruneDeletedQueues || !['death-drain', 'balanced-churn'].includes(scenario)) {
|
|
expect(report.result.deadQueueRetentionCount).toBe(0);
|
|
} else {
|
|
expect(report.result.deadQueueRetentionCount).toBe(cycles * batchSize);
|
|
}
|
|
expect(world.peekDirtyState().lifecycleEvents).toHaveLength(0);
|
|
expect(reservedTurns.peekDirtyState().generalIds).toHaveLength(0);
|
|
}, 600_000);
|
|
});
|