플레이 감사 국가 생멸과 장기 tick 저장을 보완하고 중간 전달 준비

This commit is contained in:
2026-09-16 07:26:05 +00:00
parent ae0dfd503f
commit 52cea882ab
24 changed files with 428 additions and 46 deletions
@@ -178,3 +178,55 @@ export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt =
});
return true;
};
/** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */
export const recordNationAuditDiplomacy = (
world: InMemoryTurnWorld,
nationIds: readonly number[],
relations: readonly TurnDiplomacy[],
operation: 'CREATED' | 'REMOVED',
observedTick?: number
): void => {
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return;
const targets = new Set(nationIds.filter((id) => id > 0));
const observed = relations
.filter(
(entry) =>
entry.fromNationId > 0 &&
entry.toNationId > 0 &&
(targets.has(entry.fromNationId) || targets.has(entry.toNationId))
)
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
if (!observed.length) return;
// 전역 순번은 실행 identity에만 사용한다. 한 사건 묶음 안의 순서는 방향별 local ordinal이다.
const executionId = `nation-relations:${world.nextAuditOrdinal()}`;
const clock = world.getGameClockState();
for (const [index, entry] of observed.entries()) {
const value = { state: entry.state, term: entry.term, dead: entry.dead };
world.queueAuditDiplomacy({
schemaVersion: 1,
serverId,
srcNationId: entry.fromNationId,
destNationId: entry.toNationId,
category: 'RELATION',
source: 'ENGINE',
eventType: `NATION_RELATION_${operation}`,
documentId: null,
documentHash: null,
previousDocumentId: null,
year: state.currentYear,
month: state.currentMonth,
tick: BigInt(observedTick ?? clock.tick),
clockRevision: BigInt(clock.revision),
executionId,
ordinal: index + 1,
requestId: null,
inputSequence: null,
actor: null,
before: operation === 'REMOVED' ? value : null,
after: operation === 'CREATED' ? value : null,
});
}
};
+3 -2
View File
@@ -28,7 +28,8 @@ export const persistAuditMonth = async (
!Number.isInteger(snapshot.year) ||
!Number.isInteger(snapshot.month) ||
snapshot.month < 1 ||
snapshot.month > 12
snapshot.month > 12 ||
(snapshot.tick !== null && (!Number.isSafeInteger(snapshot.tick) || snapshot.tick < 0))
) {
throw new Error('Invalid play audit month identity');
}
@@ -42,7 +43,7 @@ export const persistAuditMonth = async (
year: snapshot.year,
month: snapshot.month,
kind: snapshot.kind,
tick: snapshot.tick,
tick: snapshot.tick === null ? null : BigInt(snapshot.tick),
settlementsComplete: snapshot.settlementsComplete,
hash,
},
@@ -8,6 +8,8 @@ export const persistAuditPolicies = async (
): Promise<void> => {
for (let offset = 0; offset < policies.length; offset += 200) {
const batch = policies.slice(offset, offset + 200).map((policy) => {
if (!Number.isSafeInteger(policy.tick) || policy.tick < 0)
throw new Error('Invalid play audit policy tick');
if (policy.requestId && !command) throw new Error('Play audit policy input event context missing');
if (
policy.requestId &&
@@ -18,6 +20,7 @@ export const persistAuditPolicies = async (
}
return {
...policy,
tick: BigInt(policy.tick),
inputSequence: policy.requestId && command ? command.sequence : null,
actor: policy.actor ? (JSON.parse(JSON.stringify(policy.actor)) as InputJsonValue) : GamePrisma.DbNull,
before: policy.before
+45 -13
View File
@@ -1,4 +1,8 @@
import { recordTurnAuditDiplomacy, type AuditDiplomacyAction } from '../playAudit/diplomacy.js';
import {
recordTurnAuditDiplomacy,
recordNationAuditDiplomacy,
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';
@@ -1635,6 +1639,7 @@ export class InMemoryTurnWorld {
this.createdNationIds.add(nation.id);
this.ensureDiplomacyMatrix();
initializeNationAuditPolicies(this, nation.id);
recordNationAuditDiplomacy(this, [nation.id], this.collectNationDiplomacy([nation.id]), 'CREATED');
return true;
}
@@ -1723,7 +1728,7 @@ export class InMemoryTurnWorld {
return true;
}
removeNation(id: number): boolean {
removeNation(id: number, auditTick?: number): boolean {
if (!this.nations.has(id)) {
return false;
}
@@ -1731,13 +1736,16 @@ export class InMemoryTurnWorld {
this.dirtyNationIds.delete(id);
this.createdNationIds.delete(id);
this.deletedNationIds.add(id);
const removedRelations: TurnDiplomacy[] = [];
for (const [key, entry] of this.diplomacy) {
if (entry.fromNationId === id || entry.toNationId === id) {
removedRelations.push(entry);
this.diplomacy.delete(key);
this.dirtyDiplomacyKeys.delete(key);
this.createdDiplomacyKeys.delete(key);
}
}
recordNationAuditDiplomacy(this, [id], removedRelations, 'REMOVED', auditTick);
return true;
}
@@ -2100,7 +2108,7 @@ export class InMemoryTurnWorld {
this.createdGeneralIds.add(createdGeneral.id);
}
if (result.created.nations) {
let addedNation = false;
const addedNationIds: number[] = [];
for (const createdNation of result.created.nations) {
if (this.nations.has(createdNation.id)) {
continue;
@@ -2108,10 +2116,18 @@ export class InMemoryTurnWorld {
this.nations.set(createdNation.id, { ...createdNation });
this.dirtyNationIds.add(createdNation.id);
this.createdNationIds.add(createdNation.id);
addedNation = true;
addedNationIds.push(createdNation.id);
}
if (addedNation) {
if (addedNationIds.length) {
this.ensureDiplomacyMatrix();
for (const id of addedNationIds) initializeNationAuditPolicies(this, id);
recordNationAuditDiplomacy(
this,
addedNationIds,
this.collectNationDiplomacy(addedNationIds),
'CREATED',
executionTick
);
}
}
if (result.created.troops) {
@@ -2133,7 +2149,7 @@ export class InMemoryTurnWorld {
if (result.successorlessNationId !== undefined) {
// 사망 군주도 삭제 전 archive의 장수 목록과 멸망 로그에 포함한다.
this.generals.set(currentGeneral.id, result.general ?? currentGeneral);
this.dissolveNationWithoutSuccessor(result.successorlessNationId, currentGeneral.id);
this.dissolveNationWithoutSuccessor(result.successorlessNationId, currentGeneral.id, executionTick);
}
if (result.deleted?.general) {
this.removeGeneral(currentGeneral.id);
@@ -2142,7 +2158,7 @@ export class InMemoryTurnWorld {
this.lifecycleEvents.push(result.lifecycleEvent);
}
this.removeCollapsedNations();
this.removeCollapsedNations(executionTick);
return {
nextTurnAt,
@@ -2343,7 +2359,7 @@ export class InMemoryTurnWorld {
return changes;
}
dissolveNationWithoutSuccessor(nationId: number, dyingLordId?: number): boolean {
dissolveNationWithoutSuccessor(nationId: number, dyingLordId?: number, auditTick?: number): boolean {
const nation = this.nations.get(nationId);
if (!nation) {
return false;
@@ -2398,10 +2414,10 @@ export class InMemoryTurnWorld {
}
// 과거 누락으로 군주가 이미 삭제된 국가의 운영 복구도 같은 정산을 쓴다.
if (dyingLordId === undefined) pushHistory();
return this.collapseNation(nationId);
return this.collapseNation(nationId, auditTick);
}
collapseNation(nationId: number): boolean {
collapseNation(nationId: number, auditTick?: number): boolean {
const nation = this.nations.get(nationId);
if (!nation) {
return false;
@@ -2480,11 +2496,11 @@ export class InMemoryTurnWorld {
this.removeTroop(troop.id);
}
}
this.removeNation(nationId);
this.removeNation(nationId, auditTick);
return true;
}
private removeCollapsedNations(): void {
private removeCollapsedNations(auditTick?: number): void {
const collapsedNationIds: number[] = [];
for (const nation of this.nations.values()) {
if (nation.id <= 0) {
@@ -2502,10 +2518,26 @@ export class InMemoryTurnWorld {
}
for (const nationId of collapsedNationIds) {
this.collapseNation(nationId);
this.collapseNation(nationId, auditTick);
}
}
/** 감사 때문에 전체 관계 matrix를 복제하지 않고 대상 국가에 연결된 행만 가져온다. */
private collectNationDiplomacy(nationIds: readonly number[]): TurnDiplomacy[] {
const relations = new Map<string, TurnDiplomacy>();
for (const nationId of nationIds) {
if (nationId <= 0) continue;
for (const otherId of this.nations.keys()) {
if (otherId <= 0 || otherId === nationId) continue;
for (const key of [buildDiplomacyKey(nationId, otherId), buildDiplomacyKey(otherId, nationId)]) {
const relation = this.diplomacy.get(key);
if (relation) relations.set(key, relation);
}
}
}
return Array.from(relations.values());
}
private ensureDiplomacyMatrix(): void {
const nationIds = Array.from(this.nations.keys());
for (const srcNationId of nationIds) {