feat: 예약 턴 명령별 중간 외교 전이와 실행 정보를 기록
This commit is contained in:
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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를 보존한다.
|
||||
|
||||
Reference in New Issue
Block a user