NPC 결정 요약과 상세를 턴 transaction에 저장하고 기수별 정리 연결
This commit is contained in:
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user