NPC 판단 절차와 난수 결과의 선택적 감사 관측 기반 추가
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { observeAiRng, type AiDecisionTraceObserver, type AiTraceStep } from './trace.js';
|
||||||
import type {
|
import type {
|
||||||
City,
|
City,
|
||||||
GeneralActionDefinition,
|
GeneralActionDefinition,
|
||||||
@@ -188,6 +189,65 @@ export class GeneralAI {
|
|||||||
public readonly nationFallback: GeneralActionDefinition;
|
public readonly nationFallback: GeneralActionDefinition;
|
||||||
|
|
||||||
public readonly rng: RandUtil;
|
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 env: ConstraintEnv;
|
||||||
public readonly startYear: number;
|
public readonly startYear: number;
|
||||||
public readonly turnTermMinutes: number;
|
public readonly turnTermMinutes: number;
|
||||||
@@ -302,6 +362,7 @@ export class GeneralAI {
|
|||||||
})}\n`
|
})}\n`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
this.onDecisionTrace = options.onDecisionTrace;
|
||||||
const baseRng = new RandUtil(LiteHashDRBG.build(seed));
|
const baseRng = new RandUtil(LiteHashDRBG.build(seed));
|
||||||
const traceRng = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id));
|
const traceRng = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id));
|
||||||
let traceSequence = 0;
|
let traceSequence = 0;
|
||||||
@@ -345,6 +406,8 @@ export class GeneralAI {
|
|||||||
})
|
})
|
||||||
: baseRng;
|
: baseRng;
|
||||||
|
|
||||||
|
if (this.onDecisionTrace) this.rng = observeAiRng(this.rng, (step) => this.trace(step));
|
||||||
|
|
||||||
const constValues = asRecord(this.scenarioConfig.const);
|
const constValues = asRecord(this.scenarioConfig.const);
|
||||||
this.aiConst = {
|
this.aiConst = {
|
||||||
baseGold: this.commandEnv.baseGold,
|
baseGold: this.commandEnv.baseGold,
|
||||||
@@ -401,6 +464,10 @@ export class GeneralAI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
chooseNationTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
chooseNationTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
||||||
|
return this.traceDecision('nation', reservedTurn, () => this.chooseNationTurnObserved(reservedTurn));
|
||||||
|
}
|
||||||
|
|
||||||
|
private chooseNationTurnObserved(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
||||||
this.updateInstance();
|
this.updateInstance();
|
||||||
if (!this.nation || !this.worldRef) {
|
if (!this.nation || !this.worldRef) {
|
||||||
return null;
|
return null;
|
||||||
@@ -425,16 +492,15 @@ export class GeneralAI {
|
|||||||
|
|
||||||
for (const actionName of this.nationPolicy.priority) {
|
for (const actionName of this.nationPolicy.priority) {
|
||||||
if (!this.nationPolicy.can(actionName)) {
|
if (!this.nationPolicy.can(actionName)) {
|
||||||
|
this.trace({ kind: 'PROCEDURE_SKIP', procedure: actionName, reason: 'POLICY' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!canUseAutomatedNationAction(this.general, actionName)) {
|
if (!canUseAutomatedNationAction(this.general, actionName)) {
|
||||||
|
this.trace({ kind: 'PROCEDURE_SKIP', procedure: actionName, reason: 'AUTOMATION' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const handler = nationActionHandlers[actionName];
|
const handler = nationActionHandlers[actionName];
|
||||||
if (!handler) {
|
const result = this.traceProcedure(actionName, handler);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const result = handler(this);
|
|
||||||
if (result) {
|
if (result) {
|
||||||
// Ref refreshes the cached AI state after these selected nation
|
// Ref refreshes the cached AI state after these selected nation
|
||||||
// commands, before choosing the general command with the same
|
// commands, before choosing the general command with the same
|
||||||
@@ -506,6 +572,10 @@ export class GeneralAI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
||||||
|
return this.traceDecision('general', reservedTurn, () => this.chooseGeneralTurnObserved(reservedTurn));
|
||||||
|
}
|
||||||
|
|
||||||
|
private chooseGeneralTurnObserved(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
||||||
this.updateInstance();
|
this.updateInstance();
|
||||||
if (!this.worldRef) {
|
if (!this.worldRef) {
|
||||||
return null;
|
return null;
|
||||||
@@ -524,7 +594,7 @@ export class GeneralAI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.general.officerLevel === 12 && this.generalPolicy.can('선양')) {
|
if (this.general.officerLevel === 12 && this.generalPolicy.can('선양')) {
|
||||||
const abdication = generalActionHandlers['선양']?.(this);
|
const abdication = this.traceProcedure('선양', generalActionHandlers['선양']);
|
||||||
if (abdication) {
|
if (abdication) {
|
||||||
return abdication;
|
return abdication;
|
||||||
}
|
}
|
||||||
@@ -535,7 +605,7 @@ export class GeneralAI {
|
|||||||
this.general.meta = { ...this.general.meta, killturn: 1 };
|
this.general.meta = { ...this.general.meta, killturn: 1 };
|
||||||
return { action: reservedTurn.action, args: reservedTurn.args, reason: '사망' };
|
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');
|
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) {
|
if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) {
|
||||||
const rebellion = generalActionHandlers['거병']?.(this);
|
const rebellion = this.traceProcedure('거병', generalActionHandlers['거병']);
|
||||||
if (rebellion) {
|
if (rebellion) {
|
||||||
return rebellion;
|
return rebellion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.general.nationId === 0 && this.generalPolicy.can('국가선택')) {
|
if (this.general.nationId === 0 && this.generalPolicy.can('국가선택')) {
|
||||||
const pickNation = generalActionHandlers['국가선택']?.(this);
|
const pickNation = this.traceProcedure('국가선택', generalActionHandlers['국가선택']);
|
||||||
if (pickNation) {
|
if (pickNation) {
|
||||||
return pickNation;
|
return pickNation;
|
||||||
}
|
}
|
||||||
const neutral = generalActionHandlers['중립']?.(this);
|
const neutral = this.traceProcedure('중립', generalActionHandlers['중립']);
|
||||||
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
|
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,17 +646,17 @@ export class GeneralAI {
|
|||||||
const relYearMonth =
|
const relYearMonth =
|
||||||
joinYearMonth(this.world.currentYear, this.world.currentMonth) - joinYearMonth(initYear, initMonth);
|
joinYearMonth(this.world.currentYear, this.world.currentMonth) - joinYearMonth(initYear, initMonth);
|
||||||
if (relYearMonth > 1) {
|
if (relYearMonth > 1) {
|
||||||
const establish = generalActionHandlers['건국']?.(this);
|
const establish = this.traceProcedure('건국', generalActionHandlers['건국']);
|
||||||
if (establish) {
|
if (establish) {
|
||||||
return establish;
|
return establish;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const move = generalActionHandlers['방랑군이동']?.(this);
|
const move = this.traceProcedure('방랑군이동', generalActionHandlers['방랑군이동']);
|
||||||
if (move) {
|
if (move) {
|
||||||
return move;
|
return move;
|
||||||
}
|
}
|
||||||
if (relYearMonth > 1) {
|
if (relYearMonth > 1) {
|
||||||
const disband = generalActionHandlers['해산']?.(this);
|
const disband = this.traceProcedure('해산', generalActionHandlers['해산']);
|
||||||
if (disband) {
|
if (disband) {
|
||||||
return disband;
|
return disband;
|
||||||
}
|
}
|
||||||
@@ -596,6 +666,7 @@ export class GeneralAI {
|
|||||||
for (const actionName of this.generalPolicy.priority) {
|
for (const actionName of this.generalPolicy.priority) {
|
||||||
const allowed = this.generalPolicy.can(actionName);
|
const allowed = this.generalPolicy.can(actionName);
|
||||||
if (!allowed) {
|
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))) {
|
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result: null })}\n`
|
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result: null })}\n`
|
||||||
@@ -604,10 +675,7 @@ export class GeneralAI {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const handler = generalActionHandlers[actionName];
|
const handler = generalActionHandlers[actionName];
|
||||||
if (!handler) {
|
const result = this.traceProcedure(actionName, handler);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const result = handler(this);
|
|
||||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
|
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result })}\n`
|
`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');
|
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,6 +1115,7 @@ export class GeneralAI {
|
|||||||
const definition = definitions.get(action) ?? fallback;
|
const definition = definitions.get(action) ?? fallback;
|
||||||
const parsedArgs = definition.parseArgs(args);
|
const parsedArgs = definition.parseArgs(args);
|
||||||
if (parsedArgs === null) {
|
if (parsedArgs === null) {
|
||||||
|
this.trace({ kind: 'CANDIDATE', action, result: 'INVALID_ARGS', constraint: null });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const constraintArgs = withCanonicalArgumentAliases(parsedArgs as Record<string, unknown>);
|
const constraintArgs = withCanonicalArgumentAliases(parsedArgs as Record<string, unknown>);
|
||||||
@@ -1066,6 +1135,12 @@ export class GeneralAI {
|
|||||||
});
|
});
|
||||||
const constraints = definition.buildConstraints(ctx, parsedArgs as never);
|
const constraints = definition.buildConstraints(ctx, parsedArgs as never);
|
||||||
const result = evaluateConstraints(constraints, ctx, view);
|
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 (result.kind !== 'allow') {
|
||||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
|
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
@@ -1359,9 +1434,7 @@ export class GeneralAI {
|
|||||||
if (
|
if (
|
||||||
asRecord(candidate.meta).permission !== 'ambassador' ||
|
asRecord(candidate.meta).permission !== 'ambassador' ||
|
||||||
assignedAmbassadorIds.has(candidate.id) ||
|
assignedAmbassadorIds.has(candidate.id) ||
|
||||||
this.promotionPatches.some(
|
this.promotionPatches.some((patch) => patch.generalId === candidate.id && patch.permission === 'normal')
|
||||||
(patch) => patch.generalId === candidate.id && patch.permission === 'normal'
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
continue;
|
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 {
|
import type {
|
||||||
City,
|
City,
|
||||||
GeneralActionDefinition,
|
GeneralActionDefinition,
|
||||||
@@ -14,6 +15,7 @@ import type { TurnGeneral, TurnWorldState } from '../../types.js';
|
|||||||
import type { AiReservedTurnProvider, AiWorldView } from '../types.js';
|
import type { AiReservedTurnProvider, AiWorldView } from '../types.js';
|
||||||
|
|
||||||
export interface GeneralAIOptions {
|
export interface GeneralAIOptions {
|
||||||
|
onDecisionTrace?: AiDecisionTraceObserver;
|
||||||
general: TurnGeneral;
|
general: TurnGeneral;
|
||||||
city?: City;
|
city?: City;
|
||||||
nation?: Nation | null;
|
nation?: Nation | null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AiDecisionTraceObserver } from './ai/generalAi/trace.js';
|
||||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||||
import type {
|
import type {
|
||||||
ActionContextBase,
|
ActionContextBase,
|
||||||
@@ -900,6 +901,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
nation: Nation,
|
nation: Nation,
|
||||||
currentMonth: number
|
currentMonth: number
|
||||||
) => Nation['meta'] | null;
|
) => Nation['meta'] | null;
|
||||||
|
onDecisionTrace?: AiDecisionTraceObserver;
|
||||||
onActionResolved?: (payload: {
|
onActionResolved?: (payload: {
|
||||||
kind: 'nation' | 'general';
|
kind: 'nation' | 'general';
|
||||||
generalId: number;
|
generalId: number;
|
||||||
@@ -1934,6 +1936,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
nationUsedAi = true;
|
nationUsedAi = true;
|
||||||
const aiStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
|
const aiStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
|
||||||
sharedAi = new GeneralAI({
|
sharedAi = new GeneralAI({
|
||||||
|
onDecisionTrace: options.onDecisionTrace,
|
||||||
general: currentGeneral,
|
general: currentGeneral,
|
||||||
city: currentCity,
|
city: currentCity,
|
||||||
nation: currentNation,
|
nation: currentNation,
|
||||||
@@ -2090,6 +2093,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
const ai =
|
const ai =
|
||||||
sharedAi ??
|
sharedAi ??
|
||||||
new GeneralAI({
|
new GeneralAI({
|
||||||
|
onDecisionTrace: options.onDecisionTrace,
|
||||||
general: currentGeneral,
|
general: currentGeneral,
|
||||||
city: currentCity,
|
city: currentCity,
|
||||||
nation: currentNation,
|
nation: currentNation,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AiDecisionTraceEvent } from '../src/turn/ai/generalAi/trace.js';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { loadItemModules, type City, type General, type Nation } from '@sammo-ts/logic';
|
import { loadItemModules, type City, type General, type Nation } from '@sammo-ts/logic';
|
||||||
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
|
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]);
|
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'];
|
dispatchScenarioEvent?: InMemoryTurnProcessorOptions['dispatchScenarioEvent'];
|
||||||
};
|
};
|
||||||
worldRef?: { current: InMemoryTurnWorld | null };
|
worldRef?: { current: InMemoryTurnWorld | null };
|
||||||
|
onDecisionTrace?: Parameters<typeof createReservedTurnHandler>[0]['onDecisionTrace'];
|
||||||
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
|
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
|
||||||
onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled'];
|
onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled'];
|
||||||
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory'];
|
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory'];
|
||||||
@@ -113,6 +114,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
|
|||||||
map: options.map,
|
map: options.map,
|
||||||
unitSet: options.snapshot.unitSet,
|
unitSet: options.snapshot.unitSet,
|
||||||
getWorld: () => worldRef.current,
|
getWorld: () => worldRef.current,
|
||||||
|
onDecisionTrace: options.onDecisionTrace,
|
||||||
onActionResolved: options.onActionResolved,
|
onActionResolved: options.onActionResolved,
|
||||||
onActionProfiled: options.onActionProfiled,
|
onActionProfiled: options.onActionProfiled,
|
||||||
commandRngFactory: options.commandRngFactory,
|
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 { describe, expect, it, vi } from 'vitest';
|
||||||
import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
||||||
import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } 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({
|
const { runUntil } = await createTurnTestHarness({
|
||||||
|
onDecisionTrace: auditEnabled ? (event) => decisionTrace.push(event) : undefined,
|
||||||
snapshot,
|
snapshot,
|
||||||
state,
|
state,
|
||||||
schedule,
|
schedule,
|
||||||
@@ -435,5 +438,14 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
|
|||||||
debug.dumpWatched('출병 기록 누락');
|
debug.dumpWatched('출병 기록 누락');
|
||||||
}
|
}
|
||||||
expect(dispatchCount).toBeGreaterThan(0);
|
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 } }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 정밀도 보완
|
### 전달 전 DB tick 정밀도 보완
|
||||||
|
|
||||||
월 표본과 정책의 기존 INTEGER tick은 1개월36,000,000 기준 약60개월에 넘친다.
|
월 표본과 정책의 기존 INTEGER tick은 1개월36,000,000 기준 약60개월에 넘친다.
|
||||||
|
|||||||
Reference in New Issue
Block a user