merge deterministic NPC memory profile
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"start": "pnpm run build && node dist/index.js",
|
"start": "pnpm run build && node dist/index.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
|
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
|
||||||
"test": "vitest run --config vitest.config.ts",
|
"test": "vitest run --config vitest.config.ts",
|
||||||
"typecheck": "tsc -b"
|
"typecheck": "tsc -b"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
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 child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--expose-gc',
|
||||||
|
vitestPath,
|
||||||
|
'run',
|
||||||
|
'--config',
|
||||||
|
'vitest.config.ts',
|
||||||
|
'--pool=threads',
|
||||||
|
'--maxWorkers=1',
|
||||||
|
'test/npcNationUprisingUnification.test.ts',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: packageRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NPC_UNIFICATION_MEMORY_PROFILE: '1',
|
||||||
|
},
|
||||||
|
stdio: 'inherit',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
child.once('error', (error) => {
|
||||||
|
console.error('[npc-unification-memory] failed to start profiler', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
console.error(`[npc-unification-memory] profiler terminated by ${signal}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.exitCode = code ?? 1;
|
||||||
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { buildScenarioBootstrap, type GeneralMeta, type ScenarioBootstrapWarning, type WorldSeedPayload } from '@sammo-ts/logic';
|
import {
|
||||||
|
buildScenarioBootstrap,
|
||||||
|
resolveScenarioGeneralDeathMonth,
|
||||||
|
type GeneralMeta,
|
||||||
|
type ScenarioBootstrapWarning,
|
||||||
|
type WorldSeedPayload,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { MapLoaderOptions } from './mapLoader.js';
|
import type { MapLoaderOptions } from './mapLoader.js';
|
||||||
import { loadMapDefinitionByName } from './mapLoader.js';
|
import { loadMapDefinitionByName } from './mapLoader.js';
|
||||||
@@ -150,12 +156,12 @@ const resolveKillturnFromDeathYear = (
|
|||||||
currentYear: number,
|
currentYear: number,
|
||||||
currentMonth: number,
|
currentMonth: number,
|
||||||
deathYear: number,
|
deathYear: number,
|
||||||
|
deathMonth: number,
|
||||||
fallback: number
|
fallback: number
|
||||||
): number => {
|
): number => {
|
||||||
if (!Number.isFinite(deathYear) || deathYear <= 0) {
|
if (!Number.isFinite(deathYear) || deathYear <= 0) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
const deathMonth = Math.floor(Math.random() * 12) + 1;
|
|
||||||
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
|
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
|
||||||
return Math.max(diff, 0);
|
return Math.max(diff, 0);
|
||||||
};
|
};
|
||||||
@@ -442,15 +448,32 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
delete meta.deadYear;
|
delete meta.deadYear;
|
||||||
const fallbackKillturn =
|
const fallbackKillturn =
|
||||||
typeof meta.killturn === 'number' && Number.isFinite(meta.killturn) ? meta.killturn : 0;
|
typeof meta.killturn === 'number' && Number.isFinite(meta.killturn) ? meta.killturn : 0;
|
||||||
|
const deathMonth =
|
||||||
|
typeof meta.deathMonth === 'number' &&
|
||||||
|
Number.isInteger(meta.deathMonth) &&
|
||||||
|
meta.deathMonth >= 1 &&
|
||||||
|
meta.deathMonth <= 12
|
||||||
|
? meta.deathMonth
|
||||||
|
: resolveScenarioGeneralDeathMonth({
|
||||||
|
scenarioTitle: String(seed.scenarioMeta?.title ?? ''),
|
||||||
|
startYear: seed.scenarioMeta?.startYear ?? null,
|
||||||
|
contextLabel:
|
||||||
|
typeof meta.source === 'string' ? meta.source : 'general',
|
||||||
|
generalId: general.id,
|
||||||
|
generalName: general.name,
|
||||||
|
deathYear: general.deathYear,
|
||||||
|
});
|
||||||
const killturn = resolveKillturnFromDeathYear(
|
const killturn = resolveKillturnFromDeathYear(
|
||||||
startState.currentYear,
|
startState.currentYear,
|
||||||
startState.currentMonth,
|
startState.currentMonth,
|
||||||
general.deathYear,
|
general.deathYear,
|
||||||
|
deathMonth,
|
||||||
fallbackKillturn
|
fallbackKillturn
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
killturn,
|
killturn,
|
||||||
|
deathMonth,
|
||||||
npcType: general.npcType,
|
npcType: general.npcType,
|
||||||
crewTypeId: general.crewTypeId,
|
crewTypeId: general.crewTypeId,
|
||||||
} satisfies GeneralMeta;
|
} satisfies GeneralMeta;
|
||||||
|
|||||||
@@ -27,17 +27,6 @@ const resolveServerId = (world: InMemoryTurnWorld): string | null => {
|
|||||||
return typeof value === 'string' && value !== '' ? value : null;
|
return typeof value === 'string' && value !== '' ? value : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shuffleLegacy = <T>(values: T[]): T[] => {
|
|
||||||
const result = [...values];
|
|
||||||
// ref의 follower 병종 숙련 배열은 action DRBG가 아니라 PHP process-global
|
|
||||||
// shuffle()로 섞인다.
|
|
||||||
for (let index = result.length - 1; index > 0; index -= 1) {
|
|
||||||
const target = Math.floor(Math.random() * (index + 1));
|
|
||||||
[result[index], result[target]] = [result[target]!, result[index]!];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createTurnTime = (rng: RandUtil, environment: MonthlyEventEnvironment, tickSeconds: number): Date => {
|
const createTurnTime = (rng: RandUtil, environment: MonthlyEventEnvironment, tickSeconds: number): Date => {
|
||||||
const turnMinutes = tickSeconds / 60;
|
const turnMinutes = tickSeconds / 60;
|
||||||
if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) {
|
if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) {
|
||||||
@@ -235,6 +224,17 @@ export const createRaiseInvaderHandler = (options: {
|
|||||||
simpleSerialize(resolveHiddenSeed(world), 'RaiseInvader', environment.year, environment.month)
|
simpleSerialize(resolveHiddenSeed(world), 'RaiseInvader', environment.year, environment.month)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const dexShuffleRng = new RandUtil(
|
||||||
|
new LiteHashDRBG(
|
||||||
|
simpleSerialize(
|
||||||
|
resolveHiddenSeed(world),
|
||||||
|
'RaiseInvader',
|
||||||
|
environment.year,
|
||||||
|
environment.month,
|
||||||
|
'martialDex'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
for (const nation of world.listNations()) {
|
for (const nation of world.listNations()) {
|
||||||
world.updateNation(nation.id, {
|
world.updateNation(nation.id, {
|
||||||
@@ -363,7 +363,7 @@ export const createRaiseInvaderHandler = (options: {
|
|||||||
const mainStat = rng.nextRangeInt(toInteger(specAverage * 1.2), toInteger(specAverage * 1.4));
|
const mainStat = rng.nextRangeInt(toInteger(specAverage * 1.2), toInteger(specAverage * 1.4));
|
||||||
const subStat = specAverage * 3 - leadership - mainStat;
|
const subStat = specAverage * 3 - leadership - mainStat;
|
||||||
const isWarrior = rng.nextBit();
|
const isWarrior = rng.nextBit();
|
||||||
const martialDex = isWarrior ? shuffleLegacy([dex * 2, dex, dex]) : [dex, dex, dex];
|
const martialDex = isWarrior ? dexShuffleRng.shuffle([dex * 2, dex, dex]) : [dex, dex, dex];
|
||||||
createInvaderGeneral({
|
createInvaderGeneral({
|
||||||
world,
|
world,
|
||||||
reservedTurns: options.reservedTurns,
|
reservedTurns: options.reservedTurns,
|
||||||
|
|||||||
@@ -111,17 +111,6 @@ const calculateAverageCity = (rng: RandUtil, cities: City[]): CityValues => {
|
|||||||
) as CityValues;
|
) as CityValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shuffleLegacy = <T>(values: T[]): T[] => {
|
|
||||||
const result = [...values];
|
|
||||||
// ref Util::shuffle_assoc()도 action DRBG가 아닌 PHP의 process-global
|
|
||||||
// shuffle()을 사용한다.
|
|
||||||
for (let index = result.length - 1; index > 0; index -= 1) {
|
|
||||||
const target = Math.floor(Math.random() * (index + 1));
|
|
||||||
[result[index], result[target]] = [result[target]!, result[index]!];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number =>
|
const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number =>
|
||||||
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
||||||
|
|
||||||
@@ -245,9 +234,20 @@ export const createRaiseNpcNationHandler = (options: {
|
|||||||
simpleSerialize(resolveHiddenSeed(world), 'RaiseNPCNation', environment.year, environment.month)
|
simpleSerialize(resolveHiddenSeed(world), 'RaiseNPCNation', environment.year, environment.month)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const cityShuffleRng = new RandUtil(
|
||||||
|
new LiteHashDRBG(
|
||||||
|
simpleSerialize(
|
||||||
|
resolveHiddenSeed(world),
|
||||||
|
'RaiseNPCNation',
|
||||||
|
environment.year,
|
||||||
|
environment.month,
|
||||||
|
'emptyCities'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
const averageCity = calculateAverageCity(rng, targetCities);
|
const averageCity = calculateAverageCity(rng, targetCities);
|
||||||
const occupiedCityIds = targetCities.filter((city) => city.nationId !== 0).map((city) => city.id);
|
const occupiedCityIds = targetCities.filter((city) => city.nationId !== 0).map((city) => city.id);
|
||||||
const emptyCities = shuffleLegacy(targetCities.filter((city) => city.nationId === 0));
|
const emptyCities = cityShuffleRng.shuffle(targetCities.filter((city) => city.nationId === 0));
|
||||||
const activeNations = world.listNations().filter((nation) => nation.id !== 0 && nation.level > 0);
|
const activeNations = world.listNations().filter((nation) => nation.id !== 0 && nation.level > 0);
|
||||||
const generalCounts = activeNations.map(
|
const generalCounts = activeNations.map(
|
||||||
(nation) => world.listGenerals().filter((general) => general.nationId === nation.id).length
|
(nation) => world.listGenerals().filter((general) => general.nationId === nation.id).length
|
||||||
|
|||||||
@@ -57,14 +57,16 @@ const readPattern = (world: InMemoryTurnWorld, config: Record<string, unknown>):
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const shuffledDefaultPattern = (): number[] => {
|
const shuffledDefaultPattern = (
|
||||||
const pattern = [0, 0, 1, 2, 3];
|
hiddenSeed: string | number,
|
||||||
for (let index = pattern.length - 1; index > 0; index -= 1) {
|
previousYear: number,
|
||||||
const swapIndex = Math.floor(Math.random() * (index + 1));
|
previousMonth: number
|
||||||
[pattern[index], pattern[swapIndex]] = [pattern[swapIndex]!, pattern[index]!];
|
): number[] =>
|
||||||
}
|
new RandUtil(
|
||||||
return pattern;
|
new LiteHashDRBG(
|
||||||
};
|
simpleSerialize(hiddenSeed, 'monthly', previousYear, previousMonth, 'tournamentPattern')
|
||||||
|
)
|
||||||
|
).shuffle([0, 0, 1, 2, 3]);
|
||||||
|
|
||||||
export const createTournamentAutoStartHandler = (options: {
|
export const createTournamentAutoStartHandler = (options: {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
@@ -112,7 +114,10 @@ export const createTournamentAutoStartHandler = (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pattern = readPattern(world, config);
|
const pattern = readPattern(world, config);
|
||||||
const resolvedPattern = pattern.length > 0 ? pattern : shuffledDefaultPattern();
|
const resolvedPattern =
|
||||||
|
pattern.length > 0
|
||||||
|
? pattern
|
||||||
|
: shuffledDefaultPattern(hiddenSeed, context.previousYear, context.previousMonth);
|
||||||
const type = resolvedPattern.pop() ?? 0;
|
const type = resolvedPattern.pop() ?? 0;
|
||||||
world.updateWorldMeta({ tournamentPattern: resolvedPattern });
|
world.updateWorldMeta({ tournamentPattern: resolvedPattern });
|
||||||
const now = options.now?.() ?? new Date();
|
const now = options.now?.() ?? new Date();
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { serialize } from 'node:v8';
|
||||||
|
|
||||||
|
import type { InMemoryReservedTurnStore } from '../../src/turn/reservedTurnStore.js';
|
||||||
|
import type { InMemoryTurnWorld } from '../../src/turn/inMemoryWorld.js';
|
||||||
|
|
||||||
|
type MemoryUsageSnapshot = {
|
||||||
|
rssBytes: number;
|
||||||
|
heapTotalBytes: number;
|
||||||
|
heapUsedBytes: number;
|
||||||
|
externalBytes: number;
|
||||||
|
arrayBuffersBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NpcUnificationMemorySample = {
|
||||||
|
label: string;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
activeNationCount: number;
|
||||||
|
generalCount: number;
|
||||||
|
cityCount: number;
|
||||||
|
troopCount: number;
|
||||||
|
processBeforeSnapshot: MemoryUsageSnapshot;
|
||||||
|
processWithSnapshot: MemoryUsageSnapshot;
|
||||||
|
processAfterRelease: MemoryUsageSnapshot;
|
||||||
|
worldSnapshotBytes: number;
|
||||||
|
reservedTurnSnapshotBytes: number;
|
||||||
|
totalParticipantSnapshotBytes: number;
|
||||||
|
participantSnapshotCloneMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TickObservation = {
|
||||||
|
heapUsedBytes: number;
|
||||||
|
rssBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readMemoryUsage = (): MemoryUsageSnapshot => {
|
||||||
|
const usage = process.memoryUsage();
|
||||||
|
return {
|
||||||
|
rssBytes: usage.rss,
|
||||||
|
heapTotalBytes: usage.heapTotal,
|
||||||
|
heapUsedBytes: usage.heapUsed,
|
||||||
|
externalBytes: usage.external,
|
||||||
|
arrayBuffersBytes: usage.arrayBuffers,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentile = (values: number[], ratio: number): number => {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const sorted = [...values].sort((left, right) => left - right);
|
||||||
|
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1));
|
||||||
|
return sorted[index] ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const average = (values: number[]): number =>
|
||||||
|
values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||||
|
|
||||||
|
export class NpcUnificationMemoryProfiler {
|
||||||
|
private readonly samples: NpcUnificationMemorySample[] = [];
|
||||||
|
private readonly tickObservations: TickObservation[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly world: InMemoryTurnWorld,
|
||||||
|
private readonly reservedTurns: InMemoryReservedTurnStore
|
||||||
|
) {}
|
||||||
|
|
||||||
|
observeTick(): void {
|
||||||
|
const usage = readMemoryUsage();
|
||||||
|
this.tickObservations.push({
|
||||||
|
heapUsedBytes: usage.heapUsedBytes,
|
||||||
|
rssBytes: usage.rssBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sample(label: string): NpcUnificationMemorySample {
|
||||||
|
globalThis.gc?.();
|
||||||
|
const processBeforeSnapshot = readMemoryUsage();
|
||||||
|
const snapshotMetrics = (() => {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const worldSnapshot = this.world.captureState();
|
||||||
|
const reservedTurnSnapshot = this.reservedTurns.captureState();
|
||||||
|
return {
|
||||||
|
participantSnapshotCloneMs: performance.now() - startedAt,
|
||||||
|
processWithSnapshot: readMemoryUsage(),
|
||||||
|
worldSnapshotBytes: serialize(worldSnapshot).byteLength,
|
||||||
|
reservedTurnSnapshotBytes: serialize(reservedTurnSnapshot).byteLength,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
globalThis.gc?.();
|
||||||
|
const processAfterRelease = readMemoryUsage();
|
||||||
|
|
||||||
|
const state = this.world.getState();
|
||||||
|
const cities = this.world.listCities();
|
||||||
|
const nations = this.world.listNations();
|
||||||
|
const sample: NpcUnificationMemorySample = {
|
||||||
|
label,
|
||||||
|
year: state.currentYear,
|
||||||
|
month: state.currentMonth,
|
||||||
|
activeNationCount: nations.filter(
|
||||||
|
(nation) => nation.level > 0 && cities.some((city) => city.nationId === nation.id)
|
||||||
|
).length,
|
||||||
|
generalCount: this.world.listGenerals().length,
|
||||||
|
cityCount: cities.length,
|
||||||
|
troopCount: this.world.listTroops().length,
|
||||||
|
processBeforeSnapshot,
|
||||||
|
processWithSnapshot: snapshotMetrics.processWithSnapshot,
|
||||||
|
processAfterRelease,
|
||||||
|
worldSnapshotBytes: snapshotMetrics.worldSnapshotBytes,
|
||||||
|
reservedTurnSnapshotBytes: snapshotMetrics.reservedTurnSnapshotBytes,
|
||||||
|
totalParticipantSnapshotBytes:
|
||||||
|
snapshotMetrics.worldSnapshotBytes + snapshotMetrics.reservedTurnSnapshotBytes,
|
||||||
|
participantSnapshotCloneMs: snapshotMetrics.participantSnapshotCloneMs,
|
||||||
|
};
|
||||||
|
this.samples.push(sample);
|
||||||
|
return sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildReport(input: {
|
||||||
|
startedAtMs: number;
|
||||||
|
initialGeneralCount: number;
|
||||||
|
foundedNationCount: number;
|
||||||
|
declarationCount: number;
|
||||||
|
sortieCount: number;
|
||||||
|
unifiedAt: { year: number; month: number };
|
||||||
|
startYear: number;
|
||||||
|
startMonth: number;
|
||||||
|
}) {
|
||||||
|
const cloneTimes = this.samples.map((sample) => sample.participantSnapshotCloneMs);
|
||||||
|
const snapshotSizes = this.samples.map((sample) => sample.totalParticipantSnapshotBytes);
|
||||||
|
const retainedSnapshotHeapDeltas = this.samples.map(
|
||||||
|
(sample) =>
|
||||||
|
sample.processWithSnapshot.heapUsedBytes - sample.processBeforeSnapshot.heapUsedBytes
|
||||||
|
);
|
||||||
|
const releasedSnapshotHeapDeltas = this.samples.map(
|
||||||
|
(sample) =>
|
||||||
|
sample.processAfterRelease.heapUsedBytes - sample.processBeforeSnapshot.heapUsedBytes
|
||||||
|
);
|
||||||
|
const observedHeap = [
|
||||||
|
...this.tickObservations.map((entry) => entry.heapUsedBytes),
|
||||||
|
...this.samples.flatMap((sample) => [
|
||||||
|
sample.processBeforeSnapshot.heapUsedBytes,
|
||||||
|
sample.processWithSnapshot.heapUsedBytes,
|
||||||
|
sample.processAfterRelease.heapUsedBytes,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
const observedRss = [
|
||||||
|
...this.tickObservations.map((entry) => entry.rssBytes),
|
||||||
|
...this.samples.flatMap((sample) => [
|
||||||
|
sample.processBeforeSnapshot.rssBytes,
|
||||||
|
sample.processWithSnapshot.rssBytes,
|
||||||
|
sample.processAfterRelease.rssBytes,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
const startIndex = input.startYear * 12 + input.startMonth - 1;
|
||||||
|
const unifiedIndex = input.unifiedAt.year * 12 + input.unifiedAt.month - 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
runtime: {
|
||||||
|
node: process.version,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
explicitGc: typeof globalThis.gc === 'function',
|
||||||
|
},
|
||||||
|
scenario: {
|
||||||
|
name: 'npcNationUprisingUnification-large-test-map',
|
||||||
|
initialGeneralCount: input.initialGeneralCount,
|
||||||
|
cityCount: this.samples[0]?.cityCount ?? 0,
|
||||||
|
startYear: input.startYear,
|
||||||
|
startMonth: input.startMonth,
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
unifiedAt: input.unifiedAt,
|
||||||
|
simulatedMonths: unifiedIndex - startIndex,
|
||||||
|
foundedNationCount: input.foundedNationCount,
|
||||||
|
declarationCount: input.declarationCount,
|
||||||
|
sortieCount: input.sortieCount,
|
||||||
|
wallDurationMs: performance.now() - input.startedAtMs,
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
processIncludes: ['node', 'vitest-worker', 'scenario-harness', 'engine-state'],
|
||||||
|
maxObservedHeapUsedBytes: Math.max(0, ...observedHeap),
|
||||||
|
maxObservedRssBytes: Math.max(0, ...observedRss),
|
||||||
|
processResourceMaxRssBytes: process.resourceUsage().maxRSS * 1024,
|
||||||
|
participantSnapshotBytes: {
|
||||||
|
initial: snapshotSizes[0] ?? 0,
|
||||||
|
final: snapshotSizes.at(-1) ?? 0,
|
||||||
|
peak: Math.max(0, ...snapshotSizes),
|
||||||
|
},
|
||||||
|
participantSnapshotHeapDeltaBytes: {
|
||||||
|
peakWhileRetained: Math.max(0, ...retainedSnapshotHeapDeltas),
|
||||||
|
peakAfterRelease: Math.max(0, ...releasedSnapshotHeapDeltas),
|
||||||
|
},
|
||||||
|
participantSnapshotCloneMs: {
|
||||||
|
count: cloneTimes.length,
|
||||||
|
average: average(cloneTimes),
|
||||||
|
p95: percentile(cloneTimes, 0.95),
|
||||||
|
max: Math.max(0, ...cloneTimes),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
samples: this.samples,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { City, Nation } from '@sammo-ts/logic';
|
import type { City, Nation } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
@@ -166,6 +166,16 @@ const buildHarness = (options?: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('invader monthly actions', () => {
|
describe('invader monthly actions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||||
|
throw new Error('monthly invader actions must not use Math.random');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => {
|
it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => {
|
||||||
const harness = buildHarness();
|
const harness = buildHarness();
|
||||||
const handler = createRaiseInvaderHandler({
|
const handler = createRaiseInvaderHandler({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { PERSONALITY_TRAIT_KEYS, type City, type MapDefinition, type Nation } from '@sammo-ts/logic';
|
import { PERSONALITY_TRAIT_KEYS, type City, type MapDefinition, type Nation } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
@@ -192,6 +192,16 @@ const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fi
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('RaiseNPCNation monthly action', () => {
|
describe('RaiseNPCNation monthly action', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||||
|
throw new Error('RaiseNPCNation must not use Math.random');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('creates only distance-qualified NPC nations and initializes their ruler and turns', async () => {
|
it('creates only distance-qualified NPC nations and initializes their ruler and turns', async () => {
|
||||||
const { world, reservedTurns, handler, environment } = buildHarness();
|
const { world, reservedTurns, handler, environment } = buildHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
||||||
import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js';
|
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js';
|
||||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
|
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
|
||||||
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||||
|
import { NpcUnificationMemoryProfiler } from './helpers/npcUnificationMemoryProfiler.js';
|
||||||
|
|
||||||
const mockDate = new Date('0181-08-01T00:00:00Z');
|
const mockDate = new Date('0181-08-01T00:00:00Z');
|
||||||
|
|
||||||
@@ -148,6 +152,8 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => {
|
|||||||
|
|
||||||
describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||||
it('건국, 선포, 출병, 점령과 장기 국가 감소가 안정적으로 진행되어야 한다', async () => {
|
it('건국, 선포, 출병, 점령과 장기 국가 감소가 안정적으로 진행되어야 한다', async () => {
|
||||||
|
const memoryProfileEnabled = process.env.NPC_UNIFICATION_MEMORY_PROFILE === '1';
|
||||||
|
const profileStartedAtMs = performance.now();
|
||||||
const cities = buildLargeTestCities().map(maxCityStats);
|
const cities = buildLargeTestCities().map(maxCityStats);
|
||||||
for (const city of cities) {
|
for (const city of cities) {
|
||||||
city.nationId = 0;
|
city.nationId = 0;
|
||||||
@@ -253,6 +259,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
|
|
||||||
const worldRef = { current: null as InMemoryTurnWorld | null };
|
const worldRef = { current: null as InMemoryTurnWorld | null };
|
||||||
|
|
||||||
|
let unificationLogObserved = false;
|
||||||
const unificationHandler: TurnCalendarHandler = {
|
const unificationHandler: TurnCalendarHandler = {
|
||||||
onMonthChanged: () => {
|
onMonthChanged: () => {
|
||||||
const world = worldRef.current;
|
const world = worldRef.current;
|
||||||
@@ -279,6 +286,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
}
|
}
|
||||||
world.updateWorldMeta({ isUnited: 2 });
|
world.updateWorldMeta({ isUnited: 2 });
|
||||||
world.pushLog(buildUnificationLog(winner.name));
|
world.pushLog(buildUnificationLog(winner.name));
|
||||||
|
unificationLogObserved = true;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -287,7 +295,14 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
let sortieCount = 0;
|
let sortieCount = 0;
|
||||||
let lastResolvedAction = 'none';
|
let lastResolvedAction = 'none';
|
||||||
|
|
||||||
const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } =
|
const {
|
||||||
|
runUntil,
|
||||||
|
reservedTurnStore,
|
||||||
|
getCollectedLogs,
|
||||||
|
getCollectedLogsCount,
|
||||||
|
getCollectedLogsRange,
|
||||||
|
getAndClearCollectedLogs,
|
||||||
|
} =
|
||||||
await createTurnTestHarness({
|
await createTurnTestHarness({
|
||||||
snapshot,
|
snapshot,
|
||||||
state,
|
state,
|
||||||
@@ -326,6 +341,30 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const memoryProfiler =
|
||||||
|
memoryProfileEnabled && worldRef.current
|
||||||
|
? new NpcUnificationMemoryProfiler(worldRef.current, reservedTurnStore)
|
||||||
|
: null;
|
||||||
|
memoryProfiler?.sample('initialized');
|
||||||
|
let lastProfileYearMonth = -1;
|
||||||
|
const observeProfileMonth = (current: TurnWorldState) => {
|
||||||
|
if (!memoryProfiler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const yearMonth = current.currentYear * 100 + current.currentMonth;
|
||||||
|
if (yearMonth === lastProfileYearMonth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastProfileYearMonth = yearMonth;
|
||||||
|
memoryProfiler.observeTick();
|
||||||
|
const logs = getAndClearCollectedLogs();
|
||||||
|
if (logs.some((log) => log.text.includes('전토를 통일하였습니다.'))) {
|
||||||
|
unificationLogObserved = true;
|
||||||
|
}
|
||||||
|
if (current.currentMonth === 1) {
|
||||||
|
memoryProfiler.sample(`year-${current.currentYear}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let monthlyLogCursor = 0;
|
let monthlyLogCursor = 0;
|
||||||
const maxMonthlyLogEntries = 20;
|
const maxMonthlyLogEntries = 20;
|
||||||
@@ -355,7 +394,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await runUntil(
|
await runUntil(
|
||||||
(current) => current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1)
|
(current) => current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1),
|
||||||
|
undefined,
|
||||||
|
observeProfileMonth
|
||||||
);
|
);
|
||||||
|
|
||||||
const world = worldRef.current;
|
const world = worldRef.current;
|
||||||
@@ -366,10 +407,14 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
|
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
|
||||||
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
|
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
|
||||||
const foundedNationCount = foundedNations.length;
|
const foundedNationCount = foundedNations.length;
|
||||||
|
memoryProfiler?.sample('nations-founded');
|
||||||
|
|
||||||
await runUntil(
|
await runUntil(
|
||||||
(current) => current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6)
|
(current) => current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6),
|
||||||
|
undefined,
|
||||||
|
observeProfileMonth
|
||||||
);
|
);
|
||||||
|
memoryProfiler?.sample('all-cities-occupied');
|
||||||
|
|
||||||
const neutralCities = world.listCities().filter((city) => city.nationId <= 0);
|
const neutralCities = world.listCities().filter((city) => city.nationId <= 0);
|
||||||
expect(neutralCities.length).toBe(0);
|
expect(neutralCities.length).toBe(0);
|
||||||
@@ -381,7 +426,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
|
|
||||||
if (declarationCount === 0) {
|
if (declarationCount === 0) {
|
||||||
await runUntil(
|
await runUntil(
|
||||||
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1)
|
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1),
|
||||||
|
undefined,
|
||||||
|
observeProfileMonth
|
||||||
);
|
);
|
||||||
if (declarationCount === 0) {
|
if (declarationCount === 0) {
|
||||||
const generals = world.listGenerals().filter((general) => general.nationId > 0);
|
const generals = world.listGenerals().filter((general) => general.nationId > 0);
|
||||||
@@ -488,7 +535,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
await runUntil(
|
await runUntil(
|
||||||
(current) =>
|
(current) =>
|
||||||
current.currentYear > target.year ||
|
current.currentYear > target.year ||
|
||||||
(current.currentYear === target.year && current.currentMonth >= target.month)
|
(current.currentYear === target.year && current.currentMonth >= target.month),
|
||||||
|
undefined,
|
||||||
|
observeProfileMonth
|
||||||
);
|
);
|
||||||
//_dumpMonthlyLogs(`${target.year}-${String(target.month).padStart(2, '0')}`);
|
//_dumpMonthlyLogs(`${target.year}-${String(target.month).padStart(2, '0')}`);
|
||||||
|
|
||||||
@@ -521,9 +570,12 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
await runUntil(
|
await runUntil(
|
||||||
(current) =>
|
(current) =>
|
||||||
current.currentYear > nextMonth.year ||
|
current.currentYear > nextMonth.year ||
|
||||||
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month)
|
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month),
|
||||||
|
undefined,
|
||||||
|
observeProfileMonth
|
||||||
);
|
);
|
||||||
unifiedAt = nextMonth;
|
unifiedAt = nextMonth;
|
||||||
|
memoryProfiler?.sample('unified');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +589,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
|
|
||||||
const meta = world.getState().meta as Record<string, unknown>;
|
const meta = world.getState().meta as Record<string, unknown>;
|
||||||
const logs = getCollectedLogs();
|
const logs = getCollectedLogs();
|
||||||
const hasUnificationLog = logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
|
const hasUnificationLog =
|
||||||
|
unificationLogObserved || logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
|
||||||
if (unifiedAt) {
|
if (unifiedAt) {
|
||||||
expect(meta.isUnited).toBe(2);
|
expect(meta.isUnited).toBe(2);
|
||||||
expect(hasUnificationLog).toBe(true);
|
expect(hasUnificationLog).toBe(true);
|
||||||
@@ -546,6 +599,39 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
expect(meta.isUnited ?? 0).toBe(0);
|
expect(meta.isUnited ?? 0).toBe(0);
|
||||||
}
|
}
|
||||||
expect(sortieCount).toBeGreaterThan(0);
|
expect(sortieCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
if (memoryProfiler) {
|
||||||
|
expect(typeof globalThis.gc).toBe('function');
|
||||||
|
expect(unifiedAt).not.toBeNull();
|
||||||
|
if (!unifiedAt) {
|
||||||
|
throw new Error('memory profile requires actual unification');
|
||||||
|
}
|
||||||
|
const report = memoryProfiler.buildReport({
|
||||||
|
startedAtMs: profileStartedAtMs,
|
||||||
|
initialGeneralCount: generals.length,
|
||||||
|
foundedNationCount,
|
||||||
|
declarationCount,
|
||||||
|
sortieCount,
|
||||||
|
unifiedAt,
|
||||||
|
startYear: state.currentYear,
|
||||||
|
startMonth: state.currentMonth,
|
||||||
|
});
|
||||||
|
const reportPath = resolve(
|
||||||
|
process.env.NPC_UNIFICATION_MEMORY_REPORT_PATH ??
|
||||||
|
'test-results/npc-unification-memory.json'
|
||||||
|
);
|
||||||
|
mkdirSync(dirname(reportPath), { recursive: true });
|
||||||
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||||
|
console.log(
|
||||||
|
`[NPC_UNIFICATION_MEMORY_REPORT]${JSON.stringify({
|
||||||
|
reportPath,
|
||||||
|
runtime: report.runtime,
|
||||||
|
scenario: report.scenario,
|
||||||
|
result: report.result,
|
||||||
|
memory: report.memory,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const world = worldRef.current;
|
const world = worldRef.current;
|
||||||
if (world) {
|
if (world) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
import type { Nation } from '@sammo-ts/logic';
|
import type { Nation } from '@sammo-ts/logic';
|
||||||
|
|
||||||
@@ -143,4 +143,86 @@ describe('monthly tournament auto start', () => {
|
|||||||
expect(consumed).toEqual([false]);
|
expect(consumed).toEqual([false]);
|
||||||
expect(world.peekDirtyState().logs).toEqual([]);
|
expect(world.peekDirtyState().logs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('derives an empty tournament pattern from the monthly seed without Math.random', async () => {
|
||||||
|
const run = async () => {
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 193,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('0193-01-01T00:00:00.000Z'),
|
||||||
|
meta: { hiddenSeed: 'monthly-post-tail-2' },
|
||||||
|
};
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: {
|
||||||
|
total: 300,
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
npcTotal: 150,
|
||||||
|
npcMax: 50,
|
||||||
|
npcMin: 10,
|
||||||
|
chiefMin: 70,
|
||||||
|
},
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'test', unitSet: 'default' },
|
||||||
|
},
|
||||||
|
map: { id: 'test', name: 'test', cities: [] },
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
generals: [],
|
||||||
|
cities: [],
|
||||||
|
nations: [buildNation(1), buildNation(2)],
|
||||||
|
troops: [],
|
||||||
|
};
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const redis = {
|
||||||
|
get: async (key: string) => values.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => {
|
||||||
|
values.set(key, value);
|
||||||
|
return 'OK';
|
||||||
|
},
|
||||||
|
} as unknown as RedisConnector['client'];
|
||||||
|
let world: InMemoryTurnWorld | null = null;
|
||||||
|
world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
calendarHandler: createTournamentAutoStartHandler({
|
||||||
|
profileName: 'test',
|
||||||
|
getWorld: () => world,
|
||||||
|
getRedisClient: () => redis,
|
||||||
|
getWorldConfig: () => ({ tournamentTrig: true }),
|
||||||
|
getNationPowerRollCount: () => 2,
|
||||||
|
now: () => new Date('2026-07-25T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournamentState: JSON.parse(values.get('sammo:test:tournament:state') ?? '{}') as {
|
||||||
|
type?: number;
|
||||||
|
},
|
||||||
|
remainingPattern: world.getState().meta.tournamentPattern,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const random = vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||||
|
throw new Error('tournament fallback must not use Math.random');
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const first = await run();
|
||||||
|
const second = await run();
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(first).toEqual({
|
||||||
|
tournamentState: expect.objectContaining({ type: 1 }),
|
||||||
|
remainingPattern: [2, 0, 0, 3],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
random.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,6 +118,30 @@ Dynamic event actions can use deterministic RNG by constructing
|
|||||||
`LiteHashDRBG` with `UniqueConst::$hiddenSeed` and an event-specific tag.
|
`LiteHashDRBG` with `UniqueConst::$hiddenSeed` and an event-specific tag.
|
||||||
Examples include `RandomizeCityTradeRate` and `UpdateNationLevel`.
|
Examples include `RandomizeCityTradeRate` and `UpdateNationLevel`.
|
||||||
|
|
||||||
|
core2026의 authoritative game-state 계산에서는 `Math.random()`을 사용하지
|
||||||
|
않는다. 레거시 PHP 전역 `shuffle()`을 사용하던 다음 경로도 입력별 독립
|
||||||
|
`LiteHashDRBG` substream으로 고정한다.
|
||||||
|
|
||||||
|
- `RaiseNPCNation`: `hiddenSeed, "RaiseNPCNation", year, month,
|
||||||
|
"emptyCities"`
|
||||||
|
- `RaiseInvader`: `hiddenSeed, "RaiseInvader", year, month,
|
||||||
|
"martialDex"`
|
||||||
|
- 빈 tournament pattern: `hiddenSeed, "monthly", previousYear,
|
||||||
|
previousMonth, "tournamentPattern"`
|
||||||
|
- scenario general 사망월: scenario title/start year, source group,
|
||||||
|
general id/name/death year와 `"deathMonth"`
|
||||||
|
|
||||||
|
독립 substream을 쓰는 이유는 레거시 전역 shuffle의 비결정성만 제거하고
|
||||||
|
이미 호환 검증된 action/monthly RNG의 후속 소비 위치는 바꾸지 않기
|
||||||
|
위해서다. 테스트는 해당 경로에서 `Math.random()`을 호출하면 즉시
|
||||||
|
실패한다. 인증 token, request/event correlation ID 같은 보안·운영 식별자의
|
||||||
|
`crypto` RNG와 사용자가 랜덤 능력치 버튼으로 만드는 클라이언트 입력은
|
||||||
|
이 게임-state 재현 계약과 구분한다.
|
||||||
|
|
||||||
|
root ESLint 설정도 `app/game-engine/src`와 `packages/logic/src`에서
|
||||||
|
`Math.random` property 사용을 오류로 처리하므로 새 authoritative 경로가
|
||||||
|
같은 결함을 다시 도입할 수 없다.
|
||||||
|
|
||||||
## Open Questions / Follow-ups
|
## Open Questions / Follow-ups
|
||||||
|
|
||||||
- `Event\Engine` is a stub with a TODO; it is not currently used in the main
|
- `Event\Engine` is a stub with a TODO; it is not currently used in the main
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# NPC 통일 장기 실행 메모리 프로파일
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
`npcNationUprisingUnification.test.ts`의 고정 시나리오를 실제 턴 처리기로
|
||||||
|
통일까지 실행하고, 장수 수백 명 규모에서 Node 프로세스와 계산 savepoint가
|
||||||
|
차지하는 메모리를 분리해 관찰한다. 이 프로파일은 성능 회귀 관찰용이며
|
||||||
|
운영 capacity 산정이나 레거시 결과 동등성의 단독 근거가 아니다.
|
||||||
|
|
||||||
|
## 시나리오 계약
|
||||||
|
|
||||||
|
- 시작: 181년 8월, NPC 300명, 도시 9개, 국가 없음
|
||||||
|
- 고정 입력: world id/seed `1`, 고정 장수 능력치와 배치, 고정 map/unit set
|
||||||
|
- 실제 경로: NPC 건국, 국가 AI 선전포고, 장수 AI 출병, 전투, 점령, 멸망,
|
||||||
|
월 경계와 통일 판정
|
||||||
|
- 수렴 보조: 매 월 국가 ID 1 소유 도시의 인구·방어·성벽·민심을 최대값으로
|
||||||
|
복원한다. 따라서 이것은 자연 분포 예측이 아니라 통일 도달을 보장하기 위한
|
||||||
|
synthetic stress fixture다.
|
||||||
|
- 통일 handler는 실제 조건인 “활성 국가 1개이며 모든 도시를 소유”와
|
||||||
|
`isUnited=2`/통일 history만 재현한다. 운영 handler의 PostgreSQL
|
||||||
|
inheritance/hall-of-fame/dynasty 정산은 실행하지 않는다.
|
||||||
|
- yearbook PostgreSQL upsert와 tournament Redis lifecycle도 실행하지 않는다.
|
||||||
|
이 외부 저장소들의 메모리는 아래 프로세스 수치에 포함되지 않는다.
|
||||||
|
|
||||||
|
연감 map/nation payload는 같은 월 경계 world 상태라면 결정적이고 hash가
|
||||||
|
같다. 토너먼트 월 판정도 `hiddenSeed + previous year/month`와 선행 국가
|
||||||
|
power RNG 소비 횟수가 같으면 결정적이다. 이 profile 조사 중 발견한 빈
|
||||||
|
`tournamentPattern`의 `Math.random()` fallback은
|
||||||
|
`hiddenSeed, "monthly", previousYear, previousMonth, "tournamentPattern"`
|
||||||
|
전용 `LiteHashDRBG` shuffle로 교체했다. 이 독립 substream은 pattern을
|
||||||
|
결정적으로 만들면서 뒤따르는 중립 경매의 기존 monthly RNG 위치를 바꾸지
|
||||||
|
않는다.
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @sammo-ts/game-engine profile:npc-unification-memory
|
||||||
|
```
|
||||||
|
|
||||||
|
runner는 `node --expose-gc`, Vitest thread worker 1개로 이 파일만 실행한다.
|
||||||
|
전체 JSON은 기본적으로
|
||||||
|
`app/game-engine/test-results/npc-unification-memory.json`에 기록되며
|
||||||
|
`test-results/`는 Git에서 제외된다. 경로는
|
||||||
|
`NPC_UNIFICATION_MEMORY_REPORT_PATH`로 바꿀 수 있다.
|
||||||
|
|
||||||
|
## 측정 정의
|
||||||
|
|
||||||
|
- `maxObservedHeapUsedBytes`: 월별 관찰과 명시적 GC 전후 savepoint sample에서
|
||||||
|
본 Node heap 최고치
|
||||||
|
- `maxObservedRssBytes`: 같은 관찰점의 프로세스 RSS 최고치
|
||||||
|
- `processResourceMaxRssBytes`: OS가 보고한 실행 전체 high-water RSS
|
||||||
|
- `participantSnapshotBytes`: world와 reserved-turn snapshot을 V8 serialize한
|
||||||
|
크기. 실제 live heap 크기가 아니라 비교 가능한 payload 크기다.
|
||||||
|
- `participantSnapshotHeapDeltaBytes.peakWhileRetained`: 명시적 GC 직후
|
||||||
|
snapshot을 잡아 둔 동안의 heap 증가 최고치
|
||||||
|
- `participantSnapshotHeapDeltaBytes.peakAfterRelease`: snapshot 참조를
|
||||||
|
해제하고 다시 GC한 뒤 baseline 대비 heap 차이 최고치
|
||||||
|
- `participantSnapshotCloneMs`: `captureState()` 두 개의 복제 시간
|
||||||
|
|
||||||
|
프로세스 수치는 Node, Vitest worker, 테스트 harness, engine state를 모두
|
||||||
|
포함한다. 반대로 PostgreSQL, Redis와 production daemon 주변 프로세스는
|
||||||
|
포함하지 않는다.
|
||||||
|
|
||||||
|
## 2026-07-28 관찰 결과
|
||||||
|
|
||||||
|
Node `v24.14.1`, Linux x64에서 독립 실행 두 번 모두 다음 게임 결과가
|
||||||
|
동일했다.
|
||||||
|
|
||||||
|
- 193년 1월 통일, 137개월 진행
|
||||||
|
- 5개국 건국, 선전포고 6회, 출병 2,136회
|
||||||
|
- 종료 시 장수 356명
|
||||||
|
- participant snapshot: 초기 139,363 bytes, 종료 449,359 bytes,
|
||||||
|
최고 452,686 bytes
|
||||||
|
- snapshot 유지 중 heap 증가 최고 2,399,640 bytes, 해제·GC 후 최고
|
||||||
|
1,132,104 bytes
|
||||||
|
|
||||||
|
프로세스 관찰값은 실행별로 다음과 같았다.
|
||||||
|
|
||||||
|
| 실행 | wall time | max heap used | max observed RSS | OS max RSS | clone 평균 / 최고 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 1 | 19.87 s | 145,309,008 B | 481,943,552 B | 486,830,080 B | 3.18 / 4.33 ms |
|
||||||
|
| 2 | 19.86 s | 145,253,136 B | 488,198,144 B | 489,697,280 B | 3.24 / 3.91 ms |
|
||||||
|
|
||||||
|
게임 결과와 serialized snapshot 크기는 두 번 모두 정확히 같았다. RSS는
|
||||||
|
allocator/JIT/Vitest 영향으로 약 6.3 MB 차이가 났으므로 단일 숫자를 엔진
|
||||||
|
상태 크기로 해석하지 않는다.
|
||||||
|
|
||||||
|
## 해석과 남은 범위
|
||||||
|
|
||||||
|
이 fixture에서 계산 rollback용 participant snapshot payload의 최고치는
|
||||||
|
약 0.43 MiB이고, snapshot이 살아 있는 순간의 관찰 heap 증가는 약
|
||||||
|
2.29 MiB였다. 반면 전체 테스트 프로세스 high-water RSS는 약
|
||||||
|
464–467 MiB였다. 따라서 이 실행에서 RSS 대부분을 savepoint payload
|
||||||
|
자체가 설명하지는 않는다.
|
||||||
|
|
||||||
|
운영 통일 handler는 `isUnited`와 history를 memory에 반영한 뒤 inheritance,
|
||||||
|
hall-of-fame, dynasty PostgreSQL 정산 세 개를 await하지 않고 시작한다.
|
||||||
|
이 프로파일은 통일 판정까지의 engine memory를 측정하지만 그 비동기 정산의
|
||||||
|
완료, 실패 복구, 메모리 또는 distributed atomicity는 검증하지 않는다.
|
||||||
|
그 경로를 관찰하려면 격리 PostgreSQL fixture와 정산 완료 barrier 또는
|
||||||
|
durable outbox가 별도로 필요하다.
|
||||||
@@ -98,6 +98,20 @@ export default tseslint.config(
|
|||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['app/game-engine/src/**/*.{ts,tsx}', 'packages/logic/src/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-properties': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
object: 'Math',
|
||||||
|
property: 'random',
|
||||||
|
message:
|
||||||
|
'Authoritative game state must use an explicitly seeded RandUtil/LiteHashDRBG stream.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.js', '**/*.mjs', '**/*.cjs'],
|
files: ['**/*.js', '**/*.mjs', '**/*.cjs'],
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
import type {
|
import type {
|
||||||
City,
|
City,
|
||||||
General,
|
General,
|
||||||
@@ -18,6 +19,7 @@ import type {
|
|||||||
WorldSeedPayload,
|
WorldSeedPayload,
|
||||||
WorldSnapshot,
|
WorldSnapshot,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { simpleSerialize } from '../war/utils.js';
|
||||||
|
|
||||||
export interface ScenarioBootstrapOptions {
|
export interface ScenarioBootstrapOptions {
|
||||||
includeNeutralNation?: boolean;
|
includeNeutralNation?: boolean;
|
||||||
@@ -144,16 +146,38 @@ const resolveKillturnFromDeathYear = (
|
|||||||
currentYear: number | null,
|
currentYear: number | null,
|
||||||
currentMonth: number,
|
currentMonth: number,
|
||||||
deathYear: number,
|
deathYear: number,
|
||||||
|
deathMonth: number,
|
||||||
fallback: number
|
fallback: number
|
||||||
): number => {
|
): number => {
|
||||||
if (currentYear === null || !Number.isFinite(deathYear) || deathYear <= 0) {
|
if (currentYear === null || !Number.isFinite(deathYear) || deathYear <= 0) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
const deathMonth = Math.floor(Math.random() * 12) + 1;
|
|
||||||
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
|
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
|
||||||
return Math.max(diff, 0);
|
return Math.max(diff, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveScenarioGeneralDeathMonth = (input: {
|
||||||
|
scenarioTitle: string;
|
||||||
|
startYear: number | null;
|
||||||
|
contextLabel: string;
|
||||||
|
generalId: number;
|
||||||
|
generalName: string;
|
||||||
|
deathYear: number;
|
||||||
|
}): number =>
|
||||||
|
new RandUtil(
|
||||||
|
new LiteHashDRBG(
|
||||||
|
simpleSerialize(
|
||||||
|
input.scenarioTitle,
|
||||||
|
input.startYear ?? 0,
|
||||||
|
input.contextLabel,
|
||||||
|
input.generalId,
|
||||||
|
input.generalName,
|
||||||
|
input.deathYear,
|
||||||
|
'deathMonth'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).nextRangeInt(1, 12);
|
||||||
|
|
||||||
const resolveAge = (startYear: number | null, birthYear: number): number => {
|
const resolveAge = (startYear: number | null, birthYear: number): number => {
|
||||||
if (startYear === null || birthYear <= 0) {
|
if (startYear === null || birthYear <= 0) {
|
||||||
return 20;
|
return 20;
|
||||||
@@ -309,6 +333,14 @@ const buildGeneralSeeds = (
|
|||||||
const cityId = resolveCityId(row.city, cityByName, warnings, row.name);
|
const cityId = resolveCityId(row.city, cityByName, warnings, row.name);
|
||||||
const birthYear = resolveBirthYear(row.birthYear, scenario.startYear);
|
const birthYear = resolveBirthYear(row.birthYear, scenario.startYear);
|
||||||
const deathYear = resolveDeathYear(row.deathYear, birthYear, scenario.startYear);
|
const deathYear = resolveDeathYear(row.deathYear, birthYear, scenario.startYear);
|
||||||
|
const deathMonth = resolveScenarioGeneralDeathMonth({
|
||||||
|
scenarioTitle: scenario.title,
|
||||||
|
startYear: scenario.startYear,
|
||||||
|
contextLabel,
|
||||||
|
generalId: id,
|
||||||
|
generalName: row.name,
|
||||||
|
deathYear,
|
||||||
|
});
|
||||||
const officerLevel = resolveOfficerLevel(row.officerLevel, nationId);
|
const officerLevel = resolveOfficerLevel(row.officerLevel, nationId);
|
||||||
const age = resolveAge(scenario.startYear, birthYear);
|
const age = resolveAge(scenario.startYear, birthYear);
|
||||||
const stats = {
|
const stats = {
|
||||||
@@ -319,6 +351,7 @@ const buildGeneralSeeds = (
|
|||||||
|
|
||||||
const seedMeta: Record<string, unknown> = {
|
const seedMeta: Record<string, unknown> = {
|
||||||
source: contextLabel,
|
source: contextLabel,
|
||||||
|
deathMonth,
|
||||||
specage: buildSpecialityAge(retirementYear, age, 12),
|
specage: buildSpecialityAge(retirementYear, age, 12),
|
||||||
specage2: buildSpecialityAge(retirementYear, age, 6),
|
specage2: buildSpecialityAge(retirementYear, age, 6),
|
||||||
};
|
};
|
||||||
@@ -379,7 +412,14 @@ const buildGeneralSeeds = (
|
|||||||
seeds.push(seed);
|
seeds.push(seed);
|
||||||
|
|
||||||
const generalMeta: GeneralMeta = {
|
const generalMeta: GeneralMeta = {
|
||||||
killturn: resolveKillturnFromDeathYear(scenario.startYear, 1, deathYear, DEFAULT_GENERAL_KILLTURN),
|
killturn: resolveKillturnFromDeathYear(
|
||||||
|
scenario.startYear,
|
||||||
|
1,
|
||||||
|
deathYear,
|
||||||
|
deathMonth,
|
||||||
|
DEFAULT_GENERAL_KILLTURN
|
||||||
|
),
|
||||||
|
deathMonth,
|
||||||
npcType,
|
npcType,
|
||||||
crewTypeId: defaultCrewTypeId,
|
crewTypeId: defaultCrewTypeId,
|
||||||
specage: buildSpecialityAge(retirementYear, age, 12),
|
specage: buildSpecialityAge(retirementYear, age, 12),
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { ScenarioDefinition } from '../src/scenario/types.js';
|
import type { ScenarioDefinition } from '../src/scenario/types.js';
|
||||||
import type { MapDefinition, UnitSetDefinition } from '../src/world/types.js';
|
import type { MapDefinition, UnitSetDefinition } from '../src/world/types.js';
|
||||||
import { buildScenarioBootstrap } from '../src/world/bootstrap.js';
|
import { buildScenarioBootstrap } from '../src/world/bootstrap.js';
|
||||||
|
|
||||||
describe('scenario bootstrap', () => {
|
describe('scenario bootstrap', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||||
|
throw new Error('scenario bootstrap must not use Math.random');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('builds snapshot and seed from scenario/map inputs', () => {
|
it('builds snapshot and seed from scenario/map inputs', () => {
|
||||||
const scenario: ScenarioDefinition = {
|
const scenario: ScenarioDefinition = {
|
||||||
title: 'Test Scenario',
|
title: 'Test Scenario',
|
||||||
@@ -120,7 +130,14 @@ describe('scenario bootstrap', () => {
|
|||||||
expect(result.snapshot.generals[0]?.role.specialDomestic).toBe('Special');
|
expect(result.snapshot.generals[0]?.role.specialDomestic).toBe('Special');
|
||||||
expect(result.snapshot.generals[0]?.role.specialWar).toBeNull();
|
expect(result.snapshot.generals[0]?.role.specialWar).toBeNull();
|
||||||
expect(result.snapshot.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 });
|
expect(result.snapshot.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 });
|
||||||
expect(result.seed.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 });
|
expect(result.seed.generals[0]?.meta).toMatchObject({
|
||||||
|
deathMonth: expect.any(Number),
|
||||||
|
specage: 25,
|
||||||
|
specage2: 30,
|
||||||
|
});
|
||||||
|
expect(buildScenarioBootstrap({ scenario, map, unitSet }).snapshot.generals[0]?.meta).toEqual(
|
||||||
|
result.snapshot.generals[0]?.meta
|
||||||
|
);
|
||||||
expect(result.seed.generals[0]?.npcType).toBe(2);
|
expect(result.seed.generals[0]?.npcType).toBe(2);
|
||||||
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
|
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user