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

211 lines
7.9 KiB
TypeScript

import { asRecord } from '@sammo-ts/common';
import {
createIncomeActionContext,
getGoldIncome,
getOutcome,
getRiceIncome,
getWallIncome,
getWarGoldIncome,
type City,
type CityIncomeSource,
type Nation,
type NationIncomeContext,
type NationTraitModule,
type ScenarioConfig,
type TurnCommandEnv,
type UnitSetDefinition,
} from '@sammo-ts/logic';
import type { TurnCalendarContext, TurnCalendarHandler, InMemoryTurnWorld } from './inMemoryWorld.js';
import { readNumber } from './ai/aiUtils.js';
import { AutorunNationPolicy } from './ai/policies.js';
const calcNationDevelopedRate = (cities: City[]): { pop: number; all: number } => {
if (cities.length === 0) {
return { pop: 0, all: 0 };
}
let pop = 0;
let agri = 0;
let comm = 0;
let secu = 0;
let def = 0;
let wall = 0;
for (const city of cities) {
pop += city.populationMax > 0 ? city.population / city.populationMax : 0;
agri += city.agricultureMax > 0 ? city.agriculture / city.agricultureMax : 0;
comm += city.commerceMax > 0 ? city.commerce / city.commerceMax : 0;
secu += city.securityMax > 0 ? city.security / city.securityMax : 0;
def += city.defenceMax > 0 ? city.defence / city.defenceMax : 0;
wall += city.wallMax > 0 ? city.wall / city.wallMax : 0;
}
const count = cities.length;
pop /= count;
agri /= count;
comm /= count;
secu /= count;
def /= count;
wall /= count;
const all = (pop + agri + comm + secu + def + wall) / 6;
return { pop, all };
};
const resolveNpcTaxRate = (cities: City[]): number => {
if (cities.length === 0) {
return 15;
}
const devRate = calcNationDevelopedRate(cities);
const avg = (devRate.pop + devRate.all) / 2;
if (avg > 0.95) {
return 25;
}
if (avg > 0.7) {
return 20;
}
if (avg > 0.5) {
return 15;
}
return 10;
};
const shouldUpdateRate = (month: number): boolean => month === 6 || month === 12;
const toIncomeCity = (city: City): CityIncomeSource => ({
id: city.id,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
trust: readNumber(asRecord(city.meta).trust, 50),
supplyState: city.supplyState,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
meta: asRecord(city.meta),
});
const buildOfficerCounts = (world: InMemoryTurnWorld, nationId: number): Map<number, number> => {
const result = new Map<number, number>();
for (const general of world.listGenerals()) {
if (general.nationId !== nationId || general.officerLevel < 2 || general.officerLevel > 4) continue;
const officerCity = readNumber(asRecord(general.meta).officer_city, 0);
if (officerCity > 0 && general.cityId === officerCity) {
result.set(officerCity, (result.get(officerCity) ?? 0) + 1);
}
}
return result;
};
const clampBill = (value: number): number => Math.max(20, Math.min(200, Math.trunc(value)));
const resolveNpcMonarch = (nation: Nation, world: InMemoryTurnWorld) => {
const chief = nation.chiefGeneralId
? world.getGeneralById(nation.chiefGeneralId)
: world
.listGenerals()
.find((general) => general.nationId === nation.id && general.officerLevel === 12) ?? null;
return chief && chief.npcState >= 2 ? chief : null;
};
type NpcFinanceOptions = {
commandEnv?: TurnCommandEnv;
scenarioConfig?: ScenarioConfig;
unitSet?: UnitSetDefinition;
nationTraits?: ReadonlyMap<string, NationTraitModule>;
};
export const calculateNpcNationFinance = (
world: InMemoryTurnWorld,
nation: Nation,
currentMonth: number,
options: NpcFinanceOptions
): Nation['meta'] | null => {
if (!shouldUpdateRate(currentMonth)) {
return null;
}
const chief = resolveNpcMonarch(nation, world);
if (!chief) return null;
const cities = world.listCities();
const rawNationCities = cities.filter((city) => city.nationId === nation.id && city.supplyState > 0);
const rate = resolveNpcTaxRate(rawNationCities);
// Ref chooses the default rate during the ruler turn even when a newly
// founded nation has not supplied its first city yet. In that state it
// leaves the existing bill untouched because there is no income basis.
if (rawNationCities.length === 0) {
return { ...nation.meta, rate };
}
if (!options.commandEnv || !options.scenarioConfig) {
return { ...nation.meta, rate };
}
const trait = options.nationTraits?.get(nation.typeCode);
const actionContext = createIncomeActionContext(nation);
const incomeContext: NationIncomeContext = {
rate,
...(trait?.onCalcNationalIncome
? { modifyIncome: (type, amount) => trait.onCalcNationalIncome!(actionContext, type, amount) }
: {}),
};
const nationCities = rawNationCities.map(toIncomeCity);
const officerCounts = buildOfficerCounts(world, nation.id);
const generals = world
.listGenerals()
// Ref's GeneralAI::$nationGenerals is built with `no != current ruler`.
// chooseGoldBillRate therefore omits the ruler's own stipend from the
// outcome used to choose bill, despite a dead local append that looks
// as if it intended to include the ruler.
.filter((general) => general.nationId === nation.id && general.id !== chief.id && general.npcState !== 5);
const outcome = Math.max(1, getOutcome(100, generals));
const policy = new AutorunNationPolicy({
general: chief,
aiOptions: null,
nationPolicy: asRecord(nation.meta).npc_nation_policy as Record<string, unknown> | null,
serverPolicy: asRecord(world.getState().meta).npc_nation_policy as Record<string, unknown> | null,
nation,
env: options.commandEnv,
scenarioConfig: options.scenarioConfig,
...(options.unitSet ? { unitSet: options.unitSet } : {}),
});
const income =
currentMonth === 12
? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level) +
getWarGoldIncome(incomeContext, nationCities)
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level) +
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level);
const currentResource = currentMonth === 12 ? nation.gold : nation.rice;
const requiredResource = currentMonth === 12 ? policy.reqNationGold : policy.reqNationRice;
let bill = Math.trunc((income / outcome) * 90);
if (currentResource + income - outcome > requiredResource * 2) {
const moreBill = ((currentResource + income - requiredResource * 2) / outcome) * 80;
if (moreBill > bill) bill = Math.trunc((moreBill + bill) / 2);
}
return { ...nation.meta, rate, bill: clampBill(bill) };
};
export const createNpcTaxHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
commandEnv?: TurnCommandEnv;
scenarioConfig?: ScenarioConfig;
unitSet?: UnitSetDefinition;
nationTraits?: ReadonlyMap<string, NationTraitModule>;
}): TurnCalendarHandler => {
return {
onMonthChanged: (context: TurnCalendarContext) => {
if (!shouldUpdateRate(context.currentMonth)) {
return;
}
const world = options.getWorld();
if (!world) {
return;
}
for (const nation of world.listNations()) {
const nextMeta = calculateNpcNationFinance(world, nation, context.currentMonth, options);
if (nextMeta) world.updateNation(nation.id, { meta: nextMeta });
}
},
};
};