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