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
@@ -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 } }]);
});
});