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
1033 lines
47 KiB
C++
1033 lines
47 KiB
C++
#include "game/sim/colony.h"
|
|
|
|
#include <cmath>
|
|
|
|
#include "check.h"
|
|
#include "game/sim/economy.h"
|
|
#include "game/sim/numeric.h"
|
|
|
|
using namespace sots::sim;
|
|
|
|
static TuningTable tuning() {
|
|
TuningTable t;
|
|
t.POPULATION_GROWTH_MOD = 1.2;
|
|
t.POPULATION_GROWTH_EXP = 2.0;
|
|
t.INDSYS_IMPERIAL_POPULATION_MOD = 0.1;
|
|
t.SLAVES_DEATH_RATE = 0.05;
|
|
t.SLAVES_DEATH_RATE_BYHAZARD = 0.5;
|
|
t.SLAVES_DEATH_RATE_BYOUTPUT = 0.1;
|
|
t.SLAVES_MIN_DEATHS = 0;
|
|
t.SLAVES_MAX_DEATHS = -1;
|
|
t.MORALE_INCREASE_OUTPUT = 75;
|
|
t.MORALE_INCREASE_OUTPUT_MOD = 1.1;
|
|
t.MORALE_DECREASE_OUTPUT = 25;
|
|
t.MORALE_DECREASE_OUTPUT_MOD = 0.9;
|
|
t.STATION_BONUS_IMPERIAL_OUTPUT = 0.1;
|
|
t.STATION_BONUS_SHIPCON = 0.25;
|
|
t.ADDICTION_OUTPUT_MOD = 0.5;
|
|
t.ADDICTION_INCOME_MOD = 0.9;
|
|
t.SLAVES_INCOME_MOD = 3.0;
|
|
t.SYSTEMBONUS_MINTURNS = 10;
|
|
t.SYSTEMBONUS_POPBONUS = 0.1;
|
|
t.SYSTEMBONUS_POPBONUS_HOME = 0.2;
|
|
t.SYSTEMBONUS_POPBONUS_INC = 0.01;
|
|
t.SYSTEMBONUS_INFRABONUS = 0.2;
|
|
t.SYSTEMBONUS_INFRABONUS_HOME = 0.5;
|
|
t.SYSTEMBONUS_INFRABONUS_INC = 0.05;
|
|
return t;
|
|
}
|
|
|
|
static void test_capacity() {
|
|
TuningTable t = tuning();
|
|
CapacityInputs c;
|
|
c.planetSize = 5;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{500000000});
|
|
c.hazardMod = 0.5;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{250000000});
|
|
c.arcologyTech = true;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{350000000});
|
|
c.group = PopGroup::Civilian;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{450000000});
|
|
c.group = PopGroup::Slaves;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{250000000}); // no arcology bonus for slaves
|
|
c.group = PopGroup::Imperial;
|
|
c.groupMaxEnabled = true;
|
|
c.groupMax = 300000000;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{300000000});
|
|
c.ownerIsNpc = true;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{30000000});
|
|
c.ownerIsNpc = false;
|
|
c.ownerIsDifferentSpecies = true;
|
|
c.crossSpeciesMod = 0.5;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{225000000}); // 5e8 x 0.5 x 0.5 + 1e8
|
|
c.species = Species::NPC;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{0});
|
|
c.species = Species::Liir;
|
|
c.speciesCanLive = false;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{0});
|
|
c.speciesCanLive = true;
|
|
c.planetSize = 0;
|
|
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{100000000}); // arcology alone
|
|
|
|
// hazard = clamp01(1 - |suit - ideal| / (tol + 0.1))
|
|
CHECK_NEAR(HazardModifier(0.5, 0.5, 0.2), 1.0, 0.0);
|
|
CHECK_NEAR(HazardModifier(0.6, 0.5, 0.2), 1.0 - 0.1 / 0.3, 1e-12);
|
|
CHECK_NEAR(HazardModifier(0.5, 0.65, 0.2), 0.5, 1e-12); // symmetric
|
|
CHECK_NEAR(HazardModifier(0.8, 0.5, 0.2), 0.0, 0.0); // at the band edge
|
|
CHECK_NEAR(HazardModifier(0.9, 0.5, 0.2), 0.0, 0.0);
|
|
CHECK_NEAR(HazardModifier(0.55, 0.5, 0.0), 0.5, 1e-12); // zero tolerance keeps a 0.1 band
|
|
CHECK_NEAR(HazardModifier(0.7, 0.5, 0.0), 0.0, 0.0);
|
|
// both adaptation techs: 0.2 + 0.75 + 1.5 -> band 2.55
|
|
CHECK_NEAR(HazardModifier(0.8, 0.5, 2.45), 1.0 - 0.3 / 2.55, 1e-12);
|
|
}
|
|
|
|
static void test_growth() {
|
|
TuningTable t = tuning();
|
|
// B4: the curve is driven by suitability, not by how full the colony is. `tol` is the
|
|
// owner's SuitTol and doubles as the divisor, so d/tol is the fraction of the habitable
|
|
// band the planet is off by.
|
|
GrowthInputs g;
|
|
g.suitTolerance = 1.0;
|
|
g.idealSuitability = 1.0;
|
|
g.suitability = 0.5; // half a band off: base 0.5, ^2 = 0.25, x1.2 = 0.3
|
|
g.pop = 500000;
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{150000});
|
|
|
|
g.playerPopMod = 0.5; // 75000
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{75000});
|
|
g.playerPopMod = 1.0;
|
|
g.groupGrowthMult = 2.0; // 300000
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{300000});
|
|
g.groupGrowthMult = 0.0; // a zero column is ignored, not applied
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{150000});
|
|
g.extraFactor = 0.0; // so is a zero extra factor
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{150000});
|
|
g.extraFactor = 1.0;
|
|
|
|
g.pop = 0; // an empty group does not grow at all
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0});
|
|
g.pop = 1; // trunc(1 x 0.3) == 0 -> forced to 1
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{1});
|
|
g.pop = 500000;
|
|
|
|
g.suitability = 1.0; // exactly at the ideal: base 1
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{600000}); // 1 x 1.2 x 5e5
|
|
g.suitability = 0.0; // a whole band off: base 0 -> no growth
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0});
|
|
g.suitability = -5.0; // clamped up to 0 first, so still a full band
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0});
|
|
g.suitability = 0.5;
|
|
|
|
g.accommodated = true; // suitability ignored entirely: base 1
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{600000});
|
|
g.accommodated = false;
|
|
|
|
g.blockaded = true;
|
|
CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0});
|
|
g.blockaded = false;
|
|
|
|
// the suitability distance clamps the planet's own value into [0, 20] before the
|
|
// difference, and is itself capped by the tolerance
|
|
CHECK_NEAR(GrowthSuitabilityDistance(25.0, 0.0, 100.0, false), 20.0, 1e-6);
|
|
CHECK_NEAR(GrowthSuitabilityDistance(-3.0, 5.0, 100.0, false), 5.0, 1e-6);
|
|
CHECK_NEAR(GrowthSuitabilityDistance(0.0, 5.0, 2.0, false), 2.0, 1e-6);
|
|
CHECK_NEAR(GrowthSuitabilityDistance(0.0, 5.0, 2.0, true), 0.0, 0.0);
|
|
|
|
// the exponent is clamped into [0.01f, 1000] before pow()
|
|
TuningTable big = t;
|
|
big.POPULATION_GROWTH_EXP = 100000.0;
|
|
TuningTable capped = t;
|
|
capped.POPULATION_GROWTH_EXP = 1000.0;
|
|
CHECK_NEAR(PopulationGrowthFraction(g, big), PopulationGrowthFraction(g, capped), 0.0);
|
|
TuningTable tiny = t;
|
|
tiny.POPULATION_GROWTH_EXP = 1e-9;
|
|
TuningTable floored = t;
|
|
floored.POPULATION_GROWTH_EXP = kGrowthExponentMin;
|
|
CHECK_NEAR(PopulationGrowthFraction(g, tiny), PopulationGrowthFraction(g, floored), 0.0);
|
|
|
|
CHECK_EQ(ApplyImperialGrowth(500000, 1000000, 150000), std::int64_t{650000});
|
|
CHECK_EQ(ApplyImperialGrowth(999999, 1000000, 5), std::int64_t{1000000}); // lands on the cap
|
|
CHECK_EQ(ApplyImperialGrowth(2000000, 1000000, 0), std::int64_t{1000000});
|
|
CHECK_EQ(ApplyImperialGrowth(1000000000, 100000000, 0), std::int64_t{950000000}); // shrink capped
|
|
CHECK_EQ(ApplyImperialGrowth(50, 10, 0), std::int64_t{50}); // floor min(pop, 100)
|
|
CHECK_EQ(ApplyImperialGrowth(500, 10, 0), std::int64_t{100});
|
|
// the 50,000,000 cap is on the delta, and it lives in the apply, not in the fraction
|
|
CHECK_EQ(ApplyImperialGrowth(1000, 2000000000, 100000000), std::int64_t{50001000});
|
|
CHECK_EQ(ApplyImperialGrowth(1000, 2000000000, -100), std::int64_t{900});
|
|
}
|
|
|
|
static void test_infra_terraform() {
|
|
CHECK_NEAR(InfrastructurePointsNeeded(0.0), 30304.0, 0.0); // ceil(30303.03)
|
|
CHECK_NEAR(InfrastructurePointsNeeded(1.0), 0.0, 0.0);
|
|
CHECK_NEAR(InfrastructureGain(500), 0.0165, 1e-7);
|
|
CHECK_NEAR(InfrastructureGain(1000), 0.033, 1e-7);
|
|
CHECK_NEAR(InfrastructureGain(-100), 0.0, 0.0); // clamped at zero
|
|
double unused = -1;
|
|
double delta = ApplyInfrastructurePoints(0.5, 100000, &unused);
|
|
CHECK_NEAR(unused, 100000.0 - 15152.0, 0.0); // ceil(0.5 / 3.3e-5)
|
|
CHECK_NEAR(ApplyInfrastructureDelta(0.5, delta), 1.0, 0.0); // clamped to exactly 1
|
|
delta = ApplyInfrastructurePoints(0.5, 1000, &unused);
|
|
CHECK_NEAR(unused, 0.0, 0.0);
|
|
CHECK_NEAR(ApplyInfrastructureDelta(0.5, delta), 0.533, 1e-7);
|
|
CHECK_NEAR(ApplyInfrastructureDelta(1.0, 0.5), 1.0, 0.0); // already built out: no-op
|
|
CHECK_NEAR(DecayUnownedInfrastructure(0.5), 0.48, 1e-7);
|
|
CHECK_NEAR(DecayUnownedInfrastructure(0.01), 0.0, 0.0);
|
|
// the decay constant is the widened float literal, not the decimal 0.02
|
|
CHECK(DecayUnownedInfrastructure(0.5) != 0.5 - 0.02);
|
|
|
|
// the terraforming modifier is inside the point count, and the result is a ceil
|
|
CHECK_NEAR(TerraformPointsNeeded(0.5, 0.8, 1.0), 3334.0, 0.0);
|
|
CHECK_NEAR(TerraformPointsNeeded(0.8, 0.5, 1.0), 3334.0, 0.0);
|
|
CHECK_NEAR(TerraformPointsNeeded(0.5, 0.8, 2.0), 1667.0, 0.0); // twice the modifier, half the points
|
|
CHECK_NEAR(TerraformPointsNeeded(0.5, 0.5, 1.0), 0.0, 0.0);
|
|
CHECK_NEAR(TerraformDelta(1000, 1.0, 0.5, 0.8), 0.09, 1e-7);
|
|
CHECK_NEAR(TerraformDelta(1000, 1.0, 0.8, 0.5), -0.09, 1e-7);
|
|
CHECK_NEAR(TerraformDelta(1000, 1.0, 0.5, 0.5), 0.09, 1e-7); // at the ideal the sign is +1
|
|
CHECK_NEAR(TerraformDelta(1000, 2.0, 0.5, 0.8), 0.18, 1e-7);
|
|
CHECK_NEAR(TerraformDelta(0, 2.0, 0.5, 0.8), 0.0, 0.0);
|
|
// suitability stops at the ideal from whichever side it came
|
|
CHECK_NEAR(ApplyTerraformDelta(0.5, 0.09, 0.8), 0.59, 1e-7);
|
|
CHECK_NEAR(ApplyTerraformDelta(0.75, 0.09, 0.8), 0.8, 1e-7);
|
|
CHECK_NEAR(ApplyTerraformDelta(0.85, -0.09, 0.8), 0.8, 1e-7);
|
|
CHECK_NEAR(ApplyTerraformDelta(0.8, 0.09, 0.8), 0.8, 0.0);
|
|
}
|
|
|
|
static void test_slaves() {
|
|
TuningTable t = tuning();
|
|
SpeciesTechFlags f;
|
|
// 0.5 x 0.1 + 0.2 x 0.5 + 0.05 = 0.2
|
|
CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.2, 1e-6);
|
|
f.translation1 = true;
|
|
CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.16, 1e-6);
|
|
f.translation2 = f.translation3 = true;
|
|
CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.08, 1e-6);
|
|
SpeciesTechFlags none;
|
|
CHECK_NEAR(SlaveDeathRate(0.0, 0.5, 0.5, none, t), 0.05, 1e-6); // base rate only
|
|
// an unowned system short-circuits to a rate of 1, not 0
|
|
CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, none, t, false), 1.0, 0.0);
|
|
|
|
SpeciesTechFlags bits = SpeciesTechFlags::FromBits(0x087); // bits 0,1,2,7
|
|
CHECK(bits.translation1 && bits.translation2 && bits.translation3 && bits.accommodate);
|
|
CHECK(!bits.incorporate && !bits.addict && !bits.temperance && !bits.subjugate && !bits.proliferate);
|
|
CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, bits, t), 0.08, 1e-6);
|
|
CHECK(SpeciesTechFlags::FromBits(0x100).proliferate);
|
|
CHECK(SpeciesTechFlags::FromBits(0x020).temperance);
|
|
|
|
CHECK_EQ(SlaveDeaths(1000, 0.2, 0.0, t), std::int64_t{200});
|
|
CHECK_EQ(SlaveDeaths(0, 0.2, 0.0, t), std::int64_t{0});
|
|
CHECK_EQ(SlaveDeaths(1000, 0.2, 0.1, t), std::int64_t{300}); // the plague rate ADDS
|
|
t.SLAVES_MIN_DEATHS = 300;
|
|
CHECK_EQ(SlaveDeaths(1000, 0.2, 0.0, t), std::int64_t{300});
|
|
CHECK_EQ(SlaveDeaths(100, 0.2, 0.0, t), std::int64_t{100}); // never more than present
|
|
t.SLAVES_MAX_DEATHS = 150;
|
|
CHECK_EQ(SlaveDeaths(1000, 0.2, 0.0, t), std::int64_t{150});
|
|
t.SLAVES_MIN_DEATHS = -1; // any negative disables it
|
|
t.SLAVES_MAX_DEATHS = -7;
|
|
CHECK_EQ(SlaveDeaths(1000, 0.2, 0.0, t), std::int64_t{200});
|
|
t.SLAVES_MIN_DEATHS = 0;
|
|
t.SLAVES_MAX_DEATHS = -1;
|
|
CHECK_EQ(SlaveDeaths(7, 0.2, 0.0, t), std::int64_t{1}); // 1.4 truncates
|
|
}
|
|
|
|
static void test_output() {
|
|
TuningTable t = tuning();
|
|
// B4: the trade slider is PINNED. Everything else is rescaled to what is left of 1.
|
|
OutputRates r = NormaliseOutputRates({1, 1, 1, 1}, false, false);
|
|
CHECK_NEAR(r.trade, 1.0, 0.0);
|
|
CHECK_NEAR(r.construction, 0.0, 0.0); // nothing left over for the other three
|
|
CHECK_NEAR(r.terraform, 0.0, 0.0);
|
|
CHECK_NEAR(r.infra, 0.0, 0.0);
|
|
|
|
r = NormaliseOutputRates({0.25, 0.25, 0.25, 0.25}, false, false);
|
|
CHECK_NEAR(r.trade, 0.25, 0.0); // untouched
|
|
CHECK_NEAR(r.construction, 0.25, 1e-7);
|
|
CHECK_NEAR(r.terraform, 0.25, 1e-7);
|
|
CHECK_NEAR(r.infra, 0.25, 1e-7);
|
|
|
|
r = NormaliseOutputRates({0, 1, 1, 1}, true, false);
|
|
CHECK_NEAR(r.terraform, 0.0, 0.0);
|
|
CHECK_NEAR(r.construction, 0.5, 1e-7);
|
|
CHECK_NEAR(r.infra, 0.5, 1e-7);
|
|
|
|
// all-zero: the three unpinned channels split evenly, trade stays at zero
|
|
r = NormaliseOutputRates({0, 0, 0, 0}, false, false);
|
|
CHECK_NEAR(r.trade, 0.0, 0.0);
|
|
CHECK_NEAR(r.construction, 1.0 / 3.0, 1e-6);
|
|
CHECK_NEAR(r.terraform, 1.0 / 3.0, 1e-6);
|
|
CHECK_NEAR(r.infra, 1.0 / 3.0, 1e-6);
|
|
// ... and the suppressions still apply inside the fallback
|
|
r = NormaliseOutputRates({0, 0, 0, 0}, true, true);
|
|
CHECK_NEAR(r.construction, 1.0, 1e-7);
|
|
CHECK_NEAR(r.terraform, 0.0, 0.0);
|
|
CHECK_NEAR(r.infra, 0.0, 0.0);
|
|
|
|
// a slider at or below the threshold counts as off
|
|
r = NormaliseOutputRates({kOutputRateThreshold, 1, 0, 0}, false, false);
|
|
CHECK_NEAR(r.trade, 0.0, 0.0);
|
|
CHECK_NEAR(r.construction, 1.0, 1e-7);
|
|
|
|
r = NormaliseOutputRates({-1, 3, 0, 1}, false, true);
|
|
CHECK_NEAR(r.trade, 0.0, 0.0);
|
|
CHECK_NEAR(r.construction, 1.0, 1e-7);
|
|
CHECK_NEAR(r.infra, 0.0, 0.0);
|
|
|
|
CHECK_NEAR(MoraleOutputMultiplier(80, t), 1.1, 0.0);
|
|
CHECK_NEAR(MoraleOutputMultiplier(75, t), 1.1, 0.0);
|
|
CHECK_NEAR(MoraleOutputMultiplier(50, t), 1.0, 0.0);
|
|
CHECK_NEAR(MoraleOutputMultiplier(25, t), 0.9, 0.0);
|
|
// A morale entry of exactly 0 means "no record": the thresholds are not consulted,
|
|
// which matters because 0 <= MORALE_DECREASE_OUTPUT would otherwise apply the penalty.
|
|
CHECK_NEAR(MoraleOutputMultiplier(0, t), 1.0, 0.0);
|
|
// A modifier that is not strictly positive is ignored (an unloaded tuning table has
|
|
// every field at zero, and a zero multiplier would silently wipe the term).
|
|
TuningTable zero;
|
|
zero.MORALE_INCREASE_OUTPUT = 60;
|
|
CHECK_NEAR(MoraleOutputMultiplier(80, zero), 1.0, 0.0);
|
|
|
|
OutputModifiers m;
|
|
m.baseOutput = 1000;
|
|
CHECK_NEAR(TotalSystemOutput(m, t), 1000.0, 0.0);
|
|
m.addictionPhase3 = true;
|
|
CHECK_NEAR(TotalSystemOutput(m, t), 500.0, 0.0);
|
|
m.addictionPhase3 = false;
|
|
m.playerOutMod = 0.5;
|
|
m.systemOutMod = 0.5;
|
|
CHECK_NEAR(TotalSystemOutput(m, t), 250.0, 0.0);
|
|
// B4: the engine's round is ties-to-EVEN, so 302.5 goes DOWN to 302 (it used to be 303)
|
|
m.baseOutput = 1210;
|
|
CHECK_NEAR(TotalSystemOutput(m, t), 302.0, 0.0);
|
|
m.baseOutput = 0;
|
|
CHECK_NEAR(TotalSystemOutput(m, t), 0.0, 0.0);
|
|
// No owner and a rebelling system both return zero before any multiplier runs.
|
|
m.baseOutput = 1000;
|
|
m.playerOutMod = 1.0;
|
|
m.systemOutMod = 1.0;
|
|
m.owned = false;
|
|
CHECK_NEAR(TotalSystemOutput(m, t), 0.0, 0.0);
|
|
m.owned = true;
|
|
m.rebelling = true;
|
|
CHECK_NEAR(TotalSystemOutput(m, t), 0.0, 0.0);
|
|
m.rebelling = false;
|
|
|
|
// ---- the population -> output term (lane N) ----------------------------------------
|
|
// Output per head is typeOutputMod x 1.8 / 500000; the imperial row's modifier is 1.
|
|
GroupOutputInputs g;
|
|
g.group = PopGroup::Imperial;
|
|
g.count = 2000000000LL;
|
|
CHECK_NEAR(GroupOutput(g, t), 7200.0, 1e-9); // 2e9 / 5e5 x 1.8
|
|
g.stations = 2; // 1 + 2 x 0.1
|
|
CHECK_NEAR(GroupOutput(g, t), 8640.0, 1e-9);
|
|
g.stations = 0;
|
|
g.count = 0;
|
|
CHECK_NEAR(GroupOutput(g, t), 0.0, 0.0);
|
|
g.count = -5;
|
|
CHECK_NEAR(GroupOutput(g, t), 0.0, 0.0);
|
|
// The station bonus is imperial-only, and civilians carry the morale multiplier.
|
|
g.group = PopGroup::Civilian;
|
|
g.count = 500000000LL;
|
|
g.stations = 4;
|
|
g.morale = 0;
|
|
CHECK_NEAR(GroupOutput(g, t), 500000000.0 / 500000.0 * F32(0.33) * 1.8, 1e-9);
|
|
g.morale = 80; // above MORALE_INCREASE_OUTPUT
|
|
CHECK_NEAR(GroupOutput(g, t), 500000000.0 / 500000.0 * F32(0.33) * 1.8 * 1.1, 1e-9);
|
|
g.independent = true; // an independent colony has no morale
|
|
CHECK_NEAR(GroupOutput(g, t), 500000000.0 / 500000.0 * F32(0.33) * 1.8, 1e-9);
|
|
|
|
// The whole base-output sum on the reference save's human homeworld, with the two
|
|
// data-file species fields left at zero so only the terms the executable carries move.
|
|
BaseOutputInputs b;
|
|
b.imperialPopulation = 2000000000LL; // Pop 1e9 + pbon 1e9
|
|
b.civilianPopulation = 500000000LL;
|
|
b.civilianMorale = 75;
|
|
b.transitResources = 0;
|
|
b.resourcesAvailable = 5000;
|
|
b.infra = 1.0f;
|
|
b.infraBonus = 1.0f;
|
|
b.overHarvestRate = 0.0;
|
|
// cbrt(2e9/100) x 0.01 = 2.71 -> clamps to 1, and a clamped value at or above 1 - 1e-4
|
|
// is *substituted* by the infrastructure term rather than capping it. Infra + ibon = 2
|
|
// here, so the fraction is 2, not 1 -- the branch is a substitution, not a min, and a
|
|
// pending infrastructure bonus can push a colony's extraction above unity.
|
|
CHECK_NEAR(StripMineFraction({b.imperialPopulation, b.infra, b.infraBonus}), 2.0f, 0.0);
|
|
// ... and a colony whose population term has not saturated is capped by it as usual.
|
|
CHECK_NEAR(StripMineFraction({1000000, 0.4f, 0.0f}), 0.21544346f, 1e-6f);
|
|
CHECK_NEAR(StripMineFraction({100000000LL, 0.4f, 0.0f}), 0.4f, 0.0);
|
|
const double expected = 7200.0 + 500000000.0 / 500000.0 * F32(0.33) * 1.8 * 1.1 + 9000.0;
|
|
CHECK_NEAR(SystemBaseOutput(b, t), expected, 1e-6);
|
|
// Linear in population: a tenth of the imperial pop is a tenth of that term.
|
|
b.imperialPopulation = 200000000LL;
|
|
b.civilianPopulation = 0;
|
|
b.resourcesAvailable = 0;
|
|
CHECK_NEAR(SystemBaseOutput(b, t), 720.0, 1e-9);
|
|
// With SRoh = 0 the over-harvest demand degenerates to min(available, speciesBaseDemand).
|
|
OverHarvestInputs oh;
|
|
oh.resourcesAvailable = 5000;
|
|
oh.population = 2000000000LL;
|
|
oh.speciesBaseDemand = 120;
|
|
CHECK_NEAR(OverHarvestDemand(oh), 120.0, 0.0);
|
|
oh.speciesBaseDemand = 9000;
|
|
CHECK_NEAR(OverHarvestDemand(oh), 5000.0, 0.0);
|
|
// ... and with SRoh > 0 it adds rate x available x clamp01(pop x 1e-5), floored at 1.
|
|
oh.speciesBaseDemand = 0;
|
|
oh.overHarvestRate = 0.5;
|
|
CHECK_NEAR(OverHarvestDemand(oh), 2500.0, 1e-9); // clamp01(2e9 x 1e-5) = 1
|
|
oh.population = 10000; // clamp01(0.1)
|
|
CHECK_NEAR(OverHarvestDemand(oh), 250.0, 1e-9);
|
|
oh.population = 0; // the floor of 1, not 0
|
|
CHECK_NEAR(OverHarvestDemand(oh), 1.0, 0.0);
|
|
|
|
// The population-type table the executable builds in code.
|
|
CHECK_NEAR(PopTypeOf(PopGroup::Imperial, t).outputMod, 1.0, 0.0);
|
|
CHECK_NEAR(PopTypeOf(PopGroup::Civilian, t).outputMod, F32(0.33), 0.0);
|
|
CHECK_EQ(static_cast<int>(PopTypeOf(PopGroup::Imperial, t).maxPopulation), 50000000);
|
|
CHECK_EQ(static_cast<int>(PopTypeOf(PopGroup::Civilian, t).maxPopulation), 20000000);
|
|
CHECK_NEAR(RoundHalfEven(0.5), 0.0, 0.0);
|
|
CHECK_NEAR(RoundHalfEven(1.5), 2.0, 0.0);
|
|
CHECK_NEAR(RoundHalfEven(-2.5), -2.0, 0.0);
|
|
|
|
OutputSplit s = SplitOutput(1000, {0.5, 0.25, 0.125, 0.125});
|
|
CHECK_NEAR(s.trade, 500.0, 0.0);
|
|
CHECK_NEAR(s.construction, 250.0, 0.0);
|
|
CHECK_NEAR(s.terraform, 125.0, 0.0);
|
|
CHECK_NEAR(s.infra, 125.0, 0.0);
|
|
|
|
CHECK_EQ(ConstructionPoints(250, 2, t), 375);
|
|
CHECK_EQ(ConstructionPoints(250, 0, t), 250);
|
|
CHECK_EQ(ConstructionPoints(3, 1, t), 3); // 3.75 TRUNCATES, it does not round
|
|
|
|
OutputSplit l = SplitLeftover(100, {0.5, 0.25, 0.125, 0.125}, false, false);
|
|
CHECK_NEAR(l.trade, 67.0, 0.0);
|
|
CHECK_NEAR(l.terraform, 17.0, 0.0);
|
|
CHECK_NEAR(l.infra, 17.0, 0.0); // and 67+17+17 != 100
|
|
l = SplitLeftover(100, {0, 1, 0, 0}, true, false); // construction rate exactly 1
|
|
CHECK_NEAR(l.trade, 50.0, 0.0);
|
|
CHECK_NEAR(l.terraform, 0.0, 0.0);
|
|
CHECK_NEAR(l.infra, 50.0, 0.0);
|
|
l = SplitLeftover(100, {0, 1, 0, 0}, true, true);
|
|
CHECK_NEAR(l.trade, 100.0, 0.0);
|
|
l = SplitLeftover(0, {0.5, 0.25, 0.125, 0.125}, false, false);
|
|
CHECK_NEAR(l.trade, 0.0, 0.0);
|
|
}
|
|
|
|
static void test_system_money() {
|
|
CHECK_NEAR(SuitabilityCostMod(0.3, 0.5, 0.15, false, true), 0.15, 0.0); // capped by SuitTol
|
|
CHECK_NEAR(SuitabilityCostMod(0.45, 0.5, 0.15, false, true), 0.05, 1e-12);
|
|
CHECK_NEAR(SuitabilityCostMod(0.3, 0.5, 0.15, true, true), 0.0, 0.0); // rebel AI pays nothing
|
|
CHECK_NEAR(SuitabilityCostMod(0.5, 0.5, 0.15, false, false), 20.0, 0.0); // unowned
|
|
|
|
SystemMoneyInputs m;
|
|
m.tradePoints = 20; // 4 blocks x 5 = 100
|
|
CHECK_EQ(SystemMoneyIncome(m), 100);
|
|
m.tradePoints = 4.9; // no whole block
|
|
CHECK_EQ(SystemMoneyIncome(m), 0);
|
|
m.tradePoints = 123; // 24 blocks -> 600
|
|
CHECK_EQ(SystemMoneyIncome(m), 600);
|
|
|
|
m.popIncomeImperial = 100;
|
|
m.popIncomeCivilian = 50;
|
|
m.slaveIncome = 10;
|
|
m.speciesIncomeFactor = 1.1; // Zuul
|
|
m.speciesCostFactor = 0.7;
|
|
m.suitCostMod = 0.2;
|
|
// (600 + 160) x 1.1 = 836.0000000000001; cost 0.7 x 0.2 x 15000 = 2100;
|
|
// -1263.9999999999998 truncates toward zero
|
|
CHECK_EQ(SystemMoneyIncome(m), -1263);
|
|
m.speciesIncomeFactor = 1.0; // 760 - 0.25 x 15000 = -2990 exactly
|
|
m.speciesCostFactor = 1.0;
|
|
m.suitCostMod = 0.25;
|
|
CHECK_EQ(SystemMoneyIncome(m), -2990);
|
|
|
|
SystemMoneyInputs n;
|
|
n.tradePoints = 10;
|
|
n.speciesIncomeFactor = 0.8; // Morrigi
|
|
CHECK_EQ(SystemMoneyIncome(n), 40);
|
|
|
|
n.speciesIncomeFactor = 1.0;
|
|
n.tradePoints = 100; // 500
|
|
n.playerIncMod = 1.2; // 600
|
|
n.serverIncomeMod = 0.5;
|
|
n.difficultyIncomeMult = 2.0; // x1 net
|
|
CHECK_EQ(SystemMoneyIncome(n), 600);
|
|
|
|
SystemMoneyInputs unowned;
|
|
unowned.suitCostMod = 20.0; // 20 x 15000
|
|
CHECK_EQ(SystemMoneyIncome(unowned), -300000);
|
|
|
|
// the cost is not scaled by the income multipliers
|
|
SystemMoneyInputs c;
|
|
c.tradePoints = 10; // 50
|
|
c.playerIncMod = 3.0; // 150
|
|
c.suitCostMod = 0.01; // cost 150
|
|
CHECK_EQ(SystemMoneyIncome(c), 0);
|
|
|
|
CHECK_NEAR(ConstantsOf(Species::Zuul).incomeFactor, 1.1, 0.0);
|
|
CHECK_NEAR(ConstantsOf(Species::Zuul).hazardCostFactor, 0.7, 0.0);
|
|
CHECK_NEAR(ConstantsOf(Species::Morrigi).incomeFactor, 0.8, 0.0);
|
|
CHECK_NEAR(ConstantsOf(Species::Human).incomeFactor, 1.0, 0.0);
|
|
CHECK(!ConstantsOf(Species::Zuul).systemBonusEligible);
|
|
CHECK(ConstantsOf(Species::Hiver).systemBonusEligible);
|
|
|
|
// A von Neumann machine at the system zeroes the cost before anything else is looked at.
|
|
CHECK_NEAR(SuitabilityCostMod(0.3, 0.5, 0.15, false, true, true), 0.0, 0.0);
|
|
CHECK_NEAR(SuitabilityCostMod(0.5, 0.5, 0.15, false, false, true), 0.0, 0.0);
|
|
}
|
|
|
|
static void test_population_income() {
|
|
TuningTable t = tuning();
|
|
|
|
// Income per head is typeIncomeMod / 14000 -- no 1.8, no 500000. An imperial billion
|
|
// is 1e9/14000 = 71428.57..., truncated.
|
|
CHECK_EQ(GroupIncome(PopGroup::Imperial, 1000000000, t), 71428);
|
|
CHECK_EQ(GroupIncome(PopGroup::Imperial, 13999, t), 0);
|
|
CHECK_EQ(GroupIncome(PopGroup::Imperial, 14000, t), 1);
|
|
// The civilian row's modifier is the float32 0.33, so half a billion civilians give
|
|
// ftol(0.33000001311302185 x 35714.2857...) = 11785.
|
|
CHECK_EQ(GroupIncome(PopGroup::Civilian, 500000000, t), 11785);
|
|
CHECK_EQ(GroupIncome(PopGroup::Slaves, 14000, t), 3); // SLAVES_INCOME_MOD = 3
|
|
|
|
PopIncomeRow rows[kSpeciesCount] = {};
|
|
rows[0].count = 1000000000;
|
|
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, rows, true, false, t), 71428.0, 0.0);
|
|
|
|
// Two species truncate SEPARATELY, so the sum is not the truncation of the sum.
|
|
PopIncomeRow two[kSpeciesCount] = {};
|
|
two[0].count = 20999; // -> 1
|
|
two[2].count = 20999; // -> 1
|
|
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, two, true, false, t), 2.0, 0.0);
|
|
CHECK_EQ(GroupIncome(PopGroup::Imperial, 41998, t), 2); // ... which happens to agree here
|
|
PopIncomeRow three[kSpeciesCount] = {};
|
|
three[0].count = 13999; // -> 0
|
|
three[2].count = 13999; // -> 0
|
|
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, three, true, false, t), 0.0, 0.0);
|
|
CHECK_EQ(GroupIncome(PopGroup::Imperial, 27998, t), 1); // ... and here it does NOT
|
|
|
|
// Morale applies to the civilian row only, and the product truncates again.
|
|
PopIncomeRow mor[kSpeciesCount] = {};
|
|
mor[0].count = 500000000;
|
|
mor[0].morale = 80; // >= MORALE_INCREASE_OUTPUT (75)
|
|
CHECK_NEAR(PopulationIncome(PopGroup::Civilian, mor, true, false, t),
|
|
std::floor(11785.0 * 1.1), 0.0);
|
|
// ... but not to the imperial row.
|
|
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, mor, true, false, t),
|
|
static_cast<double>(GroupIncome(PopGroup::Imperial, 500000000, t)), 0.0);
|
|
// ... and an independent colony bypasses morale entirely.
|
|
CHECK_NEAR(PopulationIncome(PopGroup::Civilian, mor, true, true, t), 11785.0, 0.0);
|
|
|
|
// Addiction multiplies every row, imperial included.
|
|
PopIncomeRow add[kSpeciesCount] = {};
|
|
add[0].count = 1000000000;
|
|
add[0].addicted = true;
|
|
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, add, true, false, t),
|
|
std::floor(71428.0 * 0.9), 0.0);
|
|
}
|
|
|
|
static void test_max_income() {
|
|
// The whole chain, with the numbers a level-1 AI Zuul colony produces: the trade points
|
|
// are the rounded output total, blocks of five are worth five each after the x5, the
|
|
// species factor is 1.1 and the difficulty income modifier another 1.1.
|
|
SystemMoneyInputs m;
|
|
m.popIncomeImperial = 71428;
|
|
m.speciesIncomeFactor = ConstantsOf(Species::Zuul).incomeFactor;
|
|
m.speciesCostFactor = ConstantsOf(Species::Zuul).hazardCostFactor;
|
|
m.suitCostMod = 0.0; // a homeworld sits exactly at its ideal
|
|
m.difficultyIncomeMult = DifficultyModsFor(1, /*isAI=*/true, /*isNpc=*/false).incomeMult;
|
|
const int ai = SystemMaxIncome(12345.0, m);
|
|
m.difficultyIncomeMult = DifficultyModsFor(1, /*isAI=*/false, /*isNpc=*/false).incomeMult;
|
|
const int human = SystemMaxIncome(12345.0, m);
|
|
// The AI's advantage on this row is exactly the 1.1 in the difficulty table.
|
|
CHECK(ai > human);
|
|
CHECK_NEAR(static_cast<double>(ai) / static_cast<double>(human), 1.1, 1e-5);
|
|
|
|
// A colony whose money comes out negative contributes ZERO to the empire total rather
|
|
// than reducing it -- the `jg` at the end of ComputeMaxIncome.
|
|
SystemMoneyInputs bad;
|
|
bad.suitCostMod = 20.0; // 20 x 15000 of cost against no income
|
|
CHECK_EQ(SystemMoneyIncome(bad), -300000);
|
|
CHECK_EQ(SystemMaxIncome(0.0, bad), 0);
|
|
|
|
// The rate vector is trade = 1, so the trade points are the half-to-even rounded total.
|
|
SystemMoneyInputs r;
|
|
CHECK_EQ(SystemMaxIncome(20.5, r), SystemMoneyIncome([] {
|
|
SystemMoneyInputs x;
|
|
x.tradePoints = 20.0; // 20.5 ties to the even neighbour
|
|
return x;
|
|
}()));
|
|
CHECK_EQ(SystemMaxIncome(21.5, r), SystemMoneyIncome([] {
|
|
SystemMoneyInputs x;
|
|
x.tradePoints = 22.0;
|
|
return x;
|
|
}()));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// The turn path: ComputeOutput
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
static void test_ideal_suitability() {
|
|
IdealSuitabilityInputs in;
|
|
in.owned = false;
|
|
in.systemSuitability = 7.5;
|
|
in.ownerIdealSuitability = 11.0;
|
|
// An unowned system reports its OWN suitability, which is what makes it "at its ideal"
|
|
// and suppresses the terraform channel.
|
|
CHECK_NEAR(IdealSuitability(in), 7.5, 0.0);
|
|
|
|
in.owned = true;
|
|
CHECK_NEAR(IdealSuitability(in), 11.0, 0.0);
|
|
|
|
in.independent = true;
|
|
in.serverIdealSuitability = 9.25;
|
|
CHECK_NEAR(IdealSuitability(in), 9.25, 0.0);
|
|
|
|
// The per-system override beats both, and the sentinel is FLT_MAX.
|
|
in.systemOverride = 3.0;
|
|
CHECK_NEAR(IdealSuitability(in), 3.0, 0.0);
|
|
in.systemOverride = kIdealSuitabilityNoOverride;
|
|
CHECK_NEAR(IdealSuitability(in), 9.25, 0.0);
|
|
}
|
|
|
|
static void test_terraform_points() {
|
|
// A planet at its ideal needs nothing, whichever direction it would move.
|
|
CHECK_NEAR(TerraformPointsNeeded(10.0, 10.0, 1.0), 0.0, 0.0);
|
|
// The rate is TerraMod x 1.8f / 20000, and the sign cancels: the count is the same
|
|
// whether the planet is above or below the ideal.
|
|
const double up = TerraformPointsNeeded(9.0, 10.0, 1.0);
|
|
const double down = TerraformPointsNeeded(11.0, 10.0, 1.0);
|
|
CHECK_NEAR(up, down, 0.0);
|
|
// Our helper folds in the `ceil` that ComputeOutputFromRates applies to the original's
|
|
// return value, so the expected numbers are the ceilings.
|
|
CHECK_NEAR(up, std::ceil(1.0 / (1.8000000715255737 / 20000.0)), 0.0); // 11112
|
|
// A bigger TerraMod needs proportionally fewer points.
|
|
CHECK_NEAR(TerraformPointsNeeded(9.0, 10.0, 3.7),
|
|
std::ceil(1.0 / (3.7 * 1.8000000715255737 / 20000.0)), 0.0); // 3004
|
|
}
|
|
|
|
static void test_repair_pass() {
|
|
// The round robin's outcome, as a min. Every case the loop can reach:
|
|
RepairPassResult r = RepairShipsInOrbit(100, 0); // nothing damaged
|
|
CHECK_EQ(r.spent, 0);
|
|
CHECK_EQ(r.left, 100);
|
|
r = RepairShipsInOrbit(100, 40); // points win
|
|
CHECK_EQ(r.spent, 40);
|
|
CHECK_EQ(r.left, 60);
|
|
r = RepairShipsInOrbit(40, 100); // demand wins; nothing cascades
|
|
CHECK_EQ(r.spent, 40);
|
|
CHECK_EQ(r.left, 0);
|
|
r = RepairShipsInOrbit(0, 100); // the early return
|
|
CHECK_EQ(r.spent, 0);
|
|
CHECK_EQ(r.left, 0);
|
|
}
|
|
|
|
// A helper matching the corpus's shape: at the ideal, infrastructure exactly 1, no station,
|
|
// so the terraform and infrastructure channels are suppressed and both needs are zero.
|
|
static SystemOutputInputs CorpusColony(double trade, double construction, double total) {
|
|
SystemOutputInputs in;
|
|
in.rates.trade = trade;
|
|
in.rates.construction = construction;
|
|
in.suitAtIdeal = true;
|
|
in.infraFull = true;
|
|
in.infraExactlyOne = true;
|
|
in.infra = 1.0;
|
|
in.totalOutputRaw = total;
|
|
in.terraformPointsNeeded = 0.0;
|
|
return in;
|
|
}
|
|
|
|
static void test_turn_path_output() {
|
|
const TuningTable t; // unloaded: no station bonus, which the corpus never exercises
|
|
|
|
// 1. The claim the whole lane turns on: with an empty build queue and nothing to repair,
|
|
// every construction point comes back to the money channel, so a colony that puts
|
|
// everything into ship construction earns exactly as much as one that puts everything
|
|
// into trade.
|
|
{
|
|
const SystemOutput allTrade = ComputeSystemOutput(CorpusColony(1.0, 0.0, 4000.0), t);
|
|
const SystemOutput allCons = ComputeSystemOutput(CorpusColony(0.0, 1.0, 4000.0), t);
|
|
const SystemOutput half = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4000.0), t);
|
|
CHECK_EQ(allCons.money, allTrade.money);
|
|
CHECK_EQ(half.money, allTrade.money);
|
|
// and the leftover really is what carries it on the construction colony
|
|
CHECK_NEAR(allCons.tradePoints, 0.0, 0.0);
|
|
CHECK_NEAR(allCons.leftoverToTrade, 4000.0, 0.0);
|
|
CHECK_EQ(allCons.construction, 4000);
|
|
}
|
|
|
|
// 2. ... which makes it equal to the PROJECTED path, up to the trade-point rounding.
|
|
// An even total agrees exactly; an odd one can differ by one trade point because
|
|
// `2 x round(T/2)` is not `T`.
|
|
{
|
|
SystemMoneyInputs m;
|
|
const SystemOutput even = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4000.0), t);
|
|
CHECK_EQ(even.money, SystemMaxIncome(4000.0, m));
|
|
const SystemOutput odd = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4001.0), t);
|
|
// 4001 x 0.5 = 2000.5, ties to even -> 2000 twice, so 4000 trade points, not 4001.
|
|
CHECK_NEAR(odd.tradePoints + odd.leftoverToTrade, 4000.0, 0.0);
|
|
}
|
|
|
|
// 3. The build queue eats construction points BEFORE the leftover is redistributed, so a
|
|
// funded queue is a direct loss of money.
|
|
{
|
|
SystemOutputInputs in = CorpusColony(0.0, 1.0, 4000.0);
|
|
in.buildQueueDemand = 1500;
|
|
const SystemOutput o = ComputeSystemOutput(in, t);
|
|
CHECK_EQ(o.constructionToQueue, 1500);
|
|
CHECK_NEAR(o.leftoverToTrade, 2500.0, 0.0);
|
|
// a queue larger than the output takes all of it and leaves nothing
|
|
in.buildQueueDemand = 999999;
|
|
const SystemOutput starved = ComputeSystemOutput(in, t);
|
|
CHECK_EQ(starved.constructionToQueue, 4000);
|
|
CHECK_NEAR(starved.leftoverToTrade, 0.0, 0.0);
|
|
CHECK_EQ(starved.money, 0);
|
|
}
|
|
|
|
// 4. The repair pass takes its share after the queue and before the redistribution.
|
|
{
|
|
SystemOutputInputs in = CorpusColony(0.0, 1.0, 4000.0);
|
|
in.buildQueueDemand = 1000;
|
|
in.repairDemand = 700;
|
|
const SystemOutput o = ComputeSystemOutput(in, t);
|
|
CHECK_EQ(o.constructionToQueue, 1000);
|
|
CHECK_EQ(o.constructionToRepair, 700);
|
|
CHECK_NEAR(o.leftoverToTrade, 2300.0, 0.0);
|
|
}
|
|
|
|
// 5. The two cascades, which the max-income path proves ARE zero and this one does not.
|
|
// A colony below full infrastructure with a funded infra channel spends what it needs
|
|
// and passes the rest to terraforming; terraforming passes ITS rest to money.
|
|
{
|
|
SystemOutputInputs in;
|
|
in.rates.trade = 0.0;
|
|
in.rates.construction = 0.0;
|
|
in.rates.infra = 1.0;
|
|
in.suitAtIdeal = true; // so the terraform channel is suppressed and needs 0
|
|
in.infraFull = false;
|
|
in.infraExactlyOne = false;
|
|
in.infra = 1.0 - 3.3e-5 * 100.0; // exactly 100 points short of full
|
|
in.totalOutputRaw = 4000.0;
|
|
in.terraformPointsNeeded = 0.0;
|
|
const SystemOutput o = ComputeSystemOutput(in, t);
|
|
// 100 points close the infrastructure gap, the other 3900 fall through terraforming
|
|
// (which needs nothing) into the money channel.
|
|
CHECK_NEAR(o.leftoverToMoney, 3900.0, 1e-6);
|
|
CHECK(o.infraDelta > 0.0);
|
|
SystemMoneyInputs m;
|
|
m.tradePoints = 3900.0;
|
|
CHECK_EQ(o.money, SystemMoneyIncome(m));
|
|
}
|
|
|
|
// 6. A terraforming colony consumes what it needs and cascades the rest, and the sign of
|
|
// the suitability delta follows the direction of travel.
|
|
{
|
|
SystemOutputInputs in;
|
|
in.rates.terraform = 1.0;
|
|
in.suitAtIdeal = false;
|
|
in.infraFull = true;
|
|
in.infraExactlyOne = true;
|
|
in.infra = 1.0;
|
|
in.totalOutputRaw = 4000.0;
|
|
in.terraformPointsNeeded = 250.0;
|
|
in.terraformMod = 1.0;
|
|
const SystemOutput up = ComputeSystemOutput(in, t);
|
|
CHECK_NEAR(up.leftoverToMoney, 3750.0, 1e-6);
|
|
CHECK(up.suitabilityDelta > 0.0);
|
|
in.terraformDown = true;
|
|
const SystemOutput down = ComputeSystemOutput(in, t);
|
|
CHECK_NEAR(down.suitabilityDelta, -up.suitabilityDelta, 0.0);
|
|
// The point count and the point value use the same rate, so spending exactly the
|
|
// needed points closes exactly the gap it was computed from.
|
|
const double gap = 250.0 * (1.5 * kTerraform12 * 1.0) / 20000.0;
|
|
CHECK_NEAR(up.suitabilityDelta, static_cast<double>(static_cast<float>(gap)), 0.0);
|
|
}
|
|
|
|
// 7. The `SRsc == 1` leftover branch really is a different rule: with construction at
|
|
// exactly 1 the weights become 1 / (suit off ideal) / (infra below 1) rather than the
|
|
// sliders, so a colony that is off its ideal sends HALF its leftover to terraforming
|
|
// instead of all of it to trade.
|
|
{
|
|
SystemOutputInputs in;
|
|
in.rates.construction = 1.0;
|
|
in.suitAtIdeal = false; // terraform weight 1
|
|
in.infraFull = true; // infra suppressed by the normaliser ...
|
|
in.infraExactlyOne = true; // ... and weight 0 in the leftover split
|
|
in.infra = 1.0;
|
|
in.totalOutputRaw = 4000.0;
|
|
in.terraformPointsNeeded = 0.0; // nothing to spend it on, so it cascades to money
|
|
const SystemOutput o = ComputeSystemOutput(in, t);
|
|
CHECK_NEAR(o.normalisedRates.construction, 1.0, 0.0);
|
|
CHECK_NEAR(o.leftoverToTrade, 2000.0, 0.0);
|
|
CHECK_NEAR(o.leftoverToMoney, 2000.0, 0.0);
|
|
// Both halves reach the money channel here, so the total is the same as if it had
|
|
// all gone to trade -- the split matters only when terraforming has work to do.
|
|
SystemMoneyInputs m;
|
|
m.tradePoints = 4000.0;
|
|
CHECK_EQ(o.money, SystemMoneyIncome(m));
|
|
}
|
|
|
|
// 8. An unfunded channel produces nothing, and a total of zero produces no money.
|
|
{
|
|
const SystemOutput o = ComputeSystemOutput(CorpusColony(0.5, 0.5, 0.0), t);
|
|
CHECK_EQ(o.totalOutput, 0);
|
|
CHECK_EQ(o.construction, 0);
|
|
CHECK_EQ(o.money, 0);
|
|
}
|
|
}
|
|
|
|
static void test_difficulty_table() {
|
|
// Level 0 gives the break to the human; levels 1 and 2 give it to the AI.
|
|
const DifficultyMods e_ai = DifficultyModsFor(0, true, false);
|
|
const DifficultyMods e_pl = DifficultyModsFor(0, false, false);
|
|
CHECK_NEAR(e_ai.maintenanceDivisor, 1.0, 0.0);
|
|
CHECK_NEAR(e_ai.incomeMult, 1.0, 0.0);
|
|
CHECK_NEAR(e_pl.maintenanceDivisor, 1.5, 0.0);
|
|
CHECK_NEAR(e_pl.incomeMult, 1.5, 0.0);
|
|
|
|
const DifficultyMods n_ai = DifficultyModsFor(1, true, false);
|
|
CHECK_NEAR(n_ai.maintenanceDivisor, 3.0, 0.0);
|
|
CHECK_NEAR(n_ai.incomeMult, 1.1, 1e-7);
|
|
CHECK_NEAR(n_ai.researchMult, 1.5, 0.0);
|
|
CHECK_NEAR(DifficultyModsFor(1, false, false).incomeMult, 1.0, 0.0);
|
|
|
|
const DifficultyMods h_ai = DifficultyModsFor(2, true, false);
|
|
CHECK_NEAR(h_ai.maintenanceDivisor, 1000000.0, 0.0);
|
|
CHECK_NEAR(h_ai.incomeMult, 1.7, 1e-7);
|
|
CHECK_NEAR(h_ai.researchMult, 2.0, 0.0);
|
|
// Hard maintenance really is "divided by a million", i.e. free.
|
|
CHECK_EQ(MaintenanceCost(999999, h_ai.maintenanceDivisor), 0);
|
|
|
|
// An NPC player takes the non-AI triple whatever its AI flag says ...
|
|
CHECK_NEAR(DifficultyModsFor(1, true, true).incomeMult, 1.0, 0.0);
|
|
// ... and an out-of-range level falls back to all ones rather than failing.
|
|
CHECK_NEAR(DifficultyModsFor(-1, true, false).incomeMult, 1.0, 0.0);
|
|
CHECK_NEAR(DifficultyModsFor(3, true, false).incomeMult, 1.0, 0.0);
|
|
CHECK_NEAR(DifficultyModsFor(3, true, false).maintenanceDivisor, 1.0, 0.0);
|
|
}
|
|
|
|
static void test_bonuses() {
|
|
TuningTable t = tuning();
|
|
std::int64_t pop = 900, bonus = 500;
|
|
ApplyPopulationBonus(pop, 1000, bonus);
|
|
CHECK_EQ(pop, std::int64_t{1000});
|
|
CHECK_EQ(bonus, std::int64_t{400});
|
|
pop = 1200; // over cap: nothing applied
|
|
ApplyPopulationBonus(pop, 1000, bonus);
|
|
CHECK_EQ(pop, std::int64_t{1200});
|
|
CHECK_EQ(bonus, std::int64_t{400});
|
|
|
|
double infra = 0.95, ibon = 0.1;
|
|
BonusApplyResult br = ApplyInfrastructureBonus(infra, ibon);
|
|
CHECK_NEAR(infra, 1.0, 0.0); // the pool covered the remainder: exactly 1, not 0.999...
|
|
CHECK_NEAR(ibon, 0.05, 1e-7);
|
|
CHECK(br.applied);
|
|
CHECK(!br.resetTurnsDeveloping); // a home system is not reset
|
|
br = ApplyInfrastructureBonus(infra, ibon, /*homeSystem=*/false);
|
|
CHECK(!br.applied); // already at 1: nothing happens, no reset either
|
|
infra = 0.5;
|
|
ibon = 0.1;
|
|
br = ApplyInfrastructureBonus(infra, ibon, /*homeSystem=*/false);
|
|
CHECK_NEAR(infra, 0.6, 1e-7);
|
|
CHECK_NEAR(ibon, 0.0, 1e-7);
|
|
CHECK(br.resetTurnsDeveloping); // a non-home colony absorbing a bonus resets ntdev
|
|
|
|
std::int64_t up = 10, ub = 500;
|
|
ApplyPopulationBonus(up, 1000, ub, /*owned=*/false);
|
|
CHECK_EQ(up, std::int64_t{10}); // an unowned system drops the whole pool
|
|
CHECK_EQ(ub, std::int64_t{0});
|
|
|
|
SystemBonusInputs in;
|
|
in.stable = true;
|
|
in.turnsOwned = 11;
|
|
in.turnsDeveloping = 11;
|
|
in.capacity = 1000000;
|
|
std::int64_t pbon = 0;
|
|
double ibonus = 0;
|
|
AccrueSystemBonus(in, pbon, ibonus, t);
|
|
CHECK_EQ(pbon, std::int64_t{10000}); // 1e6 x 0.01
|
|
CHECK_NEAR(ibonus, 0.05, 1e-12);
|
|
for (int i = 0; i < 20; ++i) AccrueSystemBonus(in, pbon, ibonus, t);
|
|
CHECK_EQ(pbon, std::int64_t{100000}); // capped at 1e6 x 0.1
|
|
CHECK_NEAR(ibonus, 0.2, 1e-12); // capped at INFRABONUS
|
|
|
|
// the increment truncates: 12345 x 0.005 = 61.725 -> 61; target 1234.5 -> 1234
|
|
TuningTable small = t;
|
|
small.SYSTEMBONUS_POPBONUS_INC = 0.005;
|
|
in.capacity = 12345;
|
|
std::int64_t p3 = 0;
|
|
double i3 = 0;
|
|
AccrueSystemBonus(in, p3, i3, small);
|
|
CHECK_EQ(p3, std::int64_t{61});
|
|
for (int i = 0; i < 30; ++i) AccrueSystemBonus(in, p3, i3, small);
|
|
CHECK_EQ(p3, std::int64_t{1234});
|
|
p3 = 5000; // already above the target: untouched
|
|
AccrueSystemBonus(in, p3, i3, small);
|
|
CHECK_EQ(p3, std::int64_t{5000});
|
|
in.capacity = 1000000;
|
|
|
|
// Zuul never accrue either bonus
|
|
std::int64_t pz = 0;
|
|
double iz = 0;
|
|
in.ownerSpeciesEligible = false;
|
|
AccrueSystemBonus(in, pz, iz, t);
|
|
CHECK_EQ(pz, std::int64_t{0});
|
|
CHECK_NEAR(iz, 0.0, 0.0);
|
|
in.ownerSpeciesEligible = true;
|
|
|
|
std::int64_t p2 = 0;
|
|
double i2 = 0;
|
|
in.turnsOwned = 10; // not strictly more than MINTURNS
|
|
AccrueSystemBonus(in, p2, i2, t);
|
|
CHECK_EQ(p2, std::int64_t{0});
|
|
in.turnsOwned = 11;
|
|
in.stable = false;
|
|
AccrueSystemBonus(in, p2, i2, t);
|
|
CHECK_EQ(p2, std::int64_t{0});
|
|
in.stable = true;
|
|
in.turnsDeveloping = 10;
|
|
AccrueSystemBonus(in, p2, i2, t);
|
|
CHECK_EQ(p2, std::int64_t{0});
|
|
}
|
|
|
|
static void test_build_queue() {
|
|
std::vector<BuildOrder> q = {{1, 11, 100, 100, 50}, {2, 12, 200, 200, 0}, {3, 13, 300, 300, 70}};
|
|
BuildQueueResult r = ProcessBuildQueue(q, 250);
|
|
CHECK_EQ(r.completedOrderIds.size(), std::size_t{1});
|
|
CHECK_EQ(r.completedOrderIds[0], 11);
|
|
CHECK_EQ(r.moneyCharged, 50);
|
|
CHECK_EQ(r.pointsLeft, 0);
|
|
CHECK_EQ(q.size(), std::size_t{2});
|
|
CHECK_EQ(q[0].orderId, 12);
|
|
CHECK_EQ(q[0].constructionLeft, 50);
|
|
CHECK_EQ(q[1].constructionLeft, 300);
|
|
|
|
r = ProcessBuildQueue(q, 700);
|
|
CHECK_EQ(r.completedOrderIds.size(), std::size_t{2});
|
|
CHECK_EQ(r.moneyCharged, 70);
|
|
CHECK_EQ(r.pointsLeft, 350);
|
|
CHECK(q.empty());
|
|
|
|
r = ProcessBuildQueue(q, 100); // empty queue: points pass through
|
|
CHECK_EQ(r.pointsLeft, 100);
|
|
|
|
std::vector<BuildOrder> exact = {{1, 21, 100, 100, 0}};
|
|
r = ProcessBuildQueue(exact, 100); // exactly enough completes
|
|
CHECK_EQ(r.completedOrderIds.size(), std::size_t{1});
|
|
CHECK(exact.empty());
|
|
|
|
std::vector<BuildOrder> zero = {{1, 31, 100, 100, 0}};
|
|
r = ProcessBuildQueue(zero, 0);
|
|
CHECK(r.completedOrderIds.empty());
|
|
CHECK_EQ(zero[0].constructionLeft, 100);
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// G3: civilian growth, the step cap and the rescale
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
static void test_civilian_growth() {
|
|
// The two reference pairs, from the wire. Gamma Cephei: 500,000,000 civilians of one
|
|
// species, a `dcs` settle limit of 1,000,000,000 and a modelled capacity of 2,000,000,000.
|
|
// The growth fraction is float32(1.2f x 1.0 x 1.0 x 0.25) = 0.30000001192092896, so the
|
|
// uncapped delta is 150,000,005 -- 7.5x the step cap.
|
|
CivilianGrowthRow rows[kSpeciesCount] = {};
|
|
rows[0].delta = 150000005;
|
|
rows[0].current = 500000000;
|
|
rows[0].capacity = 2000000000;
|
|
rows[0].settleLimit = 1000000000;
|
|
CivilianGrowthResult r = GrowCivilianPopulations(rows, false);
|
|
CHECK_EQ(r.rawTotal, 150000005);
|
|
CHECK_EQ(r.committedTotal, 20000000);
|
|
CHECK(r.stepCapBound);
|
|
// THE float that decides the value: trunc(150000005 x (20000000 / 150000005)). The
|
|
// product's error can exceed half an ulp of 20,000,000, so this is a measurement, not a
|
|
// theorem, and one ulp low would cost a whole person and move the human's savings.
|
|
CHECK_EQ(r.applied[0], 20000000);
|
|
CHECK(!r.hitLimit[0]);
|
|
|
|
// Pair 2: the colony starts 20,000,000 higher and takes the same step again. This is the
|
|
// discriminator that proves POPTYPE[1]+0x08 is a per-turn STEP cap and not a population
|
|
// ceiling -- a ceiling of 20,000,000 would make the colony collapse instead.
|
|
rows[0].delta = 156000006;
|
|
rows[0].current = 520000000;
|
|
r = GrowCivilianPopulations(rows, false);
|
|
CHECK_EQ(r.applied[0], 20000000);
|
|
|
|
// Koa'Vo: the settle limit equals the current population exactly, so the headroom is zero
|
|
// and nothing grows -- and because the total is then zero the clamp does not bite, so the
|
|
// settle-limit flag survives to raise a morale event.
|
|
CivilianGrowthRow tight[kSpeciesCount] = {};
|
|
tight[2].delta = 241500000;
|
|
tight[2].current = 500000000;
|
|
tight[2].capacity = 1000000000;
|
|
tight[2].settleLimit = 500000000;
|
|
r = GrowCivilianPopulations(tight, false);
|
|
CHECK_EQ(r.applied[2], 0);
|
|
CHECK_EQ(r.committedTotal, 0);
|
|
CHECK(!r.stepCapBound);
|
|
CHECK(r.hitLimit[2]);
|
|
|
|
// A halted colony: the flag is cleared with the growth.
|
|
r = GrowCivilianPopulations(rows, true);
|
|
CHECK_EQ(r.applied[0], 0);
|
|
CHECK(!r.hitLimit[0]);
|
|
|
|
// A limit BELOW the current population is a shrink, floored by the decline clamp at
|
|
// -50,000,000 and rescaled the same way.
|
|
CivilianGrowthRow over[kSpeciesCount] = {};
|
|
over[0].delta = 0;
|
|
over[0].current = 900000000;
|
|
over[0].capacity = 2000000000;
|
|
over[0].settleLimit = 100000000;
|
|
r = GrowCivilianPopulations(over, false);
|
|
CHECK_EQ(r.rawTotal, -800000000);
|
|
CHECK_EQ(r.committedTotal, -50000000);
|
|
CHECK_EQ(r.applied[0], -50000000);
|
|
|
|
// Two species over the cap: the rescale is proportional and TRUNCATING, and the original
|
|
// does not renormalise, so the shares need not add back up to the cap.
|
|
CivilianGrowthRow two[kSpeciesCount] = {};
|
|
two[0].delta = 30000000; two[0].current = 0; two[0].capacity = INT64_MAX;
|
|
two[0].settleLimit = INT64_MAX;
|
|
two[1].delta = 30000001; two[1].current = 0; two[1].capacity = INT64_MAX;
|
|
two[1].settleLimit = INT64_MAX;
|
|
r = GrowCivilianPopulations(two, false);
|
|
CHECK_EQ(r.rawTotal, 60000001);
|
|
CHECK_EQ(r.committedTotal, 20000000);
|
|
CHECK(r.applied[0] + r.applied[1] <= 20000000);
|
|
CHECK(r.applied[0] > 0);
|
|
CHECK(r.applied[1] > 0);
|
|
|
|
// The step caps are the population-type table's own int64 column.
|
|
CHECK_EQ(kCivilianGrowthStepCap, 20000000);
|
|
CHECK_EQ(kImperialGrowthStepCap, 50000000);
|
|
CHECK_EQ(kCivilianDeclineFloor, -50000000);
|
|
}
|
|
|
|
static void test_ship_repair_cost() {
|
|
// max(0, target - (progress + allowance)), plain 32-bit integers, floored at zero.
|
|
CHECK_EQ(ShipRepairCost(1000, 400, 100, true), 500);
|
|
CHECK_EQ(ShipRepairCost(1000, 400, 100, false), 600);
|
|
CHECK_EQ(ShipRepairCost(1000, 1000, 0, false), 0);
|
|
CHECK_EQ(ShipRepairCost(1000, 1200, 0, false), 0); // the floor, not a negative
|
|
CHECK_EQ(ShipRepairCost(1000, 950, 100, true), 0); // the allowance can cross the floor
|
|
}
|
|
|
|
int main() {
|
|
test_capacity();
|
|
test_growth();
|
|
test_infra_terraform();
|
|
test_slaves();
|
|
test_output();
|
|
test_system_money();
|
|
test_population_income();
|
|
test_max_income();
|
|
test_ideal_suitability();
|
|
test_terraform_points();
|
|
test_repair_pass();
|
|
test_turn_path_output();
|
|
test_difficulty_table();
|
|
test_bonuses();
|
|
test_build_queue();
|
|
test_civilian_growth();
|
|
test_ship_repair_cost();
|
|
return simtest::finish("test_colony");
|
|
}
|