Files
core2026/app/game-engine/scripts/profile-npc-lifecycle-memory.mjs
T

121 lines
4.7 KiB
JavaScript

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 });
}