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 };
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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를 연결하는 것이다.
|
||||||
|
|||||||
@@ -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이다. 원인을 현재 주체로 추정하지 않는다.
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user