Files
core2026/app/game-engine/src/turn/yearbookHandler.ts
T

255 lines
9.0 KiB
TypeScript

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<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)) {
return Math.floor(raw);
}
return 0;
};
const resolveStartYear = (meta: Record<string, unknown>): 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<number, { popSum: number; valueSum: number; maxSum: number }>();
const cityNamesByNation = new Map<number, string[]>();
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);
}
return nations.map((nation) => {
const generalStats = generalStatsByNation.get(nation.id) ?? {
goldRice: 0,
statPower: 0,
expDed: 0,
generalCount: 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,
generalCount: generalStats.generalCount,
cities: cityNamesByNation.get(nation.id) ?? [],
};
});
};
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: {
profileName: string;
getWorld: () => InMemoryTurnWorld | null;
}): { handler: TurnCalendarHandler } => ({
handler: {
beforeMonthChanged: (context) => {
const world = options.getWorld();
if (!world) return;
updateDynastyStatistics(world);
queueYearbookSnapshot(world, options.profileName, context.previousYear, context.previousMonth);
},
},
});