From 0691c502faf9b1d4aa569e9d3b8711195b41d258 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 06:09:24 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=ED=84=B4=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=EB=B3=84=20=EC=A4=91=EA=B0=84=20=EC=99=B8?= =?UTF-8?q?=EA=B5=90=20=EC=A0=84=EC=9D=B4=EC=99=80=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/src/playAudit/diplomacy.ts | 53 +++++++++++++++++++ app/game-engine/src/turn/inMemoryWorld.ts | 15 +++++- .../src/turn/reservedTurnHandler.ts | 19 +++++-- .../test/npcNationWarDeclaration.test.ts | 14 +++-- .../test/playAuditCollection.test.ts | 46 +++++++++++++++- docs/design/play-audit-implementation.md | 17 ++++++ 6 files changed, 153 insertions(+), 11 deletions(-) diff --git a/app/game-engine/src/playAudit/diplomacy.ts b/app/game-engine/src/playAudit/diplomacy.ts index 5998e6d3..2c6d554e 100644 --- a/app/game-engine/src/playAudit/diplomacy.ts +++ b/app/game-engine/src/playAudit/diplomacy.ts @@ -46,3 +46,56 @@ export const recordMonthlyAuditDiplomacy = ( }); } }; + +export interface AuditDiplomacyAction { + actionKey: string; + kind: 'nation' | 'general'; + actionOrdinal: number; + actor: { + generalId: number; + userId: string | null; + name: string; + nationId: number; + officerLevel: number; + npcState: number; + }; +} + +export const recordTurnAuditDiplomacy = ( + world: InMemoryTurnWorld, + before: TurnDiplomacy, + after: TurnDiplomacy, + action: AuditDiplomacyAction, + turn: { generalId: number; tick: number; ordinal: number } +): void => { + const state = world.getState(); + const serverId = state.meta.serverId; + if (typeof serverId !== 'string' || !serverId.trim()) return; + const previousState = { state: before.state, term: before.term, dead: before.dead }; + const nextState = { state: after.state, term: after.term, dead: after.dead }; + if (JSON.stringify(previousState) === JSON.stringify(nextState)) return; + const clock = world.getGameClockState(); + world.queueAuditDiplomacy({ + schemaVersion: 1, + serverId, + srcNationId: before.fromNationId, + destNationId: before.toNationId, + category: 'RELATION', + source: 'ENGINE', + eventType: 'TURN_RELATION_CHANGED', + documentId: null, + documentHash: null, + previousDocumentId: null, + year: state.currentYear, + month: state.currentMonth, + tick: BigInt(turn.tick), + clockRevision: BigInt(clock.revision), + executionId: `turn:${turn.generalId}:${turn.tick}:${clock.revision}`, + ordinal: turn.ordinal, + requestId: null, + inputSequence: null, + actor: { ...action.actor, actionKey: action.actionKey, kind: action.kind, actionOrdinal: action.actionOrdinal }, + before: previousState, + after: nextState, + }); +}; diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 4a09fe71..c167f96a 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1,3 +1,4 @@ +import { recordTurnAuditDiplomacy, type AuditDiplomacyAction } from '../playAudit/diplomacy.js'; import type { AuditDiplomacyEventDraft } from '@sammo-ts/infra'; import { initializeNationAuditPolicies, type PendingAuditPolicy } from '../playAudit/policy.js'; import type { PendingAuditMonth } from '../playAudit/persistence.js'; @@ -71,6 +72,7 @@ export interface GeneralTurnResult { srcNationId: number; destNationId: number; patch: DiplomacyPatch; + audit?: AuditDiplomacyAction; }>; created?: { generals: TurnGeneral[]; @@ -1940,6 +1942,7 @@ export class InMemoryTurnWorld { executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution { assertGameplayCommitAllowed(this.getGameClock().phase); const currentGeneral = this.generals.get(general.id) ?? general; + const executionTick = currentGeneral.turnTick ?? this.getGameClock().dateToTick(currentGeneral.turnTime); const executionYear = this.state.currentYear; const executionMonth = this.state.currentMonth; const city = this.cities.get(currentGeneral.cityId); @@ -2064,12 +2067,22 @@ export class InMemoryTurnWorld { } } if (result.diplomacyPatches) { - for (const patch of result.diplomacyPatches) { + for (const [index, patch] of result.diplomacyPatches.entries()) { + const key = buildDiplomacyKey(patch.srcNationId, patch.destNationId); + const before = this.diplomacy.get(key) ?? buildDefaultDiplomacy(patch.srcNationId, patch.destNationId); this.applyDiplomacyPatch({ srcNationId: patch.srcNationId, destNationId: patch.destNationId, patch: patch.patch, }); + const after = this.diplomacy.get(key); + if (patch.audit && after) { + recordTurnAuditDiplomacy(this, before, after, patch.audit, { + generalId: currentGeneral.id, + tick: executionTick, + ordinal: index + 1, + }); + } } } if (result.created) { diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index bab44756..5492c428 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1056,11 +1056,8 @@ export const createReservedTurnHandler = async (options: { nations: [] as Array<{ id: number; patch: Partial }>, troops: [] as Array<{ id: number; patch: Partial }>, }; - const diplomacyPatches: Array<{ - srcNationId: number; - destNationId: number; - patch: DiplomacyPatch; - }> = []; + const diplomacyPatches: NonNullable = []; + let auditActionOrdinal = 0; const createdGenerals: TurnGeneral[] = []; const createdNations: Nation[] = []; const commandDeletedTroopIds = new Set(); @@ -1333,6 +1330,15 @@ export const createReservedTurnHandler = async (options: { const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {}); const generalBeforeExecution = currentGeneral; + const actionOrdinal = ++auditActionOrdinal; + const auditActor = { + generalId: currentGeneral.id, + userId: currentGeneral.userId ?? null, + name: currentGeneral.name, + nationId: currentGeneral.nationId, + officerLevel: currentGeneral.officerLevel, + npcState: currentGeneral.npcState, + }; const cityNationIdsBeforeExecution = new Map( (worldView?.listCities() ?? []).map((city) => [city.id, city.nationId] as const) ); @@ -1574,6 +1580,9 @@ export const createReservedTurnHandler = async (options: { srcNationId: effect.srcNationId, destNationId: effect.destNationId, patch: effect.patch, + ...(typeof context.world.meta.serverId === 'string' + ? { audit: { actionKey, kind, actionOrdinal, actor: auditActor } } + : {}), }); worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch); } diff --git a/app/game-engine/test/npcNationWarDeclaration.test.ts b/app/game-engine/test/npcNationWarDeclaration.test.ts index 32137f2e..563e3e21 100644 --- a/app/game-engine/test/npcNationWarDeclaration.test.ts +++ b/app/game-engine/test/npcNationWarDeclaration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js'; @@ -71,7 +71,7 @@ const buildUnificationLog = (nationName: string): LogEntryDraft => ({ }); describe('NPC 선전포고·개전·점령 흐름 테스트', () => { - it('선전포고부터 개전과 첫 도시 점령까지 진행되어야 한다', async () => { + it.each([false, true])('감사 수집 %s에서 선전포고부터 개전과 첫 도시 점령까지 진행되어야 한다', async (auditEnabled) => { const cities = buildLargeTestCities().map(maxCityStats); const cityA1 = cities.find((city) => city.id === 1)!; const cityA2 = cities.find((city) => city.id === 2)!; @@ -206,7 +206,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => { currentMonth: 5, tickSeconds: 600, lastTurnTime: mockDate, - meta: { seed: 1, initYear: 183, initMonth: 5 }, + meta: { seed: 1, initYear: 183, initMonth: 5, ...(auditEnabled ? { serverId: 'npc-declaration-audit' } : {}) }, }; const schedule: TurnSchedule = { @@ -278,6 +278,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => { }, }); + const auditQueue = vi.spyOn(worldRef.current!, 'queueAuditDiplomacy'); const debug = createWorldDebugger(() => worldRef.current, { nationIds: [1, 2], cityIds: [cityA1.id, cityA2.id, cityB1.id, cityB2.id], @@ -326,6 +327,13 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => { } expect(declareEntry).not.toBeNull(); expect(declareEntry?.state).toBe(DIPLOMACY_STATE.DECLARATION); + const declarationAudit = auditQueue.mock.calls.map(([event]) => event).filter( + (event) => event.actor?.actionKey === 'che_선전포고' + ); + expect(declarationAudit).toHaveLength(auditEnabled ? 2 : 0); + expect(declarationAudit.every((event) => event.eventType === 'TURN_RELATION_CHANGED' && event.actor?.kind === 'nation')).toBe(true); + expect(declarationAudit.map((event) => event.after?.state)).toEqual(auditEnabled ? [DIPLOMACY_STATE.DECLARATION, DIPLOMACY_STATE.DECLARATION] : []); + const remainTurns = Math.max(0, (declareEntry?.term ?? 0) - 1); const preWarTarget = addMonths(world!.getState().currentYear, world!.getState().currentMonth, remainTurns); diff --git a/app/game-engine/test/playAuditCollection.test.ts b/app/game-engine/test/playAuditCollection.test.ts index c000c13c..f43f0a36 100644 --- a/app/game-engine/test/playAuditCollection.test.ts +++ b/app/game-engine/test/playAuditCollection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { City, Nation } from '@sammo-ts/logic'; -import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createPlayAuditHandler, @@ -77,7 +77,7 @@ const buildNation = (id: number, power: number, meta: Nation['meta']): Nation => meta, }); -const buildWorld = () => { +const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => { const state: TurnWorldState = { id: 1, currentYear: 200, @@ -122,11 +122,53 @@ const buildWorld = () => { }; const world = new InMemoryTurnWorld(state, snapshot, { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler, }); return world; }; describe('play audit collection durability state', () => { + it('preserves consecutive diplomacy transitions in one turn and restores them with the checkpoint', () => { + const world = buildWorld({ + execute: ({ general }) => ({ + diplomacyPatches: [1, 0].map((state, index) => ({ + srcNationId: 1, + destNationId: 2, + patch: { state, term: 6 }, + audit: { + actionKey: index === 0 ? 'che_선전포고' : 'che_급습', + kind: 'nation', + actionOrdinal: index + 1, + actor: { + generalId: general.id, + userId: null, + name: general.name, + nationId: 1, + officerLevel: 12, + npcState: 2, + }, + }, + })), + }), + }); + const checkpoint = world.captureState(); + const general = world.getGeneralById(3)!; + world.executeGeneralTurn(general); + const events = world.peekDirtyState().pendingAuditDiplomacy; + expect(events).toHaveLength(2); + expect(events.map((event) => [event.before?.state, event.after?.state])).toEqual([ + [2, 1], + [1, 0], + ]); + expect(events.map((event) => event.ordinal)).toEqual([1, 2]); + expect(new Set(events.map((event) => event.executionId)).size).toBe(1); + expect(events.map((event) => event.actor?.actionKey)).toEqual(['che_선전포고', 'che_급습']); + world.restoreState(checkpoint); + expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]); + world.executeGeneralTurn(world.getGeneralById(3)!); + expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(events); + }); + it('freezes the initial observation separately from month-end and restores its marker on rollback', () => { const world = buildWorld(); const before = world.captureState(); diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 468fad82..b63585bc 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -149,6 +149,23 @@ queue는 재시도까지 유지하며 commit 후에만 제거한다. 기존 계 메모리 checkpoint 복구, 감사 INSERT 실패 rollback, 재시도/중복 방지를 검증했다. 엔진 개별 명령 전이와 초기 외교 기준, 조회 API/UI 및 전체 비용 실측은 남았다. +### 예약 턴 명령의 외교 전이 + +`createReservedTurnHandler`는 각 실제 action의 실행 순번과 실행 전 장수 identity, +국가/개인 명령 구분, actionKey를 diplomacy patch에 운반한다. world가 patch를 실제 +적용하는 순서대로 직전/직후 state/term/dead를 queue하므로 같은 턴의 중간 전이를 +최종값으로 덮어쓰지 않는다. API 응답 동기화의 직접 world patch는 다시 기록하지 않는다. + +실행 ID는 장수/실행 전 scheduled tick/clock revision이며 기수 ID와 함께 unique하다. +ordinal은 해당 턴의 patch 순서이고 무변경을 생략하면 간격이 생길 수 있다. 입력 접수 +sequence를 예약 턴의 실행 ID로 오인하지 않는다. 메모리 Map을 직접 읽어 before +관측 때문에 기본 관계 생성 순서가 달라지지 않도록 했다. 상태 SELECT/RNG 호출은 없다. + +연속 두 전이와 checkpoint 복구 fixture, 실제 NPC 선전포고의 양방향 사건, +감사 on/off에서 기존 개전·점령 진행을 검증했다. 완전한 RNG trace 동일성, 모든 명령의 +실제 DB 재로드와 당시 NPC 정책/결정 trace 연결은 후속 gate다. 즉시 명령 executor와 +특수 상태 변경을 포함한 최종 mutation inventory 및 초기 기준/조회 화면도 남았다. + ## NPC·국방 정책 버전 저장 기반 `PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.