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
@@ -11,6 +11,8 @@ const zTick = z
const zSummary = z.object({ const zSummary = z.object({
schemaVersion: z.literal(1), schemaVersion: z.literal(1),
coverage: z.literal('PROCEDURES'), coverage: z.literal('PROCEDURES'),
executionCoverage: z.literal('ATTEMPTS').optional(),
executionStatus: z.enum(['PREPARING', 'BLOCKED', 'RESOLVED']).optional(),
clockRevision: z.number().int(), clockRevision: z.number().int(),
codeVersion: z.string().nullable(), codeVersion: z.string().nullable(),
policyRefs: z.object({ policyRefs: z.object({
@@ -48,6 +50,27 @@ const zStep = z.intersection(
tick: z.number().nullable(), tick: z.number().nullable(),
}), }),
z.discriminatedUnion('kind', [ z.discriminatedUnion('kind', [
z.object({
kind: z.literal('EXECUTION_ATTEMPT'),
attempt: z.number().int().min(0).max(5),
requestedAction: z.string(),
resolvedAction: z.string(),
executedAction: z.string().nullable(),
completed: z.boolean(),
usedFallback: z.boolean(),
alternativeAction: z.string().nullable(),
preparation: z.object({ term: z.number().int().positive(), total: z.number().int().positive() }).nullable(),
checks: z
.array(
z.object({
stage: z.enum(['ARGS', 'CONSTRAINT', 'COOLDOWN', 'CONTEXT', 'BLOCK']),
action: z.string(),
result: z.enum(['allow', 'deny', 'unknown']),
reason: z.string().nullable(),
})
)
.max(5),
}),
z.object({ kind: z.literal('DECISION_START'), reservedAction: z.string() }), z.object({ kind: z.literal('DECISION_START'), reservedAction: z.string() }),
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') }),
@@ -2298,7 +2298,34 @@ integration('game API security over HTTP transport', () => {
ordinal: 0, ordinal: 0,
steps: Array.from({ length: 128 }, (_, sequence) => ({ ...step, sequence })), steps: Array.from({ length: 128 }, (_, sequence) => ({ ...step, sequence })),
}, },
{ decisionId: decisionIds[0]!, ordinal: 1, steps: [{ ...step, sequence: 128 }] }, {
decisionId: decisionIds[0]!,
ordinal: 1,
steps: [
{
...step,
sequence: 128,
kind: 'EXECUTION_ATTEMPT',
attempt: 0,
requestedAction: 'che_징병',
resolvedAction: 'che_징병',
executedAction: '휴식',
completed: true,
usedFallback: true,
alternativeAction: null,
preparation: null,
checks: [
{
stage: 'CONSTRAINT',
action: 'che_징병',
result: 'deny',
reason: '자원 부족',
secret: 'decision-secret',
},
],
},
],
},
], ],
}); });
const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 }; const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 };
@@ -2351,8 +2378,27 @@ 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((await get('decisionDetail', admin, { ...decisionDetailInput, cursor: 0 })).body).toMatchObject({ expect((await get('decisionDetail', admin, { ...decisionDetailInput, cursor: 0 })).body).toMatchObject({
result: { data: { chunks: [{ ordinal: 1, steps: [{ sequence: 128 }] }], nextCursor: null } }, result: {
data: {
chunks: [
{
ordinal: 1,
steps: [
{
sequence: 128,
kind: 'EXECUTION_ATTEMPT',
checks: [{ stage: 'CONSTRAINT', result: 'deny', reason: '자원 부족' }],
},
],
},
],
nextCursor: null,
},
},
}); });
expect(
JSON.stringify((await get('decisionDetail', admin, { ...decisionDetailInput, cursor: 0 })).body)
).not.toContain('decision-secret');
expect((await get('decisionDetail', admin, { ...decisionDetailInput, generalId })).status).toBe(404); expect((await get('decisionDetail', admin, { ...decisionDetailInput, generalId })).status).toBe(404);
expect((await get('decisionDetail', admin, { ...decisionDetailInput, limit: 5 })).status).toBe(400); expect((await get('decisionDetail', admin, { ...decisionDetailInput, limit: 5 })).status).toBe(400);
expect((await get('decisionHistory', admin, { ...decisionInput, limit: 201 })).status).toBe(400); expect((await get('decisionHistory', admin, { ...decisionInput, limit: 201 })).status).toBe(400);
@@ -16,6 +16,8 @@ export interface PendingAuditDecision {
summary: { summary: {
schemaVersion: 1; schemaVersion: 1;
coverage: 'PROCEDURES'; coverage: 'PROCEDURES';
executionCoverage?: 'ATTEMPTS';
executionStatus?: 'PREPARING' | 'BLOCKED' | 'RESOLVED';
clockRevision: number; clockRevision: number;
codeVersion: string | null; codeVersion: string | null;
policyRefs: Record<string, string>; policyRefs: Record<string, string>;
@@ -14,7 +14,12 @@ export const persistAuditDecisions = async (
const headers = batch.map(({ steps, ...decision }) => { const headers = batch.map(({ steps, ...decision }) => {
if (!Number.isSafeInteger(decision.tick) || decision.tick < 0 || !steps.length) if (!Number.isSafeInteger(decision.tick) || decision.tick < 0 || !steps.length)
throw new Error('Invalid play audit decision'); 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'); throw new Error('Incomplete play audit decision');
if ( if (
steps.some( steps.some(
@@ -2,7 +2,26 @@ import type { RandUtil } from '@sammo-ts/common';
/** 원문 meta/seed/임의 객체를 받지 않는 관측 계약. 내부 후보 조건은 별도 계측으로 확장한다. */ /** 원문 meta/seed/임의 객체를 받지 않는 관측 계약. 내부 후보 조건은 별도 계측으로 확장한다. */
export type AiTraceValue = string | number | boolean | null | { entityId: number } | { unprojected: true }; 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 = export type AiTraceStep =
| AiExecutionAttempt
| { kind: 'DECISION_START'; reservedAction: string } | { kind: 'DECISION_START'; reservedAction: string }
| { kind: 'DECISION_END'; action: string | null; reason: string | null } | { kind: 'DECISION_END'; action: string | null; reason: string | null }
| { kind: 'DECISION_ERROR' } | { kind: 'DECISION_ERROR' }
+108 -4
View File
@@ -1,6 +1,6 @@
import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js'; import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js';
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.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 type { AiDecisionTraceObserver } from './ai/generalAi/trace.js';
import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import type { import type {
@@ -1079,6 +1079,7 @@ export const createReservedTurnHandler = async (options: {
options.collectAuditDecisions !== false && Boolean(auditServerId?.trim()) && Boolean(worldRef); options.collectAuditDecisions !== false && Boolean(auditServerId?.trim()) && Boolean(worldRef);
const auditDecisions: PendingAuditDecision[] = []; const auditDecisions: PendingAuditDecision[] = [];
const decisionSteps = new Map<'general' | 'nation', AiDecisionTraceEvent[]>(); const decisionSteps = new Map<'general' | 'nation', AiDecisionTraceEvent[]>();
let storedDecisionSequence = 0;
const decisionPolicyRefs = new Map<'general' | 'nation', Record<string, string>>(); const decisionPolicyRefs = new Map<'general' | 'nation', Record<string, string>>();
const onDecisionTrace: AiDecisionTraceObserver | undefined = const onDecisionTrace: AiDecisionTraceObserver | undefined =
collectDecisions || options.onDecisionTrace 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); options.onDecisionTrace?.(event);
} }
: undefined; : 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 = ( const finishDecision = (
phase: 'general' | 'nation', phase: 'general' | 'nation',
outcome: { outcome: {
@@ -1115,7 +1136,7 @@ export const createReservedTurnHandler = async (options: {
): void => { ): void => {
const steps = decisionSteps.get(phase); const steps = decisionSteps.get(phase);
const first = steps?.[0]; const first = steps?.[0];
const last = steps?.at(-1); const last = steps?.find((step) => step.kind === 'DECISION_END');
if ( if (
!collectDecisions || !collectDecisions ||
!auditServerId || !auditServerId ||
@@ -1128,6 +1149,7 @@ export const createReservedTurnHandler = async (options: {
const tick = context.general.turnTick ?? worldRef.dateToGameTick(context.general.turnTime); const tick = context.general.turnTick ?? worldRef.dateToGameTick(context.general.turnTime);
const revision = worldRef.getGameClockState().revision; const revision = worldRef.getGameClockState().revision;
const executionId = auditDecisionIdentity(auditServerId, context.general.id, tick, revision); const executionId = auditDecisionIdentity(auditServerId, context.general.id, tick, revision);
const execution = steps.at(-1);
auditDecisions.push({ auditDecisions.push({
id: auditPolicyHash([executionId, phase]), id: auditPolicyHash([executionId, phase]),
serverId: auditServerId, serverId: auditServerId,
@@ -1143,6 +1165,14 @@ export const createReservedTurnHandler = async (options: {
summary: { summary: {
schemaVersion: 1, schemaVersion: 1,
coverage: 'PROCEDURES', 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, clockRevision: revision,
codeVersion: auditCodeVersion ?? null, codeVersion: auditCodeVersion ?? null,
policyRefs: decisionPolicyRefs.get(phase) ?? {}, policyRefs: decisionPolicyRefs.get(phase) ?? {},
@@ -1151,7 +1181,13 @@ export const createReservedTurnHandler = async (options: {
selectedReason: last.reason, selectedReason: last.reason,
executedAction: outcome.actionKey, executedAction: outcome.actionKey,
completed: outcome.completed ?? null, 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, blockedReason: outcome.blockedReason ?? null,
}, },
steps, steps,
@@ -1177,6 +1213,15 @@ export const createReservedTurnHandler = async (options: {
completed: boolean; completed: boolean;
blockedReason?: string; 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 resolvedDefinition = resolveDefinition(command.action, definitionMap, kind);
const rawArgs = extractArgsRecord(command.args); const rawArgs = extractArgsRecord(command.args);
const parsedArgs = resolvedDefinition.parseArgs(rawArgs); const parsedArgs = resolvedDefinition.parseArgs(rawArgs);
@@ -1185,6 +1230,31 @@ export const createReservedTurnHandler = async (options: {
let actionKey = definition.key; let actionKey = definition.key;
let usedFallback = false; let usedFallback = false;
let blockedReason: string | undefined = undefined; 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) { if (parsedArgs === null) {
const failureText = `인자가 올바르지 않습니다. ${resolvedDefinition.name} 실패.`; const failureText = `인자가 올바르지 않습니다. ${resolvedDefinition.name} 실패.`;
@@ -1216,6 +1286,7 @@ export const createReservedTurnHandler = async (options: {
}); });
const constraints = definition.buildConstraints(constraintCtx, actionArgs); const constraints = definition.buildConstraints(constraintCtx, actionArgs);
const result = evaluateConstraints(constraints, constraintCtx, view); const result = evaluateConstraints(constraints, constraintCtx, view);
check('CONSTRAINT', definition.key, result.kind, result.kind === 'deny' ? result.reason : null);
if (result.kind !== 'allow') { if (result.kind !== 'allow') {
const failedDefinition = definition; const failedDefinition = definition;
const failedActionArgs = actionArgs; const failedActionArgs = actionArgs;
@@ -1237,6 +1308,14 @@ export const createReservedTurnHandler = async (options: {
kind === 'general' kind === 'general'
? readGeneralNextAvailableTurn(currentGeneral, definition.name) ? readGeneralNextAvailableTurn(currentGeneral, definition.name)
: readNextAvailableTurn(currentNation!, 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) { if (nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn) {
const remainTurn = nextAvailableTurn - currentYearMonth; const remainTurn = nextAvailableTurn - currentYearMonth;
definition = fallbackDefinition; definition = fallbackDefinition;
@@ -1325,6 +1404,14 @@ export const createReservedTurnHandler = async (options: {
}, },
actionContextBuilders actionContextBuilders
); );
check(
'CONTEXT',
actionKey,
specificContext ? 'allow' : actionKey === fallbackDefinition.key ? 'allow' : 'deny',
!specificContext && actionKey !== fallbackDefinition.key
? '예약된 명령을 실행하지 못했습니다.'
: null
);
if (!specificContext && actionKey !== fallbackDefinition.key) { if (!specificContext && actionKey !== fallbackDefinition.key) {
definition = fallbackDefinition; definition = fallbackDefinition;
actionArgs = definition.parseArgs({}) ?? {}; actionArgs = definition.parseArgs({}) ?? {};
@@ -1416,6 +1503,7 @@ export const createReservedTurnHandler = async (options: {
executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ?? executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ??
`${definition.name} 수행중... (${nextTerm}/${termMax})`; `${definition.name} 수행중... (${nextTerm}/${termMax})`;
logs.push(createGeneralActionLog(currentGeneral.id, progressText)); logs.push(createGeneralActionLog(currentGeneral.id, progressText));
recordAttempt(false, null, { term: nextTerm, total: termMax });
return { actionKey, usedFallback, completed: false, blockedReason }; 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 (resolution.alternative) {
if (alternativeDepth >= 5) { if (alternativeDepth >= 5) {
throw new Error('Command fallback loop limit exceeded'); throw new Error('Command fallback loop limit exceeded');
@@ -2270,6 +2359,21 @@ export const createReservedTurnHandler = async (options: {
blockedReason: '블럭 대상자입니다.', blockedReason: '블럭 대상자입니다.',
} }
: runAction('general', generalDefinitions, generalFallback, generalCommand, true); : 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); finishDecision('general', generalResult);
const generalActionDurationNs = options.onActionProfiled const generalActionDurationNs = options.onActionProfiled
? process.hrtime.bigint() - generalActionStartedAt ? 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 { describe, expect, it } from 'vitest';
import type { PendingAuditDecision } from '../src/playAudit/decision.js';
import { ConstantRNG, RandUtil } from '@sammo-ts/common'; import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import { import {
LogFormat, 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({ const general = makeGeneral({
npcState,
role: { role: {
personality: null, personality: null,
specialDomestic: null, specialDomestic: null,
@@ -406,10 +438,15 @@ describe('legacy general-turn execution contract', () => {
}); });
const harness = await createTurnTestHarness({ const harness = await createTurnTestHarness({
snapshot: makeSnapshot(general), snapshot: makeSnapshot(general),
state: makeState(), state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution' } },
schedule, schedule,
map, map,
collectLogs: true, collectLogs: true,
wrapGeneralTurnHandler: handler => ({ execute: context => {
const result = handler.execute(context);
decisions.push(...(result.auditDecisions ?? []));
return result;
} }),
}); });
const turns = harness.reservedTurnStore.getGeneralTurns(1); const turns = harness.reservedTurnStore.getGeneralTurns(1);
turns[0] = { action: 'che_전투특기초기화', args: {} }; turns[0] = { action: 'che_전투특기초기화', args: {} };
@@ -427,10 +464,17 @@ describe('legacy general-turn execution contract', () => {
expect(updated.role.specialWar).toBeNull(); expect(updated.role.specialWar).toBeNull();
expect(updated.meta['next_execute_전투 특기 초기화']).toBe(2460); expect(updated.meta['next_execute_전투 특기 초기화']).toBe(2460);
expect(updated.meta.prev_types_special2).toEqual(['che_격노']); 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({ const general = makeGeneral({
npcState,
role: { role: {
personality: null, personality: null,
specialDomestic: null, specialDomestic: null,
@@ -441,15 +485,22 @@ describe('legacy general-turn execution contract', () => {
}); });
const harness = await createTurnTestHarness({ const harness = await createTurnTestHarness({
snapshot: makeSnapshot(general), snapshot: makeSnapshot(general),
state: makeState(), state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution' } },
schedule, schedule,
map, map,
collectLogs: true, 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: {} }; harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_전투특기초기화', args: {} };
await harness.runOneTick(); 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)!; const updated = harness.world.getGeneralById(1)!;
expect(updated.role.specialWar).toBe('che_격노'); expect(updated.role.specialWar).toBe('che_격노');
expect(updated.lastTurn).not.toEqual({ command: '전투 특기 초기화', term: 1 }); 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(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 === 'nation' && row.summary.executedAction === 'che_선전포고')).toBe(true);
expect(savedDecisions.some((row) => row.phase === 'general' && 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_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);
expect(decisionTrace.some((step) => step.kind === 'RNG')).toBe(true); expect(decisionTrace.some((step) => step.kind === 'RNG')).toBe(true);
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js'; 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'; import { prunePreviousAuditBatch } from '../src/playAudit/retention.js';
const databaseUrl = process.env.PLAY_AUDIT_DECISION_DATABASE_URL; 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 () => { 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');
decision.summary.codeVersion = 'a'.repeat(40); 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( 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 $$` `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 } }); const header = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } });
expect(header).toMatchObject({ expect(header).toMatchObject({
tick: 4_320_000_000n, tick: 4_320_000_000n,
stepCount: 302, stepCount: 303,
summary: { codeVersion: 'a'.repeat(40) }, summary: { codeVersion: 'a'.repeat(40) },
}); });
await expect( await expect(
@@ -629,7 +629,7 @@ describe('Reserved Turn Execution Integration', () => {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: mockDate, lastTurnTime: mockDate,
meta: {}, meta: { serverId: 'audit-execution' },
}; };
const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_농지개간', arg: {} }]; const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_농지개간', arg: {} }];
@@ -676,6 +676,11 @@ describe('Reserved Turn Execution Integration', () => {
}); });
const dirty = world.consumeDirtyState(); 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); expect(world.getCityById(1)!.agriculture).toBe(2000);
const denyLog = dirty.logs.find((log) => log.text.includes('농지 개간이 충분합니다.')); const denyLog = dirty.logs.find((log) => log.text.includes('농지 개간이 충분합니다.'));
expect(denyLog?.text).toContain('농지 개간 실패.'); expect(denyLog?.text).toContain('농지 개간 실패.');
+51 -4
View File
@@ -59,6 +59,7 @@ const decision = {
summary: { summary: {
schemaVersion: 1, schemaVersion: 1,
coverage: 'PROCEDURES', coverage: 'PROCEDURES',
executionCoverage: 'ATTEMPTS',
clockRevision: 1, clockRevision: 1,
codeVersion: null, codeVersion: null,
policyRefs: { DEFENCE: 'a'.repeat(64) }, policyRefs: { DEFENCE: 'a'.repeat(64) },
@@ -71,7 +72,12 @@ const decision = {
blockedReason: '자원 부족', blockedReason: '자원 부족',
}, },
}; };
const install = async (page: Page, denied = false, baseline: boolean | 'document' | 'created' | 'removed' = false) => { const install = async (
page: Page,
denied = false,
baseline: boolean | 'document' | 'created' | 'removed' = false,
executionStatus?: 'PREPARING' | 'BLOCKED'
) => {
const requests: { operation: string; input: Record<string, unknown> }[] = []; const requests: { operation: string; input: Record<string, unknown> }[] = [];
await page.addInitScript((profile) => { await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_audit'); localStorage.setItem('sammo-game-token', 'ga_audit');
@@ -109,6 +115,7 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document
items: [ items: [
{ {
...decision, ...decision,
summary: { ...decision.summary, executionStatus },
id: input.cursor ? 'c'.repeat(64) : decision.id, id: input.cursor ? 'c'.repeat(64) : decision.id,
phase: input.cursor ? 'nation' : 'general', phase: input.cursor ? 'nation' : 'general',
}, },
@@ -118,7 +125,7 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document
case 'playAudit.decisionDetail': case 'playAudit.decisionDetail':
return result({ return result({
...world, ...world,
decision, decision: { ...decision, summary: { ...decision.summary, executionStatus } },
chunks: [ chunks: [
{ {
ordinal: input.cursor === undefined ? 0 : 1, ordinal: input.cursor === undefined ? 0 : 1,
@@ -135,7 +142,25 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document
sequence: input.cursor === undefined ? 0 : 128, sequence: input.cursor === undefined ? 0 : 128,
...(input.cursor === undefined ...(input.cursor === undefined
? { kind: 'PROCEDURE_START', procedure: '<b>징병판정</b>' } ? { kind: 'PROCEDURE_START', procedure: '<b>징병판정</b>' }
: { kind: 'DECISION_END', action: 'che_징병', reason: '징병 선택' }), : {
kind: 'EXECUTION_ATTEMPT',
attempt: 0,
requestedAction: 'che_징병',
resolvedAction: 'che_징병',
executedAction: '휴식',
completed: true,
usedFallback: true,
alternativeAction: null,
preparation: null,
checks: [
{
stage: 'CONSTRAINT',
action: 'che_징병',
result: 'deny',
reason: '<b>자원 부족</b>',
},
],
}),
}, },
], ],
}, },
@@ -1106,7 +1131,15 @@ test('NPC decisions are explicit, paginated, independently addressable and escap
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.getByRole('button', { name: '판단 절차 더 불러오기', exact: true }).click(); await page.getByRole('button', { name: '판단 절차 더 불러오기', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('최종 선택'); await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('실행 시도 1');
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText(
'조건 · che_징병 · 차단 · <b>자원 부족</b>'
);
await expect(page.getByRole('list', { name: '판단 절차' }).locator('b')).toHaveCount(0);
await expect(page.getByRole('list', { name: '판단 절차' }).locator(':scope > li').last()).toHaveAttribute(
'value',
'129'
);
expect(requests.filter((r) => r.operation === 'playAudit.decisionDetail').at(-1)?.input).toMatchObject({ expect(requests.filter((r) => r.operation === 'playAudit.decisionDetail').at(-1)?.input).toMatchObject({
id: decision.id, id: decision.id,
generalId: 1, generalId: 1,
@@ -1181,3 +1214,17 @@ test('NPC decision opens its immutable policy without querying policy history',
expect(requests.filter((r) => r.operation === 'playAudit.policyVersion')).toHaveLength(policyReads); expect(requests.filter((r) => r.operation === 'playAudit.policyVersion')).toHaveLength(policyReads);
await expect(page.getByRole('heading', { name: /선택 정책 버전/ })).toHaveCount(0); await expect(page.getByRole('heading', { name: /선택 정책 버전/ })).toHaveCount(0);
}); });
for (const [status, label] of [
['PREPARING', '준비 중'],
['BLOCKED', '실행 차단'],
] as const) {
test(`NPC execution ${status} is distinguished from a failed execution`, async ({ page }) => {
await install(page, false, false, status);
await page.goto(gamePath(`/play-audit?tab=generals&general=1&decision=${decision.id}`));
const region = page.getByRole('region', { name: 'NPC 결정 기록', exact: true });
await expect(region.getByRole('table')).toContainText(label);
await expect(region.getByRole('region', { name: '선택 결정 상세', exact: true })).toContainText(label);
await expect(region).not.toContainText('실행 실패');
});
}
@@ -79,15 +79,34 @@ const loadDetail = async (more = false) => {
} }
}; };
const select = (id: string | null) => router.push({ query: { ...route.query, decision: id ?? undefined } }); const select = (id: string | null) => router.push({ query: { ...route.query, decision: id ?? undefined } });
const outcome = (done: boolean | null) => (done === null ? '결과 미관측' : done ? '실행 완료' : '실행 실패'); const outcome = (done: boolean | null, status?: 'PREPARING' | 'BLOCKED' | 'RESOLVED') =>
status === 'PREPARING'
? '준비 중'
: status === 'BLOCKED'
? '실행 차단'
: done === null
? '결과 미관측'
: done
? '실행 완료'
: '실행 실패';
const rngValue = (value: Extract<Step, { kind: 'RNG' }>['result']): string => { const rngValue = (value: Extract<Step, { kind: 'RNG' }>['result']): string => {
if (Array.isArray(value)) return value.map(rngValue).join(', '); if (Array.isArray(value)) return value.map(rngValue).join(', ');
if (value === null) return '없음'; if (value === null) return '없음';
if (typeof value === 'object') return 'entityId' in value ? `대상 #${value.entityId}` : '상세 값 미수집'; if (typeof value === 'object') return 'entityId' in value ? `대상 #${value.entityId}` : '상세 값 미수집';
return String(value); return String(value);
}; };
const checkLabels = {
ARGS: '인자',
CONSTRAINT: '조건',
COOLDOWN: '재사용 대기',
CONTEXT: '실행 문맥',
BLOCK: '실행 제한',
};
const checkResultLabels = { allow: '통과', deny: '차단', unknown: '미확인' };
const stepText = (step: Step): string => { const stepText = (step: Step): string => {
switch (step.kind) { switch (step.kind) {
case 'EXECUTION_ATTEMPT':
return `실행 시도 ${step.attempt + 1} · 요청 ${step.requestedAction} · 처리 ${step.resolvedAction} → 실행 ${step.executedAction ?? '미실행'} · ${step.preparation ? '준비 중' : outcome(step.completed)}${step.usedFallback ? ' · 대체 실행' : ''}${step.alternativeAction ? ` · 다음 대안 ${step.alternativeAction}` : ''}${step.preparation ? ` · 준비 ${step.preparation.term}/${step.preparation.total}` : ''}`;
case 'DECISION_START': case 'DECISION_START':
return `판단 시작 · 예약 ${step.reservedAction}`; return `판단 시작 · 예약 ${step.reservedAction}`;
case 'DECISION_END': case 'DECISION_END':
@@ -165,7 +184,8 @@ watch(
<td>{{ item.npcState < 2 ? '유저 자동턴' : item.npcState === 5 ? '부대장 NPC' : 'NPC' }}</td> <td>{{ item.npcState < 2 ? '유저 자동턴' : item.npcState === 5 ? '부대장 NPC' : 'NPC' }}</td>
<td>{{ item.summary.selectedAction ?? '선택 없음' }} {{ item.summary.executedAction }}</td> <td>{{ item.summary.selectedAction ?? '선택 없음' }} {{ item.summary.executedAction }}</td>
<td> <td>
{{ outcome(item.summary.completed) }}{{ item.summary.usedFallback ? ' · 대체 실행' : '' }} {{ outcome(item.summary.completed, item.summary.executionStatus)
}}{{ item.summary.usedFallback ? ' · 대체 실행' : '' }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -182,6 +202,9 @@ watch(
<button class="legacy-button" @click="loadDetail(Boolean(detail))">결정 상세 다시 조회</button> <button class="legacy-button" @click="loadDetail(Boolean(detail))">결정 상세 다시 조회</button>
</p> </p>
<template v-if="detail"> <template v-if="detail">
<p v-if="!detail.decision.summary.executionCoverage">
기록에는 실행 단계의 시도 이력이 수집되지 않았습니다.
</p>
<p> <p>
{{ detail.decision.year }} {{ detail.decision.month }} · 국가 #{{ detail.decision.nationId }} · {{ detail.decision.year }} {{ detail.decision.month }} · 국가 #{{ detail.decision.nationId }} ·
도시 #{{ detail.decision.cityId }} · tick {{ detail.decision.tick }} 도시 #{{ detail.decision.cityId }} · tick {{ detail.decision.tick }}
@@ -193,7 +216,7 @@ watch(
</p> </p>
<p> <p>
선택 사유: {{ detail.decision.summary.selectedReason ?? '미관측' }} · 선택 사유: {{ detail.decision.summary.selectedReason ?? '미관측' }} ·
{{ outcome(detail.decision.summary.completed) }} {{ outcome(detail.decision.summary.completed, detail.decision.summary.executionStatus) }}
</p> </p>
<p v-if="detail.decision.summary.blockedReason"> <p v-if="detail.decision.summary.blockedReason">
차단 사유: {{ detail.decision.summary.blockedReason }} 차단 사유: {{ detail.decision.summary.blockedReason }}
@@ -222,6 +245,12 @@ 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) }}
<ul v-if="step.kind === 'EXECUTION_ATTEMPT'">
<li v-for="(check, index) in step.checks" :key="index">
{{ checkLabels[check.stage] }} · {{ check.action }} ·
{{ checkResultLabels[check.result] }}{{ check.reason ? ` · ${check.reason}` : '' }}
</li>
</ul>
</li></template </li></template
> >
</ol> </ol>
+18
View File
@@ -28,6 +28,24 @@ migration58은 기존 장수 인덱스를 `(server, general, year, month, tick,
포함되지 않은 URL policy는 해당 결정의 정책으로 읽거나 표시하지 않는다. 포함되지 않은 URL policy는 해당 결정의 정책으로 읽거나 표시하지 않는다.
국가의 저장 설정과 NPC별 합성 유효 값은 구분하며 현재 설정으로 보충하지 않는다. 국가의 저장 설정과 NPC별 합성 유효 값은 구분하며 현재 설정으로 보충하지 않는다.
### NPC 실행 단계와 대체 명령
선택 이후 `runAction`이 실제 수행한 인자/조건/재사용 대기/실행 문맥 검사를
`EXECUTION_ATTEMPT`로 상세 chunk에 기록한다. depth별 요청·해석·실행 명령과 결과,
대안 명령, 준비 term/total을 남긴다. 검사 함수를 다시 호출하지 않으며 실제로 건너뛴
검사는 추가하지 않는다. 준비·블럭으로 명령을 실행하지 않았으면 executedAction은 null이다.
원래 resolve 결과의 alternative를 따라간 순서와 공유 RNG를 그대로 유지한다.
저장 순번은 선택 관측과 실행 시도를 하나의 증가 순서로 매긴다. 기존 외부 AI observer의
원래 sequence는 변경하지 않는다. `DECISION_END`는 선택 종료이며 새 저장 기록은 실행
시도 뒤 종료한다. `executionCoverage=ATTEMPTS`로 기존 선택만 있는 기록과 구분하고,
이 flag가 있는데 실행 시도가 없으면 persistence가 거부한다. 과거 hash/행을 재작성하지 않는다.
요약의 대체 여부에는 이전 단계의 대안/휴식도 반영한다. 실제 게임 실행 결과 객체는 바꾸지 않는다.
API는 검사/결과의 허용 필드만 읽고 UI는 순서와 한국어 검사명을 표시한다. 목록에 상세를
추가하지 않는다. 체크 배열은 시도당 최대4개(블럭은1개), 기존 대안 depth 한도5를 유지한다.
전체 월간 부하 gate와 throw/rollback 실행의 별도 실패 원장, 내부 후보 조건은 후속이다.
### NPC 결정 저장·복구 기반 ### NPC 결정 저장·복구 기반
새 migration57은 `play_audit_decision` 요약과 `play_audit_decision_chunk` 상세를 분리한다. 새 migration57은 `play_audit_decision` 요약과 `play_audit_decision_chunk` 상세를 분리한다.
+2 -2
View File
@@ -15,7 +15,7 @@
| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 | | 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 | | 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 | | 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 |
| NPC 결정 | 장수별 개인·수뇌 판단, 절차 시도/차단, 관측한 RNG 결과와 선택·실제 실행 | 새 실행부터 수집하며 후보 내부 조건 전체는 미완성 | | NPC 결정 | 장수별 개인·수뇌 판단, 절차 시도/차단, 관측한 RNG 결과와 선택·실제 실행·대체 시도 | 새 실행부터 수집하며 후보 내부 조건 전체는 미완성 |
| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면은 아직 없음. NPC 결정에서 저장된 당시 버전을 직접 조회 가능 | | 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면은 아직 없음. NPC 결정에서 저장된 당시 버전을 직접 조회 가능 |
목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로 목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로
@@ -90,7 +90,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
현재 감사 API도 사용할 수 없다.** 취소 후 다음 초기화까지 읽는 수명주기는 후속 작업이다. 현재 감사 API도 사용할 수 없다.** 취소 후 다음 초기화까지 읽는 수명주기는 후속 작업이다.
- 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 조사와 알려진
버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다. 버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다.