export type CanonicalEngine = 'ref' | 'core2026'; export interface TurnSnapshotSelector { generalIds: number[]; cityIds: number[]; nationIds: number[]; logAfterId?: number; messageAfterId?: number; includeNationHistoryLogs?: boolean; includeGlobalHistoryLogs?: boolean; } export interface CanonicalTurnSnapshot { schemaVersion: 1; engine: CanonicalEngine; world: Record; generals: Array>; rankData: Array>; cities: Array>; nations: Array>; diplomacy: Array>; generalTurns: Array>; nationTurns: Array>; logs: Array>; messages: Array>; watermarks: { logId: number; historyLogId: number; messageId: number; }; } export interface CanonicalTurnCommandTrace { schemaVersion: 1; engine: CanonicalEngine; execution: { kind: 'general' | 'nation' | 'instantNation'; actorGeneralId: number; action: string; args: unknown; seedDomain: 'generalCommand' | 'nationCommand' | 'none'; outcome?: unknown; }; before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot; rng: Array<{ seq: number; operation: string; arguments: Record; result: unknown; }>; } const legacyArgumentAliases: Readonly> = { destCityID: 'destCityId', destNationID: 'destNationId', destGeneralID: 'destGeneralId', destTroopID: 'destTroopId', }; export const canonicalizeTurnCommandArgs = (value: unknown): unknown => { if (Array.isArray(value)) { return value.map(canonicalizeTurnCommandArgs); } if (typeof value !== 'object' || value === null) { return value; } return Object.fromEntries( Object.entries(value as Record) .map(([key, entry]) => [legacyArgumentAliases[key] ?? key, canonicalizeTurnCommandArgs(entry)] as const) .sort(([left], [right]) => left.localeCompare(right)) ); }; const asRecord = (value: unknown): Record => typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; const readNumber = (record: Record, key: string, fallback = 0): number => { const value = record[key]; return typeof value === 'number' && Number.isFinite(value) ? value : fallback; }; const readString = (record: Record, key: string): string | null => { const value = record[key]; return typeof value === 'string' ? value : null; }; const serializeDate = (value: Date | null): string | null => value?.toISOString() ?? null; export const projectCoreDatabaseSnapshot = (rows: { world: { currentYear: number; currentMonth: number; tickSeconds: number; meta: unknown; }; generals: Array>; rankData: Array>; cities: Array>; nations: Array>; diplomacy: Array>; generalTurns: Array>; nationTurns: Array>; logs: Array>; }): CanonicalTurnSnapshot => { const worldMeta = asRecord(rows.world.meta); const legacyRankTypes = new Set(LEGACY_RANK_DATA_TYPES); const generals = rows.generals.map((row) => { const meta = asRecord(row.meta); return { id: row.id, name: row.name, nationId: row.nationId, cityId: row.cityId, troopId: row.troopId, leadership: row.leadership, strength: row.strength, intelligence: row.intel, experience: row.experience, dedication: row.dedication, officerLevel: row.officerLevel, officerCityId: readNumber( row, 'officerCityId', readNumber(meta, 'officer_city', readNumber(meta, 'officerCity', readNumber(meta, 'officerCityId'))) ), belong: readNumber(row, 'belong', readNumber(meta, 'belong')), permission: readString(row, 'permission') ?? readString(meta, 'permission') ?? 'normal', maxBelong: readNumber(meta, 'max_belong'), maxDomesticCritical: readNumber(meta, 'max_domestic_critical'), betray: row.betray, personality: row.personality ?? null, specialDomestic: row.specialDomestic ?? null, specialWar: row.specialWar ?? null, itemHorse: row.itemHorse ?? null, itemWeapon: row.itemWeapon ?? null, itemBook: row.itemBook ?? null, itemExtra: row.itemExtra ?? null, injury: row.injury, gold: row.gold, rice: row.rice, crew: row.crew, crewTypeId: row.crewTypeId, train: row.train, atmos: row.atmos, age: row.age, npcState: row.npcState, turnTime: row.turnTime instanceof Date ? serializeDate(row.turnTime) : row.turnTime, recentWarTime: row.recentWarTime instanceof Date ? serializeDate(row.recentWarTime) : row.recentWarTime, lastTurn: row.lastTurn, meta, leadershipExp: readNumber(meta, 'leadership_exp'), strengthExp: readNumber(meta, 'strength_exp'), intelExp: readNumber(meta, 'intel_exp'), dex1: readNumber(meta, 'dex1'), dex2: readNumber(meta, 'dex2'), dex3: readNumber(meta, 'dex3'), dex4: readNumber(meta, 'dex4'), dex5: readNumber(meta, 'dex5'), specAge: readNumber(meta, 'specage'), specAge2: readNumber(meta, 'specage2'), inheritActiveActionPoints: readNumber(meta, 'inherit_active_action') * 3, makeLimit: readNumber(meta, 'makelimit'), penalty: asRecord(row.penalty), killTurn: readNumber(row, 'killTurn', readNumber(meta, 'killturn')), mySet: readNumber(row, 'mySet', readNumber(meta, 'myset')), }; }); const cities = rows.cities.map((row) => { const meta = asRecord(row.meta); return { id: row.id, name: row.name, nationId: row.nationId, level: row.level, population: row.population, populationMax: row.populationMax, agriculture: row.agriculture, agricultureMax: row.agricultureMax, commerce: row.commerce, commerceMax: row.commerceMax, security: row.security, securityMax: row.securityMax, supplyState: row.supplyState, frontState: row.frontState, defence: row.defence, defenceMax: row.defenceMax, wall: row.wall, wallMax: row.wallMax, conflict: asRecord(row.conflict), state: readNumber(meta, 'state'), term: readNumber(meta, 'term'), trust: row.trust, trade: row.trade, officerSet: readNumber(meta, 'officer_set'), }; }); const nations = rows.nations.map((row) => { const meta = asRecord(row.meta); return { id: row.id, name: row.name, color: row.color, capitalCityId: row.capitalCityId, gold: row.gold, rice: row.rice, tech: row.tech, level: row.level, typeCode: row.typeCode, generalCount: readNumber(meta, 'gennum'), power: readNumber(meta, 'power'), war: readNumber(meta, 'war'), diplomacyLimit: readNumber(meta, 'surlimit'), capitalRevision: readNumber(meta, 'capset'), strategicCommandLimit: readNumber(meta, 'strategic_cmd_limit'), meta, }; }); const diplomacy = rows.diplomacy.map((row) => ({ fromNationId: row.srcNationId, toNationId: row.destNationId, state: row.stateCode, term: row.term, dead: row.isDead === true ? 1 : 0, })); const generalTurns = rows.generalTurns.map((row) => ({ generalId: row.generalId, turnIndex: row.turnIdx, action: row.actionCode, args: row.arg, })); const nationTurns = rows.nationTurns.map((row) => ({ nationId: row.nationId, officerLevel: row.officerLevel, turnIndex: row.turnIdx, action: row.actionCode, args: row.arg, })); const logs = rows.logs.map((row) => ({ id: row.id, scope: readString(row, 'scope'), category: readString(row, 'category')?.toLowerCase() ?? null, generalId: row.generalId, nationId: row.nationId, year: row.year, month: row.month, text: row.text, })); return { schemaVersion: 1, engine: 'core2026', world: { year: rows.world.currentYear, month: rows.world.currentMonth, tickMinutes: Math.max(1, Math.round(rows.world.tickSeconds / 60)), turnTime: readString(worldMeta, 'lastTurnTime'), isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')), }, generals, rankData: rows.rankData .filter((row) => typeof row.type === 'string' && legacyRankTypes.has(row.type)) .map((row) => ({ generalId: row.generalId, nationId: row.nationId, type: row.type, value: row.value, })), cities, nations, diplomacy, generalTurns, nationTurns, logs, messages: [], watermarks: { logId: logs.reduce((max, row) => Math.max(max, Number(row.id) || 0), 0), historyLogId: logs.reduce((max, row) => Math.max(max, Number(row.id) || 0), 0), messageId: 0, }, }; }; import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';