feat: NPC 판단 당시 합성 정책을 기록하고 조회한다
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
@@ -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: '기밀 공개 기준 (년)',
|
||||||
|
};
|
||||||
@@ -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 정밀도 보완
|
||||||
|
|
||||||
|
|||||||
@@ -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이다. 원인을 현재 주체로 추정하지 않는다.
|
||||||
|
|||||||
Reference in New Issue
Block a user