diff --git a/app/game-engine/src/playAudit/decision.ts b/app/game-engine/src/playAudit/decision.ts new file mode 100644 index 00000000..956a44d0 --- /dev/null +++ b/app/game-engine/src/playAudit/decision.ts @@ -0,0 +1,34 @@ +import { auditPolicyHash } from './policy.js'; +import type { AiDecisionTraceEvent } from '../turn/ai/generalAi/trace.js'; + +export interface PendingAuditDecision { + id: string; + serverId: string; + executionId: string; + phase: 'general' | 'nation'; + generalId: number; + nationId: number; + cityId: number; + npcState: number; + year: number; + month: number; + tick: number; + summary: { + schemaVersion: 1; + coverage: 'PROCEDURES'; + clockRevision: number; + codeVersion: string | null; + policyRefs: Record; + requestedAction: string; + selectedAction: string | null; + selectedReason: string | null; + executedAction: string; + completed: boolean | null; + usedFallback: boolean; + blockedReason: string | null; + }; + steps: AiDecisionTraceEvent[]; +} + +export const auditDecisionIdentity = (serverId: string, generalId: number, tick: number, revision: number) => + auditPolicyHash([serverId, generalId, tick, revision]); diff --git a/app/game-engine/src/playAudit/decisionPersistence.ts b/app/game-engine/src/playAudit/decisionPersistence.ts new file mode 100644 index 00000000..d4cebf3b --- /dev/null +++ b/app/game-engine/src/playAudit/decisionPersistence.ts @@ -0,0 +1,58 @@ +import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; +import { auditPolicyHash } from './policy.js'; +import type { PendingAuditDecision } from './decision.js'; + +export const AUDIT_DECISION_CHUNK_STEPS = 128; +const BATCH = 200; +/** 요약과 모든 chunk를 기존 gameplay transaction에 저장한다. 후보별 SQL은 발행하지 않는다. */ +export const persistAuditDecisions = async ( + tx: GamePrisma.TransactionClient, + decisions: readonly PendingAuditDecision[] +): Promise => { + for (let offset = 0; offset < decisions.length; offset += BATCH) { + const batch = decisions.slice(offset, offset + BATCH); + 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') + throw new Error('Incomplete play audit decision'); + if ( + steps.some( + (step, index) => + step.phase !== decision.phase || (index > 0 && step.sequence <= steps[index - 1]!.sequence) + ) + ) + throw new Error('Invalid play audit decision order'); + return { + ...decision, + tick: BigInt(decision.tick), + stepCount: steps.length, + summary: decision.summary as InputJsonValue, + hash: auditPolicyHash({ ...decision, steps }), + }; + }); + await tx.playAuditDecision.createMany({ data: headers, skipDuplicates: true }); + const saved = await tx.playAuditDecision.findMany({ + where: { id: { in: headers.map((row) => row.id) } }, + select: { id: true, hash: true }, + }); + const hashes = new Map(saved.map((row) => [row.id, row.hash])); + if (headers.some((row) => hashes.get(row.id) !== row.hash)) + throw new Error('Play audit decision replay conflict'); + let chunks: GamePrisma.PlayAuditDecisionChunkCreateManyInput[] = []; + for (const decision of batch) { + for (let start = 0; start < decision.steps.length; start += AUDIT_DECISION_CHUNK_STEPS) { + chunks.push({ + decisionId: decision.id, + ordinal: start / AUDIT_DECISION_CHUNK_STEPS, + steps: decision.steps.slice(start, start + AUDIT_DECISION_CHUNK_STEPS) as InputJsonValue, + }); + if (chunks.length === BATCH) { + await tx.playAuditDecisionChunk.createMany({ data: chunks, skipDuplicates: true }); + chunks = []; + } + } + } + if (chunks.length) await tx.playAuditDecisionChunk.createMany({ data: chunks, skipDuplicates: true }); + } +}; diff --git a/app/game-engine/src/playAudit/retention.ts b/app/game-engine/src/playAudit/retention.ts index 8ead1fd6..92149c71 100644 --- a/app/game-engine/src/playAudit/retention.ts +++ b/app/game-engine/src/playAudit/retention.ts @@ -32,6 +32,30 @@ export const prunePreviousAuditBatch = async ( }); return { status: 'progress', deleted: deleted.count }; } + const [decision] = await tx.$queryRaw<{ id: string }[]>` + SELECT id FROM play_audit_decision WHERE id = COALESCE( + (SELECT id FROM play_audit_decision WHERE server_id < ${expectedServerId} + ORDER BY server_id, general_id, tick, id LIMIT 1), + (SELECT id FROM play_audit_decision WHERE server_id > ${expectedServerId} + ORDER BY server_id, general_id, tick, id LIMIT 1) + ) FOR UPDATE + `; + if (decision) { + const chunks = await tx.playAuditDecisionChunk.findMany({ + where: { decisionId: decision.id }, + orderBy: { ordinal: 'asc' }, + take: AUDIT_RETENTION_BATCH_SIZE, + select: { ordinal: true }, + }); + if (chunks.length) { + const deleted = await tx.playAuditDecisionChunk.deleteMany({ + where: { decisionId: decision.id, ordinal: { in: chunks.map((row) => row.ordinal) } }, + }); + return { status: 'progress', deleted: deleted.count }; + } + await tx.playAuditDecision.delete({ where: { id: decision.id } }); + return { status: 'progress', deleted: 1 }; + } const policies = await tx.playAuditPolicy.findMany({ where: { serverId: { not: expectedServerId } }, orderBy: { id: 'asc' }, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index e47dc0bd..c156faf6 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,3 +1,4 @@ +import { persistAuditDecisions } from '../playAudit/decisionPersistence.js'; import { persistAuditDiplomacyEvents } from '@sammo-ts/infra'; import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js'; import { persistAuditPolicies } from '../playAudit/policyPersistence.js'; @@ -1152,6 +1153,7 @@ export const createDatabaseTurnHooks = async ( pendingAuditMonths, pendingAuditPolicies, pendingAuditDiplomacy, + pendingAuditDecisions, pendingUnificationFinalizations, } = changes; const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); @@ -1894,6 +1896,7 @@ export const createDatabaseTurnHooks = async ( } await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand); await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy); + await persistAuditDecisions(prisma, pendingAuditDecisions); for (const snapshot of pendingAuditMonths) { await persistAuditMonth(prisma, snapshot); } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 8cde5334..3b81ac2f 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1,3 +1,4 @@ +import type { PendingAuditDecision } from '../playAudit/decision.js'; import { recordTurnAuditDiplomacy, recordNationAuditDiplomacy, @@ -60,6 +61,7 @@ export interface GeneralTurnContext { } export interface GeneralTurnResult { + auditDecisions?: PendingAuditDecision[]; general?: TurnGeneral; city?: City; nation?: Nation | null; @@ -211,6 +213,7 @@ export interface TurnWorldChanges { pendingAuditMonths: PendingAuditMonth[]; pendingAuditPolicies: PendingAuditPolicy[]; pendingAuditDiplomacy: AuditDiplomacyEventDraft[]; + pendingAuditDecisions: PendingAuditDecision[]; pendingUnificationFinalizations: PendingUnificationFinalization[]; } @@ -254,6 +257,7 @@ export interface InMemoryTurnWorldStateSnapshot { pendingAuditMonths: PendingAuditMonth[]; pendingAuditPolicies: PendingAuditPolicy[]; pendingAuditDiplomacy: AuditDiplomacyEventDraft[]; + pendingAuditDecisions: PendingAuditDecision[]; pendingUnificationFinalizations: PendingUnificationFinalization[]; pendingRealtimeBacklogShiftTicks: number; } @@ -561,6 +565,7 @@ export class InMemoryTurnWorld { private readonly pendingAuditMonths: PendingAuditMonth[] = []; private readonly pendingAuditPolicies: PendingAuditPolicy[] = []; private readonly pendingAuditDiplomacy: AuditDiplomacyEventDraft[] = []; + private readonly pendingAuditDecisions: PendingAuditDecision[] = []; private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = []; private pendingRealtimeBacklogShiftTicks = 0; private readonly scenarioConfig: ScenarioConfig; @@ -1112,6 +1117,7 @@ export class InMemoryTurnWorld { pendingAuditMonths: this.pendingAuditMonths, pendingAuditPolicies: this.pendingAuditPolicies, pendingAuditDiplomacy: this.pendingAuditDiplomacy, + pendingAuditDecisions: this.pendingAuditDecisions, pendingUnificationFinalizations: this.pendingUnificationFinalizations, pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks, } satisfies InMemoryTurnWorldStateSnapshot); @@ -1161,6 +1167,7 @@ export class InMemoryTurnWorld { this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths); this.replaceArray(this.pendingAuditPolicies, restored.pendingAuditPolicies); this.replaceArray(this.pendingAuditDiplomacy, restored.pendingAuditDiplomacy); + this.replaceArray(this.pendingAuditDecisions, restored.pendingAuditDecisions); this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations); this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0; } @@ -1389,6 +1396,10 @@ export class InMemoryTurnWorld { this.pendingAuditDiplomacy.push(structuredClone(event)); } + queueAuditDecision(decision: PendingAuditDecision): void { + this.pendingAuditDecisions.push(structuredClone(decision)); + } + queueAuditPolicy(policy: PendingAuditPolicy): void { this.pendingAuditPolicies.push(structuredClone(policy)); } @@ -1397,7 +1408,8 @@ export class InMemoryTurnWorld { return ( this.pendingAuditPolicies.length > 0 || this.pendingAuditMonths.length > 0 || - this.pendingAuditDiplomacy.length > 0 + this.pendingAuditDiplomacy.length > 0 || + this.pendingAuditDecisions.length > 0 ); } @@ -1964,6 +1976,8 @@ export class InMemoryTurnWorld { schedule: this.schedule, }); + if (result.auditDecisions?.length) this.pendingAuditDecisions.push(...structuredClone(result.auditDecisions)); + let nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule); if (!result.deleted?.general) { const resolvedGeneral = result.general ?? currentGeneral; @@ -2274,6 +2288,7 @@ export class InMemoryTurnWorld { const pendingAuditMonths = structuredClone(this.pendingAuditMonths); const pendingAuditPolicies = structuredClone(this.pendingAuditPolicies); const pendingAuditDiplomacy = structuredClone(this.pendingAuditDiplomacy); + const pendingAuditDecisions = structuredClone(this.pendingAuditDecisions); const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations); const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort( (left, right) => left - right @@ -2309,6 +2324,7 @@ export class InMemoryTurnWorld { pendingAuditMonths, pendingAuditPolicies, pendingAuditDiplomacy, + pendingAuditDecisions, pendingUnificationFinalizations, }; } @@ -2350,6 +2366,7 @@ export class InMemoryTurnWorld { this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length); this.pendingAuditPolicies.splice(0, changes.pendingAuditPolicies.length); this.pendingAuditDiplomacy.splice(0, changes.pendingAuditDiplomacy.length); + this.pendingAuditDecisions.splice(0, changes.pendingAuditDecisions.length); this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length); } diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index f1c53269..253b1c47 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1,3 +1,6 @@ +import { auditDecisionIdentity, 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 { AiDecisionTraceObserver } from './ai/generalAi/trace.js'; import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import type { @@ -902,6 +905,8 @@ export const createReservedTurnHandler = async (options: { currentMonth: number ) => Nation['meta'] | null; onDecisionTrace?: AiDecisionTraceObserver; + collectAuditDecisions?: boolean; + auditCodeVersion?: string; onActionResolved?: (payload: { kind: 'nation' | 'general'; generalId: number; @@ -1068,6 +1073,90 @@ export const createReservedTurnHandler = async (options: { let currentGeneral = context.general; let currentCity = context.city; let currentNation = context.nation ?? null; + const auditServerId = typeof context.world.meta.serverId === 'string' ? context.world.meta.serverId : null; + const collectDecisions = + options.collectAuditDecisions !== false && Boolean(auditServerId?.trim()) && Boolean(worldRef); + const auditDecisions: PendingAuditDecision[] = []; + const decisionSteps = new Map<'general' | 'nation', AiDecisionTraceEvent[]>(); + const decisionPolicyRefs = new Map<'general' | 'nation', Record>(); + const onDecisionTrace: AiDecisionTraceObserver | undefined = + collectDecisions || options.onDecisionTrace + ? (event) => { + if (collectDecisions) { + if (event.kind === 'DECISION_START') { + decisionSteps.set(event.phase, []); + const heads = asRecord(currentNation?.meta._playAuditPolicy); + decisionPolicyRefs.set( + event.phase, + Object.fromEntries( + AUDIT_POLICY_AREAS.flatMap((area) => { + const head = asRecord(heads[area]); + return head.serverId === auditServerId && typeof head.id === 'string' + ? [[area, head.id]] + : []; + }) + ) + ); + } + decisionSteps.get(event.phase)?.push(structuredClone(event)); + } + options.onDecisionTrace?.(event); + } + : undefined; + const finishDecision = ( + phase: 'general' | 'nation', + outcome: { + actionKey: string; + usedFallback: boolean; + completed?: boolean; + blockedReason?: string; + } + ): void => { + const steps = decisionSteps.get(phase); + const first = steps?.[0]; + const last = steps?.at(-1); + if ( + !collectDecisions || + !auditServerId || + !worldRef || + !steps || + first?.kind !== 'DECISION_START' || + last?.kind !== 'DECISION_END' + ) + return; + const tick = context.general.turnTick ?? worldRef.dateToGameTick(context.general.turnTime); + const revision = worldRef.getGameClockState().revision; + const executionId = auditDecisionIdentity(auditServerId, context.general.id, tick, revision); + auditDecisions.push({ + id: auditPolicyHash([executionId, phase]), + serverId: auditServerId, + executionId, + phase, + generalId: first.generalId, + nationId: first.nationId, + cityId: first.cityId, + npcState: first.npcState, + year: first.year, + month: first.month, + tick, + summary: { + schemaVersion: 1, + coverage: 'PROCEDURES', + clockRevision: revision, + codeVersion: options.auditCodeVersion ?? null, + policyRefs: decisionPolicyRefs.get(phase) ?? {}, + requestedAction: first.reservedAction, + selectedAction: last.action, + selectedReason: last.reason, + executedAction: outcome.actionKey, + completed: outcome.completed ?? null, + usedFallback: outcome.usedFallback, + blockedReason: outcome.blockedReason ?? null, + }, + steps, + }); + }; + // Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다. // 같은 장수 lifecycle의 국호변경은 뒤이은 유니크 획득 로그의 국호를 바꾸지 않는다. const legacyStaticNationName = currentNation?.name ?? '재야'; @@ -1936,7 +2025,7 @@ export const createReservedTurnHandler = async (options: { nationUsedAi = true; const aiStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n; sharedAi = new GeneralAI({ - onDecisionTrace: options.onDecisionTrace, + onDecisionTrace, general: currentGeneral, city: currentCity, nation: currentNation, @@ -2009,6 +2098,7 @@ export const createReservedTurnHandler = async (options: { } const nationActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n; const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false); + finishDecision('nation', nationResult); const nationActionDurationNs = options.onActionProfiled ? process.hrtime.bigint() - nationActionStartedAt : 0n; @@ -2093,7 +2183,7 @@ export const createReservedTurnHandler = async (options: { const ai = sharedAi ?? new GeneralAI({ - onDecisionTrace: options.onDecisionTrace, + onDecisionTrace, general: currentGeneral, city: currentCity, nation: currentNation, @@ -2179,6 +2269,7 @@ export const createReservedTurnHandler = async (options: { blockedReason: '블럭 대상자입니다.', } : runAction('general', generalDefinitions, generalFallback, generalCommand, true); + finishDecision('general', generalResult); const generalActionDurationNs = options.onActionProfiled ? process.hrtime.bigint() - generalActionStartedAt : 0n; @@ -2411,6 +2502,7 @@ export const createReservedTurnHandler = async (options: { } const result: GeneralTurnResult = { + ...(auditDecisions.length ? { auditDecisions } : {}), general: currentGeneral, city: currentCity, nation: currentNation, diff --git a/app/game-engine/test/fixtures/playAuditDecision.ts b/app/game-engine/test/fixtures/playAuditDecision.ts new file mode 100644 index 00000000..9b0eedc4 --- /dev/null +++ b/app/game-engine/test/fixtures/playAuditDecision.ts @@ -0,0 +1,45 @@ +import type { PendingAuditDecision } from '../../src/playAudit/decision.js'; +export const buildAuditDecisionFixture = (id: string, serverId = 'decision-old'): PendingAuditDecision => { + const base = { + phase: 'general' as const, + generalId: 990321, + nationId: 990321, + cityId: 1, + npcState: 2, + year: 190, + month: 1, + tick: 4_320_000_000, + }; + return { + ...base, + id, + serverId, + executionId: id, + summary: { + schemaVersion: 1, + coverage: 'PROCEDURES', + clockRevision: 1, + codeVersion: null, + policyRefs: {}, + requestedAction: '휴식', + selectedAction: 'che_징병', + selectedReason: '징병', + executedAction: '휴식', + completed: false, + usedFallback: true, + blockedReason: '자원 부족', + }, + steps: [ + { ...base, sequence: 0, kind: 'DECISION_START', reservedAction: '휴식' }, + ...Array.from({ length: 300 }, (_, index) => ({ + ...base, + sequence: index + 1, + kind: 'RNG' as const, + method: 'nextBool', + parameters: [0.5], + result: true, + })), + { ...base, sequence: 301, kind: 'DECISION_END', action: 'che_징병', reason: '징병' }, + ], + }; +}; diff --git a/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts b/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts index c201fa8d..37d5e06c 100644 --- a/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts @@ -1,3 +1,4 @@ +import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import type { Nation } from '@sammo-ts/logic'; @@ -49,6 +50,8 @@ integration('monthly diplomacy persistence', () => { db = connector.prisma; closeDb = () => connector.disconnect(); await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } }); + await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: scenarioCode } } }); + await db.playAuditDecision.deleteMany({ where: { serverId: scenarioCode } }); await db.playAuditPolicy.deleteMany({ where: { serverId: scenarioCode } }); await db.diplomacy.deleteMany({ where: { @@ -62,6 +65,8 @@ integration('monthly diplomacy persistence', () => { afterAll(async () => { await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } }); + await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: scenarioCode } } }); + await db.playAuditDecision.deleteMany({ where: { serverId: scenarioCode } }); await db.playAuditPolicy.deleteMany({ where: { serverId: scenarioCode } }); await db.diplomacy.deleteMany({ where: { @@ -186,6 +191,20 @@ integration('monthly diplomacy persistence', () => { await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()'); } + const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode); + world.queueAuditDecision(decision); + await db.$executeRawUnsafe(`CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`); + await db.$executeRawUnsafe(`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`); + try { + await expect(hooks.flushChanges()).rejects.toThrow('decision flush rollback'); + expect(world.peekDirtyState().pendingAuditDecisions).toEqual([decision]); + expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0); + expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0); + expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(1); + } finally { + await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk'); + await db.$executeRawUnsafe('DROP FUNCTION reject_decision_flush_fixture()'); + } await hooks.hooks.flushChanges?.({ lastTurnTime: '0193-02-01T00:00:00.000Z', processedGenerals: 0, @@ -211,6 +230,8 @@ integration('monthly diplomacy persistence', () => { }); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]); + expect(world.peekDirtyState().pendingAuditDecisions).toEqual([]); + expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(1); const events = await db.playAuditDiplomacyEvent.findMany({ where: { serverId: scenarioCode }, orderBy: { sequence: 'asc' }, diff --git a/app/game-engine/test/npcNationWarDeclaration.test.ts b/app/game-engine/test/npcNationWarDeclaration.test.ts index 8950ec14..96bf8e80 100644 --- a/app/game-engine/test/npcNationWarDeclaration.test.ts +++ b/app/game-engine/test/npcNationWarDeclaration.test.ts @@ -1,3 +1,4 @@ +import type { PendingAuditDecision } from '../src/playAudit/decision.js'; import type { AiDecisionTraceEvent } from '../src/turn/ai/generalAi/trace.js'; import { describe, expect, it, vi } from 'vitest'; import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; @@ -247,8 +248,14 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => { }; const decisionTrace: AiDecisionTraceEvent[] = []; + const savedDecisions: PendingAuditDecision[] = []; const { runUntil } = await createTurnTestHarness({ onDecisionTrace: auditEnabled ? (event) => decisionTrace.push(event) : undefined, + wrapGeneralTurnHandler: (handler) => ({ execute: (context) => { + const result = handler.execute(context); + savedDecisions.push(...(result.auditDecisions ?? [])); + return result; + } }), snapshot, state, schedule, @@ -439,6 +446,11 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => { } expect(dispatchCount).toBeGreaterThan(0); if (auditEnabled) { + expect(savedDecisions.length).toBeGreaterThan(0); + 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(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/playAuditCollection.test.ts b/app/game-engine/test/playAuditCollection.test.ts index 9e469385..be4224af 100644 --- a/app/game-engine/test/playAuditCollection.test.ts +++ b/app/game-engine/test/playAuditCollection.test.ts @@ -1,3 +1,4 @@ +import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js'; import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js'; import { describe, expect, it } from 'vitest'; import type { City, Nation } from '@sammo-ts/logic'; @@ -129,6 +130,22 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => { return world; }; describe('play audit collection durability state', () => { + it('restores decision buffers and acknowledges only the committed prefix', () => { + const world = buildWorld(); + const first = buildAuditDecisionFixture('first'); + world.queueAuditDecision(first); + const checkpoint = world.captureState(); + const committed = world.peekDirtyState(); + world.queueAuditDecision(buildAuditDecisionFixture('second')); + world.acknowledgeDirtyState(committed); + expect(world.peekDirtyState().pendingAuditDecisions.map((row) => row.id)).toEqual(['second']); + world.restoreState(checkpoint); + expect(world.peekDirtyState().pendingAuditDecisions).toEqual([first]); + const peeked = world.peekDirtyState(); + peeked.pendingAuditDecisions[0]!.steps.pop(); + expect(world.peekDirtyState().pendingAuditDecisions[0]!.steps).toHaveLength(302); + }); + it('captures direct reserved-turn nation batches once and initializes their policies', () => { const world = buildWorld({ execute: () => ({ created: { generals: [], nations: [buildNation(3, 0, {}), buildNation(4, 0, {})] } }), diff --git a/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts b/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts new file mode 100644 index 00000000..46fd6bd8 --- /dev/null +++ b/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts @@ -0,0 +1,100 @@ +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 { prunePreviousAuditBatch } from '../src/playAudit/retention.js'; + +const databaseUrl = process.env.PLAY_AUDIT_DECISION_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +integration('decision persistence and bounded retention', () => { + let db: GamePrismaClient; + let close: () => Promise; + beforeAll(async () => { + if (!new URL(databaseUrl!).searchParams.get('schema')?.endsWith('_decision_fixture')) + throw new Error('Dedicated decision fixture required'); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + close = () => connector.disconnect(); + await db.playAuditDecisionChunk.deleteMany(); + await db.playAuditDecision.deleteMany(); + await db.nation.deleteMany({ where: { id: 990321 } }); + await db.worldState.deleteMany(); + await db.worldState.create({ + data: { + scenarioCode: 'decision', + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: { serverId: 'decision-current' }, + }, + }); + }); + afterAll(async () => { + await close?.(); + }); + it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => { + const decision = draft('decision-one'); + 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 $$` + ); + await db.$executeRawUnsafe( + `CREATE TRIGGER decision_fixture_failure BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION decision_fixture_failure()` + ); + try { + await expect( + db.$transaction(async (tx) => { + await tx.nation.create({ data: { id: 990321, name: '결정국', color: '#fff' } }); + await persistAuditDecisions(tx, [decision]); + }) + ).rejects.toThrow('decision chunk failure'); + } finally { + await db.$executeRawUnsafe('DROP TRIGGER decision_fixture_failure ON play_audit_decision_chunk'); + } + expect(await db.nation.findUnique({ where: { id: 990321 } })).toBeNull(); + expect(await db.playAuditDecision.count()).toBe(0); + expect(await db.playAuditDecisionChunk.count()).toBe(0); + await db.$transaction(async (tx) => { + await tx.nation.create({ data: { id: 990321, name: '결정국', color: '#fff' } }); + await persistAuditDecisions(tx, [decision]); + }); + await db.$transaction((tx) => persistAuditDecisions(tx, [decision])); + const header = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } }); + expect(header).toMatchObject({ tick: 4_320_000_000n, stepCount: 302 }); + const chunks = await db.playAuditDecisionChunk.findMany({ + where: { decisionId: decision.id }, + orderBy: { ordinal: 'asc' }, + }); + expect(chunks).toHaveLength(3); + expect(chunks.flatMap((chunk) => chunk.steps)).toEqual(decision.steps); + await expect( + db.$transaction((tx) => + persistAuditDecisions(tx, [{ ...decision, summary: { ...decision.summary, completed: true } }]) + ) + ).rejects.toThrow('replay conflict'); + await expect(db.playAuditDecision.delete({ where: { id: decision.id } })).rejects.toThrow(); + expect(await db.playAuditDecision.count()).toBe(1); + }); + it('deletes at most 200 chunks before a header and preserves current season', async () => { + const active = draft('decision-active', 'decision-current'); + await db.$transaction((tx) => persistAuditDecisions(tx, [active])); + await db.playAuditDecisionChunk.createMany({ + data: Array.from({ length: 401 }, (_, index) => ({ + decisionId: 'decision-one', + ordinal: index + 3, + steps: [], + })), + }); + expect(await prunePreviousAuditBatch(db, 'wrong')).toEqual({ status: 'identityChanged', deleted: 0 }); + expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 200 }); + expect(await db.playAuditDecisionChunk.count({ where: { decisionId: 'decision-one' } })).toBe(204); + expect(await db.playAuditDecision.count({ where: { id: 'decision-one' } })).toBe(1); + expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 200 }); + expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 4 }); + expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 1 }); + expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'complete', deleted: 0 }); + expect(await db.playAuditDecision.count()).toBe(1); + expect(await db.playAuditDecisionChunk.count()).toBe(3); + }); +}); diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index 93d036af..ecce70eb 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -118,7 +118,8 @@ describe('durable read-model change journal mapping', () => { pendingYearbookSnapshots: [], pendingAuditMonths: [], pendingAuditPolicies: [], - pendingAuditDiplomacy: [], + pendingAuditDiplomacy: [], + pendingAuditDecisions: [], pendingUnificationFinalizations: [], } satisfies TurnWorldChanges; const readModelChanges = createEmptyRealtimeReadModelChanges(); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 4507c078..9c5a98a2 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260825000000_add_bulk_release_batches', - gameSchemaHead: '20260916060000_widen_play_audit_ticks', + gameSchemaHead: '20260916070000_add_play_audit_decision', }); }); diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 7d17b04b..99589df0 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -9,7 +9,27 @@ NPC 결정 trace와 조사 A~F의 완성, 전체 종료 경계 및 COST gate는 ## 현재 구현 -### NPC 판단 관측 기반 — 아직 운영 수집 아님 +### NPC 결정 저장·복구 기반 + +새 migration57은 `play_audit_decision` 요약과 `play_audit_decision_chunk` 상세를 분리한다. +reservedTurnHandler가 기수 identity가 있는 새 AI 실행을 phase별로 모으고 실제 요청/선택/ +실행·성공/대체 결과를 함께 반환한다. 실행 tick은 해당 장수 기준, ID는 serverId·장수·tick· +clock revision·phase로 결정한다. 정책 head 참조는 시작 시 확보하며 없는 값은 채우지 않는다. +현재 codeVersion 주입과 유효 정책 합성 상세, 내부 후보 조건은 남아 있어 coverage는 +`PROCEDURES`다. 수동 턴 중 AI를 사용하지 않은 경우 결정 행을 만들지 않는다. + +GeneralTurnResult→world pending→capture/restore/peek/ack→기존 fenced DB flush를 연결했다. +요약 hash와 실행/phase unique로 같은 재시도는 중복 없이 통과하고 다른 payload는 실패한다. +상세는128개 event씩 나누며 최대200행씩 batch insert한다. 목록을 위해 전체 trace를 하나의 +header JSON으로 저장하지 않고 후보별 SQL도 추가하지 않는다. 실패하면 게임 상태와 모두 +rollback되고 commit 뒤에만 pending prefix를 비운다. 실패한 실행의 별도 진단 원장은 남는다. + +정리는 이전 기수 header를 잠그고200개 chunk씩 삭제한 후 header를 제거한다. FK RESTRICT로 +무제한 cascade를 막으며 현재 기수는 기존 schema lock/identity 재검사로 보호한다. +빈57·기존56→57·재실행 no-op,302 event/3chunk 복원, 삽입 실패/재시도/충돌 및 실제 +DB hooks와 정리 회귀를 검증했다. 전체 비용 gate와 전용 API/GUI는 아직 후속이다. + +### NPC 판단 관측 기반 GeneralAI와 예약 실행 handler에 선택적 `onDecisionTrace` 관측 경계를 추가했다. 공유 AI의 수뇌→개인 결정 순서에 동일 sequence를 유지하며 시작/최종 선택/오류, @@ -21,7 +41,7 @@ GeneralAI와 예약 실행 handler에 선택적 `onDecisionTrace` 관측 경계 고정 seed3종에서16개 RNG utility 호출의 반환값·객체 identity·다음 RNG 결과가 같고, 실제 NPC 선전포고→개전→점령 fixture에서도 수집 on/off 회귀가 통과했다. -현재 default daemon에는 observer를 켜지 않았으며 DB 쓰기를 추가하지 않았다. +초기 관측 단계에서는 default daemon을 켜지 않았다. 이후 아래 저장 경계에서 현재 기수의 새 실행을 수집하도록 연결했다. 이는 R5의 관측 기반일 뿐 완료가 아니다. 다음 작업은 불변 결정 ID·정책/code version, 후보/조건별 실제 관측값, 실행 결과 연결, 같은 gameplay transaction의 pending/rollback, 정식 migration·bounded 정리, 프로필 목록/상세 API와 GUI를 연결하는 것이다. diff --git a/docs/play-audit-operations.md b/docs/play-audit-operations.md index a4e5d326..076e33a9 100644 --- a/docs/play-audit-operations.md +++ b/docs/play-audit-operations.md @@ -42,7 +42,7 @@ ## DB migration과 적용 순서 정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`나 -수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은56개이며 다음 감사 +수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은57개이며 다음 감사 migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다. | migration | 준비되는 저장소/제약 | @@ -54,6 +54,7 @@ migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세 | `20260916040000_add_play_audit_initial` | 기수당 INITIAL 표본 1개 제약 | | `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 | | `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 | +| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index | 운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정 commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의 @@ -85,7 +86,11 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway 즉시 차단하고 이전 감사 자료를200행 단위로 정리한다. 기존 연감/계정 원장은 별도다. - 통일 시 FINAL 표본은 정규 월말과 구분한다. **CANCELLED가 runtime을 중단한 프로필은 현재 감사 API도 사용할 수 없다.** 취소 후 다음 초기화까지 읽는 수명주기는 후속 작업이다. -- NPC의 개인/수뇌/유저 자동턴 상세 판정 trace는 아직 수집·조회하지 않는다. +- NPC/유저 자동턴의 개인·수뇌 절차, 정책 차단, RNG utility 결과와 최종 실행 결과는 + migration 이후 새 실행부터 저장한다. 후보 내부 조건 전체는 아직 없으며 `PROCEDURES` + coverage로 구분한다. 전용 조회 화면은 후속 작업이다. 과거 결정은 역산하지 않는다. +- 결정은 당시 확보된 정책 참조를 보존한다. 합성된 유효 정책 상세와 코드 버전 연결은 + 아직 미완성이다. 코드 버전이 주입되지 않은 실행은 null로 남기며 현재 버전으로 메우지 않는다. - 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진 버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다. - 일부 국가 생성/소멸 사건은 actor/request가 null이다. 원인을 현재 주체로 추정하지 않는다. diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 1ef73253..be0380fb 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -1201,3 +1201,38 @@ model PlayAuditDiplomacyEvent { @@index([serverId, documentId, sequence]) @@map("play_audit_diplomacy_event") } + +// NPC/자동턴 결정 요약. 큰 절차 본문은 chunk로 나누어 목록에서 제외한다. +model PlayAuditDecision { + id String @id + serverId String @map("server_id") + executionId String @map("execution_id") + phase String + generalId Int @map("general_id") + nationId Int @map("nation_id") + cityId Int @map("city_id") + npcState Int @map("npc_state") + year Int + month Int + tick BigInt + stepCount Int @map("step_count") + summary Json + hash String + createdAt DateTime @default(now()) @map("created_at") + chunks PlayAuditDecisionChunk[] + + @@unique([executionId, phase]) + @@index([serverId, generalId, tick, id]) + @@index([serverId, nationId, tick, id]) + @@map("play_audit_decision") +} + +model PlayAuditDecisionChunk { + decisionId String @map("decision_id") + ordinal Int + steps Json + decision PlayAuditDecision @relation(fields: [decisionId], references: [id], onDelete: Restrict) + + @@id([decisionId, ordinal]) + @@map("play_audit_decision_chunk") +} diff --git a/packages/infra/prisma/migrations/20260916070000_add_play_audit_decision/migration.sql b/packages/infra/prisma/migrations/20260916070000_add_play_audit_decision/migration.sql new file mode 100644 index 00000000..0dfe5008 --- /dev/null +++ b/packages/infra/prisma/migrations/20260916070000_add_play_audit_decision/migration.sql @@ -0,0 +1,27 @@ +CREATE TABLE "play_audit_decision" ( + "id" TEXT NOT NULL PRIMARY KEY, + "server_id" TEXT NOT NULL, + "execution_id" TEXT NOT NULL, + "phase" TEXT NOT NULL CHECK ("phase" IN ('general', 'nation')), + "general_id" INTEGER NOT NULL, + "nation_id" INTEGER NOT NULL, + "city_id" INTEGER NOT NULL, + "npc_state" INTEGER NOT NULL, + "year" INTEGER NOT NULL, + "month" INTEGER NOT NULL CHECK ("month" BETWEEN 1 AND 12), + "tick" BIGINT NOT NULL CHECK ("tick" >= 0), + "step_count" INTEGER NOT NULL CHECK ("step_count" > 0), + "summary" JSONB NOT NULL, + "hash" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX "play_audit_decision_execution_id_phase_key" ON "play_audit_decision"("execution_id", "phase"); +CREATE INDEX "play_audit_decision_server_id_general_id_tick_id_idx" ON "play_audit_decision"("server_id", "general_id", "tick", "id"); +CREATE INDEX "play_audit_decision_server_id_nation_id_tick_id_idx" ON "play_audit_decision"("server_id", "nation_id", "tick", "id"); +CREATE TABLE "play_audit_decision_chunk" ( + "decision_id" TEXT NOT NULL, + "ordinal" INTEGER NOT NULL CHECK ("ordinal" >= 0), + "steps" JSONB NOT NULL CHECK (jsonb_typeof("steps") = 'array'), + PRIMARY KEY ("decision_id", "ordinal"), + FOREIGN KEY ("decision_id") REFERENCES "play_audit_decision"("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/release-manifest.json b/release-manifest.json index 6b46f712..7c733eea 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260825000000_add_bulk_release_batches", - "gameSchemaHead": "20260916060000_widen_play_audit_ticks", + "gameSchemaHead": "20260916070000_add_play_audit_decision", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }