feat: 월간 외교 전이를 상태와 함께 감사 이력으로 저장
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
import type { TurnDiplomacy } from '../turn/types.js';
|
||||
|
||||
/** 기존 월간 처리의 전후 값을 기록한다. 기본 TRADE 행 생성은 전이가 아니다. */
|
||||
export const recordMonthlyAuditDiplomacy = (
|
||||
world: InMemoryTurnWorld,
|
||||
before: readonly TurnDiplomacy[],
|
||||
afterByKey: ReadonlyMap<string, TurnDiplomacy>
|
||||
): void => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const clock = world.getGameClockState();
|
||||
const project = (entry: TurnDiplomacy) => ({ state: entry.state, term: entry.term, dead: entry.dead });
|
||||
let ordinal = 0;
|
||||
for (const entry of [...before].sort(
|
||||
(left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId
|
||||
)) {
|
||||
const next = afterByKey.get(`${entry.fromNationId}:${entry.toNationId}`);
|
||||
if (!next) continue;
|
||||
const previousState = project(entry);
|
||||
const nextState = project(next);
|
||||
if (JSON.stringify(previousState) === JSON.stringify(nextState)) continue;
|
||||
world.queueAuditDiplomacy({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: entry.fromNationId,
|
||||
destNationId: entry.toNationId,
|
||||
category: 'RELATION',
|
||||
source: 'ENGINE',
|
||||
eventType: 'MONTHLY_RELATION_CHANGED',
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(clock.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId: `monthly:${state.currentYear}:${state.currentMonth}:${clock.revision}`,
|
||||
ordinal: ++ordinal,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: null,
|
||||
before: previousState,
|
||||
after: nextState,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
|
||||
import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
|
||||
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
|
||||
import { persistAuditMonth } from '../playAudit/persistence.js';
|
||||
@@ -1148,6 +1149,7 @@ export const createDatabaseTurnHooks = async (
|
||||
pendingYearbookSnapshots,
|
||||
pendingAuditMonths,
|
||||
pendingAuditPolicies,
|
||||
pendingAuditDiplomacy,
|
||||
pendingUnificationFinalizations,
|
||||
} = changes;
|
||||
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
|
||||
@@ -1889,6 +1891,7 @@ export const createDatabaseTurnHooks = async (
|
||||
});
|
||||
}
|
||||
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand);
|
||||
await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy);
|
||||
for (const snapshot of pendingAuditMonths) {
|
||||
await persistAuditMonth(prisma, snapshot);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AuditDiplomacyEventDraft } from '@sammo-ts/infra';
|
||||
import { initializeNationAuditPolicies, type PendingAuditPolicy } from '../playAudit/policy.js';
|
||||
import type { PendingAuditMonth } from '../playAudit/persistence.js';
|
||||
import type {
|
||||
@@ -203,6 +204,7 @@ export interface TurnWorldChanges {
|
||||
pendingYearbookSnapshots: PendingYearbookSnapshot[];
|
||||
pendingAuditMonths: PendingAuditMonth[];
|
||||
pendingAuditPolicies: PendingAuditPolicy[];
|
||||
pendingAuditDiplomacy: AuditDiplomacyEventDraft[];
|
||||
pendingUnificationFinalizations: PendingUnificationFinalization[];
|
||||
}
|
||||
|
||||
@@ -245,6 +247,7 @@ export interface InMemoryTurnWorldStateSnapshot {
|
||||
pendingYearbookSnapshots: PendingYearbookSnapshot[];
|
||||
pendingAuditMonths: PendingAuditMonth[];
|
||||
pendingAuditPolicies: PendingAuditPolicy[];
|
||||
pendingAuditDiplomacy: AuditDiplomacyEventDraft[];
|
||||
pendingUnificationFinalizations: PendingUnificationFinalization[];
|
||||
pendingRealtimeBacklogShiftTicks: number;
|
||||
}
|
||||
@@ -551,6 +554,7 @@ export class InMemoryTurnWorld {
|
||||
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
|
||||
private readonly pendingAuditMonths: PendingAuditMonth[] = [];
|
||||
private readonly pendingAuditPolicies: PendingAuditPolicy[] = [];
|
||||
private readonly pendingAuditDiplomacy: AuditDiplomacyEventDraft[] = [];
|
||||
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
|
||||
private pendingRealtimeBacklogShiftTicks = 0;
|
||||
private readonly scenarioConfig: ScenarioConfig;
|
||||
@@ -1101,6 +1105,7 @@ export class InMemoryTurnWorld {
|
||||
pendingYearbookSnapshots: this.pendingYearbookSnapshots,
|
||||
pendingAuditMonths: this.pendingAuditMonths,
|
||||
pendingAuditPolicies: this.pendingAuditPolicies,
|
||||
pendingAuditDiplomacy: this.pendingAuditDiplomacy,
|
||||
pendingUnificationFinalizations: this.pendingUnificationFinalizations,
|
||||
pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks,
|
||||
} satisfies InMemoryTurnWorldStateSnapshot);
|
||||
@@ -1149,6 +1154,7 @@ export class InMemoryTurnWorld {
|
||||
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
|
||||
this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths);
|
||||
this.replaceArray(this.pendingAuditPolicies, restored.pendingAuditPolicies);
|
||||
this.replaceArray(this.pendingAuditDiplomacy, restored.pendingAuditDiplomacy);
|
||||
this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations);
|
||||
this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0;
|
||||
}
|
||||
@@ -1373,12 +1379,20 @@ export class InMemoryTurnWorld {
|
||||
return ordinal;
|
||||
}
|
||||
|
||||
queueAuditDiplomacy(event: AuditDiplomacyEventDraft): void {
|
||||
this.pendingAuditDiplomacy.push(structuredClone(event));
|
||||
}
|
||||
|
||||
queueAuditPolicy(policy: PendingAuditPolicy): void {
|
||||
this.pendingAuditPolicies.push(structuredClone(policy));
|
||||
}
|
||||
|
||||
hasPendingAuditRecords(): boolean {
|
||||
return this.pendingAuditPolicies.length > 0 || this.pendingAuditMonths.length > 0;
|
||||
return (
|
||||
this.pendingAuditPolicies.length > 0 ||
|
||||
this.pendingAuditMonths.length > 0 ||
|
||||
this.pendingAuditDiplomacy.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
queueAuditMonth(snapshot: PendingAuditMonth): void {
|
||||
@@ -2230,6 +2244,7 @@ export class InMemoryTurnWorld {
|
||||
const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots);
|
||||
const pendingAuditMonths = structuredClone(this.pendingAuditMonths);
|
||||
const pendingAuditPolicies = structuredClone(this.pendingAuditPolicies);
|
||||
const pendingAuditDiplomacy = structuredClone(this.pendingAuditDiplomacy);
|
||||
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
|
||||
const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort(
|
||||
(left, right) => left - right
|
||||
@@ -2264,6 +2279,7 @@ export class InMemoryTurnWorld {
|
||||
pendingYearbookSnapshots,
|
||||
pendingAuditMonths,
|
||||
pendingAuditPolicies,
|
||||
pendingAuditDiplomacy,
|
||||
pendingUnificationFinalizations,
|
||||
};
|
||||
}
|
||||
@@ -2304,6 +2320,7 @@ export class InMemoryTurnWorld {
|
||||
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
|
||||
this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length);
|
||||
this.pendingAuditPolicies.splice(0, changes.pendingAuditPolicies.length);
|
||||
this.pendingAuditDiplomacy.splice(0, changes.pendingAuditDiplomacy.length);
|
||||
this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recordMonthlyAuditDiplomacy } from '../playAudit/diplomacy.js';
|
||||
import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
@@ -218,6 +219,8 @@ export const createMonthlyDiplomacyHandler = (options: {
|
||||
world.listDiplomacy().map((entry) => [`${entry.fromNationId}:${entry.toNationId}`, entry] as const)
|
||||
);
|
||||
|
||||
recordMonthlyAuditDiplomacy(world, before, afterByKey);
|
||||
|
||||
for (const entry of declarationStarts) {
|
||||
const nation1 = world.getNationById(entry.fromNationId);
|
||||
const nation2 = world.getNationById(entry.toNationId);
|
||||
|
||||
@@ -48,6 +48,7 @@ integration('monthly diplomacy persistence', () => {
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.diplomacy.deleteMany({
|
||||
where: {
|
||||
OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }],
|
||||
@@ -59,6 +60,7 @@ integration('monthly diplomacy persistence', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.diplomacy.deleteMany({
|
||||
where: {
|
||||
OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }],
|
||||
@@ -112,7 +114,7 @@ integration('monthly diplomacy persistence', () => {
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
meta: {},
|
||||
meta: { serverId: scenarioCode },
|
||||
},
|
||||
});
|
||||
const state: TurnWorldState = {
|
||||
@@ -121,7 +123,7 @@ integration('monthly diplomacy persistence', () => {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0193-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
meta: { serverId: scenarioCode },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
@@ -148,7 +150,40 @@ integration('monthly diplomacy persistence', () => {
|
||||
});
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
try {
|
||||
const checkpoint = world.captureState();
|
||||
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
|
||||
const queued = world.peekDirtyState().pendingAuditDiplomacy;
|
||||
expect(queued.length).toBeGreaterThan(0);
|
||||
world.restoreState(checkpoint);
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(queued);
|
||||
await db.$executeRawUnsafe(`CREATE FUNCTION reject_monthly_audit_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'monthly audit fixture failure'; END; $$`);
|
||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_monthly_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`);
|
||||
try {
|
||||
await expect(hooks.flushChanges()).rejects.toThrow('monthly audit fixture failure');
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(queued);
|
||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
|
||||
currentMonth: 1,
|
||||
});
|
||||
expect(
|
||||
await db.diplomacy.findUniqueOrThrow({
|
||||
where: {
|
||||
srcNationId_destNationId: {
|
||||
srcNationId: nationIds[0]!,
|
||||
destNationId: nationIds[1]!,
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchObject({ stateCode: 1, term: 1 });
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()');
|
||||
}
|
||||
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: '0193-02-01T00:00:00.000Z',
|
||||
processedGenerals: 0,
|
||||
@@ -173,6 +208,33 @@ integration('monthly diplomacy persistence', () => {
|
||||
partial: false,
|
||||
});
|
||||
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
const events = await db.playAuditDiplomacyEvent.findMany({
|
||||
where: { serverId: scenarioCode },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
expect(
|
||||
events.every(
|
||||
(event) =>
|
||||
event.source === 'ENGINE' &&
|
||||
event.actor === null &&
|
||||
event.requestId === null &&
|
||||
event.inputSequence === null
|
||||
)
|
||||
).toBe(true);
|
||||
const startEvents = events.filter((event) => event.month === 2);
|
||||
expect(startEvents.map((event) => event.ordinal)).toEqual(startEvents.map((_, index) => index + 1));
|
||||
expect(
|
||||
startEvents.find((event) => event.srcNationId === nationIds[0] && event.destNationId === nationIds[1])
|
||||
).toMatchObject({
|
||||
before: { state: 1, term: 1, dead: 777 },
|
||||
after: { state: 0, term: 6, dead: 0 },
|
||||
});
|
||||
expect(
|
||||
events.some((event) => event.srcNationId === nationIds[0] && event.destNationId === nationIds[3])
|
||||
).toBe(false);
|
||||
await hooks.flushChanges();
|
||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(events.length);
|
||||
const rows = await db.diplomacy.findMany({
|
||||
where: {
|
||||
OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }],
|
||||
|
||||
@@ -118,6 +118,7 @@ describe('durable read-model change journal mapping', () => {
|
||||
pendingYearbookSnapshots: [],
|
||||
pendingAuditMonths: [],
|
||||
pendingAuditPolicies: [],
|
||||
pendingAuditDiplomacy: [],
|
||||
pendingUnificationFinalizations: [],
|
||||
} satisfies TurnWorldChanges;
|
||||
const readModelChanges = createEmptyRealtimeReadModelChanges();
|
||||
|
||||
Reference in New Issue
Block a user