From 248d457dd569ccb64d6b1d2b01a75da57b9aa64c Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 06:00:08 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9B=94=EA=B0=84=20=EC=99=B8=EA=B5=90?= =?UTF-8?q?=20=EC=A0=84=EC=9D=B4=EB=A5=BC=20=EC=83=81=ED=83=9C=EC=99=80=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EA=B0=90=EC=82=AC=20=EC=9D=B4=EB=A0=A5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/src/playAudit/diplomacy.ts | 48 ++++++++++++++ app/game-engine/src/turn/databaseHooks.ts | 3 + app/game-engine/src/turn/inMemoryWorld.ts | 19 +++++- .../src/turn/monthlyNationStatsHandler.ts | 3 + ...lyDiplomacyPersistence.integration.test.ts | 66 ++++++++++++++++++- .../test/realtimeReadModelChanges.test.ts | 1 + docs/design/play-audit-implementation.md | 18 ++++- 7 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 app/game-engine/src/playAudit/diplomacy.ts diff --git a/app/game-engine/src/playAudit/diplomacy.ts b/app/game-engine/src/playAudit/diplomacy.ts new file mode 100644 index 00000000..5998e6d3 --- /dev/null +++ b/app/game-engine/src/playAudit/diplomacy.ts @@ -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 +): 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, + }); + } +}; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 05a5bc8d..57ade6e1 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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); } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 32667ac1..4a09fe71 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -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); } diff --git a/app/game-engine/src/turn/monthlyNationStatsHandler.ts b/app/game-engine/src/turn/monthlyNationStatsHandler.ts index 390441e3..4a6fe24c 100644 --- a/app/game-engine/src/turn/monthlyNationStatsHandler.ts +++ b/app/game-engine/src/turn/monthlyNationStatsHandler.ts @@ -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); diff --git a/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts b/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts index c34dfd36..5b0759ad 100644 --- a/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts @@ -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 } }], diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index c12d7a92..93d036af 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -118,6 +118,7 @@ describe('durable read-model change journal mapping', () => { pendingYearbookSnapshots: [], pendingAuditMonths: [], pendingAuditPolicies: [], + pendingAuditDiplomacy: [], pendingUnificationFinalizations: [], } satisfies TurnWorldChanges; const readModelChanges = createEmptyRealtimeReadModelChanges(); diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 37efb944..468fad82 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -131,7 +131,23 @@ UPDATE 반환값에서 변경 전후 allowlist를 만들고, 원장 잠금 SELEC 실제 PG에서 세 응답의 양방향 before/after, 처리 순서, RESOLVED 제의 상태와 동기화 실패 후 재요청을 검증했다. 엔진 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·국방 정책 버전 저장 기반