feat: NPC 판단 당시 합성 정책을 기록하고 조회한다

This commit is contained in:
2026-09-16 09:21:50 +00:00
parent c9fe204c94
commit 292ca7662d
16 changed files with 216 additions and 40 deletions
@@ -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 });