merge: 최신 main을 예약 초기화 중간 업데이트에 반영한다

This commit is contained in:
2026-08-24 13:23:34 +00:00
15 changed files with 997 additions and 2 deletions
+1
View File
@@ -107,6 +107,7 @@
"lint:fix": "eslint . --fix",
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
"profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.mjs",
"profile:npc-lifecycle-memory": "node scripts/profile-npc-lifecycle-memory.mjs",
"profile:npc-capacity-1200": "node scripts/profile-npc-capacity-1200.mjs",
"test": "vitest run --config vitest.config.ts",
"typecheck": "pnpm -w tsc7 -b app/game-engine/tsconfig.json"
@@ -0,0 +1,120 @@
import { spawn } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const vitestPath = path.join(packageRoot, 'node_modules', 'vitest', 'vitest.mjs');
const defaultRuns = [
{ scenario: 'steady-state', pruneDeletedQueues: true },
{ scenario: 'growth', pruneDeletedQueues: true },
{ scenario: 'death-drain', pruneDeletedQueues: false },
{ scenario: 'death-drain', pruneDeletedQueues: true },
{ scenario: 'balanced-churn', pruneDeletedQueues: false },
{ scenario: 'balanced-churn', pruneDeletedQueues: true },
{ scenario: 'rollback-churn', pruneDeletedQueues: true },
];
const parseRuns = () => {
const raw = process.env.NPC_LIFECYCLE_MEMORY_SCENARIOS;
if (!raw) return defaultRuns;
return raw.split(',').map((entry) => {
const [scenario, variant = 'prune'] = entry.trim().split('@');
if (!scenario) throw new Error(`invalid scenario entry: ${entry}`);
if (variant !== 'prune' && variant !== 'retain') {
throw new Error(`scenario variant must be prune or retain: ${entry}`);
}
return { scenario, pruneDeletedQueues: variant === 'prune' };
});
};
const runChild = (run, reportPath, repetition) =>
new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[
'--expose-gc',
vitestPath,
'run',
'--config',
'vitest.config.ts',
'--pool=threads',
'--maxWorkers=1',
'test/npcLifecycleMemoryProfile.test.ts',
],
{
cwd: packageRoot,
env: {
...process.env,
NPC_LIFECYCLE_MEMORY_PROFILE: '1',
NPC_LIFECYCLE_MEMORY_SCENARIO: run.scenario,
NPC_LIFECYCLE_MEMORY_PRUNE_DELETED: run.pruneDeletedQueues ? '1' : '0',
NPC_LIFECYCLE_MEMORY_CHILD_REPORT_PATH: reportPath,
NPC_LIFECYCLE_MEMORY_REPETITION: String(repetition),
},
stdio: 'inherit',
}
);
child.once('error', reject);
child.once('exit', (code, signal) => {
if (signal) {
reject(new Error(`${run.scenario} terminated by ${signal}`));
} else if (code !== 0) {
reject(new Error(`${run.scenario} exited with code ${code}`));
} else {
resolve();
}
});
});
const runs = parseRuns();
const repetitions = Number(process.env.NPC_LIFECYCLE_MEMORY_REPETITIONS ?? 1);
if (!Number.isSafeInteger(repetitions) || repetitions <= 0) {
throw new Error('NPC_LIFECYCLE_MEMORY_REPETITIONS must be a positive integer.');
}
const reportPath = path.resolve(
packageRoot,
process.env.NPC_LIFECYCLE_MEMORY_REPORT_PATH ?? 'test-results/npc-lifecycle-memory.json'
);
const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'sammo-npc-lifecycle-memory-'));
const reports = [];
try {
for (let repetition = 1; repetition <= repetitions; repetition += 1) {
for (const [index, run] of runs.entries()) {
const childReportPath = path.join(
temporaryDirectory,
`${String(repetition).padStart(2, '0')}-${String(index).padStart(2, '0')}.json`
);
await runChild(run, childReportPath, repetition);
reports.push({
repetition,
...JSON.parse(readFileSync(childReportPath, 'utf8')),
});
}
}
mkdirSync(path.dirname(reportPath), { recursive: true });
writeFileSync(
reportPath,
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
inputs: {
cycles: Number(process.env.NPC_LIFECYCLE_MEMORY_CYCLES ?? 80),
batchSize: Number(process.env.NPC_LIFECYCLE_MEMORY_BATCH_SIZE ?? 100),
sampleEvery: Number(process.env.NPC_LIFECYCLE_MEMORY_SAMPLE_EVERY ?? 5),
baseGenerals: Number(process.env.NPC_LIFECYCLE_MEMORY_BASE_GENERALS ?? 1_200),
repetitions,
},
reports,
},
null,
2
)}\n`,
'utf8'
);
console.log(`[npc-lifecycle-memory] wrote ${reports.length} scenario reports to ${reportPath}`);
} finally {
rmSync(temporaryDirectory, { recursive: true, force: true });
}
+7
View File
@@ -96,10 +96,17 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
intervalMs: memoryReportIntervalMs,
getContext: () => {
const state = runtime.world.getState();
const queueCounts = runtime.reservedTurns?.getQueueCounts();
return {
year: state.currentYear,
month: state.currentMonth,
...runtime.world.getEntityCounts(),
...(queueCounts
? {
generalTurnQueues: queueCounts.generalQueues,
nationTurnQueues: queueCounts.nationQueues,
}
: {}),
lifecycleState: runtime.lifecycle.getStatus().state,
};
},
@@ -1787,6 +1787,7 @@ export const createDatabaseTurnHooks = async (
world.acknowledgeDirtyState(changes);
if (options?.reservedTurns && reservedTurnChanges) {
options.reservedTurns.acknowledgeDirtyState(reservedTurnChanges);
options.reservedTurns.pruneDeletedEntityQueues(deletedGenerals, deletedNations);
}
applyRealtimeReadModelBaseline(readModelBaseline, changes);
worldReadModelBaseline = persisted.worldReadModelSignature;
@@ -105,6 +105,17 @@ export interface InMemoryReservedTurnStateSnapshot {
leasedNationKeys: string[];
}
export interface ReservedTurnQueueCounts {
generalQueues: number;
nationQueues: number;
dirtyGeneralQueues: number;
dirtyNationQueues: number;
pendingGeneralInitializations: number;
pendingNationInitializations: number;
leasedGeneralQueues: number;
leasedNationQueues: number;
}
export class ReservedTurnLeaseConflictError extends Error {
constructor(readonly queueKey: string) {
super(`Reserved turn queue lease conflict: ${queueKey}.`);
@@ -189,6 +200,64 @@ export class InMemoryReservedTurnStore {
return this.captureState();
}
getQueueCounts(): ReservedTurnQueueCounts {
return {
generalQueues: this.generalTurns.size,
nationQueues: this.nationTurns.size,
dirtyGeneralQueues: this.dirtyGeneralIds.size,
dirtyNationQueues: this.dirtyNationKeys.size,
pendingGeneralInitializations: this.pendingGeneralInitializationIds.size,
pendingNationInitializations: this.pendingNationInitializationKeys.size,
leasedGeneralQueues: this.leasedGeneralIds.size,
leasedNationQueues: this.leasedNationKeys.size,
};
}
/**
* Drops queues whose owning rows were deleted by a successful world flush.
* The daemon calls this inside EngineStateManager.transaction(), so a later
* failure still restores these maps and journals from the transaction savepoint.
*/
pruneDeletedEntityQueues(
generalIds: readonly number[],
nationIds: readonly number[]
): { generalQueues: number; nationQueues: number } {
let generalQueues = 0;
for (const generalId of new Set(generalIds)) {
if (this.generalTurns.delete(generalId)) {
generalQueues += 1;
}
this.dirtyGeneralIds.delete(generalId);
this.pendingGeneralInitializationIds.delete(generalId);
this.leasedGeneralIds.delete(generalId);
}
const deletedNations = new Set(nationIds);
let nationQueues = 0;
const pruneNationKeys = (keys: Iterable<string>, remove: (key: string) => boolean | void): void => {
for (const key of keys) {
const nationId = Number(key.split(':', 1)[0]);
if (deletedNations.has(nationId) && remove(key) !== false) {
nationQueues += 1;
}
}
};
pruneNationKeys(Array.from(this.nationTurns.keys()), (key) => this.nationTurns.delete(key));
for (const keys of [
this.dirtyNationKeys,
this.pendingNationInitializationKeys,
this.leasedNationKeys,
]) {
for (const key of Array.from(keys)) {
const nationId = Number(key.split(':', 1)[0]);
if (deletedNations.has(nationId)) {
keys.delete(key);
}
}
}
return { generalQueues, nationQueues };
}
inspectGeneralTurnActivity(): Array<[number, boolean]> {
return Array.from(this.generalTurns, ([generalId, turns]) => [
generalId,
+3 -1
View File
@@ -41,7 +41,7 @@ import {
} from './monthlyNationStatsHandler.js';
import { createFrontStateHandler } from './frontStateHandler.js';
import { createReservedTurnHandler } from './reservedTurnHandler.js';
import { createReservedTurnStore } from './reservedTurnStore.js';
import { createReservedTurnStore, type InMemoryReservedTurnStore } from './reservedTurnStore.js';
import { createTurnDaemonCommandHandler } from './worldCommandHandler.js';
import { loadTurnCommandProfile } from './turnCommandProfile.js';
import { loadTurnWorldFromDatabase } from './worldLoader.js';
@@ -132,6 +132,7 @@ export interface TurnDaemonRuntime {
stateStore: InMemoryTurnStateStore;
stateManager: EngineStateManager;
processor: InMemoryTurnProcessor;
reservedTurns: InMemoryReservedTurnStore | null;
hooks?: TurnDaemonHooks;
close(): Promise<void>;
}
@@ -1012,6 +1013,7 @@ const createTurnDaemonRuntimeWithLease = async (
stateStore,
stateManager,
processor,
reservedTurns: reservedTurnStoreHandle?.store ?? null,
hooks,
close,
};
@@ -8,6 +8,8 @@ export interface TurnDaemonMemoryContext {
nations: number;
troops: number;
events: number;
generalTurnQueues?: number;
nationTurnQueues?: number;
lifecycleState: string;
}
@@ -48,6 +50,10 @@ export const buildTurnDaemonMemoryReport = (
`nations=${context.nations}`,
`troops=${context.troops}`,
`events=${context.events}`,
...(context.generalTurnQueues === undefined
? []
: [`generalTurnQueues=${context.generalTurnQueues}`]),
...(context.nationTurnQueues === undefined ? [] : [`nationTurnQueues=${context.nationTurnQueues}`]),
`lifecycle=${context.lifecycleState}`,
].join(' ');
return { message, warning: heapRatio >= HEAP_WARNING_RATIO };
@@ -474,6 +474,12 @@ integration('general turn lifecycle persistence', () => {
events: [],
initialEvents: [],
};
const reservedTurns = new InMemoryReservedTurnStore(db, {
maxGeneralTurns: 3,
maxNationTurns: 3,
});
reservedTurns.ensureGeneralTurns(general.id);
expect(reservedTurns.getQueueCounts().generalQueues).toBe(1);
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: {
@@ -517,7 +523,7 @@ integration('general turn lifecycle persistence', () => {
});
world.executeGeneralTurn(world.getGeneralById(general.id)!);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
try {
await hooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
@@ -529,6 +535,10 @@ integration('general turn lifecycle persistence', () => {
} finally {
await hooks.close();
}
expect(reservedTurns.getQueueCounts()).toMatchObject({
generalQueues: 0,
pendingGeneralInitializations: 0,
});
const archived = await db.oldGeneral.findUniqueOrThrow({
where: { by_no: { serverId, generalNo: general.id } },
@@ -0,0 +1,210 @@
import { performance } from 'node:perf_hooks';
import { serialize } from 'node:v8';
import type { InMemoryTurnWorld } from '../../src/turn/inMemoryWorld.js';
import type {
InMemoryReservedTurnStore,
ReservedTurnQueueCounts,
} from '../../src/turn/reservedTurnStore.js';
export type NpcLifecycleMemoryScenario =
| 'steady-state'
| 'growth'
| 'death-drain'
| 'balanced-churn'
| 'rollback-churn';
export interface ProcessMemorySnapshot {
rssBytes: number;
heapTotalBytes: number;
heapUsedBytes: number;
externalBytes: number;
arrayBuffersBytes: number;
}
export interface NpcLifecycleMemorySample {
cycle: number;
phase: 'initial' | 'in-transaction' | 'post-flush';
elapsedMs: number;
liveGeneralCount: number;
queueCounts: ReservedTurnQueueCounts;
process: ProcessMemorySnapshot;
pending?: {
createdGenerals: number;
deletedGenerals: number;
lifecycleEvents: number;
reservedGeneralQueues: number;
};
snapshot?: {
worldBytes: number;
reservedTurnBytes: number;
totalBytes: number;
cloneAndSerializeMs: number;
heapUsedAfterReleaseBytes: number;
};
}
export const readProcessMemory = (): ProcessMemorySnapshot => {
const usage = process.memoryUsage();
return {
rssBytes: usage.rss,
heapTotalBytes: usage.heapTotal,
heapUsedBytes: usage.heapUsed,
externalBytes: usage.external,
arrayBuffersBytes: usage.arrayBuffers,
};
};
export const linearRegressionSlope = (points: ReadonlyArray<{ x: number; y: number }>): number => {
if (points.length < 2) {
return 0;
}
const meanX = points.reduce((sum, point) => sum + point.x, 0) / points.length;
const meanY = points.reduce((sum, point) => sum + point.y, 0) / points.length;
let numerator = 0;
let denominator = 0;
for (const point of points) {
const xDelta = point.x - meanX;
numerator += xDelta * (point.y - meanY);
denominator += xDelta * xDelta;
}
return denominator === 0 ? 0 : numerator / denominator;
};
export const captureLifecycleMemorySample = (input: {
world: InMemoryTurnWorld;
reservedTurns: InMemoryReservedTurnStore;
startedAtMs: number;
cycle: number;
phase: NpcLifecycleMemorySample['phase'];
includePending: boolean;
includeSnapshot: boolean;
}): NpcLifecycleMemorySample => {
globalThis.gc?.();
const processSnapshot = readProcessMemory();
const pending = input.includePending
? (() => {
const worldChanges = input.world.peekDirtyState();
const reservedChanges = input.reservedTurns.peekDirtyState();
return {
createdGenerals: worldChanges.createdGenerals.length,
deletedGenerals: worldChanges.deletedGenerals.length,
lifecycleEvents: worldChanges.lifecycleEvents.length,
reservedGeneralQueues: reservedChanges.generalIds.length,
};
})()
: undefined;
const sample: NpcLifecycleMemorySample = {
cycle: input.cycle,
phase: input.phase,
elapsedMs: performance.now() - input.startedAtMs,
liveGeneralCount: input.world.getEntityCounts().generals,
queueCounts: input.reservedTurns.getQueueCounts(),
process: processSnapshot,
...(pending ? { pending } : {}),
};
if (input.includeSnapshot) {
const snapshotMetrics = (() => {
const snapshotStartedAt = performance.now();
const worldSnapshot = input.world.captureState();
const reservedSnapshot = input.reservedTurns.captureTransactionState();
const worldBytes = serialize(worldSnapshot).byteLength;
const reservedTurnBytes = serialize(reservedSnapshot).byteLength;
return {
worldBytes,
reservedTurnBytes,
cloneAndSerializeMs: performance.now() - snapshotStartedAt,
};
})();
globalThis.gc?.();
sample.snapshot = {
...snapshotMetrics,
totalBytes: snapshotMetrics.worldBytes + snapshotMetrics.reservedTurnBytes,
heapUsedAfterReleaseBytes: readProcessMemory().heapUsedBytes,
};
}
return sample;
};
const maxValue = (values: readonly number[]): number => Math.max(0, ...values);
export const buildNpcLifecycleMemoryReport = (input: {
scenario: NpcLifecycleMemoryScenario;
pruneDeletedQueues: boolean;
initialGeneralCount: number;
cycles: number;
batchSize: number;
sampleEvery: number;
createdTotal: number;
deletedTotal: number;
rolledBackCycles: number;
startedAtMs: number;
samples: NpcLifecycleMemorySample[];
}) => {
const retained = input.samples.filter(
(sample) => sample.phase === 'initial' || sample.phase === 'post-flush'
);
const warmSampleIndex = Math.floor(retained.length / 3);
const trendSamples = retained.slice(warmSampleIndex);
const first = retained[0];
const final = retained.at(-1);
const heapSlope = linearRegressionSlope(
trendSamples.map((sample) => ({ x: sample.cycle, y: sample.process.heapUsedBytes }))
);
const snapshotSlope = linearRegressionSlope(
trendSamples.flatMap((sample) =>
sample.snapshot ? [{ x: sample.cycle, y: sample.snapshot.totalBytes }] : []
)
);
const queueSlope = linearRegressionSlope(
trendSamples.map((sample) => ({ x: sample.cycle, y: sample.queueCounts.generalQueues }))
);
const lifecycleOperations = input.createdTotal + input.deletedTotal;
return {
schemaVersion: 1,
runtime: {
node: process.version,
platform: process.platform,
arch: process.arch,
explicitGc: typeof globalThis.gc === 'function',
},
scenario: {
name: input.scenario,
pruneDeletedQueues: input.pruneDeletedQueues,
initialGeneralCount: input.initialGeneralCount,
cycles: input.cycles,
batchSize: input.batchSize,
sampleEvery: input.sampleEvery,
},
result: {
createdTotal: input.createdTotal,
deletedTotal: input.deletedTotal,
rolledBackCycles: input.rolledBackCycles,
finalGeneralCount: final?.liveGeneralCount ?? 0,
finalGeneralQueueCount: final?.queueCounts.generalQueues ?? 0,
deadQueueRetentionCount:
(final?.queueCounts.generalQueues ?? 0) - (final?.liveGeneralCount ?? 0),
wallDurationMs: performance.now() - input.startedAtMs,
},
memory: {
retainedHeapStartBytes: first?.process.heapUsedBytes ?? 0,
retainedHeapFinalBytes: final?.process.heapUsedBytes ?? 0,
retainedHeapDeltaBytes:
(final?.process.heapUsedBytes ?? 0) - (first?.process.heapUsedBytes ?? 0),
retainedHeapSlopeBytesPerCycle: heapSlope,
retainedHeapSlopeBytesPerLifecycleOperation:
lifecycleOperations === 0 ? 0 : (heapSlope * input.cycles) / lifecycleOperations,
retainedSnapshotStartBytes: first?.snapshot?.totalBytes ?? 0,
retainedSnapshotFinalBytes: final?.snapshot?.totalBytes ?? 0,
retainedSnapshotDeltaBytes:
(final?.snapshot?.totalBytes ?? 0) - (first?.snapshot?.totalBytes ?? 0),
retainedSnapshotSlopeBytesPerCycle: snapshotSlope,
generalQueueSlopePerCycle: queueSlope,
maxObservedHeapUsedBytes: maxValue(input.samples.map((sample) => sample.process.heapUsedBytes)),
maxObservedRssBytes: maxValue(input.samples.map((sample) => sample.process.rssBytes)),
processResourceMaxRssBytes: process.resourceUsage().maxRSS * 1024,
},
samples: input.samples,
};
};
@@ -0,0 +1,388 @@
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);
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { linearRegressionSlope } from './helpers/npcLifecycleMemoryProfiler.js';
describe('NPC lifecycle memory profiler metrics', () => {
it('calculates the retained-byte slope from unevenly spaced samples', () => {
expect(
linearRegressionSlope([
{ x: 0, y: 100 },
{ x: 2, y: 140 },
{ x: 5, y: 200 },
])
).toBeCloseTo(20, 8);
});
it('returns zero when a trend cannot be established', () => {
expect(linearRegressionSlope([])).toBe(0);
expect(linearRegressionSlope([{ x: 1, y: 10 }])).toBe(0);
});
});
@@ -169,6 +169,51 @@ const buildHarness = (initialRevision: RevisionRow | null = null) => {
};
describe('reserved turn daemon lease', () => {
it('prunes deleted general and nation queues together with their journal state', () => {
const harness = buildHarness();
harness.store.ensureGeneralTurns(7);
harness.store.ensureGeneralTurns(8);
harness.store.ensureNationTurns(3, 12);
harness.store.ensureNationTurns(4, 12);
expect(harness.store.getQueueCounts()).toMatchObject({
generalQueues: 2,
nationQueues: 2,
pendingGeneralInitializations: 2,
pendingNationInitializations: 2,
});
expect(harness.store.pruneDeletedEntityQueues([7], [3])).toEqual({
generalQueues: 1,
nationQueues: 1,
});
expect(harness.store.getQueueCounts()).toMatchObject({
generalQueues: 1,
nationQueues: 1,
pendingGeneralInitializations: 1,
pendingNationInitializations: 1,
});
expect(harness.store.getGeneralTurns(8)).toHaveLength(2);
expect(harness.store.getNationTurns(4, 12)).toHaveLength(1);
});
it('restores pruned queues from the transaction savepoint', () => {
const harness = buildHarness();
harness.store.ensureGeneralTurns(7);
harness.store.ensureNationTurns(3, 12);
const savepoint = harness.store.captureTransactionState();
harness.store.pruneDeletedEntityQueues([7], [3]);
expect(harness.store.getQueueCounts()).toMatchObject({ generalQueues: 0, nationQueues: 0 });
harness.store.restoreState(savepoint);
expect(harness.store.getQueueCounts()).toMatchObject({
generalQueues: 1,
nationQueues: 1,
pendingGeneralInitializations: 1,
pendingNationInitializations: 1,
});
});
it('holds the queue lease from refresh through shift and releases it with the revision increment', async () => {
const harness = buildHarness();
@@ -10,6 +10,8 @@ const context = {
nations: 3,
troops: 15,
events: 4,
generalTurnQueues: 2461,
nationTurnQueues: 18,
lifecycleState: 'paused',
};
@@ -33,6 +35,7 @@ describe('turn daemon memory reporting', () => {
expect(result.message).toContain('profile=hwe reason=interval');
expect(result.message).toContain('heapLimitMiB=3072');
expect(result.message).toContain('year=214 month=12 generals=2461');
expect(result.message).toContain('generalTurnQueues=2461 nationTurnQueues=18');
expect(result.message).toContain('lifecycle=paused');
});