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 type { GamePrisma } from '@sammo-ts/infra';
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js'; 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 zId = z.string().regex(/^[a-f0-9]{64}$/);
const zTick = z const zTick = z
.string() .string()
@@ -71,7 +104,11 @@ const zStep = z.intersection(
) )
.max(5), .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_END'), action: z.string().nullable(), reason: z.string().nullable() }),
z.object({ kind: z.literal('DECISION_ERROR') }), z.object({ kind: z.literal('DECISION_ERROR') }),
z.object({ kind: z.literal('PROCEDURE_START'), procedure: z.string() }), z.object({ kind: z.literal('PROCEDURE_START'), procedure: z.string() }),
@@ -2317,7 +2317,9 @@ integration('game API security over HTTP transport', () => {
{ {
decisionId: decisionIds[0]!, decisionId: decisionIds[0]!,
ordinal: 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]!, 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)).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({ expect((await get('decisionDetail', admin, { ...decisionDetailInput, cursor: 0 })).body).toMatchObject({
result: { result: {
data: { data: {
@@ -1,3 +1,4 @@
import { snapshotEffectiveAiPolicy } from './effectivePolicy.js';
import { observeAiRng, type AiDecisionTraceObserver, type AiTraceStep } from './trace.js'; import { observeAiRng, type AiDecisionTraceObserver, type AiTraceStep } from './trace.js';
import type { import type {
City, City,
@@ -216,7 +217,8 @@ export class GeneralAI {
): AiCommandCandidate | null { ): AiCommandCandidate | null {
if (!this.onDecisionTrace) return choose(); if (!this.onDecisionTrace) return choose();
this.tracePhase = phase; 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 { try {
const result = choose(); const result = choose();
this.trace({ kind: 'DECISION_END', action: result?.action ?? null, reason: result?.reason ?? null }); 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'; import type { RandUtil } from '@sammo-ts/common';
/** 원문 meta/seed/임의 객체를 받지 않는 관측 계약. 내부 후보 조건은 별도 계측으로 확장한다. */ /** 원문 meta/seed/임의 객체를 받지 않는 관측 계약. 내부 후보 조건은 별도 계측으로 확장한다. */
@@ -22,7 +23,7 @@ export type AiExecutionAttempt = {
}; };
export type AiTraceStep = export type AiTraceStep =
| AiExecutionAttempt | AiExecutionAttempt
| { kind: 'DECISION_START'; reservedAction: string } | { kind: 'DECISION_START'; reservedAction: string; effectivePolicy?: EffectiveAiPolicy }
| { kind: 'DECISION_END'; action: string | null; reason: string | null } | { kind: 'DECISION_END'; action: string | null; reason: string | null }
| { kind: 'DECISION_ERROR' } | { kind: 'DECISION_ERROR' }
| { kind: 'PROCEDURE_START'; procedure: string } | { kind: 'PROCEDURE_START'; procedure: string }
@@ -366,10 +366,12 @@ const makeAi = (
}, },
}, },
generalPolicy: { generalPolicy: {
priority: [], flags: {},
can: (action: string) => can: (action: string) =>
!disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action), !disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action),
}, },
nationPolicy: { nationPolicy: {
priority: [], flags: {}, combatForce: {}, supportForce: [], developForce: [],
minWarCrew: 1500, minWarCrew: 1500,
minNpcRecruitCityPopulation: 30_000, minNpcRecruitCityPopulation: 30_000,
safeRecruitCityPopulationRatio: 0.5, safeRecruitCityPopulationRatio: 0.5,
@@ -2202,7 +2204,7 @@ describe('AI decision observation boundaries', () => {
onDecisionTrace: (event: AiDecisionTraceEvent) => events.push(event), traceSequence: 0, onDecisionTrace: (event: AiDecisionTraceEvent) => events.push(event), traceSequence: 0,
updateInstance: () => undefined, categorizeNationCities: () => undefined, updateInstance: () => undefined, categorizeNationCities: () => undefined,
categorizeNationGeneral: () => 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'; 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(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_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 === '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 === 'RNG')).toBe(true);
expect(decisionTrace.some((step) => step.kind === 'PROCEDURE_START')).toBe(true); expect(decisionTrace.some((step) => step.kind === 'PROCEDURE_START')).toBe(true);
expect(decisionTrace.some((step) => step.kind === 'CANDIDATE')).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 { initializeAuditPolicies } from '../src/playAudit/policy.js';
import { describe, expect, it } from 'vitest'; 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 { asRecord } from '@sammo-ts/common';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; 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 type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { applyNpcPolicyMutation } from '../src/turn/npcPolicyMutation.js'; import { applyNpcPolicyMutation } from '../src/turn/npcPolicyMutation.js';
@@ -256,6 +257,15 @@ describe('NPC policy lifecycle', () => {
scenarioConfig: snapshot.scenarioConfig, scenarioConfig: snapshot.scenarioConfig,
unitSet, 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.reqNationGold).toBe(4_321);
expect(policy.priority).toEqual(['천도']); expect(policy.priority).toEqual(['천도']);
expect(policy.reqNpcDevelGold).toBe(540); 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 () => { it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => {
const decision = draft('decision-one'); 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.codeVersion = 'a'.repeat(40);
decision.summary.executionCoverage = 'ATTEMPTS'; decision.summary.executionCoverage = 'ATTEMPTS';
decision.steps.push({ ...decision.steps[0]!, ...buildAuditExecutionFixture(), sequence: 302 }); 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, ordinal: input.cursor === undefined ? 0 : 1,
steps: [ 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', phase: 'general',
generalId: 1, generalId: 1,
@@ -165,7 +166,7 @@ const install = async (
year: 190, year: 190,
month: 6, month: 6,
tick: 100, tick: 100,
sequence: input.cursor === undefined ? 0 : 128, sequence: input.cursor === undefined ? 1 : 128,
...(input.cursor === undefined ...(input.cursor === undefined
? { kind: 'PROCEDURE_START', procedure: '<b>징병판정</b>' } ? { 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 page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('<b>징병판정</b>'); await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('<b>징병판정</b>');
await expect(page.getByRole('list', { name: '판단 절차' }).locator('b')).toHaveCount(0); 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 page.getByRole('button', { name: '판단 절차 더 불러오기', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('실행 시도 1'); await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('실행 시도 1');
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText( 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"> <script setup lang="ts">
import AuditEffectivePolicy from './AuditEffectivePolicy.vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { trpc } from '../../utils/trpc'; import { trpc } from '../../utils/trpc';
@@ -245,6 +246,10 @@ watch(
<template v-for="chunk in detail.chunks" :key="chunk.ordinal" <template v-for="chunk in detail.chunks" :key="chunk.ordinal"
><li v-for="step in chunk.steps" :key="step.sequence" :value="step.sequence + 1"> ><li v-for="step in chunk.steps" :key="step.sequence" :value="step.sequence + 1">
{{ stepText(step) }} {{ 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'"> <ul v-if="step.kind === 'EXECUTION_ATTEMPT'">
<li v-for="(check, index) in step.checks" :key="index"> <li v-for="(check, index) in step.checks" :key="index">
{{ checkLabels[check.stage] }} · {{ check.action }} · {{ checkLabels[check.stage] }} · {{ check.action }} ·
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { fieldLabels } from './policyLabels';
import { trpc } from '../../utils/trpc'; import { trpc } from '../../utils/trpc';
import AuditRequestState from './AuditRequestState.vue'; import AuditRequestState from './AuditRequestState.vue';
const props = withDefaults(defineProps<{ id: string; allowPrevious?: boolean }>(), { allowPrevious: true }); const props = withDefaults(defineProps<{ id: string; allowPrevious?: boolean }>(), { allowPrevious: true });
@@ -11,35 +12,7 @@ const detailLoading = ref(false);
let detailGeneration = 0; let detailGeneration = 0;
const select = (id: string | null) => emit('select', id); const select = (id: string | null) => emit('select', id);
const message = (cause: unknown) => (cause instanceof Error ? cause.message : '정책 버전을 조회하지 못했습니다.'); 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 labels = { BASELINE: '최초 관측', CHANGE: '실제 변경', OBSERVED_GAP: '관측 누락 이후 기준' };
const loadDetail = async () => { const loadDetail = async () => {
const request = ++detailGeneration; 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: '기밀 공개 기준 (년)',
};
+18 -3
View File
@@ -52,6 +52,21 @@ COST gate는 여전히 남는다.
R7 E의 연결 기반 일부이며 전체 실패·시도별 이력, 실제 mutation 횟수와 replay가 아니다. R7 E의 연결 기반 일부이며 전체 실패·시도별 이력, 실제 mutation 횟수와 replay가 아니다.
F의 알려진 버그 조사 preset은 아직 구현하지 않았다. schema migration58은 그대로다. F의 알려진 버그 조사 preset은 아직 구현하지 않았다. schema migration58은 그대로다.
### 당시 합성 정책 관측
DECISION_START.effectivePolicy(schemaVersion1)는 생성자에서 이미 합성된
General/Nation 정책 객체의 명시 필드를 복사한다. 서버→국가 설정, 사용자 자동턴 옵션,
NPC 상태와 기술력/병종/시나리오 기반 파생 기본값이 반영된 실제 priority/flags,
20개 국가 수치와 전투/지원/내정 부대 편성을 보존한다. 원문 meta 전체를 복사하지 않는다.
can()/조건/RNG를 재평가하지 않고 후보당 SQL도 추가하지 않는다. 결정당 상세에1회씩
기록하며 목록 summary에는 넣지 않는다. 기존 chunk/batch/transaction과 hash 계약을 따른다.
API는 허용 필드로 투영하고 UI는 기존 정책 한글 label을 재사용한다. details를 펼쳐도
추가 query가 없으며 이전 DECISION_START에 field가 없으면 미수집으로 표시한다.
현재 정책으로 과거를 채우지 않는다. runtime의 동적 지급 상한·개별 후보 수치·별도
자동화 권한의 판정 입력은 여전히 후속 관측이다. 초기 비용 probe의 반복 RNG fixture는
이 신규 정책 payload를 포함하지 않으므로 그 bytes를 완성된 결정의 평균으로 쓰지 않는다.
### NPC 결정 조회 API와 화면 ### NPC 결정 조회 API와 화면
`playAudit.decisionHistory/decisionDetail`은 같은 프로필 감사 권한·현재 기수 경계를 따른다. `playAudit.decisionHistory/decisionDetail`은 같은 프로필 감사 권한·현재 기수 경계를 따른다.
@@ -65,7 +80,7 @@ migration58은 기존 장수 인덱스를 `(server, general, year, month, tick,
월 조건 밖의 기수 기록을 훑지 않으며 인덱스 개수는 늘리지 않는다. UI는 기존 장수 상세와 월 조건 밖의 기수 기록을 훑지 않으며 인덱스 개수는 늘리지 않는다. UI는 기존 장수 상세와
버튼·표 스타일을 재사용한다. 열기/상세 선택/더 보기는 명시적으로 수행하고 polling하지 버튼·표 스타일을 재사용한다. 열기/상세 선택/더 보기는 명시적으로 수행하고 polling하지
않는다. 결정 URL 복원과 상세 재시도는 상위 장수 목록을 다시 읽지 않는다. 않는다. 결정 URL 복원과 상세 재시도는 상위 장수 목록을 다시 읽지 않는다.
절차 coverage와 미수집 코드 버전을 표시하며 전체 후보 조건·유효 정책 연결은 남는다. 절차 coverage와 미수집 코드 버전을 표시하며 전체 후보 조건 관측은 남는다.
당시 정책 참조는 공용 `AuditPolicyVersion`으로 연결했다. 기존 정책 이력의 버전 표시· 당시 정책 참조는 공용 `AuditPolicyVersion`으로 연결했다. 기존 정책 이력의 버전 표시·
필드 한국어 이름·실패 재시도를 재사용하며 클릭 시 버전1건만 읽는다. 결정의 참조 ID에 필드 한국어 이름·실패 재시도를 재사용하며 클릭 시 버전1건만 읽는다. 결정의 참조 ID에
포함되지 않은 URL policy는 해당 결정의 정책으로 읽거나 표시하지 않는다. 포함되지 않은 URL policy는 해당 결정의 정책으로 읽거나 표시하지 않는다.
@@ -97,7 +112,7 @@ reservedTurnHandler가 기수 identity가 있는 새 AI 실행을 phase별로
clock revision·phase로 결정한다. 정책 head 참조는 시작 시 확보하며 없는 값은 채우지 않는다. clock revision·phase로 결정한다. 정책 head 참조는 시작 시 확보하며 없는 값은 채우지 않는다.
Gateway의 프로필 buildCommitSha→daemon 환경 TURN_BUILD_COMMIT_SHA→CLI→runtime→handler로 Gateway의 프로필 buildCommitSha→daemon 환경 TURN_BUILD_COMMIT_SHA→CLI→runtime→handler로
실행 코드 버전을 전달한다. 전체40/64자리 SHA만 인정하며 누락/잘못된 값은 null이다. 실행 코드 버전을 전달한다. 전체40/64자리 SHA만 인정하며 누락/잘못된 값은 null이다.
handler 생성 시 한 번 정규화하므로 턴마다 Git/DB를 읽지 않는다. 유효 정책 합성 상세와 handler 생성 시 한 번 정규화하므로 턴마다 Git/DB를 읽지 않는다. 실행 직전 동적 정책 파생값과
내부 후보 조건은 남아 있어 coverage는 `PROCEDURES`다. 수동 턴 중 AI를 사용하지 않은 경우 결정 행을 만들지 않는다. 내부 후보 조건은 남아 있어 coverage는 `PROCEDURES`다. 수동 턴 중 AI를 사용하지 않은 경우 결정 행을 만들지 않는다.
GeneralTurnResult→world pending→capture/restore/peek/ack→기존 fenced DB flush를 연결했다. GeneralTurnResult→world pending→capture/restore/peek/ack→기존 fenced DB flush를 연결했다.
@@ -126,7 +141,7 @@ GeneralAI와 예약 실행 handler에 선택적 `onDecisionTrace` 관측 경계
초기 관측 단계에서는 default daemon을 켜지 않았다. 이후 아래 저장 경계에서 현재 기수의 새 실행을 수집하도록 연결했다. 초기 관측 단계에서는 default daemon을 켜지 않았다. 이후 아래 저장 경계에서 현재 기수의 새 실행을 수집하도록 연결했다.
이는 R5의 관측 기반일 뿐 완료가 아니다. 불변 결정 ID·기존 정책 참조·실행 결과· 이는 R5의 관측 기반일 뿐 완료가 아니다. 불변 결정 ID·기존 정책 참조·실행 결과·
pending/rollback·migration·정리·목록/상세 API와 GUI는 위 절에서 연결했다. pending/rollback·migration·정리·목록/상세 API와 GUI는 위 절에서 연결했다.
후보/조건별 실제 관측값, 합성 유효 정책의 실제 관측은 남는다. 코드 버전 전달은 위 저장 절에 연결했다. 후보/조건별 실제 관측값과 실행 직전 동적 파생값은 남는다. 코드 버전 전달은 위 저장 절에 연결했다.
### 전달 전 DB tick 정밀도 보완 ### 전달 전 DB tick 정밀도 보완
+1 -1
View File
@@ -96,7 +96,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
- NPC/유저 자동턴의 개인·수뇌 절차, 정책 차단, RNG utility 결과와 최종 실행 결과는 - NPC/유저 자동턴의 개인·수뇌 절차, 정책 차단, RNG utility 결과와 최종 실행 결과는
migration 이후 새 실행부터 저장한다. 후보 내부 조건 전체는 아직 없으며 `PROCEDURES` migration 이후 새 실행부터 저장한다. 후보 내부 조건 전체는 아직 없으며 `PROCEDURES`
coverage로 구분한다. 실행 단계 수집이 추가된 기록은 인자·조건·대기·문맥 검사와 대체 명령도 순서대로 표시하며 이전 기록과 구분한다. 장수 상세의 **NPC 결정 기록 조회**에서 선택 월의 목록과 순서별 상세를 읽는다. 과거 결정은 역산하지 않는다. coverage로 구분한다. 실행 단계 수집이 추가된 기록은 인자·조건·대기·문맥 검사와 대체 명령도 순서대로 표시하며 이전 기록과 구분한다. 장수 상세의 **NPC 결정 기록 조회**에서 선택 월의 목록과 순서별 상세를 읽는다. 과거 결정은 역산하지 않는다.
- 결정은 당시 확보된 정책 참조를 보존하며 **당시 정책 참조**에서 해당 불변 버전을 바로 조회한다. 합성된 유효 정책 상세는 아직 미완성이다. Gateway 관리 daemon은 프로필의 buildCommitSha를 새 실행에 기록한다. 수동 daemon은 실행 산출물의 전체 SHA를 TURN_BUILD_COMMIT_SHA로 전달할 수 있다. 미지정/잘못된 SHA의 실행과 기존 null 기록은 현재 버전으로 메우지 않는다. - 결정은 당시 확보된 정책 참조를 보존하며 **당시 정책 참조**에서 해당 불변 버전을 바로 조회한다. 새 결정의 시작 항목에서 **당시 합성 정책**을 펼치면 개인/수뇌 우선순위·허용 여부와 국가 정책 수치·부대 편성을 확인한다. 이전 기록은 미수집으로 표시한다. 실행 직전 자원 지급 상한·개별 후보 조건·별도 자동화 권한 판정은 이 정책 표와 구분한다. Gateway 관리 daemon은 프로필의 buildCommitSha를 새 실행에 기록한다. 수동 daemon은 실행 산출물의 전체 SHA를 TURN_BUILD_COMMIT_SHA로 전달할 수 있다. 미지정/잘못된 SHA의 실행과 기존 null 기록은 현재 버전으로 메우지 않는다.
- 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진 - 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진
버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다. 버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다.
- 일부 국가 생성/소멸 사건은 actor/request가 null이다. 원인을 현재 주체로 추정하지 않는다. - 일부 국가 생성/소멸 사건은 actor/request가 null이다. 원인을 현재 주체로 추정하지 않는다.