import { asNumber, asRecord } from '@sammo-ts/common'; import { ActionLogger, LogFormat, createIncomeActionContext, getBill, getGoldIncome, getOutcome, getRiceIncome, getWallIncome, readLegacyCityTrust, type CityIncomeSource, type Nation, type NationIncomeContext, type NationTraitModule, type TriggerNationalIncomeType, } from '@sammo-ts/logic'; import type { ScenarioConfig } from '@sammo-ts/logic'; import type { InMemoryTurnWorld, TurnCalendarHandler, TurnCalendarContext } from './inMemoryWorld.js'; import { resolveAppliedNationRate } from './nationTaxRate.js'; import type { TurnGeneral } from './types.js'; const DEFAULT_BASE_GOLD = 0; const DEFAULT_BASE_RICE = 2_000; const resolveNumber = (source: Record, keys: string[], fallback: number): number => { for (const key of keys) { const value = source[key]; if (typeof value === 'number' && Number.isFinite(value)) { return value; } } return fallback; }; const resolveNationBill = (nation: Nation): number => asNumber(nation.meta.bill, 100); const resolveOfficerCity = (meta: Record): number => { const camel = asNumber(meta.officerCity, 0); if (camel > 0) { return camel; } return asNumber(meta.officer_city, 0); }; export const resolveLegacyIncomeCityTrust = (trust: number): number => readLegacyCityTrust(trust); const resolveCityTrust = (meta: Record): number => { const trust = asNumber(meta.trust, 50); // Income is calculated in PHP after PDO exposes MariaDB FLOAT using a // six-significant-digit decimal representation. return resolveLegacyIncomeCityTrust(trust); }; const toIncomeCity = (city: ReturnType[number]): 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: resolveCityTrust(asRecord(city.meta)), supplyState: city.supplyState, defence: city.defence, defenceMax: city.defenceMax, wall: city.wall, wallMax: city.wallMax, meta: asRecord(city.meta), }); const buildNationIncomeContext = (nation: Nation, trait: NationTraitModule | null): NationIncomeContext => { const actionContext = createIncomeActionContext(nation); const modifyIncome = trait?.onCalcNationalIncome ? (type: TriggerNationalIncomeType, amount: number) => trait.onCalcNationalIncome!(actionContext, type, amount) : undefined; return { rate: resolveAppliedNationRate(nation.meta), modifyIncome, }; }; const buildOfficerCountMap = (generals: TurnGeneral[]): Map => { const officerCntByCity = new Map(); for (const general of generals) { if (general.officerLevel < 2 || general.officerLevel > 4) { continue; } const officerCity = resolveOfficerCity(asRecord(general.meta)); if (!officerCity || general.cityId !== officerCity) { continue; } officerCntByCity.set(officerCity, (officerCntByCity.get(officerCity) ?? 0) + 1); } return officerCntByCity; }; const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType): void => { for (const log of logs) { world.pushLog(log); } }; const applyIncomeOutcome = ( current: number, income: number, outcome: number, baseResource: number, originOutcome: number ): { next: number; ratio: number; realOutcome: number } => { let next = current + income; let realOutcome: number; if (next < baseResource) { realOutcome = 0; next = baseResource; } else if (next - baseResource < outcome) { realOutcome = next - baseResource; next = baseResource; } else { realOutcome = outcome; next -= realOutcome; } const ratio = originOutcome > 0 ? realOutcome / originOutcome : 0; return { next: Math.max(next, baseResource), ratio, realOutcome }; }; const processIncomeForNation = ( world: InMemoryTurnWorld, nation: Nation, generals: TurnGeneral[], cities: ReturnType, officerCounts: Map, traitMap: Map, type: 'gold' | 'rice', baseResource: number ): void => { const nationCities = cities.filter((city) => city.nationId === nation.id).map(toIncomeCity); const nationGenerals = generals.filter((general) => general.nationId === nation.id && general.npcState !== 5); const trait = traitMap.get(nation.typeCode) ?? null; const incomeContext = buildNationIncomeContext(nation, trait); const income = type === 'gold' ? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) : getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) + getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); // REF-COMPAT:BEGIN ref-income-preflush-fraction // Ref calculates the payout ratio from the pre-persistence income value. // Half-unit income (for example 943.5) is therefore not rounded before // salaries are distributed, even though the integer nation column is // rounded when the final state is flushed to MariaDB. const incomeValue = income; // REF-COMPAT:END ref-income-preflush-fraction const originOutcome = getOutcome(100, nationGenerals); const bill = resolveNationBill(nation); const outcome = Math.round((bill / 100) * originOutcome); const current = type === 'gold' ? nation.gold : nation.rice; const { next, ratio } = applyIncomeOutcome(current, incomeValue, outcome, baseResource, originOutcome); const nextMeta = { ...nation.meta, [`prev_income_${type}`]: incomeValue, }; if (type === 'gold') { world.updateNation(nation.id, { gold: next, meta: nextMeta }); } else { world.updateNation(nation.id, { rice: next, meta: nextMeta }); } const incomeText = incomeValue.toLocaleString(); const incomeLog = type === 'gold' ? `이번 수입은 금 ${incomeText}입니다.` : `이번 수입은 쌀 ${incomeText}입니다.`; for (const general of nationGenerals) { const pay = Math.round(getBill(general.dedication) * ratio); if ( process.env.SEED_PARITY_MONTHLY_RESOURCE_TRACE === '1' && (process.env.AI_TRACE_GENERAL_IDS ?? '').split(',').includes(String(general.id)) ) { process.stdout.write( `MONTHLY_RESOURCE_CORE ${JSON.stringify({ action: 'ProcessIncome', type, generalId: general.id, current: general[type], pay, ratio, originOutcome })}\n` ); } if (type === 'gold') { world.updateGeneral(general.id, { gold: general.gold + pay }); } else { world.updateGeneral(general.id, { rice: general.rice + pay }); } const logger = new ActionLogger({ generalId: general.id, nationId: nation.id }); if (general.officerLevel > 4) { logger.pushGeneralActionLog(incomeLog, LogFormat.PLAIN); } const payText = pay.toLocaleString(); const payLog = type === 'gold' ? `봉급으로 금 ${payText}을 받았습니다.` : `봉급으로 쌀 ${payText}을 받았습니다.`; logger.pushGeneralActionLog(payLog, LogFormat.PLAIN); pushLogs(world, logger.flush()); } }; export interface IncomeHandler extends TurnCalendarHandler { runResource(resource: 'gold' | 'rice'): void; } export const createIncomeHandler = (options: { getWorld: () => InMemoryTurnWorld | null; scenarioConfig: ScenarioConfig; nationTraits: Map; }): IncomeHandler => { const constValues = asRecord(options.scenarioConfig.const); const baseGold = resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD); const baseRice = resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE); const runResource = (type: 'gold' | 'rice'): void => { const world = options.getWorld(); if (!world) { return; } const nations = world.listNations(); const generals = world.listGenerals(); const cities = world.listCities(); const byNation: Map = new Map(); for (const general of generals) { const bucket = byNation.get(general.nationId) ?? []; bucket.push(general); byNation.set(general.nationId, bucket); } for (const nation of nations) { if (nation.id <= 0) { continue; } const nationGenerals = byNation.get(nation.id) ?? []; const officerCounts = buildOfficerCountMap(nationGenerals); processIncomeForNation( world, nation, nationGenerals, cities, officerCounts, options.nationTraits, type, type === 'gold' ? baseGold : baseRice ); } const logger = new ActionLogger(); if (type === 'gold') { logger.pushGlobalHistoryLog('【지급】봄이 되어 봉록에 따라 자금이 지급됩니다.'); } else { logger.pushGlobalHistoryLog('【지급】가을이 되어 봉록에 따라 군량이 지급됩니다.'); } pushLogs(world, logger.flush()); }; const handler: IncomeHandler = { runResource, onMonthChanged: (context: TurnCalendarContext) => { if (context.currentMonth === 1) { runResource('gold'); } else if (context.currentMonth === 7) { runResource('rice'); } }, }; return handler; };