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 };
}
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' },