feat: 예약 턴 명령별 중간 외교 전이와 실행 정보를 기록

This commit is contained in:
2026-09-16 06:09:24 +00:00
parent 248d457dd5
commit 0691c502fa
6 changed files with 153 additions and 11 deletions
@@ -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,
});
};
+14 -1
View File
@@ -1,3 +1,4 @@
import { recordTurnAuditDiplomacy, type AuditDiplomacyAction } from '../playAudit/diplomacy.js';
import type { AuditDiplomacyEventDraft } from '@sammo-ts/infra'; 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';
@@ -71,6 +72,7 @@ export interface GeneralTurnResult {
srcNationId: number; srcNationId: number;
destNationId: number; destNationId: number;
patch: DiplomacyPatch; patch: DiplomacyPatch;
audit?: AuditDiplomacyAction;
}>; }>;
created?: { created?: {
generals: TurnGeneral[]; generals: TurnGeneral[];
@@ -1940,6 +1942,7 @@ export class InMemoryTurnWorld {
executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution { executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution {
assertGameplayCommitAllowed(this.getGameClock().phase); assertGameplayCommitAllowed(this.getGameClock().phase);
const currentGeneral = this.generals.get(general.id) ?? general; const currentGeneral = this.generals.get(general.id) ?? general;
const executionTick = currentGeneral.turnTick ?? this.getGameClock().dateToTick(currentGeneral.turnTime);
const executionYear = this.state.currentYear; const executionYear = this.state.currentYear;
const executionMonth = this.state.currentMonth; const executionMonth = this.state.currentMonth;
const city = this.cities.get(currentGeneral.cityId); const city = this.cities.get(currentGeneral.cityId);
@@ -2064,12 +2067,22 @@ export class InMemoryTurnWorld {
} }
} }
if (result.diplomacyPatches) { 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({ this.applyDiplomacyPatch({
srcNationId: patch.srcNationId, srcNationId: patch.srcNationId,
destNationId: patch.destNationId, destNationId: patch.destNationId,
patch: patch.patch, 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) { if (result.created) {
@@ -1056,11 +1056,8 @@ export const createReservedTurnHandler = async (options: {
nations: [] as Array<{ id: number; patch: Partial<Nation> }>, nations: [] as Array<{ id: number; patch: Partial<Nation> }>,
troops: [] as Array<{ id: number; patch: Partial<Troop> }>, troops: [] as Array<{ id: number; patch: Partial<Troop> }>,
}; };
const diplomacyPatches: Array<{ const diplomacyPatches: NonNullable<GeneralTurnResult['diplomacyPatches']> = [];
srcNationId: number; let auditActionOrdinal = 0;
destNationId: number;
patch: DiplomacyPatch;
}> = [];
const createdGenerals: TurnGeneral[] = []; const createdGenerals: TurnGeneral[] = [];
const createdNations: Nation[] = []; const createdNations: Nation[] = [];
const commandDeletedTroopIds = new Set<number>(); const commandDeletedTroopIds = new Set<number>();
@@ -1333,6 +1330,15 @@ export const createReservedTurnHandler = async (options: {
const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {}); const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {});
const generalBeforeExecution = currentGeneral; 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( const cityNationIdsBeforeExecution = new Map(
(worldView?.listCities() ?? []).map((city) => [city.id, city.nationId] as const) (worldView?.listCities() ?? []).map((city) => [city.id, city.nationId] as const)
); );
@@ -1574,6 +1580,9 @@ export const createReservedTurnHandler = async (options: {
srcNationId: effect.srcNationId, srcNationId: effect.srcNationId,
destNationId: effect.destNationId, destNationId: effect.destNationId,
patch: effect.patch, patch: effect.patch,
...(typeof context.world.meta.serverId === 'string'
? { audit: { actionKey, kind, actionOrdinal, actor: auditActor } }
: {}),
}); });
worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch); worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch);
} }
@@ -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 type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js'; import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js';
@@ -71,7 +71,7 @@ const buildUnificationLog = (nationName: string): LogEntryDraft => ({
}); });
describe('NPC 선전포고·개전·점령 흐름 테스트', () => { describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
it('선전포고부터 개전과 첫 도시 점령까지 진행되어야 한다', async () => { it.each([false, true])('감사 수집 %s에서 선전포고부터 개전과 첫 도시 점령까지 진행되어야 한다', async (auditEnabled) => {
const cities = buildLargeTestCities().map(maxCityStats); const cities = buildLargeTestCities().map(maxCityStats);
const cityA1 = cities.find((city) => city.id === 1)!; const cityA1 = cities.find((city) => city.id === 1)!;
const cityA2 = cities.find((city) => city.id === 2)!; const cityA2 = cities.find((city) => city.id === 2)!;
@@ -206,7 +206,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
currentMonth: 5, currentMonth: 5,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: mockDate, lastTurnTime: mockDate,
meta: { seed: 1, initYear: 183, initMonth: 5 }, meta: { seed: 1, initYear: 183, initMonth: 5, ...(auditEnabled ? { serverId: 'npc-declaration-audit' } : {}) },
}; };
const schedule: TurnSchedule = { const schedule: TurnSchedule = {
@@ -278,6 +278,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
}, },
}); });
const auditQueue = vi.spyOn(worldRef.current!, 'queueAuditDiplomacy');
const debug = createWorldDebugger(() => worldRef.current, { const debug = createWorldDebugger(() => worldRef.current, {
nationIds: [1, 2], nationIds: [1, 2],
cityIds: [cityA1.id, cityA2.id, cityB1.id, cityB2.id], cityIds: [cityA1.id, cityA2.id, cityB1.id, cityB2.id],
@@ -326,6 +327,13 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
} }
expect(declareEntry).not.toBeNull(); expect(declareEntry).not.toBeNull();
expect(declareEntry?.state).toBe(DIPLOMACY_STATE.DECLARATION); 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 remainTurns = Math.max(0, (declareEntry?.term ?? 0) - 1);
const preWarTarget = addMonths(world!.getState().currentYear, world!.getState().currentMonth, remainTurns); const preWarTarget = addMonths(world!.getState().currentYear, world!.getState().currentMonth, remainTurns);
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic'; 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 type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { import {
createPlayAuditHandler, createPlayAuditHandler,
@@ -77,7 +77,7 @@ const buildNation = (id: number, power: number, meta: Nation['meta']): Nation =>
meta, meta,
}); });
const buildWorld = () => { const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
const state: TurnWorldState = { const state: TurnWorldState = {
id: 1, id: 1,
currentYear: 200, currentYear: 200,
@@ -122,11 +122,53 @@ const buildWorld = () => {
}; };
const world = new InMemoryTurnWorld(state, snapshot, { const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler,
}); });
return world; return world;
}; };
describe('play audit collection durability state', () => { 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', () => { it('freezes the initial observation separately from month-end and restores its marker on rollback', () => {
const world = buildWorld(); const world = buildWorld();
const before = world.captureState(); const before = world.captureState();
+17
View File
@@ -149,6 +149,23 @@ queue는 재시도까지 유지하며 commit 후에만 제거한다. 기존 계
메모리 checkpoint 복구, 감사 INSERT 실패 rollback, 재시도/중복 방지를 검증했다. 메모리 checkpoint 복구, 감사 INSERT 실패 rollback, 재시도/중복 방지를 검증했다.
엔진 개별 명령 전이와 초기 외교 기준, 조회 API/UI 및 전체 비용 실측은 남았다. 엔진 개별 명령 전이와 초기 외교 기준, 조회 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·국방 정책 버전 저장 기반 ## NPC·국방 정책 버전 저장 기반
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다. `PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.