From 9f31b2829778a328be4812f6d30aca3015c14f3e Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 07:40:29 +0000 Subject: [PATCH] =?UTF-8?q?NPC=20=ED=8C=90=EB=8B=A8=20=EC=A0=88=EC=B0=A8?= =?UTF-8?q?=EC=99=80=20=EB=82=9C=EC=88=98=20=EA=B2=B0=EA=B3=BC=EC=9D=98=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A0=81=20=EA=B0=90=EC=82=AC=20=EA=B4=80?= =?UTF-8?q?=EC=B8=A1=20=EA=B8=B0=EB=B0=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/src/turn/ai/generalAi/core.ts | 113 ++++++++++++++---- .../src/turn/ai/generalAi/trace.ts | 73 +++++++++++ .../src/turn/ai/generalAi/types.ts | 2 + .../src/turn/reservedTurnHandler.ts | 4 + .../generalAiLegacyDecisionParity.test.ts | 43 +++++++ .../test/helpers/turnTestHarness.ts | 2 + .../test/npcNationWarDeclaration.test.ts | 12 ++ app/game-engine/test/playAuditAiRng.test.ts | 65 ++++++++++ docs/design/play-audit-implementation.md | 17 +++ 9 files changed, 311 insertions(+), 20 deletions(-) create mode 100644 app/game-engine/src/turn/ai/generalAi/trace.ts create mode 100644 app/game-engine/test/playAuditAiRng.test.ts diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index 113af165..dc2149c5 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -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); @@ -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; } diff --git a/app/game-engine/src/turn/ai/generalAi/trace.ts b/app/game-engine/src/turn/ai/generalAi/trace.ts new file mode 100644 index 00000000..bce7f00e --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/trace.ts @@ -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; + }; + }, + }); diff --git a/app/game-engine/src/turn/ai/generalAi/types.ts b/app/game-engine/src/turn/ai/generalAi/types.ts index 4b9aa9cc..833436d4 100644 --- a/app/game-engine/src/turn/ai/generalAi/types.ts +++ b/app/game-engine/src/turn/ai/generalAi/types.ts @@ -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; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 5492c428..f1c53269 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -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, diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 22252e2d..a6103ede 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -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'); + }); +}); diff --git a/app/game-engine/test/helpers/turnTestHarness.ts b/app/game-engine/test/helpers/turnTestHarness.ts index 4e830c5b..0539e066 100644 --- a/app/game-engine/test/helpers/turnTestHarness.ts +++ b/app/game-engine/test/helpers/turnTestHarness.ts @@ -72,6 +72,7 @@ export type TurnTestHarnessOptions = { dispatchScenarioEvent?: InMemoryTurnProcessorOptions['dispatchScenarioEvent']; }; worldRef?: { current: InMemoryTurnWorld | null }; + onDecisionTrace?: Parameters[0]['onDecisionTrace']; onActionResolved?: Parameters[0]['onActionResolved']; onActionProfiled?: Parameters[0]['onActionProfiled']; commandRngFactory?: Parameters[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, diff --git a/app/game-engine/test/npcNationWarDeclaration.test.ts b/app/game-engine/test/npcNationWarDeclaration.test.ts index 563e3e21..8950ec14 100644 --- a/app/game-engine/test/npcNationWarDeclaration.test.ts +++ b/app/game-engine/test/npcNationWarDeclaration.test.ts @@ -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([]); }); }); diff --git a/app/game-engine/test/playAuditAiRng.test.ts b/app/game-engine/test/playAuditAiRng.test.ts new file mode 100644 index 00000000..5bd2a3b1 --- /dev/null +++ b/app/game-engine/test/playAuditAiRng.test.ts @@ -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 } }]); + }); +}); diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 8aa42e01..7d17b04b 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -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개월에 넘친다.