344 lines
14 KiB
C++
344 lines
14 KiB
C++
#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<float>(-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<float>(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<double>(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<double>(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<ResearchNode> 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<ResearchNode> 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<int> 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<int>(rng.floatDraws()));
|
|
return trace;
|
|
};
|
|
CHECK(run() == run());
|
|
const std::vector<int> 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<float>(500.0 / 1500.0));
|
|
CHECK(static_cast<double>(odds) != 500.0 / 1500.0);
|
|
CHECK(ResearchCompletionOdds(500, 500, 1500) == 0.f);
|
|
CHECK(ResearchCompletionOdds(1500, 500, 1500) == static_cast<float>(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<ResearchNode> 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<ResearchAllocEntry> 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<ResearchNode> 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<ResearchNode> 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");
|
|
}
|