merge: 최신 main을 특수 유저 커맨드 호환 브랜치에 반영한다
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TurnRunBudget>;
|
||||
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>): TurnRunBudget | undefined => {
|
||||
const budgetOverride: Partial<TurnRunBudget> = {
|
||||
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<void> => {
|
||||
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();
|
||||
|
||||
@@ -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<string, unknown>): void {
|
||||
this.state = {
|
||||
...this.state,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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<Record<string, number>>;
|
||||
weightedPairs: Array<Array<[unknown, number]>>;
|
||||
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<T extends string | number>(items: Record<T, number>): T {
|
||||
this.weightedChoices.push(Object.fromEntries(Object.entries(items)) as Record<string, number>);
|
||||
return this.choice(
|
||||
Object.keys(items).map((key) => {
|
||||
const numeric = Number(key);
|
||||
@@ -416,6 +420,71 @@ const makePromotionGeneral = (overrides: Partial<TurnGeneral>): 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<string, unknown>, {
|
||||
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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user