#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 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"); }