NPC 결정 요약과 상세를 턴 transaction에 저장하고 기수별 정리 연결

This commit is contained in:
2026-09-16 07:55:12 +00:00
parent 9f31b28297
commit 63907d95cc
18 changed files with 521 additions and 10 deletions
+34
View File
@@ -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<string, string>;
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]);
@@ -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<void> => {
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 });
}
};
@@ -32,6 +32,30 @@ export const prunePreviousAuditBatch = async (
}); });
return { status: 'progress', deleted: deleted.count }; 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({ const policies = await tx.playAuditPolicy.findMany({
where: { serverId: { not: expectedServerId } }, where: { serverId: { not: expectedServerId } },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
@@ -1,3 +1,4 @@
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra'; import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js'; import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
import { persistAuditPolicies } from '../playAudit/policyPersistence.js'; import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
@@ -1152,6 +1153,7 @@ export const createDatabaseTurnHooks = async (
pendingAuditMonths, pendingAuditMonths,
pendingAuditPolicies, pendingAuditPolicies,
pendingAuditDiplomacy, pendingAuditDiplomacy,
pendingAuditDecisions,
pendingUnificationFinalizations, pendingUnificationFinalizations,
} = changes; } = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -1894,6 +1896,7 @@ export const createDatabaseTurnHooks = async (
} }
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand); await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand);
await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy); await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy);
await persistAuditDecisions(prisma, pendingAuditDecisions);
for (const snapshot of pendingAuditMonths) { for (const snapshot of pendingAuditMonths) {
await persistAuditMonth(prisma, snapshot); await persistAuditMonth(prisma, snapshot);
} }
+18 -1
View File
@@ -1,3 +1,4 @@
import type { PendingAuditDecision } from '../playAudit/decision.js';
import { import {
recordTurnAuditDiplomacy, recordTurnAuditDiplomacy,
recordNationAuditDiplomacy, recordNationAuditDiplomacy,
@@ -60,6 +61,7 @@ export interface GeneralTurnContext {
} }
export interface GeneralTurnResult { export interface GeneralTurnResult {
auditDecisions?: PendingAuditDecision[];
general?: TurnGeneral; general?: TurnGeneral;
city?: City; city?: City;
nation?: Nation | null; nation?: Nation | null;
@@ -211,6 +213,7 @@ export interface TurnWorldChanges {
pendingAuditMonths: PendingAuditMonth[]; pendingAuditMonths: PendingAuditMonth[];
pendingAuditPolicies: PendingAuditPolicy[]; pendingAuditPolicies: PendingAuditPolicy[];
pendingAuditDiplomacy: AuditDiplomacyEventDraft[]; pendingAuditDiplomacy: AuditDiplomacyEventDraft[];
pendingAuditDecisions: PendingAuditDecision[];
pendingUnificationFinalizations: PendingUnificationFinalization[]; pendingUnificationFinalizations: PendingUnificationFinalization[];
} }
@@ -254,6 +257,7 @@ export interface InMemoryTurnWorldStateSnapshot {
pendingAuditMonths: PendingAuditMonth[]; pendingAuditMonths: PendingAuditMonth[];
pendingAuditPolicies: PendingAuditPolicy[]; pendingAuditPolicies: PendingAuditPolicy[];
pendingAuditDiplomacy: AuditDiplomacyEventDraft[]; pendingAuditDiplomacy: AuditDiplomacyEventDraft[];
pendingAuditDecisions: PendingAuditDecision[];
pendingUnificationFinalizations: PendingUnificationFinalization[]; pendingUnificationFinalizations: PendingUnificationFinalization[];
pendingRealtimeBacklogShiftTicks: number; pendingRealtimeBacklogShiftTicks: number;
} }
@@ -561,6 +565,7 @@ export class InMemoryTurnWorld {
private readonly pendingAuditMonths: PendingAuditMonth[] = []; private readonly pendingAuditMonths: PendingAuditMonth[] = [];
private readonly pendingAuditPolicies: PendingAuditPolicy[] = []; private readonly pendingAuditPolicies: PendingAuditPolicy[] = [];
private readonly pendingAuditDiplomacy: AuditDiplomacyEventDraft[] = []; private readonly pendingAuditDiplomacy: AuditDiplomacyEventDraft[] = [];
private readonly pendingAuditDecisions: PendingAuditDecision[] = [];
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = []; private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
private pendingRealtimeBacklogShiftTicks = 0; private pendingRealtimeBacklogShiftTicks = 0;
private readonly scenarioConfig: ScenarioConfig; private readonly scenarioConfig: ScenarioConfig;
@@ -1112,6 +1117,7 @@ export class InMemoryTurnWorld {
pendingAuditMonths: this.pendingAuditMonths, pendingAuditMonths: this.pendingAuditMonths,
pendingAuditPolicies: this.pendingAuditPolicies, pendingAuditPolicies: this.pendingAuditPolicies,
pendingAuditDiplomacy: this.pendingAuditDiplomacy, pendingAuditDiplomacy: this.pendingAuditDiplomacy,
pendingAuditDecisions: this.pendingAuditDecisions,
pendingUnificationFinalizations: this.pendingUnificationFinalizations, pendingUnificationFinalizations: this.pendingUnificationFinalizations,
pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks, pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks,
} satisfies InMemoryTurnWorldStateSnapshot); } satisfies InMemoryTurnWorldStateSnapshot);
@@ -1161,6 +1167,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths); this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths);
this.replaceArray(this.pendingAuditPolicies, restored.pendingAuditPolicies); this.replaceArray(this.pendingAuditPolicies, restored.pendingAuditPolicies);
this.replaceArray(this.pendingAuditDiplomacy, restored.pendingAuditDiplomacy); this.replaceArray(this.pendingAuditDiplomacy, restored.pendingAuditDiplomacy);
this.replaceArray(this.pendingAuditDecisions, restored.pendingAuditDecisions);
this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations); this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations);
this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0; this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0;
} }
@@ -1389,6 +1396,10 @@ export class InMemoryTurnWorld {
this.pendingAuditDiplomacy.push(structuredClone(event)); this.pendingAuditDiplomacy.push(structuredClone(event));
} }
queueAuditDecision(decision: PendingAuditDecision): void {
this.pendingAuditDecisions.push(structuredClone(decision));
}
queueAuditPolicy(policy: PendingAuditPolicy): void { queueAuditPolicy(policy: PendingAuditPolicy): void {
this.pendingAuditPolicies.push(structuredClone(policy)); this.pendingAuditPolicies.push(structuredClone(policy));
} }
@@ -1397,7 +1408,8 @@ export class InMemoryTurnWorld {
return ( return (
this.pendingAuditPolicies.length > 0 || this.pendingAuditPolicies.length > 0 ||
this.pendingAuditMonths.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, schedule: this.schedule,
}); });
if (result.auditDecisions?.length) this.pendingAuditDecisions.push(...structuredClone(result.auditDecisions));
let nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule); let nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
if (!result.deleted?.general) { if (!result.deleted?.general) {
const resolvedGeneral = result.general ?? currentGeneral; const resolvedGeneral = result.general ?? currentGeneral;
@@ -2274,6 +2288,7 @@ export class InMemoryTurnWorld {
const pendingAuditMonths = structuredClone(this.pendingAuditMonths); const pendingAuditMonths = structuredClone(this.pendingAuditMonths);
const pendingAuditPolicies = structuredClone(this.pendingAuditPolicies); const pendingAuditPolicies = structuredClone(this.pendingAuditPolicies);
const pendingAuditDiplomacy = structuredClone(this.pendingAuditDiplomacy); const pendingAuditDiplomacy = structuredClone(this.pendingAuditDiplomacy);
const pendingAuditDecisions = structuredClone(this.pendingAuditDecisions);
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations); const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort( const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort(
(left, right) => left - right (left, right) => left - right
@@ -2309,6 +2324,7 @@ export class InMemoryTurnWorld {
pendingAuditMonths, pendingAuditMonths,
pendingAuditPolicies, pendingAuditPolicies,
pendingAuditDiplomacy, pendingAuditDiplomacy,
pendingAuditDecisions,
pendingUnificationFinalizations, pendingUnificationFinalizations,
}; };
} }
@@ -2350,6 +2366,7 @@ export class InMemoryTurnWorld {
this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length); this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length);
this.pendingAuditPolicies.splice(0, changes.pendingAuditPolicies.length); this.pendingAuditPolicies.splice(0, changes.pendingAuditPolicies.length);
this.pendingAuditDiplomacy.splice(0, changes.pendingAuditDiplomacy.length); this.pendingAuditDiplomacy.splice(0, changes.pendingAuditDiplomacy.length);
this.pendingAuditDecisions.splice(0, changes.pendingAuditDecisions.length);
this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length); this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length);
} }
@@ -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 type { AiDecisionTraceObserver } from './ai/generalAi/trace.js';
import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import type { import type {
@@ -902,6 +905,8 @@ export const createReservedTurnHandler = async (options: {
currentMonth: number currentMonth: number
) => Nation['meta'] | null; ) => Nation['meta'] | null;
onDecisionTrace?: AiDecisionTraceObserver; onDecisionTrace?: AiDecisionTraceObserver;
collectAuditDecisions?: boolean;
auditCodeVersion?: string;
onActionResolved?: (payload: { onActionResolved?: (payload: {
kind: 'nation' | 'general'; kind: 'nation' | 'general';
generalId: number; generalId: number;
@@ -1068,6 +1073,90 @@ export const createReservedTurnHandler = async (options: {
let currentGeneral = context.general; let currentGeneral = context.general;
let currentCity = context.city; let currentCity = context.city;
let currentNation = context.nation ?? null; 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<string, string>>();
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 캐시를 채운다. // Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
// 같은 장수 lifecycle의 국호변경은 뒤이은 유니크 획득 로그의 국호를 바꾸지 않는다. // 같은 장수 lifecycle의 국호변경은 뒤이은 유니크 획득 로그의 국호를 바꾸지 않는다.
const legacyStaticNationName = currentNation?.name ?? '재야'; const legacyStaticNationName = currentNation?.name ?? '재야';
@@ -1936,7 +2025,7 @@ export const createReservedTurnHandler = async (options: {
nationUsedAi = true; nationUsedAi = true;
const aiStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n; const aiStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
sharedAi = new GeneralAI({ sharedAi = new GeneralAI({
onDecisionTrace: options.onDecisionTrace, onDecisionTrace,
general: currentGeneral, general: currentGeneral,
city: currentCity, city: currentCity,
nation: currentNation, nation: currentNation,
@@ -2009,6 +2098,7 @@ export const createReservedTurnHandler = async (options: {
} }
const nationActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n; const nationActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false); const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
finishDecision('nation', nationResult);
const nationActionDurationNs = options.onActionProfiled const nationActionDurationNs = options.onActionProfiled
? process.hrtime.bigint() - nationActionStartedAt ? process.hrtime.bigint() - nationActionStartedAt
: 0n; : 0n;
@@ -2093,7 +2183,7 @@ export const createReservedTurnHandler = async (options: {
const ai = const ai =
sharedAi ?? sharedAi ??
new GeneralAI({ new GeneralAI({
onDecisionTrace: options.onDecisionTrace, onDecisionTrace,
general: currentGeneral, general: currentGeneral,
city: currentCity, city: currentCity,
nation: currentNation, nation: currentNation,
@@ -2179,6 +2269,7 @@ export const createReservedTurnHandler = async (options: {
blockedReason: '블럭 대상자입니다.', blockedReason: '블럭 대상자입니다.',
} }
: runAction('general', generalDefinitions, generalFallback, generalCommand, true); : runAction('general', generalDefinitions, generalFallback, generalCommand, true);
finishDecision('general', generalResult);
const generalActionDurationNs = options.onActionProfiled const generalActionDurationNs = options.onActionProfiled
? process.hrtime.bigint() - generalActionStartedAt ? process.hrtime.bigint() - generalActionStartedAt
: 0n; : 0n;
@@ -2411,6 +2502,7 @@ export const createReservedTurnHandler = async (options: {
} }
const result: GeneralTurnResult = { const result: GeneralTurnResult = {
...(auditDecisions.length ? { auditDecisions } : {}),
general: currentGeneral, general: currentGeneral,
city: currentCity, city: currentCity,
nation: currentNation, nation: currentNation,
+45
View File
@@ -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: '징병' },
],
};
};
@@ -1,3 +1,4 @@
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
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 type { Nation } from '@sammo-ts/logic'; import type { Nation } from '@sammo-ts/logic';
@@ -49,6 +50,8 @@ integration('monthly diplomacy persistence', () => {
db = connector.prisma; db = connector.prisma;
closeDb = () => connector.disconnect(); closeDb = () => connector.disconnect();
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } }); 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.playAuditPolicy.deleteMany({ where: { serverId: scenarioCode } });
await db.diplomacy.deleteMany({ await db.diplomacy.deleteMany({
where: { where: {
@@ -62,6 +65,8 @@ integration('monthly diplomacy persistence', () => {
afterAll(async () => { afterAll(async () => {
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } }); 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.playAuditPolicy.deleteMany({ where: { serverId: scenarioCode } });
await db.diplomacy.deleteMany({ await db.diplomacy.deleteMany({
where: { where: {
@@ -186,6 +191,20 @@ integration('monthly diplomacy persistence', () => {
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()'); 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?.({ await hooks.hooks.flushChanges?.({
lastTurnTime: '0193-02-01T00:00:00.000Z', lastTurnTime: '0193-02-01T00:00:00.000Z',
processedGenerals: 0, processedGenerals: 0,
@@ -211,6 +230,8 @@ integration('monthly diplomacy persistence', () => {
}); });
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]); 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({ const events = await db.playAuditDiplomacyEvent.findMany({
where: { serverId: scenarioCode }, where: { serverId: scenarioCode },
orderBy: { sequence: 'asc' }, orderBy: { sequence: 'asc' },
@@ -1,3 +1,4 @@
import type { PendingAuditDecision } from '../src/playAudit/decision.js';
import type { AiDecisionTraceEvent } from '../src/turn/ai/generalAi/trace.js'; import type { AiDecisionTraceEvent } from '../src/turn/ai/generalAi/trace.js';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
@@ -247,8 +248,14 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
}; };
const decisionTrace: AiDecisionTraceEvent[] = []; const decisionTrace: AiDecisionTraceEvent[] = [];
const savedDecisions: PendingAuditDecision[] = [];
const { runUntil } = await createTurnTestHarness({ const { runUntil } = await createTurnTestHarness({
onDecisionTrace: auditEnabled ? (event) => decisionTrace.push(event) : undefined, onDecisionTrace: auditEnabled ? (event) => decisionTrace.push(event) : undefined,
wrapGeneralTurnHandler: (handler) => ({ execute: (context) => {
const result = handler.execute(context);
savedDecisions.push(...(result.auditDecisions ?? []));
return result;
} }),
snapshot, snapshot,
state, state,
schedule, schedule,
@@ -439,6 +446,11 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
} }
expect(dispatchCount).toBeGreaterThan(0); expect(dispatchCount).toBeGreaterThan(0);
if (auditEnabled) { 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_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,3 +1,4 @@
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js'; import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic'; import type { City, Nation } from '@sammo-ts/logic';
@@ -129,6 +130,22 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
return world; return world;
}; };
describe('play audit collection durability state', () => { 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', () => { it('captures direct reserved-turn nation batches once and initializes their policies', () => {
const world = buildWorld({ const world = buildWorld({
execute: () => ({ created: { generals: [], nations: [buildNation(3, 0, {}), buildNation(4, 0, {})] } }), execute: () => ({ created: { generals: [], nations: [buildNation(3, 0, {}), buildNation(4, 0, {})] } }),
@@ -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<void>;
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);
});
});
@@ -118,7 +118,8 @@ describe('durable read-model change journal mapping', () => {
pendingYearbookSnapshots: [], pendingYearbookSnapshots: [],
pendingAuditMonths: [], pendingAuditMonths: [],
pendingAuditPolicies: [], pendingAuditPolicies: [],
pendingAuditDiplomacy: [], pendingAuditDiplomacy: [],
pendingAuditDecisions: [],
pendingUnificationFinalizations: [], pendingUnificationFinalizations: [],
} satisfies TurnWorldChanges; } satisfies TurnWorldChanges;
const readModelChanges = createEmptyRealtimeReadModelChanges(); const readModelChanges = createEmptyRealtimeReadModelChanges();
+1 -1
View File
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260825000000_add_bulk_release_batches', gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
gameSchemaHead: '20260916060000_widen_play_audit_ticks', gameSchemaHead: '20260916070000_add_play_audit_decision',
}); });
}); });
+22 -2
View File
@@ -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` 관측 경계를 추가했다. GeneralAI와 예약 실행 handler에 선택적 `onDecisionTrace` 관측 경계를 추가했다.
공유 AI의 수뇌→개인 결정 순서에 동일 sequence를 유지하며 시작/최종 선택/오류, 공유 AI의 수뇌→개인 결정 순서에 동일 sequence를 유지하며 시작/최종 선택/오류,
@@ -21,7 +41,7 @@ GeneralAI와 예약 실행 handler에 선택적 `onDecisionTrace` 관측 경계
고정 seed3종에서16개 RNG utility 호출의 반환값·객체 identity·다음 RNG 결과가 같고, 고정 seed3종에서16개 RNG utility 호출의 반환값·객체 identity·다음 RNG 결과가 같고,
실제 NPC 선전포고→개전→점령 fixture에서도 수집 on/off 회귀가 통과했다. 실제 NPC 선전포고→개전→점령 fixture에서도 수집 on/off 회귀가 통과했다.
현재 default daemon에는 observer를 켜지 않았으며 DB 쓰기를 추가하지 않았다. 초기 관측 단계에서는 default daemon을 켜지 않았다. 이후 아래 저장 경계에서 현재 기수의 새 실행을 수집하도록 연결했다.
이는 R5의 관측 기반일 뿐 완료가 아니다. 다음 작업은 불변 결정 ID·정책/code version, 이는 R5의 관측 기반일 뿐 완료가 아니다. 다음 작업은 불변 결정 ID·정책/code version,
후보/조건별 실제 관측값, 실행 결과 연결, 같은 gameplay transaction의 pending/rollback, 후보/조건별 실제 관측값, 실행 결과 연결, 같은 gameplay transaction의 pending/rollback,
정식 migration·bounded 정리, 프로필 목록/상세 API와 GUI를 연결하는 것이다. 정식 migration·bounded 정리, 프로필 목록/상세 API와 GUI를 연결하는 것이다.
+7 -2
View File
@@ -42,7 +42,7 @@
## DB migration과 적용 순서 ## DB migration과 적용 순서
정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push` 정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은56개이며 다음 감사 수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은57개이며 다음 감사
migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다. migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다.
| migration | 준비되는 저장소/제약 | | migration | 준비되는 저장소/제약 |
@@ -54,6 +54,7 @@ migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세
| `20260916040000_add_play_audit_initial` | 기수당 INITIAL 표본 1개 제약 | | `20260916040000_add_play_audit_initial` | 기수당 INITIAL 표본 1개 제약 |
| `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 | | `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 |
| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 | | `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 |
| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index |
운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정 운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정
commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의 commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의
@@ -85,7 +86,11 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
즉시 차단하고 이전 감사 자료를200행 단위로 정리한다. 기존 연감/계정 원장은 별도다. 즉시 차단하고 이전 감사 자료를200행 단위로 정리한다. 기존 연감/계정 원장은 별도다.
- 통일 시 FINAL 표본은 정규 월말과 구분한다. **CANCELLED가 runtime을 중단한 프로필은 - 통일 시 FINAL 표본은 정규 월말과 구분한다. **CANCELLED가 runtime을 중단한 프로필은
현재 감사 API도 사용할 수 없다.** 취소 후 다음 초기화까지 읽는 수명주기는 후속 작업이다. 현재 감사 API도 사용할 수 없다.** 취소 후 다음 초기화까지 읽는 수명주기는 후속 작업이다.
- NPC의 개인/수뇌/유저 자동턴 상세 판정 trace는 아직 수집·조회하지 않는다. - NPC/유저 자동턴의 개인·수뇌 절차, 정책 차단, RNG utility 결과와 최종 실행 결과는
migration 이후 새 실행부터 저장한다. 후보 내부 조건 전체는 아직 없으며 `PROCEDURES`
coverage로 구분한다. 전용 조회 화면은 후속 작업이다. 과거 결정은 역산하지 않는다.
- 결정은 당시 확보된 정책 참조를 보존한다. 합성된 유효 정책 상세와 코드 버전 연결은
아직 미완성이다. 코드 버전이 주입되지 않은 실행은 null로 남기며 현재 버전으로 메우지 않는다.
- 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진 - 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진
버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다. 버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다.
- 일부 국가 생성/소멸 사건은 actor/request가 null이다. 원인을 현재 주체로 추정하지 않는다. - 일부 국가 생성/소멸 사건은 actor/request가 null이다. 원인을 현재 주체로 추정하지 않는다.
+35
View File
@@ -1201,3 +1201,38 @@ model PlayAuditDiplomacyEvent {
@@index([serverId, documentId, sequence]) @@index([serverId, documentId, sequence])
@@map("play_audit_diplomacy_event") @@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")
}
@@ -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
);
+1 -1
View File
@@ -2,6 +2,6 @@
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260825000000_add_bulk_release_batches", "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"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }