sots-engine/src/game/sim/economy.h
alex ed6602e9ed S11 civilian growth; fix ComputeBudget's interest literals
Reference pair turn1->turn2: 81 leaves closed, 0 regressed (was 78/0).
Pair turn2->turn3: 39 closed, 0 regressed (was 36/0). With
--commit-blocked=T31 --ai-player 1: 83/0 and 41/0.

game/sim/colony: GrowCivilianPopulations models ServerSystem's civilian
growth sub-pass. The whole system's delta is clamped to 20,000,000 -- an
int64 column of the population-type table, built in the executable from
its own literals -- and on both reference pairs that clamp, not the growth
curve and not any carrying capacity, is what decides the value: the
uncapped delta is 7.5x it and the capacity headroom 25x it. So the pass
commits with no tuning table loaded, and says by how much each unmodelled
input would have to be wrong before it mattered.

The one input genuinely off the wire is the per-species civilian capacity
factor. It is handled by running the pass twice, once with the modelled
capacity and once with the system's own wire-known dcs limit, and
committing only when the two agree. Imperial growth is deliberately NOT
committed: it is a no-op on this corpus and would need a capacity the
corpus can bound from below but not from above.

game/sim/economy: both interest rates in ComputeBudget are WIDENED FLOAT
literals, (double)0.01f and (double)0.15f, and are then truncated -- so a
treasury of exactly 50,000 earns 499, not 500. This module used the exact
decimals, which left the human's savings one money high on the first
reference pair and exact on the second. Sixteen hand-computed test
expectations moved by one; they were derived from the model, not measured.
The live ComputeBudget compare (4,437 calls, 0 divergences) did not catch
this because it presented only 20 distinct states and none sat on a
rounding boundary.

game/sim/colony: ShipRepairCost, the last unmodelled input of the output
turn path. The demand is still 0 -- its two design fields are cached stats
the save does not carry -- but the zero is now evidenced rather than
silent: S13 reports the candidate set, and the independent colony keeps a
ten-ship fleet over a colony whose savings close exactly at zero demand.

Gates run as separate commands: clean-room OK, host ctest 49/49, and the
CT111 shim cross-build exit 0 (required: game/sim is compiled into the
shim). The host build and report were also re-run on CT111 and produced
identical numbers.

docs/G3-civilian-growth.md; notes repo
findings/subsystems/population-growth.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 15:52:38 -04:00

244 lines
14 KiB
C++

// Economy: per-player income roll-up, research points, trade income, bankruptcy.
//
// Pure functions over plain input structs. Money is int (the treasury is a 32-bit
// integer clamped to +/-2e9); rates and multipliers are double.
#pragma once
#include <cstdint>
#include <vector>
#include "game/sim/tuning.h"
namespace sots::sim {
// ---------------------------------------------------------------------------------------
// Difficulty
// ---------------------------------------------------------------------------------------
//
// Three multipliers, selected per player from a three-row table that the executable builds
// **in code** from float literals -- there is no data-file key and no tuning-table entry for
// any of them. Each row carries two triples: one used for AI players and one for everybody
// else, so the same row makes the game easier for the human on level 0 and easier for the AI
// on levels 1 and 2. See sots-re findings/subsystems/income-term.md §3.
struct DifficultyMods {
double maintenanceDivisor = 1.0; // fleet upkeep is divided by ftol(this)
double incomeMult = 1.0; // a system's money income, and trade-route income
double researchMult = 1.0; // research points bought with money
};
// The number of rows the table holds. A level outside [0, kDifficultyLevels) selects the
// all-ones default rather than failing -- the original memcpy's that default in first and
// only overwrites it on a hit.
constexpr int kDifficultyLevels = 3;
// `level` is the player's `aidf` save field; `isAI` is the player's AI flag, which is
// **not on the wire** (it is copied from the game-setup/network player record), and an NPC
// player takes the non-AI triple whatever its AI flag says.
// CONFIDENCE: high -- table and selector both read instruction by instruction. The corpus
// carries level 1 on every player and exercises only the AI/non-AI split of that row.
DifficultyMods DifficultyModsFor(int level, bool isAI, bool isNpc);
// ---------------------------------------------------------------------------------------
// Budget
// ---------------------------------------------------------------------------------------
// One per-category expense slider (the player's expense entries {xmin, xmax, xper}).
// `fraction` is the share of the pre-expense available income the slider asks for; the
// request is honoured between min and max, and the total of the above-minimum parts is
// capped by what is left after every minimum is paid. A maximum of 0 means unlimited.
struct ExpenseSlider {
int minimum = 0;
int maximum = 0; // 0 = no upper bound
float fraction = 0.f; // xper: stored as a single-precision value by the game
};
// Everything the budget roll-up reads from the player and the server. Names follow the
// save-tag names where one exists (Sav, Maint, ResRate, ResMod, ResScl, TRM/TRA/TRP,
// shrm) so a save dump maps onto this struct directly.
struct BudgetInputs {
int savings = 0; // Sav
bool ownsSystems = false; // savings interest only accrues to landed players
std::vector<int> systemIncome; // money output of every owned, non-abandoned system
int tradeIncome = 0; // sum of the player's trade-route incomes
int secondaryManagerIncome = 0; // income reported by a second server manager
int shipCarriedPopIncome = 0; // income from population carried in slaver/colony hulls
int maintenance = 0; // Maint (raw fleet upkeep before difficulty)
double maintenanceDivisor = 1.0; // difficulty table: upkeep is divided by ftol(this)
double researchDifficultyMult = 1.0; // difficulty table: research-point multiplier
std::vector<ExpenseSlider> expenses;
bool isAI = false; // AI players do not take the human construction path
int constructionDemand = 0; // what the build queues would consume this turn
double researchRate = 0.0; // ResRate: share of available money to research (0..1)
double resMod = 1.0; // ResMod
double shrm = 0.0; // shrm (shared research modifier)
double trm = 0.0; // TRM (timed research multiplier bonuses)
double techResearchMult = 1.0; // research multiplier set by tech effects
double serverResMod = 1.0; // game-option research modifier
double resScl = 1.0; // ResScl
int tra = 0; // TRA: per-turn research-point contribution
int trp = 0; // TRP: per-turn research-point contribution
int aidResearchPercent = 0; // sum of active research-aid entries (clamped 0..100)
int aidSavings = 0; // sum of active savings-aid entries
double techIncomeMult = 1.0; // income multiplier set by tech effects (1 = none)
bool hasResearchTarget = false; // ResT set
};
struct Budget {
// income side
int systemIncomePositive = 0; // sum of positive system money outputs
int tradeIncome = 0;
int shipCarriedPopIncome = 0;
int secondaryManagerIncome = 0;
int savingsInterest = 0; // 1 % of a non-negative treasury
int bonusIncome = 0; // tech income multiplier applied to the running net
// expense side
int systemIncomeNegative = 0; // sum of |negative| system money outputs
int maintenance = 0;
int researchMoneyKept = 0; // research money minus the part given as aid
int debtInterest = 0; // 15 % of a negative treasury
int construction = 0;
int expenses = 0;
int researchMoneyGiven = 0;
int savingsGiven = 0;
// derived
int available = 0; // money left for construction/research after fixed costs
int researchMoney = 0; // money routed to research before aid
int researchPoints = 0; // RP from the research money alone
int researchPointsGiven = 0;
int totalResearchPoints = 0; // RP allocated to the current research target
bool hasResearchAllocation = false;
int net = 0; // change in savings this turn
};
// The two interest rates `ComputeBudget` multiplies by are **widened float literals** in the
// image, not the exact decimals: 0x009e31c0 holds (double)0.01f = 0.009999999776482582 and
// 0x009ed188 holds (double)0.15f = 0.15000000596046448 (the same constant lane E1 already
// carries, negated, as `kBankruptcyInterestDivisor`). Both are then truncated by `_ftol2`, so
// the difference from the exact decimal is not cosmetic: a treasury of exactly 50,000 earns
// 499, not 500. G3 correction -- the module used exact decimals, and the live `ComputeBudget`
// compare (4,437 calls, 0 divergences) did not catch it because only 20 distinct states were
// ever presented and none of them sat on a boundary.
constexpr double kSavingsInterestRate = 0.009999999776482582; // (double)0.01f
constexpr double kDebtInterestRate = 0.15000000596046448; // (double)0.15f
// Savings interest: 1 % of a non-negative treasury, only for players who own systems.
// CONFIDENCE: high.
int SavingsInterest(int savings, bool ownsSystems);
// Debt interest: 15 % of the magnitude of a negative treasury. CONFIDENCE: high.
int DebtInterest(int savings);
// Fleet upkeep after the difficulty divisor. CONFIDENCE: high.
int MaintenanceCost(int maintenance, double difficultyDivisor);
// Total of the expense sliders. Per entry, with `availPre` = the non-negative net before
// expenses:
// minC = max(min, 0); maxC = clamp(max, 0, 2e9), 0 meaning 2e9; room = maxC - minC
// request = ftol(fraction x float(availPre)) - minC; take = min(max(request, 0), room)
// total = sum(minC) + min(max(sum(take), 0), availPre - sum(minC)).
// CONFIDENCE: high -- the request term is a fraction of the pre-expense available
// income, multiplied in single precision, minus the mandatory minimum.
int ExpenseTotal(const std::vector<ExpenseSlider>& sliders, int availableBeforeExpenses);
// Research points bought with `researchMoney`:
// RP = ftol( difficulty x (money/50 x 1.15 x 0.5 x 0.85) x (ResMod + shrm + TRM)
// x techMult x serverResMod x ResScl )
// i.e. about 0.009775 RP per unit of money before multipliers. CONFIDENCE: high.
int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double resMod,
double shrm, double trm, double techMult, double serverResMod,
double resScl);
// The full per-turn budget. The order of evaluation matters because later slots read
// the running totals: interest -> system income -> trade/other income -> maintenance ->
// expenses -> available -> construction -> research money/points -> aid -> bonus ->
// savings aid -> net.
// `researchMoneyKept` -- the money the turn actually spends on research -- is only charged
// when the player has a research target; the research money and points are still reported
// (the UI shows them) but a player with no target keeps the money.
// The tech income bonus reads the full net (every income line including interest and
// trade, minus maintenance, research money, construction, expenses and research aid)
// and is only granted when that net is positive: bonus = max(0, ftol((mult - 1) x net)).
// Savings aid is capped by the projected treasury after this turn, not by the turn net:
// given = min(max(Sav + net, 0), max(aid, 0)), evaluated with the bonus already added.
// CONFIDENCE: high (line items, signs, and both running-total readers).
Budget ComputeBudget(const BudgetInputs& in, bool projected);
// ---------------------------------------------------------------------------------------
// Trade
// ---------------------------------------------------------------------------------------
// Routes a system can host: ceil(civilians / REQ_CIV) + ceil(imperials / REQ_IMP), at
// least 1. A zero requirement contributes nothing. CONFIDENCE: high.
int TradeRoutesSupported(double civilianPop, double imperialPop, const TuningTable& t);
enum class FreighterClass : int { Cruiser = 0 /*CRQ*/, CruiserRefit = 1 /*CR*/, Destroyer = 2 /*DE*/ };
struct TradeRouteState {
int ageTurns = 0; // turns since the route was established
int freighters[3] = {0, 0, 0}; // by FreighterClass index
int tradeStationsAtSystem = 0;
bool partnerAddicted = false;
};
// Gross income of one route before the owner/partner split:
// young route (age < STARTUP_TURNS): STARTUP_INCOME flat
// else MIN_INCOME + sum over classes in order CRQ, CR, DE of
// min(n_class, capLeft) x PERFREIGHTER[class], capLeft starting at MAX_FREIGHTERS,
// then x (1 + STATION_BONUS_TRADE_INCOME x stations), x ADDICTION_TRADE_MOD if addicted.
// CONFIDENCE: high on the freighter sum; medium on where the multipliers truncate.
int TradeRouteGrossIncome(const TradeRouteState& route, const TuningTable& t);
// The share one side of the route receives: owner gets OWNERS_SHARE (clamped 0..1), the
// partner the rest; AI players additionally scale by their difficulty trade multiplier.
// CONFIDENCE: high.
int TradeRouteIncome(const TradeRouteState& route, bool asOwner, double difficultyTradeMult,
const TuningTable& t);
// ---------------------------------------------------------------------------------------
// Bankruptcy
// ---------------------------------------------------------------------------------------
// The elimination limit's divisor, as the image holds it: a widened float literal, not the
// decimal -0.15. Writing -0.15 changes the truncated result for every maxIncome divisible
// by 3 and for essentially every empire above ~3,000,000 maximum income.
constexpr double kBankruptcyInterestDivisor = -0.15000000596046448;
struct BankruptcyLimits {
int eliminationFloor = 0; // BnkEl: below this the player is on the elimination clock
int protectionLimit = 0; // BnkPr: below this cost-cutting starts
};
// Limits from the sum of every owned system's maximum money output:
// eliminationFloor = max(ftol(maxIncome / kBankruptcyInterestDivisor), -2e9)
// (the debt at which 15 %/turn interest eats the whole maximum income)
// protectionLimit = max(-ftol(BANKRUPTCY_PROTECTION_LIMIT_FACTOR x maxIncome), eliminationFloor)
// The limits a turn's check uses are the ones computed at the end of the previous turn
// (and on load); the caller keeps them on the player.
// CONFIDENCE: high -- the factor is on the protection limit, the elimination limit is
// the interest break-even, both read with their constants.
BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t);
// 2 = elimination pending, 1 = protection (cost cutting), 0 = solvent. CONFIDENCE: high.
int BankruptcyLevel(int savings, const BankruptcyLimits& limits);
struct BankruptcyState {
int warningLevel = 0; // BnkWrn
int startTurn = -1; // BnkTrn: turn the current level began; -1 when solvent
};
struct BankruptcyDecision {
bool costCutting = false; // run the cost-cutting pass this turn
bool eliminate = false; // the player is eliminated this turn
};
// Per-turn bankruptcy bookkeeping. The stored state is updated first -- any change of
// level (0<->1, 1<->2 alike) restamps the start turn with the current turn, level 0
// stamps -1 -- and the decisions are then taken on the *previous* state:
// costCutting = level != 0 && old.level != 0
// eliminate = old.level == 2 && currentTurn - old.startTurn >= BANKRUPTCY_ELIMINATION_TURNS
// so both actions begin the turn after the level was reached.
// CONFIDENCE: high -- stamp-on-transition and act-on-old-state read from the code.
BankruptcyDecision BankruptcyStep(BankruptcyState& state, int level, int currentTurn,
const TuningTable& t);
} // namespace sots::sim