sots-engine/tests/game_sim/test_economy.cpp
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

411 lines
16 KiB
C++

#include "game/sim/economy.h"
#include "game/sim/numeric.h"
#include "check.h"
using namespace sots::sim;
// The two interest rates are WIDENED FLOAT literals in the image, so a round treasury earns
// one less than the exact decimal would give. Every expectation below that moved by exactly 1
// was hand-computed from the exact decimal and is corrected here (G3).
static void test_interest() {
CHECK_EQ(SavingsInterest(1000, true), 9); // 1000 x (double)0.01f = 9.99999977 -> 9
CHECK_EQ(SavingsInterest(199, true), 1); // 1.99 truncates
CHECK_EQ(SavingsInterest(1000, false), 0); // no systems, no interest
CHECK_EQ(SavingsInterest(-500, true), 0);
CHECK_EQ(SavingsInterest(0, true), 0);
CHECK_EQ(DebtInterest(-1000), 150);
CHECK_EQ(DebtInterest(-7), 1); // 1.05 truncates
CHECK_EQ(DebtInterest(5), 0);
CHECK_EQ(DebtInterest(0), 0);
CHECK_EQ(MaintenanceCost(100, 2.0), 50);
CHECK_EQ(MaintenanceCost(100, 1.9), 100); // divisor truncates to 1
CHECK_EQ(MaintenanceCost(100, 0.5), 100); // divisor 0 guarded
CHECK_EQ(SaturatingAdd(1900000000, 500000000), 2000000000);
CHECK_EQ(SaturatingAdd(-1900000000, -500000000), -2000000000);
CHECK_EQ(SaturatingAdd(5, -7), -2);
}
static void test_research_points() {
// 50000 / 50 = 1000; x1.15 = 1150; x0.5 = 575; x0.85 = 488.75 -> 488
CHECK_EQ(ResearchPointsFromMoney(50000, 1, 1, 0, 0, 1, 1, 1), 488);
// (ResMod + shrm + TRM) = 2 -> 977.5 -> 977
CHECK_EQ(ResearchPointsFromMoney(50000, 1, 1, 0.5, 0.5, 1, 1, 1), 977);
// difficulty 0.5 -> 244.375 -> 244
CHECK_EQ(ResearchPointsFromMoney(50000, 0.5, 1, 0, 0, 1, 1, 1), 244);
// tech x2, server x0.5, scale x1 -> unchanged 488
CHECK_EQ(ResearchPointsFromMoney(50000, 1, 1, 0, 0, 2, 0.5, 1), 488);
CHECK_EQ(ResearchPointsFromMoney(0, 1, 1, 0, 0, 1, 1, 1), 0);
// 100 money -> 0.9775 -> 0
CHECK_EQ(ResearchPointsFromMoney(100, 1, 1, 0, 0, 1, 1, 1), 0);
}
static void test_expenses() {
std::vector<ExpenseSlider> s = {{100, 300, 0.5f}, {50, 60, 0.1f}};
// s1: request ftol(0.5 x 1000) - 100 = 400, room 200 -> 200
// s2: request 100 - 50 = 50, room 10 -> 10; minimums 150 + takes 210 = 360
CHECK_EQ(ExpenseTotal(s, 1000), 360);
// avail 200: s1 request 0, s2 request -30 -> 0; only the minimums
CHECK_EQ(ExpenseTotal(s, 200), 150);
// avail below the minimums: total is capped at avail
CHECK_EQ(ExpenseTotal(s, 100), 100);
CHECK_EQ(ExpenseTotal({}, 1000), 0);
// max 0 = unlimited
CHECK_EQ(ExpenseTotal({{0, 0, 0.25f}}, 1000), 250);
// a negative minimum counts as 0
CHECK_EQ(ExpenseTotal({{-50, 100, 0.1f}}, 1000), 100);
// min above max: the entry contributes its minimum, its (negative) take is absorbed
CHECK_EQ(ExpenseTotal({{100, 50, 1.0f}}, 1000), 100);
// takes are capped by what is left after the minimums
CHECK_EQ(ExpenseTotal({{100, 0, 1.0f}, {200, 0, 1.0f}}, 1000), 1000);
// the product is taken in single precision: 0.7f x 1000 = 699.99998 -> 699
CHECK_EQ(ExpenseTotal({{0, 0, 0.7f}}, 1000), 699);
// ... and the income itself is rounded to a float first
CHECK_EQ(ExpenseTotal({{0, 0, 1.0f}}, 16777217), 16777216);
}
static BudgetInputs base_inputs() {
BudgetInputs in;
in.savings = 10000;
in.ownsSystems = true;
in.systemIncome = {5000, 3000, -200};
in.tradeIncome = 1000;
in.maintenance = 2000;
in.maintenanceDivisor = 1.0;
in.constructionDemand = 500;
in.researchRate = 0.5;
in.hasResearchTarget = true;
return in;
}
static void test_budget_hand_case() {
Budget b = ComputeBudget(base_inputs(), false);
CHECK_EQ(b.savingsInterest, 99);
CHECK_EQ(b.debtInterest, 0);
CHECK_EQ(b.systemIncomePositive, 8000);
CHECK_EQ(b.systemIncomeNegative, 200);
CHECK_EQ(b.maintenance, 2000);
CHECK_EQ(b.expenses, 0);
// 8000 + 1000 + 100 - 200 - 2000 = 6900
CHECK_EQ(b.available, 6899);
CHECK_EQ(b.construction, 500);
// (6900 - 500) x 0.5 = 3200
CHECK_EQ(b.researchMoney, 3199);
// 3200/50 = 64; x1.15 = 73.6; x0.5 = 36.8; x0.85 = 31.28 -> 31
CHECK_EQ(b.researchPoints, 31);
CHECK_EQ(b.totalResearchPoints, 31);
CHECK(b.hasResearchAllocation);
CHECK_EQ(b.researchMoneyKept, 3199);
CHECK_EQ(b.bonusIncome, 0);
CHECK_EQ(b.savingsGiven, 0);
// 6900 - 500 - 3200
CHECK_EQ(b.net, 3200);
}
// A player with no research target still reports its research money and points -- the UI
// shows them -- but never spends the money: the "kept" line and the research allocation are
// written in the same branch. (Verified against the live game, docs/B1.md.)
static void test_budget_without_research_target() {
BudgetInputs in = base_inputs();
in.hasResearchTarget = false;
Budget b = ComputeBudget(in, false);
CHECK_EQ(b.researchMoney, 3199);
CHECK_EQ(b.researchPoints, 31);
CHECK_EQ(b.totalResearchPoints, 31);
CHECK(!b.hasResearchAllocation);
CHECK_EQ(b.researchMoneyKept, 0);
// The research money stays in the treasury: 6900 - 500 construction.
CHECK_EQ(b.net, 6399);
}
static void test_budget_projected() {
Budget b = ComputeBudget(base_inputs(), true);
CHECK_EQ(b.researchMoney, 0);
CHECK_EQ(b.researchPoints, 0);
CHECK_EQ(b.net, 6399);
}
static void test_budget_debt() {
BudgetInputs in = base_inputs();
in.savings = -1000;
Budget b = ComputeBudget(in, false);
CHECK_EQ(b.savingsInterest, 0);
CHECK_EQ(b.debtInterest, 150);
// 8000 + 1000 - 200 - 2000 - 150 = 6650
CHECK_EQ(b.available, 6650);
CHECK_EQ(b.construction, 500);
// (6650 - 500) x 0.5 = 3075
CHECK_EQ(b.researchMoney, 3075);
CHECK_EQ(b.net, 6650 - 500 - 3075);
}
static void test_budget_aid_and_bonus() {
BudgetInputs in = base_inputs();
in.aidResearchPercent = 50;
in.aidSavings = 100;
in.tra = 4;
in.trp = 5;
Budget b = ComputeBudget(in, false);
CHECK_EQ(b.researchMoney, 3199);
CHECK_EQ(b.researchMoneyGiven, 1599);
CHECK_EQ(b.researchMoneyKept, 1600);
// 31 + 4 + 5 = 40; given 20; kept 20
CHECK_EQ(b.researchPointsGiven, 20);
CHECK_EQ(b.totalResearchPoints, 20);
CHECK_EQ(b.savingsGiven, 100);
// 6900 - 500 - 1600 - 1600 - 100
CHECK_EQ(b.net, 3100);
in.aidResearchPercent = 250; // clamps to 100
in.aidSavings = 0;
b = ComputeBudget(in, false);
CHECK_EQ(b.researchMoneyGiven, 3199);
CHECK_EQ(b.totalResearchPoints, 0);
in = base_inputs();
in.techIncomeMult = 1.1;
b = ComputeBudget(in, false);
// full net before the bonus = 3200 -> ftol(0.1 x 3200) = 320
CHECK_EQ(b.bonusIncome, 320);
CHECK_EQ(b.net, 3520);
// the bonus reads the net *after* research aid was deducted
in.aidResearchPercent = 50;
b = ComputeBudget(in, false);
CHECK_EQ(b.bonusIncome, 320); // 1600 kept + 1600 given: net unchanged
in.aidResearchPercent = 0;
// a multiplier below 1 never takes money away
in.techIncomeMult = 0.5;
b = ComputeBudget(in, false);
CHECK_EQ(b.bonusIncome, 0);
CHECK_EQ(b.net, 3200);
// no bonus on a negative net
in.techIncomeMult = 1.1;
in.maintenance = 20000;
b = ComputeBudget(in, false);
CHECK_EQ(b.available, 0);
CHECK_EQ(b.bonusIncome, 0);
CHECK_EQ(b.net, -11101);
// savings aid is capped by the projected treasury, not by the turn net
in = base_inputs();
in.savings = -5000; // debt interest 750
in.aidSavings = 100;
b = ComputeBudget(in, false);
// available 8000+1000-200-2000-750 = 6050; construction 500; research 2775; net 2775
CHECK_EQ(b.researchMoney, 2775);
CHECK_EQ(b.savingsGiven, 0); // -5000 + 2775 < 0: nothing to give
CHECK_EQ(b.net, 2775);
in.savings = -2000; // debt interest 300
in.aidSavings = 5000;
b = ComputeBudget(in, false);
// available 6500; construction 500; research 3000; net 3000; projected 1000
CHECK_EQ(b.savingsGiven, 1000);
CHECK_EQ(b.net, 2000);
in.savings = 10000;
in.techIncomeMult = 1.1; // bonus 320 counts toward the projection
in.aidSavings = 20000;
b = ComputeBudget(in, false);
CHECK_EQ(b.bonusIncome, 320);
CHECK_EQ(b.savingsGiven, 13520); // 10000 + 3200 + 320: the whole projected treasury
CHECK_EQ(b.net, -10000); // ... so the treasury ends the turn at 0
in.aidSavings = -5; // negative aid gives nothing
b = ComputeBudget(in, false);
CHECK_EQ(b.savingsGiven, 0);
}
static void test_budget_edges() {
BudgetInputs in = base_inputs();
in.isAI = true;
Budget b = ComputeBudget(in, false);
CHECK_EQ(b.construction, 0); // AI path spends construction elsewhere
CHECK_EQ(b.researchMoney, 3449); // 6900 x 0.5
in = base_inputs();
in.systemIncome = {};
in.tradeIncome = 0;
in.maintenance = 5000;
b = ComputeBudget(in, false);
CHECK_EQ(b.available, 0); // floored at zero
CHECK_EQ(b.construction, 0);
CHECK_EQ(b.researchMoney, 0);
CHECK_EQ(b.net, 99 - 5000); // interest minus maintenance
in = base_inputs();
in.constructionDemand = 100000;
b = ComputeBudget(in, false);
CHECK_EQ(b.construction, 6899); // capped at available
CHECK_EQ(b.researchMoney, 0);
in = base_inputs();
in.expenses = {{1000, 2000, 0.5f}};
b = ComputeBudget(in, false);
// pre-expense avail 6900: request 3450 - 1000 = 2450, room 1000 -> 2000 total
CHECK_EQ(b.expenses, 2000);
CHECK_EQ(b.available, 4899);
}
static void test_trade() {
TuningTable t;
t.TRADE_ROUTE_REQ_CIVPOPULATION = 1e6;
t.TRADE_ROUTE_REQ_IMPPOPULATION = 1e6;
CHECK_EQ(TradeRoutesSupported(2.5e6, 1e6, t), 4); // ceil(2.5) + 1
CHECK_EQ(TradeRoutesSupported(0, 0, t), 1); // minimum one
CHECK_EQ(TradeRoutesSupported(1, 0, t), 1);
TuningTable zero;
CHECK_EQ(TradeRoutesSupported(5e6, 5e6, zero), 1); // zero requirement guarded
t.TRADE_ROUTE_STARTUP_TURNS = 3;
t.TRADE_ROUTE_STARTUP_INCOME = 7;
t.TRADE_ROUTE_MIN_INCOME = 100;
t.TRADE_ROUTE_MAX_FREIGHTERS = 5;
t.TRADE_ROUTE_INCOME_PERFREIGHTER_CRQ = 30;
t.TRADE_ROUTE_INCOME_PERFREIGHTER_CR = 20;
t.TRADE_ROUTE_INCOME_PERFREIGHTER_DE = 10;
t.STATION_BONUS_TRADE_INCOME = 0.1;
t.ADDICTION_TRADE_MOD = 0.5;
t.TRADE_ROUTE_OWNERS_SHARE = 0.6;
TradeRouteState r;
r.ageTurns = 3;
r.freighters[0] = 2; // CRQ: 2 x 30 = 60, cap left 3
r.freighters[1] = 4; // CR: min(4,3) = 3 x 20 = 60, cap left 0
r.freighters[2] = 3; // DE: nothing left
CHECK_EQ(TradeRouteGrossIncome(r, t), 220);
r.tradeStationsAtSystem = 2;
CHECK_EQ(TradeRouteGrossIncome(r, t), 264); // x 1.2
r.partnerAddicted = true;
CHECK_EQ(TradeRouteGrossIncome(r, t), 132); // x 0.5
CHECK_EQ(TradeRouteIncome(r, true, 1.0, t), 79); // 132 x 0.6 = 79.2
CHECK_EQ(TradeRouteIncome(r, false, 1.0, t), 52); // 132 x 0.4 = 52.8
CHECK_EQ(TradeRouteIncome(r, true, 2.0, t), 158); // AI trade multiplier
r.ageTurns = 2;
CHECK_EQ(TradeRouteGrossIncome(r, t), 7); // startup income is flat
TradeRouteState empty;
empty.ageTurns = 10;
CHECK_EQ(TradeRouteGrossIncome(empty, t), 100); // MIN_INCOME with no freighters
t.TRADE_ROUTE_OWNERS_SHARE = 1.5; // clamps to 1
CHECK_EQ(TradeRouteIncome(empty, true, 1.0, t), 100);
CHECK_EQ(TradeRouteIncome(empty, false, 1.0, t), 0);
}
static void test_bankruptcy() {
TuningTable t;
t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 3.3;
t.BANKRUPTCY_ELIMINATION_TURNS = 5;
// max income 1000: elimination at 1000 / -0.15 = -6666.67 -> -6666; protection -3300
BankruptcyLimits l = ComputeBankruptcyLimits(1000, t);
CHECK_EQ(l.eliminationFloor, -6666);
CHECK_EQ(l.protectionLimit, -3300);
CHECK_EQ(BankruptcyLevel(-6667, l), 2);
CHECK_EQ(BankruptcyLevel(-6666, l), 1);
CHECK_EQ(BankruptcyLevel(-3301, l), 1);
CHECK_EQ(BankruptcyLevel(-3300, l), 0);
CHECK_EQ(BankruptcyLevel(0, l), 0);
l = ComputeBankruptcyLimits(100, t);
CHECK_EQ(l.eliminationFloor, -666);
CHECK_EQ(l.protectionLimit, -330);
// a factor beyond the interest break-even is pinned to the elimination floor
TuningTable big = t;
big.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 10.0;
l = ComputeBankruptcyLimits(1000, big);
CHECK_EQ(l.eliminationFloor, -6666);
CHECK_EQ(l.protectionLimit, -6666);
// huge income: the floor saturates at the treasury limit
l = ComputeBankruptcyLimits(400000000, t);
CHECK_EQ(l.eliminationFloor, -2000000000);
CHECK_EQ(l.protectionLimit, -1320000000);
// LANE N: the divisor is the widened float literal, not the decimal -0.15. The two
// disagree for every maxIncome divisible by 3, and maxIncome = 3 is where it first
// bites: 3 / -0.15 is -20 exactly in decimal but -19.99999920... with the image's
// constant, and the conversion TRUNCATES.
CHECK_EQ(ComputeBankruptcyLimits(3, t).eliminationFloor, -19);
CHECK_EQ(ComputeBankruptcyLimits(6, t).eliminationFloor, -39);
CHECK_EQ(ComputeBankruptcyLimits(9, t).eliminationFloor, -59);
// ... and above ~3,000,000 maximum income they differ on essentially every value.
CHECK_EQ(ComputeBankruptcyLimits(238592, t).eliminationFloor, -1590613);
CHECK_EQ(ComputeBankruptcyLimits(3000001, t).eliminationFloor, -20000005);
BankruptcyLimits none = ComputeBankruptcyLimits(0, t);
CHECK_EQ(none.eliminationFloor, 0);
CHECK_EQ(none.protectionLimit, 0);
CHECK_EQ(BankruptcyLevel(-1, none), 2); // no income at all: any debt is terminal
// Stamping happens on the transition; actions run on the previous state.
BankruptcyState s;
CHECK_EQ(s.startTurn, -1);
BankruptcyDecision d = BankruptcyStep(s, 1, 10, t);
CHECK(!d.costCutting); // first turn at level 1: not yet
CHECK(!d.eliminate);
CHECK_EQ(s.warningLevel, 1);
CHECK_EQ(s.startTurn, 10);
d = BankruptcyStep(s, 1, 11, t);
CHECK(d.costCutting); // second turn: cost cutting
CHECK_EQ(s.startTurn, 10); // no restamp while the level holds
d = BankruptcyStep(s, 2, 12, t); // 1 -> 2 restamps the clock at 12
CHECK(d.costCutting);
CHECK(!d.eliminate);
CHECK_EQ(s.startTurn, 12);
d = BankruptcyStep(s, 2, 16, t);
CHECK(!d.eliminate); // 4 turns < 5
d = BankruptcyStep(s, 2, 17, t);
CHECK(d.eliminate); // 5 turns -> eliminated
d = BankruptcyStep(s, 0, 18, t); // recovering on the turn after the clock
CHECK(!d.costCutting);
CHECK(d.eliminate); // ... still acts on the old level-2 state
CHECK_EQ(s.warningLevel, 0);
CHECK_EQ(s.startTurn, -1);
// 2 -> 1 -> 2 restarts the clock each time
BankruptcyState r;
BankruptcyStep(r, 2, 20, t);
CHECK_EQ(r.startTurn, 20);
d = BankruptcyStep(r, 1, 22, t);
CHECK(d.costCutting);
CHECK_EQ(r.startTurn, 22);
d = BankruptcyStep(r, 2, 25, t);
CHECK(!d.eliminate); // old level was 1
CHECK_EQ(r.startTurn, 25);
d = BankruptcyStep(r, 2, 29, t);
CHECK(!d.eliminate);
d = BankruptcyStep(r, 2, 30, t);
CHECK(d.eliminate);
// even with a zero-turn limit, elimination happens the turn after level 2 is reached
TuningTable instant = t;
instant.BANKRUPTCY_ELIMINATION_TURNS = 0;
BankruptcyState q;
d = BankruptcyStep(q, 2, 5, instant);
CHECK(!d.eliminate);
d = BankruptcyStep(q, 2, 6, instant);
CHECK(d.eliminate);
}
int main() {
test_interest();
test_research_points();
test_expenses();
test_budget_hand_case();
test_budget_without_research_target();
test_budget_projected();
test_budget_debt();
test_budget_aid_and_bonus();
test_budget_edges();
test_trade();
test_bankruptcy();
return simtest::finish("test_economy");
}