From 028f985e7a679d12cfe24fad7f8f46a7e12839f0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 08:46:56 +0000 Subject: [PATCH] =?UTF-8?q?NPC=20=EB=AA=85=EB=A0=B9=EC=9D=98=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EA=B2=80=EC=82=AC=EC=99=80=20=EC=A4=80=EB=B9=84=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8C=80=EC=B2=B4=20=EC=8B=A4=ED=96=89=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=EB=A5=BC=20=EA=B0=90=EC=82=AC=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=EC=97=90=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/router/playAudit/decisions.ts | 23 ++++ .../securityTransport.integration.test.ts | 50 +++++++- app/game-engine/src/playAudit/decision.ts | 2 + .../src/playAudit/decisionPersistence.ts | 7 +- .../src/turn/ai/generalAi/trace.ts | 19 +++ .../src/turn/reservedTurnHandler.ts | 112 +++++++++++++++++- .../test/fixtures/playAuditDecision.ts | 13 ++ .../generalTurnLegacyCompatibility.test.ts | 59 ++++++++- .../test/npcNationWarDeclaration.test.ts | 2 +- ...ditDecisionPersistence.integration.test.ts | 9 +- .../test/reservedTurnExecution.test.ts | 7 +- app/game-frontend/e2e/playAudit.spec.ts | 55 ++++++++- .../playAudit/AuditGeneralDecisions.vue | 35 +++++- docs/design/play-audit-implementation.md | 18 +++ docs/play-audit-operations.md | 4 +- 15 files changed, 391 insertions(+), 24 deletions(-) diff --git a/app/game-api/src/router/playAudit/decisions.ts b/app/game-api/src/router/playAudit/decisions.ts index 5b47bb9f..7c335d63 100644 --- a/app/game-api/src/router/playAudit/decisions.ts +++ b/app/game-api/src/router/playAudit/decisions.ts @@ -11,6 +11,8 @@ const zTick = z const zSummary = z.object({ schemaVersion: z.literal(1), coverage: z.literal('PROCEDURES'), + executionCoverage: z.literal('ATTEMPTS').optional(), + executionStatus: z.enum(['PREPARING', 'BLOCKED', 'RESOLVED']).optional(), clockRevision: z.number().int(), codeVersion: z.string().nullable(), policyRefs: z.object({ @@ -48,6 +50,27 @@ const zStep = z.intersection( tick: z.number().nullable(), }), 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_END'), action: z.string().nullable(), reason: z.string().nullable() }), z.object({ kind: z.literal('DECISION_ERROR') }), diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 9eb1a332..1c1d67a6 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2298,7 +2298,34 @@ integration('game API security over HTTP transport', () => { ordinal: 0, 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 }; @@ -2351,8 +2378,27 @@ integration('game API security over HTTP transport', () => { }); expect(JSON.stringify(decisionPage.body)).not.toContain('decision-secret'); 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, limit: 5 })).status).toBe(400); expect((await get('decisionHistory', admin, { ...decisionInput, limit: 201 })).status).toBe(400); diff --git a/app/game-engine/src/playAudit/decision.ts b/app/game-engine/src/playAudit/decision.ts index d0d647d7..40a2f129 100644 --- a/app/game-engine/src/playAudit/decision.ts +++ b/app/game-engine/src/playAudit/decision.ts @@ -16,6 +16,8 @@ export interface PendingAuditDecision { summary: { schemaVersion: 1; coverage: 'PROCEDURES'; + executionCoverage?: 'ATTEMPTS'; + executionStatus?: 'PREPARING' | 'BLOCKED' | 'RESOLVED'; clockRevision: number; codeVersion: string | null; policyRefs: Record; diff --git a/app/game-engine/src/playAudit/decisionPersistence.ts b/app/game-engine/src/playAudit/decisionPersistence.ts index d4cebf3b..26b7f503 100644 --- a/app/game-engine/src/playAudit/decisionPersistence.ts +++ b/app/game-engine/src/playAudit/decisionPersistence.ts @@ -14,7 +14,12 @@ export const persistAuditDecisions = async ( const headers = batch.map(({ steps, ...decision }) => { if (!Number.isSafeInteger(decision.tick) || decision.tick < 0 || !steps.length) throw new Error('Invalid play audit decision'); - if (steps[0]?.kind !== 'DECISION_START' || steps.at(-1)?.kind !== 'DECISION_END') + if ( + steps[0]?.kind !== 'DECISION_START' || + !steps.some((step) => step.kind === 'DECISION_END') || + !['DECISION_END', 'EXECUTION_ATTEMPT'].includes(steps.at(-1)!.kind) || + (decision.summary.executionCoverage === 'ATTEMPTS' && steps.at(-1)?.kind !== 'EXECUTION_ATTEMPT') + ) throw new Error('Incomplete play audit decision'); if ( steps.some( diff --git a/app/game-engine/src/turn/ai/generalAi/trace.ts b/app/game-engine/src/turn/ai/generalAi/trace.ts index bce7f00e..3d3eb492 100644 --- a/app/game-engine/src/turn/ai/generalAi/trace.ts +++ b/app/game-engine/src/turn/ai/generalAi/trace.ts @@ -2,7 +2,26 @@ import type { RandUtil } from '@sammo-ts/common'; /** 원문 meta/seed/임의 객체를 받지 않는 관측 계약. 내부 후보 조건은 별도 계측으로 확장한다. */ export type AiTraceValue = string | number | boolean | null | { entityId: number } | { unprojected: true }; +export type AiExecutionCheck = { + stage: 'ARGS' | 'CONSTRAINT' | 'COOLDOWN' | 'CONTEXT' | 'BLOCK'; + action: string; + result: 'allow' | 'deny' | 'unknown'; + reason: string | null; +}; +export type AiExecutionAttempt = { + kind: 'EXECUTION_ATTEMPT'; + attempt: number; + requestedAction: string; + resolvedAction: string; + executedAction: string | null; + checks: AiExecutionCheck[]; + completed: boolean; + usedFallback: boolean; + alternativeAction: string | null; + preparation: { term: number; total: number } | null; +}; export type AiTraceStep = + | AiExecutionAttempt | { kind: 'DECISION_START'; reservedAction: string } | { kind: 'DECISION_END'; action: string | null; reason: string | null } | { kind: 'DECISION_ERROR' } diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 89508f8b..cc742204 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1,6 +1,6 @@ import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js'; import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js'; -import type { AiDecisionTraceEvent } from './ai/generalAi/trace.js'; +import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js'; import type { AiDecisionTraceObserver } from './ai/generalAi/trace.js'; import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import type { @@ -1079,6 +1079,7 @@ export const createReservedTurnHandler = async (options: { options.collectAuditDecisions !== false && Boolean(auditServerId?.trim()) && Boolean(worldRef); const auditDecisions: PendingAuditDecision[] = []; const decisionSteps = new Map<'general' | 'nation', AiDecisionTraceEvent[]>(); + let storedDecisionSequence = 0; const decisionPolicyRefs = new Map<'general' | 'nation', Record>(); const onDecisionTrace: AiDecisionTraceObserver | undefined = collectDecisions || options.onDecisionTrace @@ -1099,11 +1100,31 @@ export const createReservedTurnHandler = async (options: { ) ); } - decisionSteps.get(event.phase)?.push(structuredClone(event)); + decisionSteps + .get(event.phase) + ?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ }); } options.onDecisionTrace?.(event); } : undefined; + const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => { + const steps = decisionSteps.get(phase); + const first = steps?.[0]; + if (!collectDecisions || !first || !steps) return; + const { generalId, nationId, cityId, npcState, year, month, tick } = first; + steps.push({ + ...attempt, + sequence: storedDecisionSequence++, + phase, + generalId, + nationId, + cityId, + npcState, + year, + month, + tick, + }); + }; const finishDecision = ( phase: 'general' | 'nation', outcome: { @@ -1115,7 +1136,7 @@ export const createReservedTurnHandler = async (options: { ): void => { const steps = decisionSteps.get(phase); const first = steps?.[0]; - const last = steps?.at(-1); + const last = steps?.find((step) => step.kind === 'DECISION_END'); if ( !collectDecisions || !auditServerId || @@ -1128,6 +1149,7 @@ export const createReservedTurnHandler = async (options: { const tick = context.general.turnTick ?? worldRef.dateToGameTick(context.general.turnTime); const revision = worldRef.getGameClockState().revision; const executionId = auditDecisionIdentity(auditServerId, context.general.id, tick, revision); + const execution = steps.at(-1); auditDecisions.push({ id: auditPolicyHash([executionId, phase]), serverId: auditServerId, @@ -1143,6 +1165,14 @@ export const createReservedTurnHandler = async (options: { summary: { schemaVersion: 1, coverage: 'PROCEDURES', + executionCoverage: 'ATTEMPTS', + executionStatus: + execution?.kind === 'EXECUTION_ATTEMPT' && execution.preparation + ? 'PREPARING' + : execution?.kind === 'EXECUTION_ATTEMPT' && + execution.checks.some((check) => check.stage === 'BLOCK') + ? 'BLOCKED' + : 'RESOLVED', clockRevision: revision, codeVersion: auditCodeVersion ?? null, policyRefs: decisionPolicyRefs.get(phase) ?? {}, @@ -1151,7 +1181,13 @@ export const createReservedTurnHandler = async (options: { selectedReason: last.reason, executedAction: outcome.actionKey, completed: outcome.completed ?? null, - usedFallback: outcome.usedFallback, + usedFallback: + outcome.usedFallback || + steps.some( + (step) => + step.kind === 'EXECUTION_ATTEMPT' && + (step.usedFallback || step.alternativeAction !== null) + ), blockedReason: outcome.blockedReason ?? null, }, steps, @@ -1177,6 +1213,15 @@ export const createReservedTurnHandler = async (options: { completed: boolean; blockedReason?: string; } => { + const checks: AiExecutionCheck[] = []; + const check = ( + stage: AiExecutionCheck['stage'], + action: string, + result: AiExecutionCheck['result'], + reason: string | null = null + ): void => { + if (collectDecisions && decisionSteps.has(kind)) checks.push({ stage, action, result, reason }); + }; const resolvedDefinition = resolveDefinition(command.action, definitionMap, kind); const rawArgs = extractArgsRecord(command.args); const parsedArgs = resolvedDefinition.parseArgs(rawArgs); @@ -1185,6 +1230,31 @@ export const createReservedTurnHandler = async (options: { let actionKey = definition.key; let usedFallback = false; let blockedReason: string | undefined = undefined; + const recordAttempt = ( + completed: boolean, + alternativeAction: string | null = null, + preparation: { term: number; total: number } | null = null + ): void => { + if (!collectDecisions || !decisionSteps.has(kind)) return; + recordExecution(kind, { + kind: 'EXECUTION_ATTEMPT', + attempt: alternativeDepth, + requestedAction: command.action, + resolvedAction: resolvedDefinition.key, + executedAction: preparation ? null : actionKey, + checks, + completed, + usedFallback, + alternativeAction, + preparation, + }); + }; + check( + 'ARGS', + actionKey, + parsedArgs === null ? 'deny' : 'allow', + parsedArgs === null ? '인자가 올바르지 않습니다.' : null + ); if (parsedArgs === null) { const failureText = `인자가 올바르지 않습니다. ${resolvedDefinition.name} 실패.`; @@ -1216,6 +1286,7 @@ export const createReservedTurnHandler = async (options: { }); const constraints = definition.buildConstraints(constraintCtx, actionArgs); const result = evaluateConstraints(constraints, constraintCtx, view); + check('CONSTRAINT', definition.key, result.kind, result.kind === 'deny' ? result.reason : null); if (result.kind !== 'allow') { const failedDefinition = definition; const failedActionArgs = actionArgs; @@ -1237,6 +1308,14 @@ export const createReservedTurnHandler = async (options: { kind === 'general' ? readGeneralNextAvailableTurn(currentGeneral, definition.name) : readNextAvailableTurn(currentNation!, definition.name); + check( + 'COOLDOWN', + definition.key, + nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn ? 'deny' : 'allow', + nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn + ? `${nextAvailableTurn - currentYearMonth}턴 더 기다려야 합니다` + : null + ); if (nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn) { const remainTurn = nextAvailableTurn - currentYearMonth; definition = fallbackDefinition; @@ -1325,6 +1404,14 @@ export const createReservedTurnHandler = async (options: { }, actionContextBuilders ); + check( + 'CONTEXT', + actionKey, + specificContext ? 'allow' : actionKey === fallbackDefinition.key ? 'allow' : 'deny', + !specificContext && actionKey !== fallbackDefinition.key + ? '예약된 명령을 실행하지 못했습니다.' + : null + ); if (!specificContext && actionKey !== fallbackDefinition.key) { definition = fallbackDefinition; actionArgs = definition.parseArgs({}) ?? {}; @@ -1416,6 +1503,7 @@ export const createReservedTurnHandler = async (options: { executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ?? `${definition.name} 수행중... (${nextTerm}/${termMax})`; logs.push(createGeneralActionLog(currentGeneral.id, progressText)); + recordAttempt(false, null, { term: nextTerm, total: termMax }); return { actionKey, usedFallback, completed: false, blockedReason }; } } @@ -1864,6 +1952,7 @@ export const createReservedTurnHandler = async (options: { } } + recordAttempt(resolution.completed, resolution.alternative?.commandKey ?? null); if (resolution.alternative) { if (alternativeDepth >= 5) { throw new Error('Command fallback loop limit exceeded'); @@ -2270,6 +2359,21 @@ export const createReservedTurnHandler = async (options: { blockedReason: '블럭 대상자입니다.', } : runAction('general', generalDefinitions, generalFallback, generalCommand, true); + if (isBlocked) + recordExecution('general', { + kind: 'EXECUTION_ATTEMPT', + attempt: 0, + requestedAction: generalCommand.action, + resolvedAction: DEFAULT_ACTION, + executedAction: null, + checks: [ + { stage: 'BLOCK', action: generalCommand.action, result: 'deny', reason: '블럭 대상자입니다.' }, + ], + completed: false, + usedFallback: true, + alternativeAction: null, + preparation: null, + }); finishDecision('general', generalResult); const generalActionDurationNs = options.onActionProfiled ? process.hrtime.bigint() - generalActionStartedAt diff --git a/app/game-engine/test/fixtures/playAuditDecision.ts b/app/game-engine/test/fixtures/playAuditDecision.ts index 9b0eedc4..1f156830 100644 --- a/app/game-engine/test/fixtures/playAuditDecision.ts +++ b/app/game-engine/test/fixtures/playAuditDecision.ts @@ -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, +}); diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index dbf2018a..c7a7a327 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import type { PendingAuditDecision } from '../src/playAudit/decision.js'; import { ConstantRNG, RandUtil } from '@sammo-ts/common'; import { LogFormat, @@ -395,8 +396,39 @@ describe('legacy general-turn execution contract', () => { ); }); - it('persists pre-turn stacking and applies the inherited 60-turn cooldown', async () => { + it('records the real disband-to-talent-search alternative in execution order', async () => { + const decisions: PendingAuditDecision[] = []; + const general = makeGeneral({ npcState: 2, officerLevel: 12 }); + const snapshot = makeSnapshot(general); + snapshot.nations[0] = { ...snapshot.nations[0]!, level: 0, chiefGeneralId: 1, capitalCityId: null }; + snapshot.cities[0] = { ...snapshot.cities[0]!, nationId: 0 }; + const harness = await createTurnTestHarness({ + snapshot, + state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution', initYear: 200, initMonth: 1 } }, + schedule, map, collectLogs: true, + commandRngFactory: () => new RandUtil(new ConstantRNG(0)), + wrapGeneralTurnHandler: handler => ({ execute: context => { + const result = handler.execute(context); + decisions.push(...(result.auditDecisions ?? [])); + return result; + } }), + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_해산', args: {} }; + await harness.runOneTick(); + const decision = decisions.find(row => row.phase === 'general'); + expect(decision?.summary).toMatchObject({ selectedAction: 'che_해산', executedAction: 'che_인재탐색', usedFallback: true }); + const attempts = decision?.steps.filter(step => step.kind === 'EXECUTION_ATTEMPT'); + expect(attempts).toMatchObject([ + { attempt: 0, executedAction: 'che_해산', alternativeAction: 'che_인재탐색' }, + { attempt: 1, requestedAction: 'che_인재탐색', executedAction: 'che_인재탐색', alternativeAction: null }, + ]); + expect(attempts?.[1]?.sequence).toBeGreaterThan(attempts?.[0]?.sequence ?? -1); + }); + + it.each([0, 2])('persists pre-turn stacking and applies the inherited 60-turn cooldown (npcState=%s)', async (npcState) => { + const decisions: PendingAuditDecision[] = []; const general = makeGeneral({ + npcState, role: { personality: null, specialDomestic: null, @@ -406,10 +438,15 @@ describe('legacy general-turn execution contract', () => { }); const harness = await createTurnTestHarness({ snapshot: makeSnapshot(general), - state: makeState(), + state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution' } }, schedule, map, collectLogs: true, + wrapGeneralTurnHandler: handler => ({ execute: context => { + const result = handler.execute(context); + decisions.push(...(result.auditDecisions ?? [])); + return result; + } }), }); const turns = harness.reservedTurnStore.getGeneralTurns(1); turns[0] = { action: 'che_전투특기초기화', args: {} }; @@ -427,10 +464,17 @@ describe('legacy general-turn execution contract', () => { expect(updated.role.specialWar).toBeNull(); expect(updated.meta['next_execute_전투 특기 초기화']).toBe(2460); expect(updated.meta.prev_types_special2).toEqual(['che_격노']); + if (npcState === 2) { + expect(decisions[0]?.summary.executionStatus).toBe('PREPARING'); + expect(decisions[0]?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', executedAction: null, preparation: { term: 1, total: 2 }, completed: false }); + expect(decisions[1]?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', executedAction: 'che_전투특기초기화', preparation: null, completed: true }); + } else expect(decisions).toEqual([]); }); - it('rejects a speciality reset cooldown before accumulating its first preparation turn', async () => { + it.each([0, 2])('rejects a speciality reset cooldown before accumulating its first preparation turn (npcState=%s)', async (npcState) => { + const decisions: PendingAuditDecision[] = []; const general = makeGeneral({ + npcState, role: { personality: null, specialDomestic: null, @@ -441,15 +485,22 @@ describe('legacy general-turn execution contract', () => { }); const harness = await createTurnTestHarness({ snapshot: makeSnapshot(general), - state: makeState(), + state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution' } }, schedule, map, collectLogs: true, + wrapGeneralTurnHandler: handler => ({ execute: context => { + const result = handler.execute(context); + decisions.push(...(result.auditDecisions ?? [])); + return result; + } }), }); harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_전투특기초기화', args: {} }; await harness.runOneTick(); + if (npcState === 2) expect(decisions[0]?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', executedAction: '휴식', usedFallback: true, preparation: null, checks: expect.arrayContaining([{ stage: 'COOLDOWN', action: 'che_전투특기초기화', result: 'deny', reason: '60턴 더 기다려야 합니다' }]) }); + else expect(decisions).toEqual([]); const updated = harness.world.getGeneralById(1)!; expect(updated.role.specialWar).toBe('che_격노'); expect(updated.lastTurn).not.toEqual({ command: '전투 특기 초기화', term: 1 }); diff --git a/app/game-engine/test/npcNationWarDeclaration.test.ts b/app/game-engine/test/npcNationWarDeclaration.test.ts index 3984e640..e416070a 100644 --- a/app/game-engine/test/npcNationWarDeclaration.test.ts +++ b/app/game-engine/test/npcNationWarDeclaration.test.ts @@ -452,7 +452,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => { expect(new Set(savedDecisions.map((row) => row.id)).size).toBe(savedDecisions.length); expect(savedDecisions.some((row) => row.phase === 'nation' && row.summary.executedAction === 'che_선전포고')).toBe(true); expect(savedDecisions.some((row) => row.phase === 'general' && row.summary.executedAction === 'che_출병')).toBe(true); - expect(savedDecisions.every((row) => row.steps[0]?.kind === 'DECISION_START' && row.steps.at(-1)?.kind === 'DECISION_END')).toBe(true); + expect(savedDecisions.every((row) => row.steps[0]?.kind === 'DECISION_START' && row.steps.at(-1)?.kind === 'EXECUTION_ATTEMPT')).toBe(true); expect(decisionTrace.some((step) => step.kind === 'DECISION_START' && step.phase === 'nation')).toBe(true); expect(decisionTrace.some((step) => step.kind === 'DECISION_END' && step.phase === 'general')).toBe(true); expect(decisionTrace.some((step) => step.kind === 'RNG')).toBe(true); diff --git a/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts b/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts index 086f8671..f267efca 100644 --- a/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts +++ b/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js'; -import { buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js'; +import { buildAuditExecutionFixture, buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js'; import { prunePreviousAuditBatch } from '../src/playAudit/retention.js'; const databaseUrl = process.env.PLAY_AUDIT_DECISION_DATABASE_URL; @@ -37,6 +37,11 @@ integration('decision persistence and bounded retention', () => { it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => { const decision = draft('decision-one'); decision.summary.codeVersion = 'a'.repeat(40); + decision.summary.executionCoverage = 'ATTEMPTS'; + decision.steps.push({ ...decision.steps[0]!, ...buildAuditExecutionFixture(), sequence: 302 }); + await expect( + db.$transaction((tx) => persistAuditDecisions(tx, [{ ...decision, steps: decision.steps.slice(0, -1) }])) + ).rejects.toThrow('Incomplete play audit decision'); await db.$executeRawUnsafe( `CREATE OR REPLACE FUNCTION decision_fixture_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.ordinal=1 THEN RAISE EXCEPTION 'decision chunk failure'; END IF; RETURN NEW; END $$` ); @@ -64,7 +69,7 @@ integration('decision persistence and bounded retention', () => { const header = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } }); expect(header).toMatchObject({ tick: 4_320_000_000n, - stepCount: 302, + stepCount: 303, summary: { codeVersion: 'a'.repeat(40) }, }); await expect( diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts index 18c13d73..5650c5f2 100644 --- a/app/game-engine/test/reservedTurnExecution.test.ts +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -629,7 +629,7 @@ describe('Reserved Turn Execution Integration', () => { currentMonth: 1, tickSeconds: 600, lastTurnTime: mockDate, - meta: {}, + meta: { serverId: 'audit-execution' }, }; const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_농지개간', arg: {} }]; @@ -676,6 +676,11 @@ describe('Reserved Turn Execution Integration', () => { }); const dirty = world.consumeDirtyState(); + if (npcState === 2) { + const decision = dirty.pendingAuditDecisions.find(row => row.phase === 'general'); + expect(decision?.summary).toMatchObject({ requestedAction: 'che_농지개간', executedAction: '휴식', usedFallback: true, executionCoverage: 'ATTEMPTS' }); + expect(decision?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', attempt: 0, requestedAction: 'che_농지개간', executedAction: '휴식', usedFallback: true, checks: expect.arrayContaining([{ stage: 'CONSTRAINT', action: 'che_농지개간', result: 'deny', reason: '농지 개간이 충분합니다.' }]) }); + } else expect(dirty.pendingAuditDecisions).toEqual([]); expect(world.getCityById(1)!.agriculture).toBe(2000); const denyLog = dirty.logs.find((log) => log.text.includes('농지 개간이 충분합니다.')); expect(denyLog?.text).toContain('농지 개간 실패.'); diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index 52bda458..a7b4fcd5 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -59,6 +59,7 @@ const decision = { summary: { schemaVersion: 1, coverage: 'PROCEDURES', + executionCoverage: 'ATTEMPTS', clockRevision: 1, codeVersion: null, policyRefs: { DEFENCE: 'a'.repeat(64) }, @@ -71,7 +72,12 @@ const decision = { 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 }[] = []; await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_audit'); @@ -109,6 +115,7 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document items: [ { ...decision, + summary: { ...decision.summary, executionStatus }, id: input.cursor ? 'c'.repeat(64) : decision.id, phase: input.cursor ? 'nation' : 'general', }, @@ -118,7 +125,7 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document case 'playAudit.decisionDetail': return result({ ...world, - decision, + decision: { ...decision, summary: { ...decision.summary, executionStatus } }, chunks: [ { 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, ...(input.cursor === undefined ? { kind: 'PROCEDURE_START', procedure: '징병판정' } - : { 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: '자원 부족', + }, + ], + }), }, ], }, @@ -1106,7 +1131,15 @@ test('NPC decisions are explicit, paginated, independently addressable and escap await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정'); await expect(page.getByRole('list', { name: '판단 절차' }).locator('b')).toHaveCount(0); 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_징병 · 차단 · 자원 부족' + ); + 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({ id: decision.id, 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); 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('실행 실패'); + }); +} diff --git a/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue b/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue index 4065ff9e..c82ca987 100644 --- a/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue +++ b/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue @@ -79,15 +79,34 @@ const loadDetail = async (more = false) => { } }; 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['result']): string => { if (Array.isArray(value)) return value.map(rngValue).join(', '); if (value === null) return '없음'; if (typeof value === 'object') return 'entityId' in value ? `대상 #${value.entityId}` : '상세 값 미수집'; return String(value); }; +const checkLabels = { + ARGS: '인자', + CONSTRAINT: '조건', + COOLDOWN: '재사용 대기', + CONTEXT: '실행 문맥', + BLOCK: '실행 제한', +}; +const checkResultLabels = { allow: '통과', deny: '차단', unknown: '미확인' }; const stepText = (step: Step): string => { 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': return `판단 시작 · 예약 ${step.reservedAction}`; case 'DECISION_END': @@ -165,7 +184,8 @@ watch( {{ item.npcState < 2 ? '유저 자동턴' : item.npcState === 5 ? '부대장 NPC' : 'NPC' }} {{ item.summary.selectedAction ?? '선택 없음' }} → {{ item.summary.executedAction }} - {{ outcome(item.summary.completed) }}{{ item.summary.usedFallback ? ' · 대체 실행' : '' }} + {{ outcome(item.summary.completed, item.summary.executionStatus) + }}{{ item.summary.usedFallback ? ' · 대체 실행' : '' }} @@ -182,6 +202,9 @@ watch(