feat: 월간 외교 전이를 상태와 함께 감사 이력으로 저장

This commit is contained in:
2026-09-16 06:00:08 +00:00
parent bfe9ef479c
commit 248d457dd5
7 changed files with 154 additions and 4 deletions
@@ -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 { persistAuditPolicies } from '../playAudit/policyPersistence.js';
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js'; import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
import { persistAuditMonth } from '../playAudit/persistence.js'; import { persistAuditMonth } from '../playAudit/persistence.js';
@@ -1148,6 +1149,7 @@ export const createDatabaseTurnHooks = async (
pendingYearbookSnapshots, pendingYearbookSnapshots,
pendingAuditMonths, pendingAuditMonths,
pendingAuditPolicies, pendingAuditPolicies,
pendingAuditDiplomacy,
pendingUnificationFinalizations, pendingUnificationFinalizations,
} = changes; } = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -1889,6 +1891,7 @@ export const createDatabaseTurnHooks = async (
}); });
} }
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand); await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand);
await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy);
for (const snapshot of pendingAuditMonths) { for (const snapshot of pendingAuditMonths) {
await persistAuditMonth(prisma, snapshot); await persistAuditMonth(prisma, snapshot);
} }
+18 -1
View File
@@ -1,3 +1,4 @@
import type { AuditDiplomacyEventDraft } from '@sammo-ts/infra';
import { initializeNationAuditPolicies, type PendingAuditPolicy } from '../playAudit/policy.js'; import { initializeNationAuditPolicies, type PendingAuditPolicy } from '../playAudit/policy.js';
import type { PendingAuditMonth } from '../playAudit/persistence.js'; import type { PendingAuditMonth } from '../playAudit/persistence.js';
import type { import type {
@@ -203,6 +204,7 @@ export interface TurnWorldChanges {
pendingYearbookSnapshots: PendingYearbookSnapshot[]; pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingAuditMonths: PendingAuditMonth[]; pendingAuditMonths: PendingAuditMonth[];
pendingAuditPolicies: PendingAuditPolicy[]; pendingAuditPolicies: PendingAuditPolicy[];
pendingAuditDiplomacy: AuditDiplomacyEventDraft[];
pendingUnificationFinalizations: PendingUnificationFinalization[]; pendingUnificationFinalizations: PendingUnificationFinalization[];
} }
@@ -245,6 +247,7 @@ export interface InMemoryTurnWorldStateSnapshot {
pendingYearbookSnapshots: PendingYearbookSnapshot[]; pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingAuditMonths: PendingAuditMonth[]; pendingAuditMonths: PendingAuditMonth[];
pendingAuditPolicies: PendingAuditPolicy[]; pendingAuditPolicies: PendingAuditPolicy[];
pendingAuditDiplomacy: AuditDiplomacyEventDraft[];
pendingUnificationFinalizations: PendingUnificationFinalization[]; pendingUnificationFinalizations: PendingUnificationFinalization[];
pendingRealtimeBacklogShiftTicks: number; pendingRealtimeBacklogShiftTicks: number;
} }
@@ -551,6 +554,7 @@ export class InMemoryTurnWorld {
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = []; private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
private readonly pendingAuditMonths: PendingAuditMonth[] = []; private readonly pendingAuditMonths: PendingAuditMonth[] = [];
private readonly pendingAuditPolicies: PendingAuditPolicy[] = []; private readonly pendingAuditPolicies: PendingAuditPolicy[] = [];
private readonly pendingAuditDiplomacy: AuditDiplomacyEventDraft[] = [];
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = []; private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
private pendingRealtimeBacklogShiftTicks = 0; private pendingRealtimeBacklogShiftTicks = 0;
private readonly scenarioConfig: ScenarioConfig; private readonly scenarioConfig: ScenarioConfig;
@@ -1101,6 +1105,7 @@ export class InMemoryTurnWorld {
pendingYearbookSnapshots: this.pendingYearbookSnapshots, pendingYearbookSnapshots: this.pendingYearbookSnapshots,
pendingAuditMonths: this.pendingAuditMonths, pendingAuditMonths: this.pendingAuditMonths,
pendingAuditPolicies: this.pendingAuditPolicies, pendingAuditPolicies: this.pendingAuditPolicies,
pendingAuditDiplomacy: this.pendingAuditDiplomacy,
pendingUnificationFinalizations: this.pendingUnificationFinalizations, pendingUnificationFinalizations: this.pendingUnificationFinalizations,
pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks, pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks,
} satisfies InMemoryTurnWorldStateSnapshot); } satisfies InMemoryTurnWorldStateSnapshot);
@@ -1149,6 +1154,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots); this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
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.pendingUnificationFinalizations, restored.pendingUnificationFinalizations); this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations);
this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0; this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0;
} }
@@ -1373,12 +1379,20 @@ export class InMemoryTurnWorld {
return ordinal; return ordinal;
} }
queueAuditDiplomacy(event: AuditDiplomacyEventDraft): void {
this.pendingAuditDiplomacy.push(structuredClone(event));
}
queueAuditPolicy(policy: PendingAuditPolicy): void { queueAuditPolicy(policy: PendingAuditPolicy): void {
this.pendingAuditPolicies.push(structuredClone(policy)); this.pendingAuditPolicies.push(structuredClone(policy));
} }
hasPendingAuditRecords(): boolean { 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 { queueAuditMonth(snapshot: PendingAuditMonth): void {
@@ -2230,6 +2244,7 @@ export class InMemoryTurnWorld {
const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots); const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots);
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 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
@@ -2264,6 +2279,7 @@ export class InMemoryTurnWorld {
pendingYearbookSnapshots, pendingYearbookSnapshots,
pendingAuditMonths, pendingAuditMonths,
pendingAuditPolicies, pendingAuditPolicies,
pendingAuditDiplomacy,
pendingUnificationFinalizations, pendingUnificationFinalizations,
}; };
} }
@@ -2304,6 +2320,7 @@ export class InMemoryTurnWorld {
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length); this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
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.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.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 { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; 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) world.listDiplomacy().map((entry) => [`${entry.fromNationId}:${entry.toNationId}`, entry] as const)
); );
recordMonthlyAuditDiplomacy(world, before, afterByKey);
for (const entry of declarationStarts) { for (const entry of declarationStarts) {
const nation1 = world.getNationById(entry.fromNationId); const nation1 = world.getNationById(entry.fromNationId);
const nation2 = world.getNationById(entry.toNationId); const nation2 = world.getNationById(entry.toNationId);
@@ -48,6 +48,7 @@ integration('monthly diplomacy persistence', () => {
await connector.connect(); await connector.connect();
db = connector.prisma; db = connector.prisma;
closeDb = () => connector.disconnect(); closeDb = () => connector.disconnect();
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } });
await db.diplomacy.deleteMany({ await db.diplomacy.deleteMany({
where: { where: {
OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }], OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }],
@@ -59,6 +60,7 @@ integration('monthly diplomacy persistence', () => {
}); });
afterAll(async () => { afterAll(async () => {
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } });
await db.diplomacy.deleteMany({ await db.diplomacy.deleteMany({
where: { where: {
OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }], OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }],
@@ -112,7 +114,7 @@ integration('monthly diplomacy persistence', () => {
const: {}, const: {},
environment: { mapName: 'test', unitSet: 'default' }, environment: { mapName: 'test', unitSet: 'default' },
}, },
meta: {}, meta: { serverId: scenarioCode },
}, },
}); });
const state: TurnWorldState = { const state: TurnWorldState = {
@@ -121,7 +123,7 @@ integration('monthly diplomacy persistence', () => {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('0193-01-01T00:00:00.000Z'), lastTurnTime: new Date('0193-01-01T00:00:00.000Z'),
meta: {}, meta: { serverId: scenarioCode },
}; };
const snapshot: TurnWorldSnapshot = { const snapshot: TurnWorldSnapshot = {
scenarioConfig: { scenarioConfig: {
@@ -148,7 +150,40 @@ integration('monthly diplomacy persistence', () => {
}); });
const hooks = await createDatabaseTurnHooks(databaseUrl!, world); const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try { try {
const checkpoint = world.captureState();
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); 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?.({ await hooks.hooks.flushChanges?.({
lastTurnTime: '0193-02-01T00:00:00.000Z', lastTurnTime: '0193-02-01T00:00:00.000Z',
processedGenerals: 0, processedGenerals: 0,
@@ -173,6 +208,33 @@ integration('monthly diplomacy persistence', () => {
partial: false, 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({ const rows = await db.diplomacy.findMany({
where: { where: {
OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }], OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }],
@@ -118,6 +118,7 @@ describe('durable read-model change journal mapping', () => {
pendingYearbookSnapshots: [], pendingYearbookSnapshots: [],
pendingAuditMonths: [], pendingAuditMonths: [],
pendingAuditPolicies: [], pendingAuditPolicies: [],
pendingAuditDiplomacy: [],
pendingUnificationFinalizations: [], pendingUnificationFinalizations: [],
} satisfies TurnWorldChanges; } satisfies TurnWorldChanges;
const readModelChanges = createEmptyRealtimeReadModelChanges(); const readModelChanges = createEmptyRealtimeReadModelChanges();
+17 -1
View File
@@ -131,7 +131,23 @@ UPDATE 반환값에서 변경 전후 allowlist를 만들고, 원장 잠금 SELEC
실제 PG에서 세 응답의 양방향 before/after, 처리 순서, RESOLVED 제의 상태와 동기화 실제 PG에서 세 응답의 양방향 before/after, 처리 순서, RESOLVED 제의 상태와 동기화
실패 후 재요청을 검증했다. 엔진 transport만 fixture 응답이므로 엔진 runtime 동기화 실패 후 재요청을 검증했다. 엔진 transport만 fixture 응답이므로 엔진 runtime 동기화
완료의 증거는 아니다. 거절/실패/무변경은 관계 전이와 구분할 시도 원장 구현에 남겼다. 완료의 증거는 아니다. 거절/실패/무변경은 관계 전이와 구분할 시도 원장 구현에 남겼다.
엔진 턴·월간 변화와 기준 수집, 외교 조회 화면은 아직 남았다. 엔진 턴 변화와 기준 수집, 외교 조회 화면은 아직 남았다.
### 엔진 월간 외교 전이
`createMonthlyDiplomacyHandler`가 이미 가진 before/after에서 state/term/dead의 실제
변화만 directed event로 모은다. 사건 순서는 국가 ID 쌍으로 고정하고 실행 identity는
기수/달력/clock revision을 사용한다. 자연 월간 실행에 actor나 입력 원장 ID를 만들지
않는다. 기본 TRADE matrix 보충 자체와 무변경 행은 기록하지 않는다.
pending queue는 world capture/restore/peek/ack에 포함하고, 기존 fenced DB transaction
안에서 bulk200 INSERT와 hash 확인을 수행한다. 실패 시 상태와 이력은 함께 rollback,
queue는 재시도까지 유지하며 commit 후에만 제거한다. 기존 계산/RNG/로그 순서는
그대로고 추가 상태 SELECT는 없다. 기존 before 목록을 정렬한 메모리 사본만 추가한다.
실제 PG에서 개전·기간 감소·사상자 처리·불가침 만료·종전의 기존 결과/로그를 유지하며,
메모리 checkpoint 복구, 감사 INSERT 실패 rollback, 재시도/중복 방지를 검증했다.
엔진 개별 명령 전이와 초기 외교 기준, 조회 API/UI 및 전체 비용 실측은 남았다.
## NPC·국방 정책 버전 저장 기반 ## NPC·국방 정책 버전 저장 기반