import { asNumber, asRecord } from '@sammo-ts/common'; 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; generalCount: number; cities: string[]; }; type DynastyStatistics = { maxNationCount: number; maxNationName: string; maxNationHist: Record; maxGeneralCount: number; currentGeneralCount: number; userGeneralCount: number; npcGeneralCount: number; personalHist: Record; specialHist: Record; special2Hist: Record; }; 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 readStoredSnapshotNumber = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; 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< number, { goldRice: number; statPower: number; expDed: number; generalCount: number } >(); for (const general of generals) { const entry = generalStatsByNation.get(general.nationId) ?? { goldRice: 0, statPower: 0, expDed: 0, generalCount: 0, }; entry.generalCount += 1; 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); } const projected = nations.map((nation) => { const generalStats = generalStatsByNation.get(nation.id) ?? { goldRice: 0, statPower: 0, expDed: 0, generalCount: 0, }; const nationMeta = asRecord(nation.meta); const storedPower = readStoredSnapshotNumber(nation.power); let power = 1; if (nation.id !== 0) { if (storedPower !== null) { power = storedPower; } else { // Old or malformed in-memory snapshots can lack the monthly // nation projection. Only that absence uses the former derived // value; current worlds archive the stored Ref-compatible value. const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; const resource = Math.round((nation.gold + nation.rice + generalStats.goldRice) / 100); const tech = asNumber(nationMeta.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); power = Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10); } } const storedGeneralCount = readStoredSnapshotNumber(nationMeta.gennum); const generalCount = nation.id === 0 ? 1 : (storedGeneralCount ?? generalStats.generalCount); return { id: nation.id, name: nation.id === 0 ? '재야' : nation.name, color: nation.id === 0 ? '#000000' : nation.color, level: nation.id === 0 ? 0 : nation.level, power, generalCount, cities: cityNamesByNation.get(nation.id) ?? [], }; }); // Ref's getCurrentHistory() replaces nation 0 with its canonical static // projection and then applies a stable descending-power sort. return projected.sort((left, right) => right.power - left.power); }; const increment = (target: Record, key: string): void => { target[key] = (target[key] ?? 0) + 1; }; export 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 = {}; for (const nation of activeNations) increment(nationHist, nation.typeCode || 'neutral'); const personalHist: Record = {}; const specialHist: Record = {}; const special2Hist: Record = {}; 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: { profileName: string; getWorld: () => InMemoryTurnWorld | null; }): { handler: TurnCalendarHandler } => ({ handler: { beforeMonthChanged: (context) => { const world = options.getWorld(); if (!world) return; queueYearbookSnapshot(world, options.profileName, context.previousYear, context.previousMonth); }, }, }); export const createDynastyStatisticsHandler = (options: { getWorld: () => InMemoryTurnWorld | null; }): { handler: TurnCalendarHandler } => ({ handler: { // Ref calls checkStatistic() after turnDate() only when the newly // entered month is January. Unification takes one additional sample. onMonthChanged: (context) => { if (context.currentMonth !== 1) return; const world = options.getWorld(); if (!world) return; updateDynastyStatistics(world); }, }, });