NPC 명령의 실제 검사와 준비 및 대체 실행 순서를 감사 기록에 연결

This commit is contained in:
2026-09-16 08:46:56 +00:00
parent eb680121c3
commit 028f985e7a
15 changed files with 391 additions and 24 deletions
@@ -16,6 +16,8 @@ export interface PendingAuditDecision {
summary: {
schemaVersion: 1;
coverage: 'PROCEDURES';
executionCoverage?: 'ATTEMPTS';
executionStatus?: 'PREPARING' | 'BLOCKED' | 'RESOLVED';
clockRevision: number;
codeVersion: string | null;
policyRefs: Record<string, string>;
@@ -14,7 +14,12 @@ export const persistAuditDecisions = async (
const headers = batch.map(({ steps, ...decision }) => {
if (!Number.isSafeInteger(decision.tick) || decision.tick < 0 || !steps.length)
throw new Error('Invalid play audit decision');
if (steps[0]?.kind !== 'DECISION_START' || steps.at(-1)?.kind !== 'DECISION_END')
if (
steps[0]?.kind !== 'DECISION_START' ||
!steps.some((step) => step.kind === 'DECISION_END') ||
!['DECISION_END', 'EXECUTION_ATTEMPT'].includes(steps.at(-1)!.kind) ||
(decision.summary.executionCoverage === 'ATTEMPTS' && steps.at(-1)?.kind !== 'EXECUTION_ATTEMPT')
)
throw new Error('Incomplete play audit decision');
if (
steps.some(
@@ -2,7 +2,26 @@ import type { RandUtil } from '@sammo-ts/common';
/** 원문 meta/seed/임의 객체를 받지 않는 관측 계약. 내부 후보 조건은 별도 계측으로 확장한다. */
export type AiTraceValue = string | number | boolean | null | { entityId: number } | { unprojected: true };
export type AiExecutionCheck = {
stage: 'ARGS' | 'CONSTRAINT' | 'COOLDOWN' | 'CONTEXT' | 'BLOCK';
action: string;
result: 'allow' | 'deny' | 'unknown';
reason: string | null;
};
export type AiExecutionAttempt = {
kind: 'EXECUTION_ATTEMPT';
attempt: number;
requestedAction: string;
resolvedAction: string;
executedAction: string | null;
checks: AiExecutionCheck[];
completed: boolean;
usedFallback: boolean;
alternativeAction: string | null;
preparation: { term: number; total: number } | null;
};
export type AiTraceStep =
| AiExecutionAttempt
| { kind: 'DECISION_START'; reservedAction: string }
| { kind: 'DECISION_END'; action: string | null; reason: string | null }
| { kind: 'DECISION_ERROR' }
+108 -4
View File
@@ -1,6 +1,6 @@
import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js';
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js';
import type { AiDecisionTraceEvent } from './ai/generalAi/trace.js';
import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js';
import type { AiDecisionTraceObserver } from './ai/generalAi/trace.js';
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import type {
@@ -1079,6 +1079,7 @@ export const createReservedTurnHandler = async (options: {
options.collectAuditDecisions !== false && Boolean(auditServerId?.trim()) && Boolean(worldRef);
const auditDecisions: PendingAuditDecision[] = [];
const decisionSteps = new Map<'general' | 'nation', AiDecisionTraceEvent[]>();
let storedDecisionSequence = 0;
const decisionPolicyRefs = new Map<'general' | 'nation', Record<string, string>>();
const onDecisionTrace: AiDecisionTraceObserver | undefined =
collectDecisions || options.onDecisionTrace
@@ -1099,11 +1100,31 @@ export const createReservedTurnHandler = async (options: {
)
);
}
decisionSteps.get(event.phase)?.push(structuredClone(event));
decisionSteps
.get(event.phase)
?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ });
}
options.onDecisionTrace?.(event);
}
: undefined;
const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => {
const steps = decisionSteps.get(phase);
const first = steps?.[0];
if (!collectDecisions || !first || !steps) return;
const { generalId, nationId, cityId, npcState, year, month, tick } = first;
steps.push({
...attempt,
sequence: storedDecisionSequence++,
phase,
generalId,
nationId,
cityId,
npcState,
year,
month,
tick,
});
};
const finishDecision = (
phase: 'general' | 'nation',
outcome: {
@@ -1115,7 +1136,7 @@ export const createReservedTurnHandler = async (options: {
): void => {
const steps = decisionSteps.get(phase);
const first = steps?.[0];
const last = steps?.at(-1);
const last = steps?.find((step) => step.kind === 'DECISION_END');
if (
!collectDecisions ||
!auditServerId ||
@@ -1128,6 +1149,7 @@ export const createReservedTurnHandler = async (options: {
const tick = context.general.turnTick ?? worldRef.dateToGameTick(context.general.turnTime);
const revision = worldRef.getGameClockState().revision;
const executionId = auditDecisionIdentity(auditServerId, context.general.id, tick, revision);
const execution = steps.at(-1);
auditDecisions.push({
id: auditPolicyHash([executionId, phase]),
serverId: auditServerId,
@@ -1143,6 +1165,14 @@ export const createReservedTurnHandler = async (options: {
summary: {
schemaVersion: 1,
coverage: 'PROCEDURES',
executionCoverage: 'ATTEMPTS',
executionStatus:
execution?.kind === 'EXECUTION_ATTEMPT' && execution.preparation
? 'PREPARING'
: execution?.kind === 'EXECUTION_ATTEMPT' &&
execution.checks.some((check) => check.stage === 'BLOCK')
? 'BLOCKED'
: 'RESOLVED',
clockRevision: revision,
codeVersion: auditCodeVersion ?? null,
policyRefs: decisionPolicyRefs.get(phase) ?? {},
@@ -1151,7 +1181,13 @@ export const createReservedTurnHandler = async (options: {
selectedReason: last.reason,
executedAction: outcome.actionKey,
completed: outcome.completed ?? null,
usedFallback: outcome.usedFallback,
usedFallback:
outcome.usedFallback ||
steps.some(
(step) =>
step.kind === 'EXECUTION_ATTEMPT' &&
(step.usedFallback || step.alternativeAction !== null)
),
blockedReason: outcome.blockedReason ?? null,
},
steps,
@@ -1177,6 +1213,15 @@ export const createReservedTurnHandler = async (options: {
completed: boolean;
blockedReason?: string;
} => {
const checks: AiExecutionCheck[] = [];
const check = (
stage: AiExecutionCheck['stage'],
action: string,
result: AiExecutionCheck['result'],
reason: string | null = null
): void => {
if (collectDecisions && decisionSteps.has(kind)) checks.push({ stage, action, result, reason });
};
const resolvedDefinition = resolveDefinition(command.action, definitionMap, kind);
const rawArgs = extractArgsRecord(command.args);
const parsedArgs = resolvedDefinition.parseArgs(rawArgs);
@@ -1185,6 +1230,31 @@ export const createReservedTurnHandler = async (options: {
let actionKey = definition.key;
let usedFallback = false;
let blockedReason: string | undefined = undefined;
const recordAttempt = (
completed: boolean,
alternativeAction: string | null = null,
preparation: { term: number; total: number } | null = null
): void => {
if (!collectDecisions || !decisionSteps.has(kind)) return;
recordExecution(kind, {
kind: 'EXECUTION_ATTEMPT',
attempt: alternativeDepth,
requestedAction: command.action,
resolvedAction: resolvedDefinition.key,
executedAction: preparation ? null : actionKey,
checks,
completed,
usedFallback,
alternativeAction,
preparation,
});
};
check(
'ARGS',
actionKey,
parsedArgs === null ? 'deny' : 'allow',
parsedArgs === null ? '인자가 올바르지 않습니다.' : null
);
if (parsedArgs === null) {
const failureText = `인자가 올바르지 않습니다. ${resolvedDefinition.name} 실패.`;
@@ -1216,6 +1286,7 @@ export const createReservedTurnHandler = async (options: {
});
const constraints = definition.buildConstraints(constraintCtx, actionArgs);
const result = evaluateConstraints(constraints, constraintCtx, view);
check('CONSTRAINT', definition.key, result.kind, result.kind === 'deny' ? result.reason : null);
if (result.kind !== 'allow') {
const failedDefinition = definition;
const failedActionArgs = actionArgs;
@@ -1237,6 +1308,14 @@ export const createReservedTurnHandler = async (options: {
kind === 'general'
? readGeneralNextAvailableTurn(currentGeneral, definition.name)
: readNextAvailableTurn(currentNation!, definition.name);
check(
'COOLDOWN',
definition.key,
nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn ? 'deny' : 'allow',
nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn
? `${nextAvailableTurn - currentYearMonth}턴 더 기다려야 합니다`
: null
);
if (nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn) {
const remainTurn = nextAvailableTurn - currentYearMonth;
definition = fallbackDefinition;
@@ -1325,6 +1404,14 @@ export const createReservedTurnHandler = async (options: {
},
actionContextBuilders
);
check(
'CONTEXT',
actionKey,
specificContext ? 'allow' : actionKey === fallbackDefinition.key ? 'allow' : 'deny',
!specificContext && actionKey !== fallbackDefinition.key
? '예약된 명령을 실행하지 못했습니다.'
: null
);
if (!specificContext && actionKey !== fallbackDefinition.key) {
definition = fallbackDefinition;
actionArgs = definition.parseArgs({}) ?? {};
@@ -1416,6 +1503,7 @@ export const createReservedTurnHandler = async (options: {
executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ??
`${definition.name} 수행중... (${nextTerm}/${termMax})`;
logs.push(createGeneralActionLog(currentGeneral.id, progressText));
recordAttempt(false, null, { term: nextTerm, total: termMax });
return { actionKey, usedFallback, completed: false, blockedReason };
}
}
@@ -1864,6 +1952,7 @@ export const createReservedTurnHandler = async (options: {
}
}
recordAttempt(resolution.completed, resolution.alternative?.commandKey ?? null);
if (resolution.alternative) {
if (alternativeDepth >= 5) {
throw new Error('Command fallback loop limit exceeded');
@@ -2270,6 +2359,21 @@ export const createReservedTurnHandler = async (options: {
blockedReason: '블럭 대상자입니다.',
}
: runAction('general', generalDefinitions, generalFallback, generalCommand, true);
if (isBlocked)
recordExecution('general', {
kind: 'EXECUTION_ATTEMPT',
attempt: 0,
requestedAction: generalCommand.action,
resolvedAction: DEFAULT_ACTION,
executedAction: null,
checks: [
{ stage: 'BLOCK', action: generalCommand.action, result: 'deny', reason: '블럭 대상자입니다.' },
],
completed: false,
usedFallback: true,
alternativeAction: null,
preparation: null,
});
finishDecision('general', generalResult);
const generalActionDurationNs = options.onActionProfiled
? process.hrtime.bigint() - generalActionStartedAt
+13
View File
@@ -43,3 +43,16 @@ export const buildAuditDecisionFixture = (id: string, serverId = 'decision-old')
],
};
};
export const buildAuditExecutionFixture = () => ({
kind: 'EXECUTION_ATTEMPT' as const,
attempt: 0,
requestedAction: 'che_징병',
resolvedAction: 'che_징병',
executedAction: '휴식',
checks: [{ stage: 'CONSTRAINT' as const, action: 'che_징병', result: 'deny' as const, reason: '자원 부족' }],
completed: true,
usedFallback: true,
alternativeAction: null,
preparation: null,
});
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { PendingAuditDecision } from '../src/playAudit/decision.js';
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import {
LogFormat,
@@ -395,8 +396,39 @@ describe('legacy general-turn execution contract', () => {
);
});
it('persists pre-turn stacking and applies the inherited 60-turn cooldown', async () => {
it('records the real disband-to-talent-search alternative in execution order', async () => {
const decisions: PendingAuditDecision[] = [];
const general = makeGeneral({ npcState: 2, officerLevel: 12 });
const snapshot = makeSnapshot(general);
snapshot.nations[0] = { ...snapshot.nations[0]!, level: 0, chiefGeneralId: 1, capitalCityId: null };
snapshot.cities[0] = { ...snapshot.cities[0]!, nationId: 0 };
const harness = await createTurnTestHarness({
snapshot,
state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution', initYear: 200, initMonth: 1 } },
schedule, map, collectLogs: true,
commandRngFactory: () => new RandUtil(new ConstantRNG(0)),
wrapGeneralTurnHandler: handler => ({ execute: context => {
const result = handler.execute(context);
decisions.push(...(result.auditDecisions ?? []));
return result;
} }),
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_해산', args: {} };
await harness.runOneTick();
const decision = decisions.find(row => row.phase === 'general');
expect(decision?.summary).toMatchObject({ selectedAction: 'che_해산', executedAction: 'che_인재탐색', usedFallback: true });
const attempts = decision?.steps.filter(step => step.kind === 'EXECUTION_ATTEMPT');
expect(attempts).toMatchObject([
{ attempt: 0, executedAction: 'che_해산', alternativeAction: 'che_인재탐색' },
{ attempt: 1, requestedAction: 'che_인재탐색', executedAction: 'che_인재탐색', alternativeAction: null },
]);
expect(attempts?.[1]?.sequence).toBeGreaterThan(attempts?.[0]?.sequence ?? -1);
});
it.each([0, 2])('persists pre-turn stacking and applies the inherited 60-turn cooldown (npcState=%s)', async (npcState) => {
const decisions: PendingAuditDecision[] = [];
const general = makeGeneral({
npcState,
role: {
personality: null,
specialDomestic: null,
@@ -406,10 +438,15 @@ describe('legacy general-turn execution contract', () => {
});
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(general),
state: makeState(),
state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution' } },
schedule,
map,
collectLogs: true,
wrapGeneralTurnHandler: handler => ({ execute: context => {
const result = handler.execute(context);
decisions.push(...(result.auditDecisions ?? []));
return result;
} }),
});
const turns = harness.reservedTurnStore.getGeneralTurns(1);
turns[0] = { action: 'che_전투특기초기화', args: {} };
@@ -427,10 +464,17 @@ describe('legacy general-turn execution contract', () => {
expect(updated.role.specialWar).toBeNull();
expect(updated.meta['next_execute_전투 특기 초기화']).toBe(2460);
expect(updated.meta.prev_types_special2).toEqual(['che_격노']);
if (npcState === 2) {
expect(decisions[0]?.summary.executionStatus).toBe('PREPARING');
expect(decisions[0]?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', executedAction: null, preparation: { term: 1, total: 2 }, completed: false });
expect(decisions[1]?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', executedAction: 'che_전투특기초기화', preparation: null, completed: true });
} else expect(decisions).toEqual([]);
});
it('rejects a speciality reset cooldown before accumulating its first preparation turn', async () => {
it.each([0, 2])('rejects a speciality reset cooldown before accumulating its first preparation turn (npcState=%s)', async (npcState) => {
const decisions: PendingAuditDecision[] = [];
const general = makeGeneral({
npcState,
role: {
personality: null,
specialDomestic: null,
@@ -441,15 +485,22 @@ describe('legacy general-turn execution contract', () => {
});
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(general),
state: makeState(),
state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution' } },
schedule,
map,
collectLogs: true,
wrapGeneralTurnHandler: handler => ({ execute: context => {
const result = handler.execute(context);
decisions.push(...(result.auditDecisions ?? []));
return result;
} }),
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_전투특기초기화', args: {} };
await harness.runOneTick();
if (npcState === 2) expect(decisions[0]?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', executedAction: '휴식', usedFallback: true, preparation: null, checks: expect.arrayContaining([{ stage: 'COOLDOWN', action: 'che_전투특기초기화', result: 'deny', reason: '60턴 더 기다려야 합니다' }]) });
else expect(decisions).toEqual([]);
const updated = harness.world.getGeneralById(1)!;
expect(updated.role.specialWar).toBe('che_격노');
expect(updated.lastTurn).not.toEqual({ command: '전투 특기 초기화', term: 1 });
@@ -452,7 +452,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
expect(new Set(savedDecisions.map((row) => row.id)).size).toBe(savedDecisions.length);
expect(savedDecisions.some((row) => row.phase === 'nation' && row.summary.executedAction === 'che_선전포고')).toBe(true);
expect(savedDecisions.some((row) => row.phase === 'general' && row.summary.executedAction === 'che_출병')).toBe(true);
expect(savedDecisions.every((row) => row.steps[0]?.kind === 'DECISION_START' && row.steps.at(-1)?.kind === 'DECISION_END')).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_END' && step.phase === 'general')).toBe(true);
expect(decisionTrace.some((step) => step.kind === 'RNG')).toBe(true);
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js';
import { buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js';
import { buildAuditExecutionFixture, buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js';
import { prunePreviousAuditBatch } from '../src/playAudit/retention.js';
const databaseUrl = process.env.PLAY_AUDIT_DECISION_DATABASE_URL;
@@ -37,6 +37,11 @@ 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');
decision.summary.codeVersion = 'a'.repeat(40);
decision.summary.executionCoverage = 'ATTEMPTS';
decision.steps.push({ ...decision.steps[0]!, ...buildAuditExecutionFixture(), sequence: 302 });
await expect(
db.$transaction((tx) => persistAuditDecisions(tx, [{ ...decision, steps: decision.steps.slice(0, -1) }]))
).rejects.toThrow('Incomplete play audit decision');
await db.$executeRawUnsafe(
`CREATE OR REPLACE FUNCTION decision_fixture_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.ordinal=1 THEN RAISE EXCEPTION 'decision chunk failure'; END IF; RETURN NEW; END $$`
);
@@ -64,7 +69,7 @@ integration('decision persistence and bounded retention', () => {
const header = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } });
expect(header).toMatchObject({
tick: 4_320_000_000n,
stepCount: 302,
stepCount: 303,
summary: { codeVersion: 'a'.repeat(40) },
});
await expect(
@@ -629,7 +629,7 @@ describe('Reserved Turn Execution Integration', () => {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: mockDate,
meta: {},
meta: { serverId: 'audit-execution' },
};
const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_농지개간', arg: {} }];
@@ -676,6 +676,11 @@ describe('Reserved Turn Execution Integration', () => {
});
const dirty = world.consumeDirtyState();
if (npcState === 2) {
const decision = dirty.pendingAuditDecisions.find(row => row.phase === 'general');
expect(decision?.summary).toMatchObject({ requestedAction: 'che_농지개간', executedAction: '휴식', usedFallback: true, executionCoverage: 'ATTEMPTS' });
expect(decision?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', attempt: 0, requestedAction: 'che_농지개간', executedAction: '휴식', usedFallback: true, checks: expect.arrayContaining([{ stage: 'CONSTRAINT', action: 'che_농지개간', result: 'deny', reason: '농지 개간이 충분합니다.' }]) });
} else expect(dirty.pendingAuditDecisions).toEqual([]);
expect(world.getCityById(1)!.agriculture).toBe(2000);
const denyLog = dirty.logs.find((log) => log.text.includes('농지 개간이 충분합니다.'));
expect(denyLog?.text).toContain('농지 개간 실패.');