sots-engine/tests/game_sim/test_economy.cpp
alex 0ebc222f45 lane N: the population -> base-output term, live-verified
Reads the whole colony output chain off the instruction stream (every range
disassembled to the next function start) and compares two of its functions
against the running game.

The population -> output law is linear and is carried by the executable:
output points per head are typeOutputModifier x 1.8 / 500000, and the
three-row population-type table is built in code rather than loaded, so the
imperial (1.0) and civilian (0.33f) modifiers are facts about the binary.

A system's total output is a SUM of three terms, not one multiplicative
chain. The station bonus scales only the imperial term and morale only the
civilian one, so OutputModifiers no longer carries either; they belong to
GroupOutputInputs. The function previously described as the base-output term
is the over-harvest RESOURCE demand, and it is corrected in place.

Live on VM140, both hooks in compare mode over two species and two workloads:
GroupOutput 13,105 calls / 0 divergences; ComputeTotalOutput 11,252 calls /
1 divergence of one ulp, in a value its caller rounds to an integer. Both
functions declare a whole-object Guard: 0 undeclared writes in 24,357 calls,
which is what makes the side-effect-free claim a measurement.

sim::Narrow forces the double rounding a 32-bit x87 build otherwise skips;
without it every civilian row came out one ulp low.

Also fixes ComputeBankruptcyLimits' elimination divisor, which was the
decimal -0.15 rather than the image's widened float -0.15000000596046448.
The two disagree for every maximum income divisible by 3 and for essentially
every empire above ~3,000,000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 12:11:36 -04:00

408 lines
16 KiB
C++

#include "game/sim/economy.h"
#include "game/sim/numeric.h"
#include "check.h"
using namespace sots::sim;
static void test_interest() {
CHECK_EQ(SavingsInterest(1000, true), 10);
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, 100);
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, 6900);
CHECK_EQ(b.construction, 500);
// (6900 - 500) x 0.5 = 3200
CHECK_EQ(b.researchMoney, 3200);
// 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, 3200);
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, 3200);
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, 6400);
}
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, 6400);
}
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, 3200);
CHECK_EQ(b.researchMoneyGiven, 1600);
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, 3200);
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, -11100);
// 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, 3450); // 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, 100 - 5000); // interest minus maintenance
in = base_inputs();
in.constructionDemand = 100000;
b = ComputeBudget(in, false);
CHECK_EQ(b.construction, 6900); // 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, 4900);
}
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");
}