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'; const MAP_VERSION = 0 as const; type MapCityCompact = [number, number, number, number, number, number]; type MapNationCompact = [number, string, string, number]; type YearbookMap = { result: true; version: number; startYear: number; year: number; month: number; cityList: MapCityCompact[]; nationList: MapNationCompact[]; }; type YearbookNation = { id: number; name: string; color: string; level: number; power: number; cities: string[]; }; const readState = (meta: Record): number => { const raw = meta.state; if (typeof raw === 'number' && Number.isFinite(raw)) { return Math.floor(raw); } return 0; }; const resolveStartYear = (meta: Record): number => { const scenarioMeta = asRecord(meta.scenarioMeta); const startYear = scenarioMeta.startYear; if (typeof startYear === 'number' && Number.isFinite(startYear)) { return startYear; } return 0; }; const buildMapSnapshot = (world: InMemoryTurnWorld, year: number, month: number): YearbookMap => { const state = world.getState(); const cityList: MapCityCompact[] = world.listCities().map((city) => { const meta = asRecord(city.meta); const stateValue = city.state ?? readState(meta); const region = asNumber(meta.region, 0); const supplyFlag = city.supplyState > 0 ? 1 : 0; return [city.id, city.level, stateValue, city.nationId, region, supplyFlag]; }); const nationList: MapNationCompact[] = world .listNations() .map((nation) => [nation.id, nation.name, nation.color, nation.capitalCityId ?? 0]); return { result: true, version: MAP_VERSION, startYear: resolveStartYear(asRecord(state.meta)), year, month, cityList, nationList, }; }; const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => { const cities = world.listCities(); const generals = world.listGenerals(); const nations = world.listNations(); const cityStatsByNation = new Map(); const cityNamesByNation = new Map(); for (const city of cities) { const entry = cityStatsByNation.get(city.nationId) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; const valueSum = city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence; const maxSum = city.populationMax + city.agricultureMax + city.commerceMax + city.securityMax + city.wallMax + city.defenceMax; entry.popSum += city.population; entry.valueSum += valueSum; entry.maxSum += maxSum; cityStatsByNation.set(city.nationId, entry); const cityNames = cityNamesByNation.get(city.nationId) ?? []; cityNames.push(city.name); cityNamesByNation.set(city.nationId, cityNames); } const generalStatsByNation = new Map(); for (const general of generals) { const entry = generalStatsByNation.get(general.nationId) ?? { goldRice: 0, statPower: 0, expDed: 0 }; entry.goldRice += general.gold + general.rice; const leadership = general.stats.leadership; const strength = general.stats.strength; const intel = general.stats.intelligence; const npcMultiplier = general.npcState < 2 ? 1.2 : 1; const leaderCore = leadership >= 40 ? leadership : 0; entry.statPower += npcMultiplier * leaderCore * 2 + (Math.sqrt(intel * strength) * 2 + leadership / 2) / 2; entry.expDed += general.experience + general.dedication; generalStatsByNation.set(general.nationId, entry); } return nations.map((nation) => { const generalStats = generalStatsByNation.get(nation.id) ?? { goldRice: 0, statPower: 0, expDed: 0 }; const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100); const tech = asNumber(asRecord(nation.meta).tech, 0); const cityPower = nation.level > 0 && cityStats.maxSum > 0 ? Math.round((cityStats.popSum * cityStats.valueSum) / cityStats.maxSum / 100) : 0; const expDed = Math.round(generalStats.expDed / 100); const power = Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10); return { id: nation.id, name: nation.name, color: nation.color, level: nation.level, power, cities: cityNamesByNation.get(nation.id) ?? [], }; }); }; const buildHash = (map: YearbookMap, nations: YearbookNation[]): string => createHash('sha256').update(JSON.stringify({ map, nations })).digest('hex'); export const createYearbookHandler = (options: { databaseUrl: string; profileName: string; getWorld: () => InMemoryTurnWorld | null; }): { handler: TurnCalendarHandler; close: () => Promise } => { const connector = createGamePostgresConnector({ url: options.databaseUrl }); const ready = connector.connect(); const handler: TurnCalendarHandler = { beforeMonthChanged: async (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: { profileName: options.profileName, year: context.previousYear, month: context.previousMonth, }, }, update: { map, nations, hash, }, create: { profileName: options.profileName, year: context.previousYear, month: context.previousMonth, map, nations, hash, }, }); }, }; const close = async () => { await connector.disconnect(); }; return { handler, close }; };