sots-engine/src/game/sim/economy.h
alex 26b041106f PL: decompose the /Sim/players residual; S00 PvSav snapshot; T31 recovers its difficulty column from the save; the bankruptcy protection factor is a widened float
Closed 2 on the reference pair and 4 on pair 2, regressed 0, with no operator
input. Measured, both pairs, never netted.

- S00 stamps PvSav from Sav on every live player, before any phase can move it.
  0 closed on pair 1 (a no-op there), 2 on pair 2.
- T31 recovers the per-player difficulty column by recomputing BnkEl from the
  colony state the input save was written from and comparing against the BnkEl
  the save carries. That removes the --ai-player flag as a blocker and turns the
  phase's self-check into a real one: it used to compare its POST-turn result
  against the PRE-turn stored value, so its 6-of-8 only ever covered the six
  players whose limit does not move. The load-time check passes for every live
  player of all eleven corpus saves. T31 Blocked -> Partial; BnkPr still needs
  the tuning constant.
- BANKRUPTCY_PROTECTION_LIMIT_FACTOR is multiplied in as fmul dword ptr, so it
  is a float32 in the image; the engine narrows it now. Zero leaves move on this
  corpus -- all seven of its BnkPr records land where the two constants agree --
  and three hand-written test expectations moved (rule 23).

docs/PL-players-residual.md carries the decomposition, the predictions written
before the build, where they were wrong, and the ranked remainder.
2026-09-08 16:34:40 -04:00

248 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(float32(BANKRUPTCY_PROTECTION_LIMIT_FACTOR) x maxIncome),
// eliminationFloor)
// The factor is narrowed to float32 first: the image reads it `fmul dword ptr` while the
// divisor above is `fld qword ptr`, so the two constants of this one routine are stored at
// different widths. See the note at the call site.
// 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