feat: NPC 판단 당시 합성 정책을 기록하고 조회한다
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { snapshotEffectiveAiPolicy } from './effectivePolicy.js';
|
||||
import { observeAiRng, type AiDecisionTraceObserver, type AiTraceStep } from './trace.js';
|
||||
import type {
|
||||
City,
|
||||
@@ -216,7 +217,8 @@ export class GeneralAI {
|
||||
): AiCommandCandidate | null {
|
||||
if (!this.onDecisionTrace) return choose();
|
||||
this.tracePhase = phase;
|
||||
this.trace({ kind: 'DECISION_START', reservedAction: reserved.action });
|
||||
this.trace({ kind: 'DECISION_START', reservedAction: reserved.action,
|
||||
effectivePolicy: snapshotEffectiveAiPolicy(this.generalPolicy, this.nationPolicy) });
|
||||
try {
|
||||
const result = choose();
|
||||
this.trace({ kind: 'DECISION_END', action: result?.action ?? null, reason: result?.reason ?? null });
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { AutorunGeneralPolicy, AutorunNationPolicy } from '../policies.js';
|
||||
|
||||
/** 이미 합성된 정책만 복사한다. can() 재평가, world/meta 복사나 RNG 호출은 하지 않는다. */
|
||||
export const snapshotEffectiveAiPolicy = (general: AutorunGeneralPolicy, nation: AutorunNationPolicy) => ({
|
||||
schemaVersion: 1 as const,
|
||||
general: { priority: [...general.priority], flags: { ...general.flags } },
|
||||
nation: {
|
||||
priority: [...nation.priority],
|
||||
flags: { ...nation.flags },
|
||||
values: {
|
||||
reqNationGold: nation.reqNationGold,
|
||||
reqNationRice: nation.reqNationRice,
|
||||
reqHumanWarUrgentGold: nation.reqHumanWarUrgentGold,
|
||||
reqHumanWarUrgentRice: nation.reqHumanWarUrgentRice,
|
||||
reqHumanWarRecommandGold: nation.reqHumanWarRecommandGold,
|
||||
reqHumanWarRecommandRice: nation.reqHumanWarRecommandRice,
|
||||
reqHumanDevelGold: nation.reqHumanDevelGold,
|
||||
reqHumanDevelRice: nation.reqHumanDevelRice,
|
||||
reqNpcWarGold: nation.reqNpcWarGold,
|
||||
reqNpcWarRice: nation.reqNpcWarRice,
|
||||
reqNpcDevelGold: nation.reqNpcDevelGold,
|
||||
reqNpcDevelRice: nation.reqNpcDevelRice,
|
||||
minimumResourceActionAmount: nation.minimumResourceActionAmount,
|
||||
maximumResourceActionAmount: nation.maximumResourceActionAmount,
|
||||
minNpcWarLeadership: nation.minNpcWarLeadership,
|
||||
minWarCrew: nation.minWarCrew,
|
||||
minNpcRecruitCityPopulation: nation.minNpcRecruitCityPopulation,
|
||||
safeRecruitCityPopulationRatio: nation.safeRecruitCityPopulationRatio,
|
||||
properWarTrainAtmos: nation.properWarTrainAtmos,
|
||||
cureThreshold: nation.cureThreshold,
|
||||
},
|
||||
combatForce: Object.fromEntries(Object.entries(nation.combatForce).map(([id, cities]) => [id, [...cities]])),
|
||||
supportForce: [...nation.supportForce],
|
||||
developForce: [...nation.developForce],
|
||||
},
|
||||
});
|
||||
export type EffectiveAiPolicy = ReturnType<typeof snapshotEffectiveAiPolicy>;
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EffectiveAiPolicy } from './effectivePolicy.js';
|
||||
import type { RandUtil } from '@sammo-ts/common';
|
||||
|
||||
/** 원문 meta/seed/임의 객체를 받지 않는 관측 계약. 내부 후보 조건은 별도 계측으로 확장한다. */
|
||||
@@ -22,7 +23,7 @@ export type AiExecutionAttempt = {
|
||||
};
|
||||
export type AiTraceStep =
|
||||
| AiExecutionAttempt
|
||||
| { kind: 'DECISION_START'; reservedAction: string }
|
||||
| { kind: 'DECISION_START'; reservedAction: string; effectivePolicy?: EffectiveAiPolicy }
|
||||
| { kind: 'DECISION_END'; action: string | null; reason: string | null }
|
||||
| { kind: 'DECISION_ERROR' }
|
||||
| { kind: 'PROCEDURE_START'; procedure: string }
|
||||
|
||||
@@ -366,10 +366,12 @@ const makeAi = (
|
||||
},
|
||||
},
|
||||
generalPolicy: {
|
||||
priority: [], flags: {},
|
||||
can: (action: string) =>
|
||||
!disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action),
|
||||
},
|
||||
nationPolicy: {
|
||||
priority: [], flags: {}, combatForce: {}, supportForce: [], developForce: [],
|
||||
minWarCrew: 1500,
|
||||
minNpcRecruitCityPopulation: 30_000,
|
||||
safeRecruitCityPopulationRatio: 0.5,
|
||||
@@ -2202,7 +2204,7 @@ describe('AI decision observation boundaries', () => {
|
||||
onDecisionTrace: (event: AiDecisionTraceEvent) => events.push(event), traceSequence: 0,
|
||||
updateInstance: () => undefined, categorizeNationCities: () => undefined,
|
||||
categorizeNationGeneral: () => undefined,
|
||||
nationPolicy: { priority: ['disabled', 'unregistered'], can: (name: string) => {
|
||||
nationPolicy: { ...ai.nationPolicy, priority: ['disabled', 'unregistered'], can: (name: string) => {
|
||||
calls.push(name); return name !== 'disabled';
|
||||
} },
|
||||
});
|
||||
|
||||
@@ -455,6 +455,13 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
|
||||
expect(savedDecisions.every((row) => row.steps[0]?.kind === 'DECISION_START' && row.steps.at(-1)?.kind === 'EXECUTION_ATTEMPT')).toBe(true);
|
||||
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);
|
||||
const start = decisionTrace.find((step) => step.kind === 'DECISION_START');
|
||||
expect(start?.kind === 'DECISION_START' && start.effectivePolicy?.schemaVersion).toBe(1);
|
||||
if (start?.kind === 'DECISION_START') {
|
||||
expect(start.effectivePolicy?.nation.values.minimumResourceActionAmount).toBeGreaterThan(0);
|
||||
expect(start.effectivePolicy?.general.priority.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { snapshotEffectiveAiPolicy } from '../src/turn/ai/generalAi/effectivePolicy.js';
|
||||
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -5,7 +6,7 @@ import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { AutorunNationPolicy } from '../src/turn/ai/policies.js';
|
||||
import { AutorunGeneralPolicy, AutorunNationPolicy } from '../src/turn/ai/policies.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { applyNpcPolicyMutation } from '../src/turn/npcPolicyMutation.js';
|
||||
|
||||
@@ -256,6 +257,15 @@ describe('NPC policy lifecycle', () => {
|
||||
scenarioConfig: snapshot.scenarioConfig,
|
||||
unitSet,
|
||||
});
|
||||
const generalPolicy = new AutorunGeneralPolicy(world.getGeneralById(1)!, null, null, null);
|
||||
const captured = snapshotEffectiveAiPolicy(generalPolicy, policy);
|
||||
expect(captured.nation.values.reqNationGold).toBe(4_321);
|
||||
expect(captured.nation.values.reqNpcDevelGold).toBe(540);
|
||||
expect(captured.nation.priority).toEqual(['천도']);
|
||||
policy.supportForce.push(123);
|
||||
policy.flags['천도'] = false;
|
||||
expect(captured.nation.supportForce).toEqual([]);
|
||||
expect(captured.nation.flags['천도']).toBe(true);
|
||||
expect(policy.reqNationGold).toBe(4_321);
|
||||
expect(policy.priority).toEqual(['천도']);
|
||||
expect(policy.reqNpcDevelGold).toBe(540);
|
||||
|
||||
@@ -36,6 +36,7 @@ integration('decision persistence and bounded retention', () => {
|
||||
});
|
||||
it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => {
|
||||
const decision = draft('decision-one');
|
||||
if (decision.steps[0]?.kind === 'DECISION_START') decision.steps[0].effectivePolicy = {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5]}};
|
||||
decision.summary.codeVersion = 'a'.repeat(40);
|
||||
decision.summary.executionCoverage = 'ATTEMPTS';
|
||||
decision.steps.push({ ...decision.steps[0]!, ...buildAuditExecutionFixture(), sequence: 302 });
|
||||
|
||||
Reference in New Issue
Block a user