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
+38 -1
View File
@@ -3,6 +3,39 @@ import { z } from 'zod';
import type { GamePrisma } from '@sammo-ts/infra';
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
const zEffectivePolicy = z.object({
schemaVersion: z.literal(1),
general: z.object({ priority: z.array(z.string()), flags: z.record(z.string(), z.boolean()) }),
nation: z.object({
priority: z.array(z.string()),
flags: z.record(z.string(), z.boolean()),
values: z.object({
reqNationGold: z.number(),
reqNationRice: z.number(),
reqHumanWarUrgentGold: z.number(),
reqHumanWarUrgentRice: z.number(),
reqHumanWarRecommandGold: z.number(),
reqHumanWarRecommandRice: z.number(),
reqHumanDevelGold: z.number(),
reqHumanDevelRice: z.number(),
reqNpcWarGold: z.number(),
reqNpcWarRice: z.number(),
reqNpcDevelGold: z.number(),
reqNpcDevelRice: z.number(),
minimumResourceActionAmount: z.number(),
maximumResourceActionAmount: z.number(),
minNpcWarLeadership: z.number(),
minWarCrew: z.number(),
minNpcRecruitCityPopulation: z.number(),
safeRecruitCityPopulationRatio: z.number(),
properWarTrainAtmos: z.number(),
cureThreshold: z.number(),
}),
combatForce: z.record(z.string(), z.array(z.number()).length(2)),
supportForce: z.array(z.number()),
developForce: z.array(z.number()),
}),
});
const zId = z.string().regex(/^[a-f0-9]{64}$/);
const zTick = z
.string()
@@ -71,7 +104,11 @@ const zStep = z.intersection(
)
.max(5),
}),
z.object({ kind: z.literal('DECISION_START'), reservedAction: z.string() }),
z.object({
kind: z.literal('DECISION_START'),
reservedAction: z.string(),
effectivePolicy: zEffectivePolicy.optional(),
}),
z.object({ kind: z.literal('DECISION_END'), action: z.string().nullable(), reason: z.string().nullable() }),
z.object({ kind: z.literal('DECISION_ERROR') }),
z.object({ kind: z.literal('PROCEDURE_START'), procedure: z.string() }),
@@ -2317,7 +2317,9 @@ integration('game API security over HTTP transport', () => {
{
decisionId: decisionIds[0]!,
ordinal: 0,
steps: Array.from({ length: 128 }, (_, sequence) => ({ ...step, sequence })),
steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127
? { ...step, sequence, kind: 'DECISION_START', reservedAction: '휴식', 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],"secret":"decision-secret"},"secret":"decision-secret"} }
: ({ ...step, sequence })),
},
{
decisionId: decisionIds[0]!,
@@ -2398,6 +2400,8 @@ integration('game API security over HTTP transport', () => {
},
});
expect(JSON.stringify(decisionPage.body)).not.toContain('decision-secret');
expect(JSON.stringify(decisionPage.body)).toContain('effectivePolicy');
expect(JSON.stringify(decisionPage.body)).toContain('4321');
expect((await get('decisionDetail', admin, { ...decisionDetailInput, cursor: 0 })).body).toMatchObject({
result: {
data: {
@@ -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 });
+5 -1
View File
@@ -156,6 +156,7 @@ const install = async (
{
ordinal: input.cursor === undefined ? 0 : 1,
steps: [
...(input.cursor === undefined ? [{ phase: 'general', generalId: 1, nationId: 2, cityId: 3, npcState: 2, year: 190, month: 6, tick: 100, sequence: 0, kind: 'DECISION_START', reservedAction: '휴식', 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]}} }] : []),
{
phase: 'general',
generalId: 1,
@@ -165,7 +166,7 @@ const install = async (
year: 190,
month: 6,
tick: 100,
sequence: input.cursor === undefined ? 0 : 128,
sequence: input.cursor === undefined ? 1 : 128,
...(input.cursor === undefined
? { kind: 'PROCEDURE_START', procedure: '<b>징병판정</b>' }
: {
@@ -1156,6 +1157,9 @@ test('NPC decisions are explicit, paginated, independently addressable and escap
await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('<b>징병판정</b>');
await expect(page.getByRole('list', { name: '판단 절차' }).locator('b')).toHaveCount(0);
await page.getByText('당시 합성 정책', { exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('국가 권장 금');
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('4321');
await page.getByRole('button', { name: '판단 절차 더 불러오기', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('실행 시도 1');
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText(
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { trpc } from '../../utils/trpc';
import { fieldLabels } from './policyLabels';
type Step = Awaited<ReturnType<typeof trpc.playAudit.decisionDetail.query>>['chunks'][number]['steps'][number];
type Policy = NonNullable<Extract<Step, { kind: 'DECISION_START' }>['effectivePolicy']>;
defineProps<{ policy: Policy }>();
const label = (key: string) => fieldLabels[key.replace('Npc', 'NPC')] ?? key;
</script>
<template>
<details>
<summary>당시 합성 정책</summary>
<p>기본값·서버·국가 설정과 유저 자동턴 옵션이 반영된 정책입니다. 현재 설정으로 재계산하지 않습니다.</p>
<section v-for="(part, key) in { general: policy.general, nation: policy.nation }" :key="key">
<h4>{{ key === 'general' ? '개인 행동' : '수뇌 행동' }}</h4>
<p>우선순위: {{ part.priority.join(' → ') || '없음' }}</p>
<ul>
<li v-for="(enabled, action) in part.flags" :key="action">
{{ action }}: {{ enabled ? '허용' : '제외' }}
</li>
</ul>
</section>
<h4>국가 정책 수치</h4>
<dl>
<template v-for="(value, key) in policy.nation.values" :key="key">
<dt>{{ label(key) }}</dt>
<dd>{{ value }}</dd>
</template>
<dt>전투 부대 편성 (장수: 출발·목적 도시)</dt>
<dd>{{ JSON.stringify(policy.nation.combatForce) }}</dd>
<dt>지원 부대 장수</dt>
<dd>{{ policy.nation.supportForce.join(', ') || '없음' }}</dd>
<dt>내정 부대 장수</dt>
<dd>{{ policy.nation.developForce.join(', ') || '없음' }}</dd>
</dl>
<p>실행 직전의 자원 지급 상한·개별 후보 조건과 별도 자동화 권한 판정은 정책 표에 포함되지 않습니다.</p>
</details>
</template>
<style scoped>
details {
min-width: 0;
overflow-wrap: anywhere;
}
dd {
margin-left: 12px;
margin-bottom: 6px;
}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import AuditEffectivePolicy from './AuditEffectivePolicy.vue';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { trpc } from '../../utils/trpc';
@@ -245,6 +246,10 @@ watch(
<template v-for="chunk in detail.chunks" :key="chunk.ordinal"
><li v-for="step in chunk.steps" :key="step.sequence" :value="step.sequence + 1">
{{ stepText(step) }}
<template v-if="step.kind === 'DECISION_START'">
<AuditEffectivePolicy v-if="step.effectivePolicy" :policy="step.effectivePolicy" />
<p v-else>당시 합성 정책 미수집</p>
</template>
<ul v-if="step.kind === 'EXECUTION_ATTEMPT'">
<li v-for="(check, index) in step.checks" :key="index">
{{ checkLabels[check.stage] }} · {{ check.action }} ·
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { fieldLabels } from './policyLabels';
import { trpc } from '../../utils/trpc';
import AuditRequestState from './AuditRequestState.vue';
const props = withDefaults(defineProps<{ id: string; allowPrevious?: boolean }>(), { allowPrevious: true });
@@ -11,35 +12,7 @@ const detailLoading = ref(false);
let detailGeneration = 0;
const select = (id: string | null) => emit('select', id);
const message = (cause: unknown) => (cause instanceof Error ? cause.message : '정책 버전을 조회하지 못했습니다.');
const fieldLabels: Record<string, string> = {
reqNationGold: '국가 권장 금',
reqNationRice: '국가 권장 쌀',
reqHumanWarUrgentGold: '유저전투장 긴급포상 금',
reqHumanWarUrgentRice: '유저전투장 긴급포상 쌀',
reqHumanWarRecommandGold: '유저전투장 권장 금',
reqHumanWarRecommandRice: '유저전투장 권장 쌀',
reqHumanDevelGold: '유저내정장 권장 금',
reqHumanDevelRice: '유저내정장 권장 쌀',
reqNPCWarGold: 'NPC전투장 권장 금',
reqNPCWarRice: 'NPC전투장 권장 쌀',
reqNPCDevelGold: 'NPC내정장 권장 금',
reqNPCDevelRice: 'NPC내정장 권장 쌀',
minimumResourceActionAmount: '포상/몰수/헌납/삼/팜 최소 단위',
maximumResourceActionAmount: '포상/몰수/헌납/삼/팜 최대 단위',
minWarCrew: '최소 전투 가능 병력 수',
minNPCRecruitCityPopulation: 'NPC 최소 징병 가능 인구 수',
safeRecruitCityPopulationRatio: '제자리 징병 허용 인구율 (비율)',
minNPCWarLeadership: 'NPC 전투 참여 통솔 기준',
properWarTrainAtmos: '훈련/사기진작 목표치',
cureThreshold: '요양 기준',
CombatForce: '전투 부대 편성',
SupportForce: '지원 부대 편성',
DevelopForce: '내정 부대 편성',
priority: '행동 우선순위',
war: '전쟁 금지 설정',
scout: '임관 권유 설정',
secretlimit: '기밀 공개 기준 (년)',
};
const labels = { BASELINE: '최초 관측', CHANGE: '실제 변경', OBSERVED_GAP: '관측 누락 이후 기준' };
const loadDetail = async () => {
const request = ++detailGeneration;
@@ -0,0 +1,29 @@
export const fieldLabels: Record<string, string> = {
reqNationGold: '국가 권장 금',
reqNationRice: '국가 권장 쌀',
reqHumanWarUrgentGold: '유저전투장 긴급포상 금',
reqHumanWarUrgentRice: '유저전투장 긴급포상 쌀',
reqHumanWarRecommandGold: '유저전투장 권장 금',
reqHumanWarRecommandRice: '유저전투장 권장 쌀',
reqHumanDevelGold: '유저내정장 권장 금',
reqHumanDevelRice: '유저내정장 권장 쌀',
reqNPCWarGold: 'NPC전투장 권장 금',
reqNPCWarRice: 'NPC전투장 권장 쌀',
reqNPCDevelGold: 'NPC내정장 권장 금',
reqNPCDevelRice: 'NPC내정장 권장 쌀',
minimumResourceActionAmount: '포상/몰수/헌납/삼/팜 최소 단위',
maximumResourceActionAmount: '포상/몰수/헌납/삼/팜 최대 단위',
minWarCrew: '최소 전투 가능 병력 수',
minNPCRecruitCityPopulation: 'NPC 최소 징병 가능 인구 수',
safeRecruitCityPopulationRatio: '제자리 징병 허용 인구율 (비율)',
minNPCWarLeadership: 'NPC 전투 참여 통솔 기준',
properWarTrainAtmos: '훈련/사기진작 목표치',
cureThreshold: '요양 기준',
CombatForce: '전투 부대 편성',
SupportForce: '지원 부대 편성',
DevelopForce: '내정 부대 편성',
priority: '행동 우선순위',
war: '전쟁 금지 설정',
scout: '임관 권유 설정',
secretlimit: '기밀 공개 기준 (년)',
};