From 4a3410d81032c78b741126e71faadb2629253c5d Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 10:24:21 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=84=B4=20=EB=8D=B0=EB=AA=AC=20?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EA=B3=84=EC=B8=A1=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RSS와 V8 heap, 월드 entity 수를 bounded 로그로 기록하고 heap 상한 80% 이상을 경고한다. --- app/game-engine/src/index.ts | 1 + app/game-engine/src/turn/cli.ts | 29 ++++++++ app/game-engine/src/turn/inMemoryWorld.ts | 16 +++++ .../src/turn/turnDaemonMemoryReporter.ts | 71 +++++++++++++++++++ .../test/turnDaemonMemoryReporter.test.ts | 56 +++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 app/game-engine/src/turn/turnDaemonMemoryReporter.ts create mode 100644 app/game-engine/test/turnDaemonMemoryReporter.test.ts diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index ad8c7b4b..aeab1633 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -24,6 +24,7 @@ export * from './turn/joinCreateGeneralService.js'; export * from './turn/npcPossessionService.js'; export * from './turn/selectPoolService.js'; export * from './turn/turnDaemon.js'; +export * from './turn/turnDaemonMemoryReporter.js'; export * from './turn/cli.js'; export const shouldRunTurnDaemon = (role: string | undefined): boolean => role === 'turn-daemon'; diff --git a/app/game-engine/src/turn/cli.ts b/app/game-engine/src/turn/cli.ts index ca67a990..9d1128a4 100644 --- a/app/game-engine/src/turn/cli.ts +++ b/app/game-engine/src/turn/cli.ts @@ -4,6 +4,7 @@ import { parseOptionalBoolean, parseOptionalNumber, type GameClockMode } from '@ import type { TurnRunBudget } from '../lifecycle/types.js'; import { resolveDatabaseUrl } from '../scenario/databaseUrl.js'; import { createTurnDaemonRuntime } from './turnDaemon.js'; +import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js'; export interface TurnDaemonCliOptions { profile?: string; @@ -16,6 +17,7 @@ export interface TurnDaemonCliOptions { budget?: Partial; enableDatabaseFlush?: boolean; adminActionIntervalMs?: number; + memoryReportIntervalMs?: number; gameClockMode?: GameClockMode; env?: NodeJS.ProcessEnv; } @@ -26,6 +28,9 @@ const DEFAULT_BUDGET: TurnRunBudget = { catchUpCap: 1, }; +const DEFAULT_MEMORY_REPORT_INTERVAL_MS = 5 * 60 * 1000; +const MIN_MEMORY_REPORT_INTERVAL_MS = 10 * 1000; + const buildBudgetOverride = (env: NodeJS.ProcessEnv, override?: Partial): TurnRunBudget | undefined => { const budgetOverride: Partial = { budgetMs: parseOptionalNumber(env.TURN_BUDGET_MS), @@ -59,6 +64,13 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom const enableDatabaseFlush = options.enableDatabaseFlush ?? parseOptionalBoolean(env.TURN_FLUSH_DB) ?? true; const pauseGateIntervalMs = parseOptionalNumber(env.TURN_PAUSE_GATE_MS); const adminActionIntervalMs = options.adminActionIntervalMs ?? parseOptionalNumber(env.TURN_ADMIN_ACTION_MS); + const memoryReportIntervalMs = + options.memoryReportIntervalMs ?? + parseOptionalNumber(env.TURN_MEMORY_REPORT_INTERVAL_MS) ?? + DEFAULT_MEMORY_REPORT_INTERVAL_MS; + if (!Number.isFinite(memoryReportIntervalMs) || memoryReportIntervalMs < MIN_MEMORY_REPORT_INTERVAL_MS) { + throw new Error(`TURN_MEMORY_REPORT_INTERVAL_MS must be at least ${MIN_MEMORY_REPORT_INTERVAL_MS}.`); + } const rawGameClockMode = options.gameClockMode ?? env.GAME_CLOCK_MODE; if (rawGameClockMode && rawGameClockMode !== 'realtime' && rawGameClockMode !== 'manual') { throw new Error(`GAME_CLOCK_MODE must be realtime or manual: ${rawGameClockMode}`); @@ -79,12 +91,28 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom gameClockMode, }); + const memoryReporter = createTurnDaemonMemoryReporter({ + profile, + intervalMs: memoryReportIntervalMs, + getContext: () => { + const state = runtime.world.getState(); + return { + year: state.currentYear, + month: state.currentMonth, + ...runtime.world.getEntityCounts(), + lifecycleState: runtime.lifecycle.getStatus().state, + }; + }, + }); + let closed = false; const closeOnce = async (): Promise => { if (closed) { return; } closed = true; + memoryReporter.report('shutdown'); + memoryReporter.stop(); await runtime.close(); }; @@ -104,6 +132,7 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom const activeTickMinutes = tickMinutes ?? Math.max(1, Math.round(runtime.world.getState().tickSeconds / 60)); console.info(`[turn-daemon] started profile=${profile} tickMinutes=${activeTickMinutes}`); + memoryReporter.report('startup'); try { await runtime.lifecycle.start(); diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 7f66223a..1f201f11 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -881,6 +881,22 @@ export class InMemoryTurnWorld { return { ...this.state }; } + getEntityCounts(): { + generals: number; + cities: number; + nations: number; + troops: number; + events: number; + } { + return { + generals: this.generals.size, + cities: this.cities.size, + nations: this.nations.size, + troops: this.troops.size, + events: this.events.size, + }; + } + updateWorldMeta(patch: Record): void { this.state = { ...this.state, diff --git a/app/game-engine/src/turn/turnDaemonMemoryReporter.ts b/app/game-engine/src/turn/turnDaemonMemoryReporter.ts new file mode 100644 index 00000000..0662ee1f --- /dev/null +++ b/app/game-engine/src/turn/turnDaemonMemoryReporter.ts @@ -0,0 +1,71 @@ +import { getHeapStatistics } from 'node:v8'; + +export interface TurnDaemonMemoryContext { + year: number; + month: number; + generals: number; + cities: number; + nations: number; + troops: number; + events: number; + lifecycleState: string; +} + +export interface TurnDaemonMemoryReporterOptions { + profile: string; + intervalMs: number; + getContext(): TurnDaemonMemoryContext; + info?: (message: string) => void; + warn?: (message: string) => void; +} + +const BYTES_PER_MIB = 1024 * 1024; +const HEAP_WARNING_RATIO = 0.8; + +const toMiB = (value: number): number => Math.round((value / BYTES_PER_MIB) * 10) / 10; + +export const buildTurnDaemonMemoryReport = ( + profile: string, + reason: string, + context: TurnDaemonMemoryContext, + memory = process.memoryUsage(), + heapLimitBytes = getHeapStatistics().heap_size_limit +): { message: string; warning: boolean } => { + const heapRatio = heapLimitBytes > 0 ? memory.heapUsed / heapLimitBytes : 0; + const message = [ + `[turn-daemon:memory] profile=${profile}`, + `reason=${reason}`, + `rssMiB=${toMiB(memory.rss)}`, + `heapUsedMiB=${toMiB(memory.heapUsed)}`, + `heapTotalMiB=${toMiB(memory.heapTotal)}`, + `heapLimitMiB=${toMiB(heapLimitBytes)}`, + `externalMiB=${toMiB(memory.external)}`, + `arrayBuffersMiB=${toMiB(memory.arrayBuffers)}`, + `year=${context.year}`, + `month=${context.month}`, + `generals=${context.generals}`, + `cities=${context.cities}`, + `nations=${context.nations}`, + `troops=${context.troops}`, + `events=${context.events}`, + `lifecycle=${context.lifecycleState}`, + ].join(' '); + return { message, warning: heapRatio >= HEAP_WARNING_RATIO }; +}; + +export const createTurnDaemonMemoryReporter = ( + options: TurnDaemonMemoryReporterOptions +): { report(reason: string): void; stop(): void } => { + const info = options.info ?? console.info; + const warn = options.warn ?? console.warn; + const report = (reason: string): void => { + const result = buildTurnDaemonMemoryReport(options.profile, reason, options.getContext()); + (result.warning ? warn : info)(result.message); + }; + const timer = setInterval(() => report('interval'), options.intervalMs); + timer.unref(); + return { + report, + stop: () => clearInterval(timer), + }; +}; diff --git a/app/game-engine/test/turnDaemonMemoryReporter.test.ts b/app/game-engine/test/turnDaemonMemoryReporter.test.ts new file mode 100644 index 00000000..7d3ff937 --- /dev/null +++ b/app/game-engine/test/turnDaemonMemoryReporter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTurnDaemonMemoryReport } from '../src/turn/turnDaemonMemoryReporter.js'; + +const context = { + year: 214, + month: 12, + generals: 2461, + cities: 78, + nations: 3, + troops: 15, + events: 4, + lifecycleState: 'paused', +}; + +describe('turn daemon memory reporting', () => { + it('reports bounded process and world-size fields without inspecting or cloning entities', () => { + const result = buildTurnDaemonMemoryReport( + 'hwe', + 'interval', + context, + { + rss: 1_258_291_200, + heapTotal: 1_100_000_000, + heapUsed: 900_000_000, + external: 20_000_000, + arrayBuffers: 10_000_000, + }, + 3_221_225_472 + ); + + expect(result.warning).toBe(false); + expect(result.message).toContain('profile=hwe reason=interval'); + expect(result.message).toContain('heapLimitMiB=3072'); + expect(result.message).toContain('year=214 month=12 generals=2461'); + expect(result.message).toContain('lifecycle=paused'); + }); + + it('marks samples at or above 80 percent of the V8 heap limit as warnings', () => { + const result = buildTurnDaemonMemoryReport( + 'hwe', + 'interval', + context, + { + rss: 1_500_000_000, + heapTotal: 1_400_000_000, + heapUsed: 1_288_490_189, + external: 0, + arrayBuffers: 0, + }, + 1_610_612_736 + ); + + expect(result.warning).toBe(true); + }); +}); From 91848f3f6fe3272682cda1c30d1c8d8174496590 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 10:25:40 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20NPC=20=EC=9E=90=EB=8F=99=20=EC=84=A0?= =?UTF-8?q?=ED=8F=AC=EC=97=90=EC=84=9C=20=EB=B0=A9=EB=9E=91=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EA=B5=AD=EC=9D=84=20=EC=A0=9C=EC=99=B8=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref GeneralAI와 같이 level 0 방랑군을 가중 선포 후보에서 제외한다. 정식 유저국과 NPC국은 같은 가중치를 유지하고 후보 및 RNG 순서를 회귀 테스트로 고정한다. --- .../src/turn/ai/generalAi/nation/diplomacy.ts | 6 +- .../generalAiLegacyDecisionParity.test.ts | 149 ++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts index 93e85d09..4de18ed5 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts @@ -175,7 +175,11 @@ export const do선전포고 = (ai: GeneralAI) => { const currentNationId = ai.nation.id; const cities = ai.worldRef.listCities(); const neighbors = ai.worldRef.listNations().filter((nation) => { - if (nation.id <= 0 || nation.id === currentNationId) { + // Ref getAllNationStaticInfo() also contains level-0 wandering nations, + // but automatic declarations explicitly skip them before adjacency and + // weighted target selection. A wandering user ruler must not become an + // NPC war target merely because its occupied city is adjacent. + if (nation.id <= 0 || nation.id === currentNationId || nation.level === 0) { return false; } return isNeighbor(ai.map!, cities, currentNationId, nation.id, true); diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index d1c76704..c82813a0 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -20,6 +20,7 @@ import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warAc import { do내정워프, do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js'; import { doNPC몰수, doNPC포상, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js'; import { do천도 } from '../src/turn/ai/generalAi/nation/capital.js'; +import { do선전포고 } from '../src/turn/ai/generalAi/nation/diplomacy.js'; import { doNPC구출발령, doNPC전방발령, @@ -42,6 +43,7 @@ type Candidate = { type ScriptedRng = { bools: boolean[]; choices: unknown[]; + weightedChoices: Array>; weightedPairs: Array>; nextBool: (probability?: number) => boolean; nextFloat1: () => number; @@ -56,6 +58,7 @@ const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng => return { bools: [...bools], choices: scriptedChoices, + weightedChoices: [], weightedPairs: [], nextBool() { return this.bools.shift() ?? false; @@ -79,6 +82,7 @@ const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng => return values[0]!; }, choiceUsingWeight(items: Record): T { + this.weightedChoices.push(Object.fromEntries(Object.entries(items)) as Record); return this.choice( Object.keys(items).map((key) => { const numeric = Number(key); @@ -416,6 +420,71 @@ const makePromotionGeneral = (overrides: Partial): TurnGeneral => ( meta: { ...baseGeneral().meta, belong: 1, ...overrides.meta }, }); +const makeDeclarationAi = ( + targetNations: Nation[], + targetRulers: General[], + rng: ScriptedRng +): GeneralAI => { + const actorNation = { + ...baseNation(), + meta: { ...baseNation().meta, tech: 2_000 }, + }; + const actor = { + ...baseGeneral(), + officerLevel: 12, + npcState: 2, + }; + const targetCities = targetNations.map((nation, index) => ({ + ...baseCity(), + id: index + 2, + name: `대상도시${nation.id}`, + nationId: nation.id, + })); + const cities = [baseCity(), ...targetCities]; + const ai = makeAi({ + general: actor, + nation: actorNation, + nations: [actorNation, ...targetNations], + generals: [actor, ...targetRulers], + rng, + }); + const worldRef = ai.worldRef!; + const mapCityTemplate = ai.map!.cities[0]!; + + Object.assign(ai as unknown as Record, { + worldRef: { + ...worldRef, + listNations: () => [actorNation, ...targetNations], + listGenerals: () => [actor, ...targetRulers], + listCities: () => cities, + getNationById: (id: number) => [actorNation, ...targetNations].find((nation) => nation.id === id) ?? null, + getGeneralById: (id: number) => + [actor, ...targetRulers].find((general) => general.id === id) ?? null, + getCityById: (id: number) => cities.find((city) => city.id === id) ?? null, + }, + map: { + ...ai.map!, + cities: [ + { ...mapCityTemplate, id: 1, connections: targetCities.map((city) => city.id) }, + ...targetCities.map((city) => ({ + ...mapCityTemplate, + id: city.id, + name: city.name, + connections: [1], + })), + ], + }, + frontCities: {}, + npcWarGenerals: { [actor.id]: actor }, + npcCivilGenerals: {}, + userWarGenerals: {}, + userCivilGenerals: {}, + devRate: { pop: 1, all: 1 }, + }); + + return ai; +}; + const makePromotionAi = (options: { ruler: TurnGeneral; generals: TurnGeneral[]; @@ -766,6 +835,86 @@ describe('legacy NPC user-chief promotion parity', () => { * selection and RNG-sensitive gates, not TypeScript implementation details. */ describe('legacy NPC AI final-decision parity', () => { + it('does not declare war on an adjacent level-0 user wandering nation', () => { + const wanderingNation = { + ...baseNation(), + id: 2, + name: '유저방랑군', + capitalCityId: 2, + chiefGeneralId: 2, + level: 0, + }; + const userRuler = { + ...baseGeneral(), + id: 2, + name: '유저방랑군주', + nationId: 2, + cityId: 2, + officerLevel: 12, + npcState: 0, + }; + const rng = makeRng([true], [0]); + const ai = makeDeclarationAi([wanderingNation], [userRuler], rng); + + expect(do선전포고(ai)).toBeNull(); + expect(rng.bools).toEqual([]); + expect(rng.weightedChoices).toEqual([]); + expect(rng.choices).toEqual([0]); + }); + + it('keeps active user and NPC nations equally eligible while excluding the user wandering nation', () => { + const targets = [ + { id: 2, name: '유저방랑군', level: 0, npcState: 0 }, + { id: 3, name: '정식유저국', level: 1, npcState: 0 }, + { id: 4, name: '정식NPC국', level: 1, npcState: 2 }, + ]; + const nations = targets.map( + ({ id, name, level }) => + ({ + ...baseNation(), + id, + name, + capitalCityId: id, + chiefGeneralId: id, + level, + power: 100, + }) satisfies Nation + ); + const rulers = targets.map( + ({ id, name, npcState }) => + ({ + ...baseGeneral(), + id, + name: `${name}군주`, + nationId: id, + cityId: id, + officerLevel: 12, + npcState, + }) satisfies General + ); + + const userTargetRng = makeRng([true], [0]); + const userTargetAi = makeDeclarationAi(nations, rulers, userTargetRng); + expect(do선전포고(userTargetAi)).toMatchObject({ + action: 'che_선전포고', + args: { destNationId: 3 }, + }); + expect(userTargetRng.weightedChoices).toEqual([ + { + '3': 1 / Math.sqrt(101), + '4': 1 / Math.sqrt(101), + }, + ]); + + const npcTargetRng = makeRng([true], [1]); + const npcTargetAi = makeDeclarationAi(nations, rulers, npcTargetRng); + expect(do선전포고(npcTargetAi)).toMatchObject({ + action: 'che_선전포고', + args: { destNationId: 4 }, + }); + expect(npcTargetRng.weightedChoices).toEqual(userTargetRng.weightedChoices); + }); + it('rejects the malformed Ref low-rice donation candidate and continues the priority loop', () => { const rng = makeRng([false], [0]); const ai = makeAi({