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' },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
|
||||
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
|
||||
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
|
||||
import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
|
||||
@@ -1152,6 +1153,7 @@ export const createDatabaseTurnHooks = async (
|
||||
pendingAuditMonths,
|
||||
pendingAuditPolicies,
|
||||
pendingAuditDiplomacy,
|
||||
pendingAuditDecisions,
|
||||
pendingUnificationFinalizations,
|
||||
} = changes;
|
||||
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
|
||||
@@ -1894,6 +1896,7 @@ export const createDatabaseTurnHooks = async (
|
||||
}
|
||||
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand);
|
||||
await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy);
|
||||
await persistAuditDecisions(prisma, pendingAuditDecisions);
|
||||
for (const snapshot of pendingAuditMonths) {
|
||||
await persistAuditMonth(prisma, snapshot);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PendingAuditDecision } from '../playAudit/decision.js';
|
||||
import {
|
||||
recordTurnAuditDiplomacy,
|
||||
recordNationAuditDiplomacy,
|
||||
@@ -60,6 +61,7 @@ export interface GeneralTurnContext {
|
||||
}
|
||||
|
||||
export interface GeneralTurnResult {
|
||||
auditDecisions?: PendingAuditDecision[];
|
||||
general?: TurnGeneral;
|
||||
city?: City;
|
||||
nation?: Nation | null;
|
||||
@@ -211,6 +213,7 @@ export interface TurnWorldChanges {
|
||||
pendingAuditMonths: PendingAuditMonth[];
|
||||
pendingAuditPolicies: PendingAuditPolicy[];
|
||||
pendingAuditDiplomacy: AuditDiplomacyEventDraft[];
|
||||
pendingAuditDecisions: PendingAuditDecision[];
|
||||
pendingUnificationFinalizations: PendingUnificationFinalization[];
|
||||
}
|
||||
|
||||
@@ -254,6 +257,7 @@ export interface InMemoryTurnWorldStateSnapshot {
|
||||
pendingAuditMonths: PendingAuditMonth[];
|
||||
pendingAuditPolicies: PendingAuditPolicy[];
|
||||
pendingAuditDiplomacy: AuditDiplomacyEventDraft[];
|
||||
pendingAuditDecisions: PendingAuditDecision[];
|
||||
pendingUnificationFinalizations: PendingUnificationFinalization[];
|
||||
pendingRealtimeBacklogShiftTicks: number;
|
||||
}
|
||||
@@ -561,6 +565,7 @@ export class InMemoryTurnWorld {
|
||||
private readonly pendingAuditMonths: PendingAuditMonth[] = [];
|
||||
private readonly pendingAuditPolicies: PendingAuditPolicy[] = [];
|
||||
private readonly pendingAuditDiplomacy: AuditDiplomacyEventDraft[] = [];
|
||||
private readonly pendingAuditDecisions: PendingAuditDecision[] = [];
|
||||
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
|
||||
private pendingRealtimeBacklogShiftTicks = 0;
|
||||
private readonly scenarioConfig: ScenarioConfig;
|
||||
@@ -1112,6 +1117,7 @@ export class InMemoryTurnWorld {
|
||||
pendingAuditMonths: this.pendingAuditMonths,
|
||||
pendingAuditPolicies: this.pendingAuditPolicies,
|
||||
pendingAuditDiplomacy: this.pendingAuditDiplomacy,
|
||||
pendingAuditDecisions: this.pendingAuditDecisions,
|
||||
pendingUnificationFinalizations: this.pendingUnificationFinalizations,
|
||||
pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks,
|
||||
} satisfies InMemoryTurnWorldStateSnapshot);
|
||||
@@ -1161,6 +1167,7 @@ export class InMemoryTurnWorld {
|
||||
this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths);
|
||||
this.replaceArray(this.pendingAuditPolicies, restored.pendingAuditPolicies);
|
||||
this.replaceArray(this.pendingAuditDiplomacy, restored.pendingAuditDiplomacy);
|
||||
this.replaceArray(this.pendingAuditDecisions, restored.pendingAuditDecisions);
|
||||
this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations);
|
||||
this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0;
|
||||
}
|
||||
@@ -1389,6 +1396,10 @@ export class InMemoryTurnWorld {
|
||||
this.pendingAuditDiplomacy.push(structuredClone(event));
|
||||
}
|
||||
|
||||
queueAuditDecision(decision: PendingAuditDecision): void {
|
||||
this.pendingAuditDecisions.push(structuredClone(decision));
|
||||
}
|
||||
|
||||
queueAuditPolicy(policy: PendingAuditPolicy): void {
|
||||
this.pendingAuditPolicies.push(structuredClone(policy));
|
||||
}
|
||||
@@ -1397,7 +1408,8 @@ export class InMemoryTurnWorld {
|
||||
return (
|
||||
this.pendingAuditPolicies.length > 0 ||
|
||||
this.pendingAuditMonths.length > 0 ||
|
||||
this.pendingAuditDiplomacy.length > 0
|
||||
this.pendingAuditDiplomacy.length > 0 ||
|
||||
this.pendingAuditDecisions.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1964,6 +1976,8 @@ export class InMemoryTurnWorld {
|
||||
schedule: this.schedule,
|
||||
});
|
||||
|
||||
if (result.auditDecisions?.length) this.pendingAuditDecisions.push(...structuredClone(result.auditDecisions));
|
||||
|
||||
let nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
|
||||
if (!result.deleted?.general) {
|
||||
const resolvedGeneral = result.general ?? currentGeneral;
|
||||
@@ -2274,6 +2288,7 @@ export class InMemoryTurnWorld {
|
||||
const pendingAuditMonths = structuredClone(this.pendingAuditMonths);
|
||||
const pendingAuditPolicies = structuredClone(this.pendingAuditPolicies);
|
||||
const pendingAuditDiplomacy = structuredClone(this.pendingAuditDiplomacy);
|
||||
const pendingAuditDecisions = structuredClone(this.pendingAuditDecisions);
|
||||
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
|
||||
const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort(
|
||||
(left, right) => left - right
|
||||
@@ -2309,6 +2324,7 @@ export class InMemoryTurnWorld {
|
||||
pendingAuditMonths,
|
||||
pendingAuditPolicies,
|
||||
pendingAuditDiplomacy,
|
||||
pendingAuditDecisions,
|
||||
pendingUnificationFinalizations,
|
||||
};
|
||||
}
|
||||
@@ -2350,6 +2366,7 @@ export class InMemoryTurnWorld {
|
||||
this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length);
|
||||
this.pendingAuditPolicies.splice(0, changes.pendingAuditPolicies.length);
|
||||
this.pendingAuditDiplomacy.splice(0, changes.pendingAuditDiplomacy.length);
|
||||
this.pendingAuditDecisions.splice(0, changes.pendingAuditDecisions.length);
|
||||
this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { auditDecisionIdentity, type PendingAuditDecision } from '../playAudit/decision.js';
|
||||
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js';
|
||||
import type { AiDecisionTraceEvent } from './ai/generalAi/trace.js';
|
||||
import type { AiDecisionTraceObserver } from './ai/generalAi/trace.js';
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||
import type {
|
||||
@@ -902,6 +905,8 @@ export const createReservedTurnHandler = async (options: {
|
||||
currentMonth: number
|
||||
) => Nation['meta'] | null;
|
||||
onDecisionTrace?: AiDecisionTraceObserver;
|
||||
collectAuditDecisions?: boolean;
|
||||
auditCodeVersion?: string;
|
||||
onActionResolved?: (payload: {
|
||||
kind: 'nation' | 'general';
|
||||
generalId: number;
|
||||
@@ -1068,6 +1073,90 @@ export const createReservedTurnHandler = async (options: {
|
||||
let currentGeneral = context.general;
|
||||
let currentCity = context.city;
|
||||
let currentNation = context.nation ?? null;
|
||||
const auditServerId = typeof context.world.meta.serverId === 'string' ? context.world.meta.serverId : null;
|
||||
const collectDecisions =
|
||||
options.collectAuditDecisions !== false && Boolean(auditServerId?.trim()) && Boolean(worldRef);
|
||||
const auditDecisions: PendingAuditDecision[] = [];
|
||||
const decisionSteps = new Map<'general' | 'nation', AiDecisionTraceEvent[]>();
|
||||
const decisionPolicyRefs = new Map<'general' | 'nation', Record<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 캐시를 채운다.
|
||||
// 같은 장수 lifecycle의 국호변경은 뒤이은 유니크 획득 로그의 국호를 바꾸지 않는다.
|
||||
const legacyStaticNationName = currentNation?.name ?? '재야';
|
||||
@@ -1936,7 +2025,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
nationUsedAi = true;
|
||||
const aiStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
|
||||
sharedAi = new GeneralAI({
|
||||
onDecisionTrace: options.onDecisionTrace,
|
||||
onDecisionTrace,
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
nation: currentNation,
|
||||
@@ -2009,6 +2098,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
const nationActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
|
||||
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
|
||||
finishDecision('nation', nationResult);
|
||||
const nationActionDurationNs = options.onActionProfiled
|
||||
? process.hrtime.bigint() - nationActionStartedAt
|
||||
: 0n;
|
||||
@@ -2093,7 +2183,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
const ai =
|
||||
sharedAi ??
|
||||
new GeneralAI({
|
||||
onDecisionTrace: options.onDecisionTrace,
|
||||
onDecisionTrace,
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
nation: currentNation,
|
||||
@@ -2179,6 +2269,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
blockedReason: '블럭 대상자입니다.',
|
||||
}
|
||||
: runAction('general', generalDefinitions, generalFallback, generalCommand, true);
|
||||
finishDecision('general', generalResult);
|
||||
const generalActionDurationNs = options.onActionProfiled
|
||||
? process.hrtime.bigint() - generalActionStartedAt
|
||||
: 0n;
|
||||
@@ -2411,6 +2502,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
|
||||
const result: GeneralTurnResult = {
|
||||
...(auditDecisions.length ? { auditDecisions } : {}),
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
nation: currentNation,
|
||||
|
||||
@@ -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 { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import type { Nation } from '@sammo-ts/logic';
|
||||
@@ -49,6 +50,8 @@ integration('monthly diplomacy persistence', () => {
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: scenarioCode } } });
|
||||
await db.playAuditDecision.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.playAuditPolicy.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.diplomacy.deleteMany({
|
||||
where: {
|
||||
@@ -62,6 +65,8 @@ integration('monthly diplomacy persistence', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: scenarioCode } } });
|
||||
await db.playAuditDecision.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.playAuditPolicy.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.diplomacy.deleteMany({
|
||||
where: {
|
||||
@@ -186,6 +191,20 @@ integration('monthly diplomacy persistence', () => {
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()');
|
||||
}
|
||||
|
||||
const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode);
|
||||
world.queueAuditDecision(decision);
|
||||
await db.$executeRawUnsafe(`CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`);
|
||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`);
|
||||
try {
|
||||
await expect(hooks.flushChanges()).rejects.toThrow('decision flush rollback');
|
||||
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([decision]);
|
||||
expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||
expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(1);
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_decision_flush_fixture()');
|
||||
}
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: '0193-02-01T00:00:00.000Z',
|
||||
processedGenerals: 0,
|
||||
@@ -211,6 +230,8 @@ integration('monthly diplomacy persistence', () => {
|
||||
});
|
||||
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([]);
|
||||
expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(1);
|
||||
const events = await db.playAuditDiplomacyEvent.findMany({
|
||||
where: { serverId: scenarioCode },
|
||||
orderBy: { sequence: 'asc' },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PendingAuditDecision } from '../src/playAudit/decision.js';
|
||||
import type { AiDecisionTraceEvent } from '../src/turn/ai/generalAi/trace.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
||||
@@ -247,8 +248,14 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
|
||||
};
|
||||
|
||||
const decisionTrace: AiDecisionTraceEvent[] = [];
|
||||
const savedDecisions: PendingAuditDecision[] = [];
|
||||
const { runUntil } = await createTurnTestHarness({
|
||||
onDecisionTrace: auditEnabled ? (event) => decisionTrace.push(event) : undefined,
|
||||
wrapGeneralTurnHandler: (handler) => ({ execute: (context) => {
|
||||
const result = handler.execute(context);
|
||||
savedDecisions.push(...(result.auditDecisions ?? []));
|
||||
return result;
|
||||
} }),
|
||||
snapshot,
|
||||
state,
|
||||
schedule,
|
||||
@@ -439,6 +446,11 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
|
||||
}
|
||||
expect(dispatchCount).toBeGreaterThan(0);
|
||||
if (auditEnabled) {
|
||||
expect(savedDecisions.length).toBeGreaterThan(0);
|
||||
expect(new Set(savedDecisions.map((row) => row.id)).size).toBe(savedDecisions.length);
|
||||
expect(savedDecisions.some((row) => row.phase === 'nation' && row.summary.executedAction === 'che_선전포고')).toBe(true);
|
||||
expect(savedDecisions.some((row) => row.phase === 'general' && row.summary.executedAction === 'che_출병')).toBe(true);
|
||||
expect(savedDecisions.every((row) => row.steps[0]?.kind === 'DECISION_START' && row.steps.at(-1)?.kind === 'DECISION_END')).toBe(true);
|
||||
expect(decisionTrace.some((step) => step.kind === 'DECISION_START' && step.phase === 'nation')).toBe(true);
|
||||
expect(decisionTrace.some((step) => step.kind === 'DECISION_END' && step.phase === 'general')).toBe(true);
|
||||
expect(decisionTrace.some((step) => step.kind === 'RNG')).toBe(true);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
|
||||
import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { City, Nation } from '@sammo-ts/logic';
|
||||
@@ -129,6 +130,22 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
|
||||
return world;
|
||||
};
|
||||
describe('play audit collection durability state', () => {
|
||||
it('restores decision buffers and acknowledges only the committed prefix', () => {
|
||||
const world = buildWorld();
|
||||
const first = buildAuditDecisionFixture('first');
|
||||
world.queueAuditDecision(first);
|
||||
const checkpoint = world.captureState();
|
||||
const committed = world.peekDirtyState();
|
||||
world.queueAuditDecision(buildAuditDecisionFixture('second'));
|
||||
world.acknowledgeDirtyState(committed);
|
||||
expect(world.peekDirtyState().pendingAuditDecisions.map((row) => row.id)).toEqual(['second']);
|
||||
world.restoreState(checkpoint);
|
||||
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([first]);
|
||||
const peeked = world.peekDirtyState();
|
||||
peeked.pendingAuditDecisions[0]!.steps.pop();
|
||||
expect(world.peekDirtyState().pendingAuditDecisions[0]!.steps).toHaveLength(302);
|
||||
});
|
||||
|
||||
it('captures direct reserved-turn nation batches once and initializes their policies', () => {
|
||||
const world = buildWorld({
|
||||
execute: () => ({ created: { generals: [], nations: [buildNation(3, 0, {}), buildNation(4, 0, {})] } }),
|
||||
|
||||
@@ -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: [],
|
||||
pendingAuditMonths: [],
|
||||
pendingAuditPolicies: [],
|
||||
pendingAuditDiplomacy: [],
|
||||
pendingAuditDiplomacy: [],
|
||||
pendingAuditDecisions: [],
|
||||
pendingUnificationFinalizations: [],
|
||||
} satisfies TurnWorldChanges;
|
||||
const readModelChanges = createEmptyRealtimeReadModelChanges();
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
|
||||
gameSchemaHead: '20260916060000_widen_play_audit_ticks',
|
||||
gameSchemaHead: '20260916070000_add_play_audit_decision',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user