diff --git a/app/game-engine/package.json b/app/game-engine/package.json index 935f222..4bfe7ba 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -11,6 +11,7 @@ "start": "pnpm run build && node dist/index.js", "lint": "eslint .", "lint:fix": "eslint . --fix", + "profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs", "test": "vitest run --config vitest.config.ts", "typecheck": "tsc -b" }, diff --git a/app/game-engine/scripts/profile-npc-unification-memory.mjs b/app/game-engine/scripts/profile-npc-unification-memory.mjs new file mode 100644 index 0000000..c959043 --- /dev/null +++ b/app/game-engine/scripts/profile-npc-unification-memory.mjs @@ -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; +}); diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index d9e1dd0..19b9937 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -1,6 +1,12 @@ import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra'; 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 { loadMapDefinitionByName } from './mapLoader.js'; @@ -150,12 +156,12 @@ const resolveKillturnFromDeathYear = ( currentYear: number, currentMonth: number, deathYear: number, + deathMonth: number, fallback: number ): number => { if (!Number.isFinite(deathYear) || deathYear <= 0) { return fallback; } - const deathMonth = Math.floor(Math.random() * 12) + 1; const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth); return Math.max(diff, 0); }; @@ -442,15 +448,32 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom delete meta.deadYear; const fallbackKillturn = 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( startState.currentYear, startState.currentMonth, general.deathYear, + deathMonth, fallbackKillturn ); return { ...meta, killturn, + deathMonth, npcType: general.npcType, crewTypeId: general.crewTypeId, } satisfies GeneralMeta; diff --git a/app/game-engine/src/turn/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts index dc10235..68bfbbe 100644 --- a/app/game-engine/src/turn/monthlyInvaderAction.ts +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -27,17 +27,6 @@ const resolveServerId = (world: InMemoryTurnWorld): string | null => { return typeof value === 'string' && value !== '' ? value : null; }; -const shuffleLegacy = (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 turnMinutes = tickSeconds / 60; if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) { @@ -235,6 +224,17 @@ export const createRaiseInvaderHandler = (options: { 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()) { 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 subStat = specAverage * 3 - leadership - mainStat; 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({ world, reservedTurns: options.reservedTurns, diff --git a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts index a437ed6..fdd5fdb 100644 --- a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts +++ b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts @@ -111,17 +111,6 @@ const calculateAverageCity = (rng: RandUtil, cities: City[]): CityValues => { ) as CityValues; }; -const shuffleLegacy = (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 => 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) ) ); + const cityShuffleRng = new RandUtil( + new LiteHashDRBG( + simpleSerialize( + resolveHiddenSeed(world), + 'RaiseNPCNation', + environment.year, + environment.month, + 'emptyCities' + ) + ) + ); const averageCity = calculateAverageCity(rng, targetCities); 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 generalCounts = activeNations.map( (nation) => world.listGenerals().filter((general) => general.nationId === nation.id).length diff --git a/app/game-engine/src/turn/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts index 750d295..d3253b8 100644 --- a/app/game-engine/src/turn/tournamentAutoStart.ts +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -57,14 +57,16 @@ const readPattern = (world: InMemoryTurnWorld, config: Record): ); }; -const shuffledDefaultPattern = (): number[] => { - const pattern = [0, 0, 1, 2, 3]; - for (let index = pattern.length - 1; index > 0; index -= 1) { - const swapIndex = Math.floor(Math.random() * (index + 1)); - [pattern[index], pattern[swapIndex]] = [pattern[swapIndex]!, pattern[index]!]; - } - return pattern; -}; +const shuffledDefaultPattern = ( + hiddenSeed: string | number, + previousYear: number, + previousMonth: number +): number[] => + new RandUtil( + new LiteHashDRBG( + simpleSerialize(hiddenSeed, 'monthly', previousYear, previousMonth, 'tournamentPattern') + ) + ).shuffle([0, 0, 1, 2, 3]); export const createTournamentAutoStartHandler = (options: { profileName: string; @@ -112,7 +114,10 @@ export const createTournamentAutoStartHandler = (options: { } 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; world.updateWorldMeta({ tournamentPattern: resolvedPattern }); const now = options.now?.() ?? new Date(); diff --git a/app/game-engine/test/helpers/npcUnificationMemoryProfiler.ts b/app/game-engine/test/helpers/npcUnificationMemoryProfiler.ts new file mode 100644 index 0000000..f834387 --- /dev/null +++ b/app/game-engine/test/helpers/npcUnificationMemoryProfiler.ts @@ -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, + }; + } +} diff --git a/app/game-engine/test/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts index 380c3d1..12f8f28 100644 --- a/app/game-engine/test/monthlyInvaderAction.test.ts +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -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 { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; @@ -166,6 +166,16 @@ const buildHarness = (options?: { }; 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 () => { const harness = buildHarness(); const handler = createRaiseInvaderHandler({ diff --git a/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts b/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts index 709e712..a3724c8 100644 --- a/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts +++ b/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts @@ -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 { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; @@ -192,6 +192,16 @@ const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fi }; 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 () => { const { world, reservedTurns, handler, environment } = buildHarness(); diff --git a/app/game-engine/test/npcNationUprisingUnification.test.ts b/app/game-engine/test/npcNationUprisingUnification.test.ts index 214f681..a074456 100644 --- a/app/game-engine/test/npcNationUprisingUnification.test.ts +++ b/app/game-engine/test/npcNationUprisingUnification.test.ts @@ -1,10 +1,14 @@ 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 { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js'; +import { NpcUnificationMemoryProfiler } from './helpers/npcUnificationMemoryProfiler.js'; const mockDate = new Date('0181-08-01T00:00:00Z'); @@ -148,6 +152,8 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => { describe('NPC 건국/통일 장기 시뮬레이션', () => { it('건국, 선포, 출병, 점령과 장기 국가 감소가 안정적으로 진행되어야 한다', async () => { + const memoryProfileEnabled = process.env.NPC_UNIFICATION_MEMORY_PROFILE === '1'; + const profileStartedAtMs = performance.now(); const cities = buildLargeTestCities().map(maxCityStats); for (const city of cities) { city.nationId = 0; @@ -253,6 +259,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { const worldRef = { current: null as InMemoryTurnWorld | null }; + let unificationLogObserved = false; const unificationHandler: TurnCalendarHandler = { onMonthChanged: () => { const world = worldRef.current; @@ -279,6 +286,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { } world.updateWorldMeta({ isUnited: 2 }); world.pushLog(buildUnificationLog(winner.name)); + unificationLogObserved = true; }, }; @@ -287,7 +295,14 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { let sortieCount = 0; let lastResolvedAction = 'none'; - const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } = + const { + runUntil, + reservedTurnStore, + getCollectedLogs, + getCollectedLogsCount, + getCollectedLogsRange, + getAndClearCollectedLogs, + } = await createTurnTestHarness({ snapshot, 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; const maxMonthlyLogEntries = 20; @@ -355,7 +394,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { try { 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; @@ -366,10 +407,14 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { const foundedNations = world.listNations().filter((nation) => nation.level > 0); expect(foundedNations.length).toBeGreaterThanOrEqual(2); const foundedNationCount = foundedNations.length; + memoryProfiler?.sample('nations-founded'); 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); expect(neutralCities.length).toBe(0); @@ -381,7 +426,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { if (declarationCount === 0) { 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) { const generals = world.listGenerals().filter((general) => general.nationId > 0); @@ -488,7 +535,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { await runUntil( (current) => 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')}`); @@ -521,9 +570,12 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { await runUntil( (current) => current.currentYear > nextMonth.year || - (current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month) + (current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month), + undefined, + observeProfileMonth ); unifiedAt = nextMonth; + memoryProfiler?.sample('unified'); break; } @@ -537,7 +589,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { const meta = world.getState().meta as Record; const logs = getCollectedLogs(); - const hasUnificationLog = logs.some((log) => log.text.includes('전토를 통일하였습니다.')); + const hasUnificationLog = + unificationLogObserved || logs.some((log) => log.text.includes('전토를 통일하였습니다.')); if (unifiedAt) { expect(meta.isUnited).toBe(2); expect(hasUnificationLog).toBe(true); @@ -546,6 +599,39 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { expect(meta.isUnited ?? 0).toBe(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) { const world = worldRef.current; if (world) { diff --git a/app/game-engine/test/tournamentAutoStart.test.ts b/app/game-engine/test/tournamentAutoStart.test.ts index 7a7ced0..513b150 100644 --- a/app/game-engine/test/tournamentAutoStart.test.ts +++ b/app/game-engine/test/tournamentAutoStart.test.ts @@ -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 { Nation } from '@sammo-ts/logic'; @@ -143,4 +143,86 @@ describe('monthly tournament auto start', () => { expect(consumed).toEqual([false]); 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(); + 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(); + } + }); }); diff --git a/docs/architecture/legacy-engine-events.md b/docs/architecture/legacy-engine-events.md index 176fbb0..9ddcd99 100644 --- a/docs/architecture/legacy-engine-events.md +++ b/docs/architecture/legacy-engine-events.md @@ -118,6 +118,30 @@ Dynamic event actions can use deterministic RNG by constructing `LiteHashDRBG` with `UniqueConst::$hiddenSeed` and an event-specific tag. 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 - `Event\Engine` is a stub with a TODO; it is not currently used in the main diff --git a/docs/architecture/npc-unification-memory-profile.md b/docs/architecture/npc-unification-memory-profile.md new file mode 100644 index 0000000..2fd6d8c --- /dev/null +++ b/docs/architecture/npc-unification-memory-profile.md @@ -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가 별도로 필요하다. diff --git a/eslint.config.js b/eslint.config.js index b904f3b..e248a20 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -98,6 +98,20 @@ export default tseslint.config( '@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'], rules: { diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts index 7ef2e92..01e8c31 100644 --- a/packages/logic/src/world/bootstrap.ts +++ b/packages/logic/src/world/bootstrap.ts @@ -1,3 +1,4 @@ +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { City, General, @@ -18,6 +19,7 @@ import type { WorldSeedPayload, WorldSnapshot, } from './types.js'; +import { simpleSerialize } from '../war/utils.js'; export interface ScenarioBootstrapOptions { includeNeutralNation?: boolean; @@ -144,16 +146,38 @@ const resolveKillturnFromDeathYear = ( currentYear: number | null, currentMonth: number, deathYear: number, + deathMonth: number, fallback: number ): number => { if (currentYear === null || !Number.isFinite(deathYear) || deathYear <= 0) { return fallback; } - const deathMonth = Math.floor(Math.random() * 12) + 1; const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth); 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 => { if (startYear === null || birthYear <= 0) { return 20; @@ -309,6 +333,14 @@ const buildGeneralSeeds = ( const cityId = resolveCityId(row.city, cityByName, warnings, row.name); const birthYear = resolveBirthYear(row.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 age = resolveAge(scenario.startYear, birthYear); const stats = { @@ -319,6 +351,7 @@ const buildGeneralSeeds = ( const seedMeta: Record = { source: contextLabel, + deathMonth, specage: buildSpecialityAge(retirementYear, age, 12), specage2: buildSpecialityAge(retirementYear, age, 6), }; @@ -379,7 +412,14 @@ const buildGeneralSeeds = ( seeds.push(seed); const generalMeta: GeneralMeta = { - killturn: resolveKillturnFromDeathYear(scenario.startYear, 1, deathYear, DEFAULT_GENERAL_KILLTURN), + killturn: resolveKillturnFromDeathYear( + scenario.startYear, + 1, + deathYear, + deathMonth, + DEFAULT_GENERAL_KILLTURN + ), + deathMonth, npcType, crewTypeId: defaultCrewTypeId, specage: buildSpecialityAge(retirementYear, age, 12), diff --git a/packages/logic/test/worldBootstrap.test.ts b/packages/logic/test/worldBootstrap.test.ts index e47362a..3379974 100644 --- a/packages/logic/test/worldBootstrap.test.ts +++ b/packages/logic/test/worldBootstrap.test.ts @@ -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 { MapDefinition, UnitSetDefinition } from '../src/world/types.js'; import { buildScenarioBootstrap } from '../src/world/bootstrap.js'; 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', () => { const scenario: ScenarioDefinition = { 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.specialWar).toBeNull(); 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.snapshot.scenarioMeta?.title).toBe('Test Scenario'); });