NPC 판단 절차와 난수 결과의 선택적 감사 관측 기반 추가
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user