#include "game/sim/research.h" #include "check.h" using namespace sots::sim; static void test_edge_roll() { EdgeAvailability e; e.SetPercent(Species::Human, 50); e.SetPercent(Species::Zuul, 0); { // roll at exactly the chance is included (<=) simtest::ScriptedRng rng({0.5f}); CHECK(RollEdgeAvailable(e, Species::Human, TreeBuildMode::Normal, rng)); CHECK_EQ(rng.floatDraws(), std::size_t{1}); } { simtest::ScriptedRng rng({0.51f}); CHECK(!RollEdgeAvailable(e, Species::Human, TreeBuildMode::Normal, rng)); } { // explicit 0 excludes without drawing simtest::ScriptedRng rng({0.0f}); CHECK(!RollEdgeAvailable(e, Species::Zuul, TreeBuildMode::Normal, rng)); CHECK_EQ(rng.floatDraws(), std::size_t{0}); } { // unlisted species default to 1.0: included, no draw simtest::ScriptedRng rng({0.99f}); CHECK(RollEdgeAvailable(e, Species::Morrigi, TreeBuildMode::Normal, rng)); CHECK(RollEdgeAvailable(e, Species::Hiver, TreeBuildMode::Normal, rng)); CHECK_EQ(rng.floatDraws(), std::size_t{0}); } { // NoRoll: any non-zero chance is in, zero stays out simtest::ScriptedRng rng; e.SetPercent(Species::Tarkas, 1); CHECK(RollEdgeAvailable(e, Species::Tarkas, TreeBuildMode::NoRoll, rng)); CHECK(!RollEdgeAvailable(e, Species::Zuul, TreeBuildMode::NoRoll, rng)); } { // Everything ignores the chance entirely simtest::ScriptedRng rng; CHECK(RollEdgeAvailable(e, Species::Zuul, TreeBuildMode::Everything, rng)); } CHECK_NEAR(e.Chance(Species::Human), 0.5, 1e-7); CHECK_NEAR(e.Chance(Species::Liir), 1.0, 0.0); } static void test_cost() { CHECK_NEAR(TechCostMultiplier(0), 1.0, 0.0); CHECK_NEAR(TechCostMultiplier(1), 0.75, 0.0); CHECK_NEAR(TechCostMultiplier(3), 0.25, 0.0); CHECK_NEAR(TechCostMultiplier(4), 0.25, 0.0); // floored CHECK_EQ(TechCost(1000, 0.75), 750); CHECK_EQ(TechCost(1, 0.25), 1); // never below 1 CHECK_EQ(TechCost(kNoResearchCost, 0.5), kNoResearchCost); CHECK_EQ(TechCost(999, 1.0), 999); } static ResearchNode node(int cost, int progress = 0) { ResearchNode n; n.cost = cost; n.progress = progress; return n; } static void test_progress_below_half() { // cost 1000: lo 500, hi 1500. 400 points -> odds (400-500)/1500 < 0, roll 0 still fails. ResearchNode n = node(1000); simtest::ScriptedRng rng({0.0f}); ResearchStepResult r = ApplyResearchPoints(n, 400, Species::Human, rng); CHECK_EQ(r.spent, 400); CHECK_EQ(r.overbudget, 0); CHECK(!r.completed); CHECK(!r.overbudgetEvent); // odds is a float32 (the original stores it in a float slot before comparing) CHECK(r.odds == static_cast(-100.0 / 1500.0)); CHECK_EQ(n.progress, 400); CHECK(n.state == TechState::Available); CHECK_EQ(rng.floatDraws(), std::size_t{1}); } static void test_progress_at_cost() { // reaching 100 %: odds = 500/1500 = 1/3 { ResearchNode n = node(1000); simtest::ScriptedRng rng({0.3f}); ResearchStepResult r = ApplyResearchPoints(n, 1000, Species::Human, rng); CHECK(r.completed); CHECK(!r.completedEarly); CHECK(r.odds == static_cast(500.0 / 1500.0)); CHECK(n.state == TechState::Researched); CHECK(n.flag == TechFlag::Default); } { ResearchNode n = node(1000); simtest::ScriptedRng rng({0.34f}); ResearchStepResult r = ApplyResearchPoints(n, 1000, Species::Human, rng); CHECK(!r.completed); CHECK(r.overbudgetEvent); // crossed 100 % without finishing CHECK(n.flag == TechFlag::OverBudgetNotified); CHECK(n.state == TechState::Available); } } static void test_progress_cap_150() { ResearchNode n = node(1000); simtest::ScriptedRng rng; // no draw expected ResearchStepResult r = ApplyResearchPoints(n, 2000, Species::Human, rng); CHECK_EQ(r.spent, 1500); CHECK_EQ(r.overbudget, 500); CHECK_NEAR(r.odds, 1.0, 0.0); CHECK(r.completed); CHECK(!r.completedEarly); CHECK_EQ(rng.floatDraws(), std::size_t{0}); CHECK_EQ(n.progress, 1500); // exactly 150 % also completes without a roll ResearchNode m = node(1000, 1400); ResearchStepResult r2 = ApplyResearchPoints(m, 100, Species::Human, rng); CHECK(r2.completed); CHECK_EQ(r2.overbudget, 0); } static void test_completed_early() { // progress 700 of 1000: odds 200/1500 = 0.1333; roll 0.1 completes, below 80 % -> early ResearchNode n = node(1000, 300); simtest::ScriptedRng rng({0.1f}); ResearchStepResult r = ApplyResearchPoints(n, 400, Species::Human, rng); CHECK(r.completed); CHECK(r.completedEarly); CHECK(n.flag == TechFlag::CompletedEarly); // wasCompleteBefore/nowComplete both false: no over-budget event CHECK(!r.overbudgetEvent); } static void test_zero_spend() { ResearchNode n = node(1000, 600); simtest::ScriptedRng rng; ResearchStepResult r = ApplyResearchPoints(n, 0, Species::Human, rng); CHECK_EQ(r.spent, 0); CHECK(!r.completed); CHECK_NEAR(r.odds, 0.0, 0.0); CHECK_NEAR(static_cast(r.roll), 1.0, 0.0); CHECK_EQ(rng.floatDraws(), std::size_t{0}); } static void test_zuul_double_roll() { { // Zuul: 0.9 then 0.1 -> keeps 0.1 -> completes at 100 % ResearchNode n = node(1000); simtest::ScriptedRng rng({0.9f, 0.1f}); ResearchStepResult r = ApplyResearchPoints(n, 1000, Species::Zuul, rng); CHECK(r.completed); CHECK_NEAR(static_cast(r.roll), 0.1, 1e-7); CHECK_EQ(rng.floatDraws(), std::size_t{2}); } { // same script for a Human: one draw of 0.9 -> fails ResearchNode n = node(1000); simtest::ScriptedRng rng({0.9f, 0.1f}); ResearchStepResult r = ApplyResearchPoints(n, 1000, Species::Human, rng); CHECK(!r.completed); CHECK_EQ(rng.floatDraws(), std::size_t{1}); } } static void test_decay() { CHECK_EQ(DecayResearchProgress(300, 1000), 250); CHECK_EQ(DecayResearchProgress(20, 1000), 0); // floored CHECK_EQ(DecayResearchProgress(10, 30), 9); // ftol(1.5) = 1 CHECK_EQ(DecayResearchProgress(0, 1000), 0); // A node with no known cost is not special-cased: the original feeds Cost()'s // INT_MAX straight into the 5 % multiply, which wipes any progress out. CHECK_EQ(DecayResearchProgress(50, kNoResearchCost), 0); std::vector nodes = {node(1000, 300), node(1000, 0), node(1000, 500)}; nodes[2].state = TechState::Researched; ResearchNode hidden = node(1000, 100); hidden.state = TechState::ParentResearched; nodes.push_back(hidden); DecayAllResearch(nodes); CHECK_EQ(nodes[0].progress, 250); CHECK_EQ(nodes[1].progress, 0); CHECK_EQ(nodes[2].progress, 500); // researched: untouched CHECK_EQ(nodes[3].progress, 100); // not available: untouched } static void test_lab_accident() { { simtest::ScriptedRng rng({}, {5, 10, 99}); CHECK(RollLabAccident(10, rng)); // 5 < 10 CHECK(!RollLabAccident(10, rng)); // 10 is not < 10 CHECK(!RollLabAccident(0, rng)); CHECK_EQ(rng.intDraws(), std::size_t{3}); } { // dyadic inputs so the ceil is not on a floating-point tie simtest::ScriptedRng rng({0.5f, 0.999f, 0.0f}); CHECK_EQ(LabAccidentLossPercent(0.125, 0.625, rng), 38); // 0.125 + 0.5 x 0.5 = 0.375 -> ceil(37.5) CHECK_EQ(LabAccidentLossPercent(0.125, 0.625, rng), 63); // 0.6245 -> ceil(62.45) CHECK_EQ(LabAccidentLossPercent(0.125, 0.625, rng), 13); // 0.125 -> ceil(12.5) CHECK_EQ(rng.floatDraws(), std::size_t{3}); } { simtest::ScriptedRng rng({0.5f}); CHECK_EQ(LabAccidentLossPercent(0.9, 1.5, rng), 100); // clamped to 1 } } static void test_determinism() { auto run = [] { std::vector nodes = {node(800), node(1200, 300)}; // Zuul: two draws per turn; the current target decays 40/turn too (cost 800). // Turn 1: 350, odds < 0. Turn 2: 310+350 = 660, odds 260/1200 vs min(0.8,0.5). // Turn 3: 620+350 = 970, odds 570/1200 = 0.475 vs min(0.3,0.9) -> completes. simtest::ScriptedRng rng({0.42f, 0.17f, 0.8f, 0.5f, 0.3f, 0.9f}); std::vector trace; for (int turn = 0; turn < 3; ++turn) { ResearchStepResult r = ApplyResearchPoints(nodes[0], 350, Species::Zuul, rng); trace.push_back(nodes[0].progress); trace.push_back(r.completed ? 1 : 0); DecayAllResearch(nodes); trace.push_back(nodes[1].progress); } trace.push_back(static_cast(rng.floatDraws())); return trace; }; CHECK(run() == run()); const std::vector expected = {350, 0, 240, 660, 0, 180, 970, 1, 120, 6}; CHECK(run() == expected); } // --- the pieces pinned against the original's instruction sequence ------------------ static void test_spend_window() { // The window is a 32-bit multiply and a truncating divide by 100, then lo floored at // 0 and hi raised to lo. CHECK_EQ(ResearchSpendFloor(1000), 500); CHECK_EQ(ResearchSpendCeiling(1000), 1500); CHECK_EQ(ResearchSpendFloor(1), 0); // 50/100 truncates to 0 CHECK_EQ(ResearchSpendCeiling(1), 1); // 150/100 truncates to 1 CHECK_EQ(ResearchSpendFloor(3), 1); // 150/100 CHECK_EQ(ResearchSpendCeiling(3), 4); // 450/100 CHECK_EQ(ResearchSpendFloor(-1000), 0); // negative floor is clamped away CHECK(ResearchSpendCeiling(-1000) >= ResearchSpendFloor(-1000)); // A cost near INT_MAX wraps in the 32-bit multiply rather than widening; the point of // the check is only that we wrap the same way, and that hi never drops below lo. CHECK_EQ(ResearchSpendFloor(kNoResearchCost), 0); CHECK(ResearchSpendCeiling(kNoResearchCost) >= 0); } static void test_odds_is_float32() { // (progress - lo) / hi in double, then narrowed. 1/3 is not representable, so the // narrowing is observable. const float odds = ResearchCompletionOdds(1000, 500, 1500); CHECK(odds == static_cast(500.0 / 1500.0)); CHECK(static_cast(odds) != 500.0 / 1500.0); CHECK(ResearchCompletionOdds(500, 500, 1500) == 0.f); CHECK(ResearchCompletionOdds(1500, 500, 1500) == static_cast(2.0 / 3.0)); } static void test_spend_is_not_floored() { // progress already past the 150 % ceiling: the spend goes negative rather than // clamping at zero, and the surplus lands in the over-budget counter. ResearchNode n = node(1000, 1600); simtest::ScriptedRng rng; ResearchStepResult r = ApplyResearchPoints(n, 100, Species::Human, rng); CHECK_EQ(r.spent, -100); // min(100, 1500 - 1600) CHECK_EQ(r.overbudget, 200); // points - spend CHECK_EQ(n.progress, 1500); CHECK(r.completed); // progress >= hi -> odds 1, roll 0, no draw CHECK_EQ(rng.floatDraws(), std::size_t{0}); } static void test_early_completion_threshold() { // The ratio is narrowed to float32 and compared against (double)0.8f, so a progress // exactly at 80 % of cost is NOT early. // exactly 80 %: (float)0.8 is not below (double)0.8f, so the node is not "early" ResearchNode n = node(1000, 799); simtest::ScriptedRng rng({0.0f}); ResearchStepResult r = ApplyResearchPoints(n, 1, Species::Human, rng); CHECK(r.completed); // odds (800-500)/1500 = 0.2 >= roll 0 CHECK_EQ(n.progress, 800); CHECK(!r.completedEarly); CHECK(n.flag == TechFlag::Default); // one point below the boundary: early ResearchNode m = node(1000, 798); simtest::ScriptedRng rng2({0.0f}); ResearchStepResult r2 = ApplyResearchPoints(m, 1, Species::Human, rng2); CHECK(r2.completed); CHECK_EQ(m.progress, 799); CHECK(r2.completedEarly); CHECK(m.flag == TechFlag::CompletedEarly); } static void test_process_turn() { // One allocation entry plus the decay sweep, in the original's order: the funded node // is decayed too, and a node that is merely available but idle loses 5 % of its cost. std::vector nodes = {node(1000, 0), node(400, 100), node(1000, 0)}; nodes[2].state = TechState::Hidden; // a slot the tree does not have simtest::ScriptedRng rng({0.9f}); std::vector alloc = {{0, 300}}; ResearchTurnResult r = ProcessResearchTurn(nodes, alloc, Species::Human, rng); CHECK_EQ(r.steps.size(), std::size_t{1}); CHECK_EQ(r.overbudget, 0); CHECK(!r.steps[0].completed); // odds (300-500)/1500 < 0 < 0.9 CHECK_EQ(nodes[0].progress, 250); // 300 spent, then -ftol(1000 * 0.05) CHECK_EQ(nodes[1].progress, 80); // idle: -ftol(400 * 0.05) CHECK_EQ(nodes[2].progress, 0); // hidden slots are never touched CHECK_EQ(rng.floatDraws(), std::size_t{1}); // An out-of-range entry is skipped without a draw. std::vector n2 = {node(1000, 0)}; simtest::ScriptedRng rng2; ResearchTurnResult r2 = ProcessResearchTurn(n2, {{7, 100}, {-1, 100}}, Species::Human, rng2); CHECK_EQ(r2.steps.size(), std::size_t{0}); CHECK_EQ(rng2.floatDraws(), std::size_t{0}); } static void test_overbudget_accumulates() { std::vector nodes = {node(100, 0)}; simtest::ScriptedRng rng({0.99f}); ResearchTurnResult r = ProcessResearchTurn(nodes, {{0, 500}}, Species::Human, rng); CHECK_EQ(r.steps[0].spent, 150); // capped at 150 % of cost CHECK_EQ(r.overbudget, 350); CHECK(r.steps[0].completed); // at the ceiling: guaranteed, no draw CHECK_EQ(rng.floatDraws(), std::size_t{0}); } int main() { test_edge_roll(); test_cost(); test_progress_below_half(); test_progress_at_cost(); test_progress_cap_150(); test_completed_early(); test_zero_spend(); test_zuul_double_roll(); test_decay(); test_lab_accident(); test_determinism(); test_spend_window(); test_odds_is_float32(); test_spend_is_not_floored(); test_early_completion_threshold(); test_process_turn(); test_overbudget_accumulates(); return simtest::finish("test_research"); }