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 { 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) {
@@ -1056,11 +1056,8 @@ export const createReservedTurnHandler = async (options: {
nations: [] as Array<{ id: number; patch: Partial<Nation> }>,
troops: [] as Array<{ id: number; patch: Partial<Troop> }>,
};
const diplomacyPatches: Array<{
srcNationId: number;
destNationId: number;
patch: DiplomacyPatch;
}> = [];
const diplomacyPatches: NonNullable<GeneralTurnResult['diplomacyPatches']> = [];
let auditActionOrdinal = 0;
const createdGenerals: TurnGeneral[] = [];
const createdNations: Nation[] = [];
const commandDeletedTroopIds = new Set<number>();
@@ -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);
}
@@ -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);
@@ -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();