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