// 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 #include #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 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 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& 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