feat(engine): finalize unification archives atomically

This commit is contained in:
2026-07-31 14:10:22 +00:00
parent 8ea8e6e26f
commit 02515303fd
20 changed files with 1516 additions and 843 deletions
+96 -51
View File
@@ -1,6 +1,4 @@
import { createHash } from 'crypto';
import { asNumber, asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import type { TurnCalendarHandler } from './inMemoryWorld.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -29,6 +27,19 @@ type YearbookNation = {
cities: string[];
};
type DynastyStatistics = {
maxNationCount: number;
maxNationName: string;
maxNationHist: Record<string, number>;
maxGeneralCount: number;
currentGeneralCount: number;
userGeneralCount: number;
npcGeneralCount: number;
personalHist: Record<string, number>;
specialHist: Record<string, number>;
special2Hist: Record<string, number>;
};
const readState = (meta: Record<string, unknown>): number => {
const raw = meta.state;
if (typeof raw === 'number' && Number.isFinite(raw)) {
@@ -136,58 +147,92 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => {
});
};
const buildHash = (map: YearbookMap, nations: YearbookNation[]): string =>
createHash('sha256').update(JSON.stringify({ map, nations })).digest('hex');
const increment = (target: Record<string, number>, key: string): void => {
target[key] = (target[key] ?? 0) + 1;
};
const updateDynastyStatistics = (world: InMemoryTurnWorld): void => {
const state = world.getState();
const previous = asRecord(state.meta.dynastyStatistics);
const activeNations = world
.listNations()
.filter((nation) => nation.level > 0)
.sort((left, right) => right.power - left.power || left.id - right.id);
const generals = world.listGenerals();
const maxNationCount = Math.max(asNumber(previous.maxNationCount, 0), activeNations.length);
const replaceNationMaximum = activeNations.length > asNumber(previous.maxNationCount, 0);
const nationHist: Record<string, number> = {};
for (const nation of activeNations) increment(nationHist, nation.typeCode || 'neutral');
const personalHist: Record<string, number> = {};
const specialHist: Record<string, number> = {};
const special2Hist: Record<string, number> = {};
let userGeneralCount = 0;
let npcGeneralCount = 0;
for (const general of generals) {
increment(personalHist, general.role.personality || 'None');
increment(specialHist, general.role.specialDomestic || 'None');
increment(special2Hist, general.role.specialWar || 'None');
if (general.npcState < 2) userGeneralCount += 1;
else npcGeneralCount += 1;
}
const statistics: DynastyStatistics = {
maxNationCount,
maxNationName: replaceNationMaximum
? activeNations.map((nation) => `${nation.name}(${nation.typeCode})`).join(', ')
: typeof previous.maxNationName === 'string'
? previous.maxNationName
: '',
maxNationHist: replaceNationMaximum
? nationHist
: Object.fromEntries(
Object.entries(asRecord(previous.maxNationHist)).flatMap(([key, value]) =>
typeof value === 'number' && Number.isFinite(value) ? [[key, value]] : []
)
),
maxGeneralCount: Math.max(asNumber(previous.maxGeneralCount, 0), generals.length),
currentGeneralCount: generals.length,
userGeneralCount,
npcGeneralCount,
personalHist,
specialHist,
special2Hist,
};
world.updateWorldMeta({ dynastyStatistics: statistics });
};
const resolveServerId = (world: InMemoryTurnWorld, fallback: string): string => {
const serverId = world.getState().meta.serverId;
return typeof serverId === 'string' && serverId.trim() ? serverId.trim() : fallback;
};
export const queueYearbookSnapshot = (
world: InMemoryTurnWorld,
profileName: string,
year: number,
month: number
): void => {
world.queueYearbookSnapshot({
serverId: resolveServerId(world, profileName),
sourceId: 0,
year,
month,
map: buildMapSnapshot(world, year, month),
nations: buildNationSnapshot(world),
});
};
export const createYearbookHandler = (options: {
databaseUrl: string;
profileName: string;
getWorld: () => InMemoryTurnWorld | null;
}): { handler: TurnCalendarHandler; close: () => Promise<void> } => {
const connector = createGamePostgresConnector({ url: options.databaseUrl });
const ready = connector.connect();
const handler: TurnCalendarHandler = {
beforeMonthChanged: async (context) => {
}): { handler: TurnCalendarHandler } => ({
handler: {
beforeMonthChanged: (context) => {
const world = options.getWorld();
if (!world) {
return;
}
await ready;
const map = buildMapSnapshot(world, context.previousYear, context.previousMonth);
const nations = buildNationSnapshot(world);
const hash = buildHash(map, nations);
await connector.prisma.yearbookHistory.upsert({
where: {
profileName_year_month_sourceId: {
profileName: options.profileName,
year: context.previousYear,
month: context.previousMonth,
sourceId: 0,
},
},
update: {
map,
nations,
hash,
},
create: {
profileName: options.profileName,
sourceId: 0,
year: context.previousYear,
month: context.previousMonth,
map,
nations,
hash,
},
});
if (!world) return;
updateDynastyStatistics(world);
queueYearbookSnapshot(world, options.profileName, context.previousYear, context.previousMonth);
},
};
const close = async () => {
await connector.disconnect();
};
return { handler, close };
};
},
});