NPC 명령의 실제 검사와 준비 및 대체 실행 순서를 감사 기록에 연결
This commit is contained in:
@@ -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' }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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('농지 개간 실패.');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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` 상세를 분리한다.
|
||||||
|
|||||||
@@ -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 조사와 알려진
|
||||||
버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다.
|
버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다.
|
||||||
|
|||||||
Reference in New Issue
Block a user