NPC 판단 절차와 난수 결과의 선택적 감사 관측 기반 추가

This commit is contained in:
2026-09-16 07:40:29 +00:00
parent 2f5036ea6b
commit 9f31b28297
9 changed files with 311 additions and 20 deletions
+93 -20
View File
@@ -1,3 +1,4 @@
import { observeAiRng, type AiDecisionTraceObserver, type AiTraceStep } from './trace.js';
import type {
City,
GeneralActionDefinition,
@@ -188,6 +189,65 @@ export class GeneralAI {
public readonly nationFallback: GeneralActionDefinition;
public readonly rng: RandUtil;
private onDecisionTrace?: AiDecisionTraceObserver;
private traceSequence = 0;
private tracePhase: 'general' | 'nation' | null = null;
private trace(step: AiTraceStep): void {
if (!this.onDecisionTrace || !this.tracePhase) return;
this.onDecisionTrace({
...step,
sequence: this.traceSequence++,
phase: this.tracePhase,
generalId: this.general.id,
nationId: this.general.nationId,
cityId: this.general.cityId,
npcState: this.general.npcState,
year: this.world.currentYear,
month: this.world.currentMonth,
tick: this.general.turnTick ?? null,
});
}
private traceDecision(
phase: 'general' | 'nation',
reserved: ReservedTurnEntry,
choose: () => AiCommandCandidate | null
): AiCommandCandidate | null {
if (!this.onDecisionTrace) return choose();
this.tracePhase = phase;
this.trace({ kind: 'DECISION_START', reservedAction: reserved.action });
try {
const result = choose();
this.trace({ kind: 'DECISION_END', action: result?.action ?? null, reason: result?.reason ?? null });
return result;
} catch (error) {
this.trace({ kind: 'DECISION_ERROR' });
throw error;
} finally {
this.tracePhase = null;
}
}
private traceProcedure(
procedure: string,
handler?: (ai: GeneralAI) => AiCommandCandidate | null
): AiCommandCandidate | null {
if (!handler) {
this.trace({ kind: 'PROCEDURE_SKIP', procedure, reason: 'NO_HANDLER' });
return null;
}
this.trace({ kind: 'PROCEDURE_START', procedure });
const result = handler(this);
this.trace({
kind: 'PROCEDURE_END',
procedure,
action: result?.action ?? null,
reason: result?.reason ?? null,
});
return result;
}
public readonly env: ConstraintEnv;
public readonly startYear: number;
public readonly turnTermMinutes: number;
@@ -302,6 +362,7 @@ export class GeneralAI {
})}\n`
);
}
this.onDecisionTrace = options.onDecisionTrace;
const baseRng = new RandUtil(LiteHashDRBG.build(seed));
const traceRng = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id));
let traceSequence = 0;
@@ -345,6 +406,8 @@ export class GeneralAI {
})
: baseRng;
if (this.onDecisionTrace) this.rng = observeAiRng(this.rng, (step) => this.trace(step));
const constValues = asRecord(this.scenarioConfig.const);
this.aiConst = {
baseGold: this.commandEnv.baseGold,
@@ -401,6 +464,10 @@ export class GeneralAI {
}
chooseNationTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
return this.traceDecision('nation', reservedTurn, () => this.chooseNationTurnObserved(reservedTurn));
}
private chooseNationTurnObserved(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
this.updateInstance();
if (!this.nation || !this.worldRef) {
return null;
@@ -425,16 +492,15 @@ export class GeneralAI {
for (const actionName of this.nationPolicy.priority) {
if (!this.nationPolicy.can(actionName)) {
this.trace({ kind: 'PROCEDURE_SKIP', procedure: actionName, reason: 'POLICY' });
continue;
}
if (!canUseAutomatedNationAction(this.general, actionName)) {
this.trace({ kind: 'PROCEDURE_SKIP', procedure: actionName, reason: 'AUTOMATION' });
continue;
}
const handler = nationActionHandlers[actionName];
if (!handler) {
continue;
}
const result = handler(this);
const result = this.traceProcedure(actionName, handler);
if (result) {
// Ref refreshes the cached AI state after these selected nation
// commands, before choosing the general command with the same
@@ -506,6 +572,10 @@ export class GeneralAI {
}
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
return this.traceDecision('general', reservedTurn, () => this.chooseGeneralTurnObserved(reservedTurn));
}
private chooseGeneralTurnObserved(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
this.updateInstance();
if (!this.worldRef) {
return null;
@@ -524,7 +594,7 @@ export class GeneralAI {
}
if (this.general.officerLevel === 12 && this.generalPolicy.can('선양')) {
const abdication = generalActionHandlers['선양']?.(this);
const abdication = this.traceProcedure('선양', generalActionHandlers['선양']);
if (abdication) {
return abdication;
}
@@ -535,7 +605,7 @@ export class GeneralAI {
this.general.meta = { ...this.general.meta, killturn: 1 };
return { action: reservedTurn.action, args: reservedTurn.args, reason: '사망' };
}
const result = generalActionHandlers['집합']?.(this);
const result = this.traceProcedure('집합', generalActionHandlers['집합']);
return result ?? this.buildGeneralCandidate(ACTION_REST, {}, 'npc_troop');
}
@@ -550,18 +620,18 @@ export class GeneralAI {
}
if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) {
const rebellion = generalActionHandlers['거병']?.(this);
const rebellion = this.traceProcedure('거병', generalActionHandlers['거병']);
if (rebellion) {
return rebellion;
}
}
if (this.general.nationId === 0 && this.generalPolicy.can('국가선택')) {
const pickNation = generalActionHandlers['국가선택']?.(this);
const pickNation = this.traceProcedure('국가선택', generalActionHandlers['국가선택']);
if (pickNation) {
return pickNation;
}
const neutral = generalActionHandlers['중립']?.(this);
const neutral = this.traceProcedure('중립', generalActionHandlers['중립']);
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
}
@@ -576,17 +646,17 @@ export class GeneralAI {
const relYearMonth =
joinYearMonth(this.world.currentYear, this.world.currentMonth) - joinYearMonth(initYear, initMonth);
if (relYearMonth > 1) {
const establish = generalActionHandlers['건국']?.(this);
const establish = this.traceProcedure('건국', generalActionHandlers['건국']);
if (establish) {
return establish;
}
}
const move = generalActionHandlers['방랑군이동']?.(this);
const move = this.traceProcedure('방랑군이동', generalActionHandlers['방랑군이동']);
if (move) {
return move;
}
if (relYearMonth > 1) {
const disband = generalActionHandlers['해산']?.(this);
const disband = this.traceProcedure('해산', generalActionHandlers['해산']);
if (disband) {
return disband;
}
@@ -596,6 +666,7 @@ export class GeneralAI {
for (const actionName of this.generalPolicy.priority) {
const allowed = this.generalPolicy.can(actionName);
if (!allowed) {
this.trace({ kind: 'PROCEDURE_SKIP', procedure: actionName, reason: 'POLICY' });
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result: null })}\n`
@@ -604,10 +675,7 @@ export class GeneralAI {
continue;
}
const handler = generalActionHandlers[actionName];
if (!handler) {
continue;
}
const result = handler(this);
const result = this.traceProcedure(actionName, handler);
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result })}\n`
@@ -618,7 +686,7 @@ export class GeneralAI {
}
}
const neutral = generalActionHandlers['중립']?.(this);
const neutral = this.traceProcedure('중립', generalActionHandlers['중립']);
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
}
@@ -1047,6 +1115,7 @@ export class GeneralAI {
const definition = definitions.get(action) ?? fallback;
const parsedArgs = definition.parseArgs(args);
if (parsedArgs === null) {
this.trace({ kind: 'CANDIDATE', action, result: 'INVALID_ARGS', constraint: null });
return null;
}
const constraintArgs = withCanonicalArgumentAliases(parsedArgs as Record<string, unknown>);
@@ -1066,6 +1135,12 @@ export class GeneralAI {
});
const constraints = definition.buildConstraints(ctx, parsedArgs as never);
const result = evaluateConstraints(constraints, ctx, view);
this.trace({
kind: 'CANDIDATE',
action: definition.key,
result: result.kind,
constraint: result.kind === 'deny' ? (result.constraintName ?? null) : null,
});
if (result.kind !== 'allow') {
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
@@ -1359,9 +1434,7 @@ export class GeneralAI {
if (
asRecord(candidate.meta).permission !== 'ambassador' ||
assignedAmbassadorIds.has(candidate.id) ||
this.promotionPatches.some(
(patch) => patch.generalId === candidate.id && patch.permission === 'normal'
)
this.promotionPatches.some((patch) => patch.generalId === candidate.id && patch.permission === 'normal')
) {
continue;
}
@@ -0,0 +1,73 @@
import type { RandUtil } from '@sammo-ts/common';
/** 원문 meta/seed/임의 객체를 받지 않는 관측 계약. 내부 후보 조건은 별도 계측으로 확장한다. */
export type AiTraceValue = string | number | boolean | null | { entityId: number } | { unprojected: true };
export type AiTraceStep =
| { kind: 'DECISION_START'; reservedAction: string }
| { kind: 'DECISION_END'; action: string | null; reason: string | null }
| { kind: 'DECISION_ERROR' }
| { kind: 'PROCEDURE_START'; procedure: string }
| { kind: 'PROCEDURE_END'; procedure: string; action: string | null; reason: string | null }
| { kind: 'PROCEDURE_SKIP'; procedure: string; reason: 'POLICY' | 'AUTOMATION' | 'NO_HANDLER' }
| {
kind: 'CANDIDATE';
action: string;
result: 'INVALID_ARGS' | 'allow' | 'deny' | 'unknown';
constraint: string | null;
}
| { kind: 'RNG'; method: string; parameters: number[] | null; result: AiTraceValue | AiTraceValue[] };
export type AiDecisionTraceEvent = AiTraceStep & {
sequence: number;
phase: 'general' | 'nation';
generalId: number;
nationId: number;
cityId: number;
npcState: number;
year: number;
month: number;
tick: number | null;
};
export type AiDecisionTraceObserver = (event: AiDecisionTraceEvent) => void;
const projectValue = (value: unknown): AiTraceValue => {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (value && typeof value === 'object' && 'id' in value && typeof value.id === 'number') {
return { entityId: value.id };
}
return { unprojected: true };
};
const observedMethods = new Set([
'nextFloat1',
'nextRange',
'nextRangeInt',
'nextInt',
'nextIntInclusive',
'nextBit',
'nextBool',
'shuffle',
'choice',
'choiceUsingWeight',
'choiceUsingWeightPair',
]);
/** 외부에서 호출한 RandUtil 결과만 관측한다. 원래 receiver로 실행해 중첩 helper와 RNG 소비를 보존한다. */
export const observeAiRng = (rng: RandUtil, observe: (step: AiTraceStep) => void): RandUtil =>
new Proxy(rng, {
get(target, property) {
const value = Reflect.get(target, property, target);
if (typeof value !== 'function' || !observedMethods.has(String(property))) return value;
return (...args: unknown[]) => {
const result: unknown = Reflect.apply(value, target, args);
observe({
kind: 'RNG',
method: String(property),
parameters: String(property).startsWith('next')
? args.filter((arg): arg is number => typeof arg === 'number' && Number.isFinite(arg))
: null,
result: Array.isArray(result) ? result.map(projectValue) : projectValue(result),
});
return result;
};
},
});
@@ -1,3 +1,4 @@
import type { AiDecisionTraceObserver } from './trace.js';
import type {
City,
GeneralActionDefinition,
@@ -14,6 +15,7 @@ import type { TurnGeneral, TurnWorldState } from '../../types.js';
import type { AiReservedTurnProvider, AiWorldView } from '../types.js';
export interface GeneralAIOptions {
onDecisionTrace?: AiDecisionTraceObserver;
general: TurnGeneral;
city?: City;
nation?: Nation | null;
@@ -1,3 +1,4 @@
import type { AiDecisionTraceObserver } from './ai/generalAi/trace.js';
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import type {
ActionContextBase,
@@ -900,6 +901,7 @@ export const createReservedTurnHandler = async (options: {
nation: Nation,
currentMonth: number
) => Nation['meta'] | null;
onDecisionTrace?: AiDecisionTraceObserver;
onActionResolved?: (payload: {
kind: 'nation' | 'general';
generalId: number;
@@ -1934,6 +1936,7 @@ export const createReservedTurnHandler = async (options: {
nationUsedAi = true;
const aiStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
sharedAi = new GeneralAI({
onDecisionTrace: options.onDecisionTrace,
general: currentGeneral,
city: currentCity,
nation: currentNation,
@@ -2090,6 +2093,7 @@ export const createReservedTurnHandler = async (options: {
const ai =
sharedAi ??
new GeneralAI({
onDecisionTrace: options.onDecisionTrace,
general: currentGeneral,
city: currentCity,
nation: currentNation,
@@ -1,3 +1,4 @@
import type { AiDecisionTraceEvent } from '../src/turn/ai/generalAi/trace.js';
import { describe, expect, it } from 'vitest';
import { loadItemModules, type City, type General, type Nation } from '@sammo-ts/logic';
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
@@ -2190,3 +2191,45 @@ describe('legacy NPC AI final-decision parity', () => {
expect(riceCandidates).toEqual([534, 77]);
});
});
describe('AI decision observation boundaries', () => {
it('keeps policy skips and fallback order without evaluating policy twice', () => {
const ai = makeAi({ general: { npcState: 2, officerLevel: 1 } });
const events: AiDecisionTraceEvent[] = [];
const calls: string[] = [];
Object.assign(ai, {
onDecisionTrace: (event: AiDecisionTraceEvent) => events.push(event), traceSequence: 0,
updateInstance: () => undefined, categorizeNationCities: () => undefined,
categorizeNationGeneral: () => undefined,
nationPolicy: { priority: ['disabled', 'unregistered'], can: (name: string) => {
calls.push(name); return name !== 'disabled';
} },
});
const selected = ai.chooseNationTurn({ action: '휴식', args: {} });
expect(selected).toMatchObject({ action: '휴식', reason: 'neutral' });
expect(calls).toEqual(['disabled', 'unregistered']);
expect(events.map((step) => step.sequence)).toEqual([0, 1, 2, 3]);
expect(events).toMatchObject([
{ kind: 'DECISION_START', phase: 'nation' },
{ kind: 'PROCEDURE_SKIP', procedure: 'disabled', reason: 'POLICY' },
{ kind: 'PROCEDURE_SKIP', procedure: 'unregistered', reason: 'NO_HANDLER' },
{ kind: 'DECISION_END', action: '휴식' },
]);
});
it('does not invent procedures after a reserved general command and propagates failures', () => {
const ai = makeAi({ general: { npcState: 1, officerLevel: 1 } });
const events: AiDecisionTraceEvent[] = [];
Object.assign(ai, { onDecisionTrace: (event: AiDecisionTraceEvent) => events.push(event),
traceSequence: 0, updateInstance: () => undefined });
expect(ai.chooseGeneralTurn({ action: 'che_이동', args: { destCityId: 2 } })).toEqual({
action: 'che_이동', args: { destCityId: 2 }, reason: 'do예약턴',
});
expect(events.map((step) => step.kind)).toEqual(['DECISION_START', 'DECISION_END']);
const failure = new Error('private diagnostic');
Object.assign(ai, { updateInstance: () => { throw failure; } });
expect(() => ai.chooseGeneralTurn({ action: '휴식', args: {} })).toThrow(failure);
expect(events.at(-1)).toMatchObject({ kind: 'DECISION_ERROR', sequence: 3 });
expect(JSON.stringify(events)).not.toContain('private diagnostic');
});
});
@@ -72,6 +72,7 @@ export type TurnTestHarnessOptions = {
dispatchScenarioEvent?: InMemoryTurnProcessorOptions['dispatchScenarioEvent'];
};
worldRef?: { current: InMemoryTurnWorld | null };
onDecisionTrace?: Parameters<typeof createReservedTurnHandler>[0]['onDecisionTrace'];
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled'];
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory'];
@@ -113,6 +114,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
map: options.map,
unitSet: options.snapshot.unitSet,
getWorld: () => worldRef.current,
onDecisionTrace: options.onDecisionTrace,
onActionResolved: options.onActionResolved,
onActionProfiled: options.onActionProfiled,
commandRngFactory: options.commandRngFactory,
@@ -1,3 +1,4 @@
import type { AiDecisionTraceEvent } from '../src/turn/ai/generalAi/trace.js';
import { describe, expect, it, vi } from 'vitest';
import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
@@ -245,7 +246,9 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
},
};
const decisionTrace: AiDecisionTraceEvent[] = [];
const { runUntil } = await createTurnTestHarness({
onDecisionTrace: auditEnabled ? (event) => decisionTrace.push(event) : undefined,
snapshot,
state,
schedule,
@@ -435,5 +438,14 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
debug.dumpWatched('출병 기록 누락');
}
expect(dispatchCount).toBeGreaterThan(0);
if (auditEnabled) {
expect(decisionTrace.some((step) => step.kind === 'DECISION_START' && step.phase === 'nation')).toBe(true);
expect(decisionTrace.some((step) => step.kind === 'DECISION_END' && step.phase === 'general')).toBe(true);
expect(decisionTrace.some((step) => step.kind === 'RNG')).toBe(true);
expect(decisionTrace.some((step) => step.kind === 'PROCEDURE_START')).toBe(true);
expect(decisionTrace.some((step) => step.kind === 'CANDIDATE')).toBe(true);
expect(JSON.stringify(decisionTrace)).not.toContain('seed');
expect(JSON.stringify(decisionTrace)).not.toContain('killturn');
} else expect(decisionTrace).toEqual([]);
});
});
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { observeAiRng, type AiTraceStep } from '../src/turn/ai/generalAi/trace.js';
describe('AI RNG observation', () => {
it.each(['audit-a', 'audit-b', 'audit-c'])(
'preserves results, identity and following random state for %s',
(seed) => {
const baseline = new RandUtil(LiteHashDRBG.build(seed));
const events: AiTraceStep[] = [];
const observed = observeAiRng(new RandUtil(LiteHashDRBG.build(seed)), (event) => events.push(event));
const candidates = [
{ id: 1, secret: 'hidden' },
{ id: 2, secret: 'hidden' },
];
const draw = (rng: RandUtil) => [
rng.nextFloat1(),
rng.nextBool(0),
rng.nextBool(1),
rng.nextBool(0.5),
rng.nextBool(0.3),
rng.nextRange(-10, 100),
rng.nextRangeInt(1, 30),
rng.nextInt(1, 2),
rng.nextIntInclusive(0),
rng.choice([42]),
rng.choice(candidates),
rng.choice(new Set(candidates)),
rng.choice({ first: candidates[0]!, second: candidates[1]! }),
rng.choiceUsingWeight({ a: 0, b: 2, c: 3 }),
rng.choiceUsingWeightPair([
[candidates[0]!, 2],
[candidates[1]!, 3],
]),
rng.shuffle(candidates),
];
const expected = draw(baseline);
const actual = draw(observed);
expect(actual).toEqual(expected);
expect(actual[10]).toBe(expected[10]);
expect(events).toHaveLength(16); // helper 내부 호출을 별도 판단으로 중복 기록하지 않는다.
expect(JSON.stringify(events)).not.toContain('hidden');
expect(JSON.stringify(events)).not.toContain(seed);
expect(events[10]).toMatchObject({
kind: 'RNG',
method: 'choice',
result: { entityId: candidates.indexOf(actual[10] as (typeof candidates)[number]) + 1 },
});
expect(observed.nextFloat1()).toBe(baseline.nextFloat1());
}
);
it('does not fabricate successful observations for failed choices', () => {
const events: AiTraceStep[] = [];
const rng = observeAiRng(new RandUtil(LiteHashDRBG.build('error')), (step) => events.push(step));
expect(() => rng.choice([])).toThrow('Empty items');
expect(events).toEqual([]);
});
it('marks unsupported result shapes instead of copying arbitrary debug data', () => {
const events: AiTraceStep[] = [];
const rng = observeAiRng(new RandUtil(LiteHashDRBG.build('projection')), (step) => events.push(step));
const value = { secret: 'not a candidate id' };
expect(rng.choice([value])).toBe(value);
expect(events).toEqual([{ kind: 'RNG', method: 'choice', parameters: null, result: { unprojected: true } }]);
});
});
+17
View File
@@ -9,6 +9,23 @@ NPC 결정 trace와 조사 A~F의 완성, 전체 종료 경계 및 COST gate는
## 현재 구현
### NPC 판단 관측 기반 — 아직 운영 수집 아님
GeneralAI와 예약 실행 handler에 선택적 `onDecisionTrace` 관측 경계를 추가했다.
공유 AI의 수뇌→개인 결정 순서에 동일 sequence를 유지하며 시작/최종 선택/오류,
우선순위 절차 진입·결과, 정책/자동화/handler 부재 skip, 명령 후보 validation 결과와
실제로 호출한 RandUtil 결과를 관측한다. 예약 우선 반환 뒤의 절차는 만들어내지 않는다.
메서드를 재호출하지 않으며 RandUtil 내부 helper는 중복 사건으로 기록하지 않는다.
원문 seed/meta/debug 객체를 복제하지 않고 난수 결과의 객체는 ID만 투영한다. 투영할 수
없는 값은 `unprojected`로 명시하며 완전한 후보 상세라고 주장하지 않는다.
고정 seed3종에서16개 RNG utility 호출의 반환값·객체 identity·다음 RNG 결과가 같고,
실제 NPC 선전포고→개전→점령 fixture에서도 수집 on/off 회귀가 통과했다.
현재 default daemon에는 observer를 켜지 않았으며 DB 쓰기를 추가하지 않았다.
이는 R5의 관측 기반일 뿐 완료가 아니다. 다음 작업은 불변 결정 ID·정책/code version,
후보/조건별 실제 관측값, 실행 결과 연결, 같은 gameplay transaction의 pending/rollback,
정식 migration·bounded 정리, 프로필 목록/상세 API와 GUI를 연결하는 것이다.
### 전달 전 DB tick 정밀도 보완
월 표본과 정책의 기존 INTEGER tick은 1개월36,000,000 기준 약60개월에 넘친다.