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.
248 lines
14 KiB
C++
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
|