game/sim + app: ComputeOutput on the turn path, and P01/P02 committed
`ComputeBudget` takes a system's money from two different functions. Projected mode
calls `ComputeMaxIncome`, which lane E1 closed 25/25 against the BnkEl oracle. The
TURN calls `ComputeOutput` with the system's own rate sliders, where the build queue,
the ship-repair pass and the infrastructure -> terraform -> money cascade are all
live and E1's proof that the cascades are zero does not apply.
Read from the instruction stream, both ranges disassembled to the next function start:
* `sim::ComputeSystemOutput` -- the channel algebra of `ComputeOutputFromRates`, with
every rounding site (round-half-even per channel, truncating for the construction
and money slots) and the association of every x87 sum as the original has them.
* `sim::IdealSuitability` -- the owner's own field, the server's species baseline for
an independent colony, and the per-system `dsu` override.
* `sim::RepairShipsInOrbit` -- the round robin, which is provably equivalent to
`points - min(points, demand)`: the per-pass share is at least 1, so the only early
exit needs every remaining cost to be zero.
* two corrections to `ConstructionPoints` and `SplitLeftover`: the station bonus is
ignored unless strictly positive and its association is `k x (b x cons) + cons`, and
the leftover weights sum as `wi + (wf + wt)`.
The load-bearing fact: the leftover construction points come back to the TRADE
channel, so a colony with an empty build queue earns the same money whichever way its
sliders point. The engine now runs both paths on every load and reports the
difference; on the 11-save corpus every delta decomposes to the unit into the build
queue's points priced through the money chain.
P01/P02 move from blocked to partial and are committed:
turn1-state -> turn2-state 209 -> 157 closed 52 regressed 0 (was 51 / 0)
turn2-state -> turn3-state 108 -> 86 closed 22 regressed 0 (was 21 / 0)
One leaf per pair, and it is the easy one: the independent colony, whose population
does not grow and whose orders the turn does not change. The human's savings are
still short by the civilian growth `S11` does not commit, and the AI's by its own
orders. The ship-repair demand is taken as 0 because `Ship::RepairCost` is unread.
sots-re: findings/subsystems/output-turn-path.md, ghidra/addresses.d/lane-c3.json
This commit is contained in:
parent
0f1c007f00
commit
d3ee45364b
5 changed files with 635 additions and 39 deletions
|
|
@ -128,26 +128,36 @@ constexpr PhaseDesc kStrategic[] = {
|
||||||
// ServerPlayer::ProcessTurn -- 12 phases, 1..12
|
// ServerPlayer::ProcessTurn -- 12 phases, 1..12
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
constexpr PhaseDesc kPlayer[] = {
|
constexpr PhaseDesc kPlayer[] = {
|
||||||
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Blocked,
|
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Partial,
|
||||||
"the formula is verified (0 divergences over 4,284 live calls) but one input is not "
|
"the formula is verified (0 divergences over 4,284 live calls) and the per-system money "
|
||||||
"modelled: the money output of each owned system. NOT the same function T31 sums -- the "
|
"input is now modelled on the TURN path -- ComputeOutput with the system's own rate "
|
||||||
"turn path takes ComputeOutput with the system's OWN rate sliders, so its money channel "
|
"sliders, so the build queue, the ship-repair pass and the infrastructure -> terraform "
|
||||||
"carries the repair pass (which is not side-effect free) and the unspent-industry and "
|
"-> money cascade are all live, none of which is the max-income form T31 sums. What is "
|
||||||
"unspent-terraforming cascades, none of which are zero once the other channels are "
|
"still missing is upstream, not here: S11's civilian growth is not committed, so a "
|
||||||
"funded. Only ComputeBudget's PROJECTED mode uses the max-income form that is now "
|
"colony that grew this turn is priced from its pre-growth population, and the repair "
|
||||||
"modelled. Evaluated and reported, not committed"},
|
"demand of damaged ships in orbit is taken as 0. The phase self-checks every run by "
|
||||||
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Blocked,
|
"running the same colonies through the projected path, which the save's own BnkEl "
|
||||||
"saturating add of the budget net into savings; blocked behind P01's missing input"},
|
"states"},
|
||||||
|
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Partial,
|
||||||
|
"saturating add of the budget net into savings, committed. Exact for a player whose "
|
||||||
|
"colonies did not grow and whose own orders the turn does not change (the independent "
|
||||||
|
"colony, on both reference pairs); short by the growth for the human, and wrong for an "
|
||||||
|
"AI whose research rate and target are set by its own orders during the turn (Rung B)"},
|
||||||
{Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
|
{Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
|
||||||
"trade income, savings-given-away and research-points-given-away land on the turn record "
|
"trade income, savings-given-away and research-points-given-away land on the turn record "
|
||||||
"and on two player words that are not identified on the wire"},
|
"and on two player words that are not identified on the wire"},
|
||||||
{Driver::Player, 4, "P04", "ProcessSpecialProjectsSpend", PhaseStatus::Stub,
|
{Driver::Player, 4, "P04", "ProcessSpecialProjectsSpend", PhaseStatus::Stub,
|
||||||
"special-project spend; the project bodies are opaque on the wire"},
|
"special-project spend; the project bodies are opaque on the wire"},
|
||||||
{Driver::Player, 5, "P05", "ProcessResearch", PhaseStatus::Blocked,
|
{Driver::Player, 5, "P05", "ProcessResearch", PhaseStatus::Blocked,
|
||||||
"the research slice is verified end to end (35 live calls, 0 divergences) but its "
|
"the research slice is verified end to end (35 live calls, 0 divergences) and P01 now "
|
||||||
"allocation comes from P01's budget, so it cannot be driven yet"},
|
"supplies the allocation, but the blocker has MOVED rather than cleared: the only "
|
||||||
|
"corpus player that reaches this phase with a research target is the AI, and its "
|
||||||
|
"research rate and target are set by its own orders during the same turn, so the "
|
||||||
|
"allocation fed in would be wrong. Evaluated and reported, not committed, until AI "
|
||||||
|
"order generation exists"},
|
||||||
{Driver::Player, 6, "P06", "ResearchRefund", PhaseStatus::Blocked,
|
{Driver::Player, 6, "P06", "ResearchRefund", PhaseStatus::Blocked,
|
||||||
"unspent research points converted back to money at the turn's own rate; needs P01 and P05"},
|
"unspent research points converted back to money at the turn's own rate; needs P05, "
|
||||||
|
"which is now blocked on the AI's orders rather than on the budget"},
|
||||||
{Driver::Player, 7, "P07", "ClearTimedResearchAccumulators", PhaseStatus::Partial,
|
{Driver::Player, 7, "P07", "ClearTimedResearchAccumulators", PhaseStatus::Partial,
|
||||||
"zeroes the three timed-research accumulators that are on the wire; two further words the "
|
"zeroes the three timed-research accumulators that are on the wire; two further words the "
|
||||||
"phase also zeroes are not identified"},
|
"phase also zeroes are not identified"},
|
||||||
|
|
|
||||||
209
src/app/turn.cpp
209
src/app/turn.cpp
|
|
@ -96,16 +96,37 @@ struct PlayerPhaseTotals {
|
||||||
int fired[13] = {}; // how many players the phase actually did something for
|
int fired[13] = {}; // how many players the phase actually did something for
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// What the caller has to hand the player driver for P01: the per-system money of every
|
||||||
|
// owned, non-abandoned system, on the TURN path, and whether the driver may trust it.
|
||||||
|
struct PlayerBudgetFeed {
|
||||||
|
std::vector<int> systemIncome;
|
||||||
|
bool isAI = false;
|
||||||
|
sim::DifficultyMods difficulty;
|
||||||
|
bool held = true; // false when a system the player owns could not be found
|
||||||
|
// The self-check: the same systems run through the PROJECTED path, which is the number
|
||||||
|
// the save's own `BnkEl` states and lane E1 scored 25/25 against. The two paths are not
|
||||||
|
// the same function and need not agree -- but on a corpus where every colony is at its
|
||||||
|
// ideal suitability with full infrastructure and an empty build queue, the whole
|
||||||
|
// construction channel returns to trade and they should agree to within the trade-point
|
||||||
|
// rounding. A large delta here is the model failing, and it is visible without a VM.
|
||||||
|
int projectedIncome = 0;
|
||||||
|
int turnIncome = 0;
|
||||||
|
};
|
||||||
|
|
||||||
void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
|
void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
|
||||||
PlayerPhaseTotals& t) {
|
PlayerPhaseTotals& t, const PlayerBudgetFeed& feed) {
|
||||||
// --- P01 ComputeBudget -- blocked on the per-system money output -----------------
|
// --- P01 ComputeBudget ------------------------------------------------------------
|
||||||
// The formula is here and is verified; what is missing is `systemIncome`. We build the
|
// The per-system money is `ComputeOutput(s).out[3]`, NOT `ComputeMaxIncome(s)`: the
|
||||||
// inputs we do hold so the shape of the gap is visible, then stop.
|
// turn path runs the system's own sliders, so the build queue, the ship-repair pass and
|
||||||
|
// the infrastructure -> terraform -> money cascade are all live. See
|
||||||
|
// sots-re findings/subsystems/output-turn-path.md.
|
||||||
{
|
{
|
||||||
sim::BudgetInputs in;
|
sim::BudgetInputs in;
|
||||||
in.savings = p.sav;
|
in.savings = p.sav;
|
||||||
in.ownsSystems = !p.owners.empty();
|
in.ownsSystems = !p.owners.empty();
|
||||||
in.maintenance = p.maint;
|
in.maintenance = p.maint;
|
||||||
|
in.maintenanceDivisor = feed.difficulty.maintenanceDivisor;
|
||||||
|
in.researchDifficultyMult = feed.difficulty.researchMult;
|
||||||
in.isAI = p.npc;
|
in.isAI = p.npc;
|
||||||
in.researchRate = p.resRate;
|
in.researchRate = p.resRate;
|
||||||
in.resMod = p.resMod;
|
in.resMod = p.resMod;
|
||||||
|
|
@ -122,14 +143,19 @@ void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
|
||||||
s.fraction = e.xper;
|
s.fraction = e.xper;
|
||||||
in.expenses.push_back(s);
|
in.expenses.push_back(s);
|
||||||
}
|
}
|
||||||
// in.systemIncome stays empty: unmodelled input.
|
in.systemIncome = feed.systemIncome;
|
||||||
const sim::Budget b = sim::ComputeBudget(in, /*projected=*/false);
|
const sim::Budget b = sim::ComputeBudget(in, /*projected=*/false);
|
||||||
const int wouldBe = sim::SaturatingAdd(p.sav, b.net);
|
const int wouldBe = sim::SaturatingAdd(p.sav, b.net);
|
||||||
++t.fired[1];
|
++t.fired[1];
|
||||||
if (wouldBe != p.sav) ++t.wouldWrite[2];
|
if (wouldBe != p.sav) ++t.wouldWrite[2];
|
||||||
if (opt.CommitBlocked("P02")) {
|
// P02 is the write. It is committed by default now that the money channel is
|
||||||
p.sav = wouldBe;
|
// modelled; a player whose owned systems could not all be resolved is still
|
||||||
++t.writes[2];
|
// evaluate-and-report.
|
||||||
|
if (feed.held || opt.CommitBlocked("P02")) {
|
||||||
|
if (wouldBe != p.sav) {
|
||||||
|
p.sav = wouldBe;
|
||||||
|
++t.writes[2];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,12 +345,23 @@ bool AddictedTo(const std::vector<mars::stream::shapes::AdctEntry>& a, int speci
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `max(ComputeMaxIncome(s), 0)` for one owned system.
|
// The two terms both money paths share: the output total and everything in the money chain
|
||||||
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
|
// except the trade points. `ComputeMaxIncome` and the turn's `ComputeOutput` differ only in
|
||||||
const MaxIncomeInputs& ctx) {
|
// how they arrive at those trade points.
|
||||||
|
struct SystemIncomeTerms {
|
||||||
|
double totalOutputRaw = 0;
|
||||||
|
sim::SystemMoneyInputs money;
|
||||||
|
int popSpecies = 0;
|
||||||
|
double idealSuitability = 0; // the SERVER's per-species baseline (the money cost's ideal)
|
||||||
|
};
|
||||||
|
|
||||||
|
SystemIncomeTerms SystemIncomeTermsFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
|
||||||
|
const MaxIncomeInputs& ctx, double overHarvestRate) {
|
||||||
|
SystemIncomeTerms out;
|
||||||
// The system's population is credited to the independent race's species when the colony
|
// The system's population is credited to the independent race's species when the colony
|
||||||
// has one, otherwise to the owner's. `hindi` is the gate; `indi` is written either way.
|
// has one, otherwise to the owner's. `hindi` is the gate; `indi` is written either way.
|
||||||
const int popSpecies = s.hindi ? s.indi.indsp : owner.species;
|
const int popSpecies = s.hindi ? s.indi.indsp : owner.species;
|
||||||
|
out.popSpecies = popSpecies;
|
||||||
const auto species = static_cast<sim::Species>(owner.species);
|
const auto species = static_cast<sim::Species>(owner.species);
|
||||||
|
|
||||||
const std::int64_t resAvail =
|
const std::int64_t resAvail =
|
||||||
|
|
@ -332,7 +369,6 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
|
||||||
const std::int64_t imperial = static_cast<std::int64_t>(s.pop) + s.pbon;
|
const std::int64_t imperial = static_cast<std::int64_t>(s.pop) + s.pbon;
|
||||||
|
|
||||||
// --- the output total (lane N's term) ---
|
// --- the output total (lane N's term) ---
|
||||||
double total = 0.0;
|
|
||||||
if (s.rbfl == 0) {
|
if (s.rbfl == 0) {
|
||||||
sim::BaseOutputInputs b;
|
sim::BaseOutputInputs b;
|
||||||
b.imperialPopulation = imperial;
|
b.imperialPopulation = imperial;
|
||||||
|
|
@ -343,7 +379,7 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
|
||||||
b.resourcesAvailable = resAvail;
|
b.resourcesAvailable = resAvail;
|
||||||
b.infra = s.infra;
|
b.infra = s.infra;
|
||||||
b.infraBonus = s.ibon;
|
b.infraBonus = s.ibon;
|
||||||
b.overHarvestRate = 0.0; // the max-income rate vector puts nothing on over-harvest
|
b.overHarvestRate = overHarvestRate;
|
||||||
b.speciesBaseDemand = sim::ConstantsOf(species).resourceDemand;
|
b.speciesBaseDemand = sim::ConstantsOf(species).resourceDemand;
|
||||||
b.speciesResourceOutput = sim::ConstantsOf(species).resourceOutput;
|
b.speciesResourceOutput = sim::ConstantsOf(species).resourceOutput;
|
||||||
sim::OutputModifiers m;
|
sim::OutputModifiers m;
|
||||||
|
|
@ -353,10 +389,10 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
|
||||||
m.rebOutMod = owner.rebOutMod;
|
m.rebOutMod = owner.rebOutMod;
|
||||||
m.scOutMod = owner.scOutMod;
|
m.scOutMod = owner.scOutMod;
|
||||||
m.techOutMod = 1.0; // ServerPlayer+0x224, not on the wire
|
m.techOutMod = 1.0; // ServerPlayer+0x224, not on the wire
|
||||||
total = sim::TotalSystemOutputRaw(m, ctx.tuning);
|
out.totalOutputRaw = sim::TotalSystemOutputRaw(m, ctx.tuning);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- the money chain (this lane's term) ---
|
// --- the money chain (lane E1's term) ---
|
||||||
sim::PopIncomeRow impRows[sim::kSpeciesCount] = {};
|
sim::PopIncomeRow impRows[sim::kSpeciesCount] = {};
|
||||||
sim::PopIncomeRow civRows[sim::kSpeciesCount] = {};
|
sim::PopIncomeRow civRows[sim::kSpeciesCount] = {};
|
||||||
sim::PopIncomeRow slvRows[sim::kSpeciesCount] = {};
|
sim::PopIncomeRow slvRows[sim::kSpeciesCount] = {};
|
||||||
|
|
@ -371,7 +407,7 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
|
||||||
impRows[q].addicted = civRows[q].addicted = slvRows[q].addicted = add;
|
impRows[q].addicted = civRows[q].addicted = slvRows[q].addicted = add;
|
||||||
}
|
}
|
||||||
|
|
||||||
sim::SystemMoneyInputs mi;
|
sim::SystemMoneyInputs& mi = out.money;
|
||||||
mi.popIncomeImperial =
|
mi.popIncomeImperial =
|
||||||
sim::PopulationIncome(sim::PopGroup::Imperial, impRows, true, s.hindi, ctx.tuning);
|
sim::PopulationIncome(sim::PopGroup::Imperial, impRows, true, s.hindi, ctx.tuning);
|
||||||
mi.popIncomeCivilian =
|
mi.popIncomeCivilian =
|
||||||
|
|
@ -384,12 +420,113 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
|
||||||
mi.serverIncomeMod = ctx.serverIncomeMod;
|
mi.serverIncomeMod = ctx.serverIncomeMod;
|
||||||
mi.difficultyIncomeMult =
|
mi.difficultyIncomeMult =
|
||||||
sim::DifficultyModsFor(owner.aidf, ownerIsAI, owner.npc).incomeMult;
|
sim::DifficultyModsFor(owner.aidf, ownerIsAI, owner.npc).incomeMult;
|
||||||
const double ideal = popSpecies >= 0 && popSpecies < static_cast<int>(ctx.idealSuit.size())
|
out.idealSuitability = popSpecies >= 0 && popSpecies < static_cast<int>(ctx.idealSuit.size())
|
||||||
? ctx.idealSuit[static_cast<std::size_t>(popSpecies)]
|
? ctx.idealSuit[static_cast<std::size_t>(popSpecies)]
|
||||||
: owner.idealSuit;
|
: owner.idealSuit;
|
||||||
mi.suitCostMod =
|
mi.suitCostMod = sim::SuitabilityCostMod(s.suit, out.idealSuitability, owner.suitTol,
|
||||||
sim::SuitabilityCostMod(s.suit, ideal, owner.suitTol, owner.rebAI, true, s.vnh);
|
owner.rebAI, true, s.vnh);
|
||||||
return sim::SystemMaxIncome(total, mi);
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ComputeOutput(s).out[3]` -- the money a system contributes to the TURN's budget, as
|
||||||
|
// opposed to the projected maximum T31 sums. Not clamped: `ComputeBudget` splits a negative
|
||||||
|
// system into its expense column itself.
|
||||||
|
//
|
||||||
|
// One input of the nine this needs is not on the wire and is taken as zero here: the repair
|
||||||
|
// demand of the owner's damaged ships in orbit, which would need `Ship::RepairCost` over the
|
||||||
|
// fleets at the system. Every point it would consume is a point that does NOT come back to
|
||||||
|
// the money channel, so a colony with a damaged fleet reads HIGH.
|
||||||
|
int SystemTurnMoneyFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
|
||||||
|
const MaxIncomeInputs& ctx) {
|
||||||
|
const SystemIncomeTerms terms =
|
||||||
|
SystemIncomeTermsFromWire(s, owner, ownerIsAI, ctx, s.rts.sroh);
|
||||||
|
|
||||||
|
sim::IdealSuitabilityInputs isi;
|
||||||
|
isi.owned = true;
|
||||||
|
isi.systemSuitability = s.suit;
|
||||||
|
isi.ownerIdealSuitability = owner.idealSuit;
|
||||||
|
isi.independent = s.hindi;
|
||||||
|
isi.serverIdealSuitability = terms.idealSuitability;
|
||||||
|
isi.systemOverride = s.dsu;
|
||||||
|
const double ideal = sim::IdealSuitability(isi);
|
||||||
|
|
||||||
|
sim::SystemOutputInputs in;
|
||||||
|
in.rates.trade = s.rts.srt;
|
||||||
|
in.rates.construction = s.rts.srsc;
|
||||||
|
in.rates.terraform = s.rts.srtf;
|
||||||
|
in.rates.infra = s.rts.sri;
|
||||||
|
// The normaliser's two suppressions, with the predicates the original uses: an EXACT
|
||||||
|
// equality for suitability and `float32(Infra + ibon) >= 1` for infrastructure.
|
||||||
|
in.suitAtIdeal = static_cast<double>(s.suit) == ideal;
|
||||||
|
in.infraFull = sim::F32(static_cast<double>(s.ibon) + s.infra) >= 1.0;
|
||||||
|
// The leftover split's own infrastructure test reads the RAW Infra against 1.
|
||||||
|
in.infraExactlyOne = static_cast<double>(s.infra) == 1.0;
|
||||||
|
in.infra = s.infra;
|
||||||
|
in.totalOutputRaw = terms.totalOutputRaw;
|
||||||
|
in.shipyardStations = 0; // StationCount(sys, owner, 1): no corpus system has a station
|
||||||
|
in.buildQueueDemand = 0;
|
||||||
|
if (s.bq)
|
||||||
|
for (const auto& o : s.bq->orders) in.buildQueueDemand += o.conleft;
|
||||||
|
in.repairDemand = 0; // see the note above
|
||||||
|
in.terraformPointsNeeded = sim::TerraformPointsNeeded(s.suit, ideal, owner.terraMod);
|
||||||
|
in.terraformDown = ideal < static_cast<double>(s.suit);
|
||||||
|
in.terraformMod = owner.terraMod;
|
||||||
|
in.money = terms.money;
|
||||||
|
return sim::ComputeSystemOutput(in, ctx.tuning).money;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `max(ComputeMaxIncome(s), 0)` for one owned system.
|
||||||
|
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
|
||||||
|
const MaxIncomeInputs& ctx) {
|
||||||
|
// The max-income rate vector puts nothing on over-harvest, and the two cascade channels
|
||||||
|
// are provably zero under it, so the trade points are simply the rounded output total.
|
||||||
|
const SystemIncomeTerms terms = SystemIncomeTermsFromWire(s, owner, ownerIsAI, ctx, 0.0);
|
||||||
|
return sim::SystemMaxIncome(terms.totalOutputRaw, terms.money);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One `PlayerBudgetFeed` per player, in save order. Built once, from the colony state as it
|
||||||
|
// stands when the player driver runs -- which is AFTER the per-system turn (the strategic
|
||||||
|
// driver runs the system turn at phase 11 and the player driver at phase 13), so anything
|
||||||
|
// `S11` fails to commit is missing from these numbers as well.
|
||||||
|
std::vector<PlayerBudgetFeed> BuildBudgetFeeds(const SaveGame& game, const TurnOptions& opt) {
|
||||||
|
MaxIncomeInputs ctx;
|
||||||
|
ctx.serverIncomeMod = game.sim.incMod;
|
||||||
|
for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu);
|
||||||
|
|
||||||
|
std::vector<const Sys*> byId;
|
||||||
|
std::vector<std::int32_t> ids;
|
||||||
|
for (const auto& e : game.sim.systems) {
|
||||||
|
ids.push_back(e.sysID);
|
||||||
|
byId.push_back(&e.sys);
|
||||||
|
}
|
||||||
|
const auto find = [&](std::int32_t id) -> const Sys* {
|
||||||
|
for (std::size_t i = 0; i < ids.size(); ++i)
|
||||||
|
if (ids[i] == id) return byId[i];
|
||||||
|
return nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<PlayerBudgetFeed> feeds;
|
||||||
|
feeds.reserve(game.sim.players.size());
|
||||||
|
for (const auto& pe : game.sim.players) {
|
||||||
|
const Player& p = pe.player;
|
||||||
|
PlayerBudgetFeed f;
|
||||||
|
f.isAI = opt.IsAIPlayer(p.plyrIdx);
|
||||||
|
f.difficulty = sim::DifficultyModsFor(p.aidf, f.isAI, p.npc);
|
||||||
|
for (std::int32_t id : p.owners) {
|
||||||
|
const Sys* s = find(id);
|
||||||
|
if (!s) {
|
||||||
|
f.held = false; // a dangling owner id: the sum is incomplete, say so
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero
|
||||||
|
const int money = SystemTurnMoneyFromWire(*s, p, f.isAI, ctx);
|
||||||
|
if (money != 0) f.systemIncome.push_back(money);
|
||||||
|
f.turnIncome += money;
|
||||||
|
f.projectedIncome += SystemMaxIncomeFromWire(*s, p, f.isAI, ctx);
|
||||||
|
}
|
||||||
|
feeds.push_back(std::move(f));
|
||||||
|
}
|
||||||
|
return feeds;
|
||||||
}
|
}
|
||||||
|
|
||||||
void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) {
|
void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) {
|
||||||
|
|
@ -788,8 +925,32 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 13: { // S13 PlayerTurn -- the nested driver
|
case 13: { // S13 PlayerTurn -- the nested driver
|
||||||
for (auto& e : game.sim.players)
|
const std::vector<PlayerBudgetFeed> feeds = BuildBudgetFeeds(game, opt);
|
||||||
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt);
|
std::size_t fi = 0;
|
||||||
|
int fed = 0, agree = 0, worst = 0;
|
||||||
|
for (auto& e : game.sim.players) {
|
||||||
|
const PlayerBudgetFeed& f = feeds[fi++];
|
||||||
|
if (!f.systemIncome.empty()) ++fed;
|
||||||
|
else {
|
||||||
|
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int d = f.turnIncome - f.projectedIncome;
|
||||||
|
if (d == 0) ++agree;
|
||||||
|
if (d > worst || -d > worst) worst = d < 0 ? -d : d;
|
||||||
|
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f);
|
||||||
|
}
|
||||||
|
rec.notes.push_back(fmt(
|
||||||
|
"%d player(s) had a non-empty per-system money roll-up on the TURN path "
|
||||||
|
"(ComputeOutput, not ComputeMaxIncome); the ship-repair demand of damaged "
|
||||||
|
"ships in orbit is taken as 0 and S11's civilian growth is not committed, "
|
||||||
|
"so a colony that grew this turn is priced from its pre-growth population",
|
||||||
|
fed));
|
||||||
|
rec.notes.push_back(fmt(
|
||||||
|
"turn path vs projected path on the same colony state: %d of %d landed "
|
||||||
|
"players agree exactly, worst |delta| %d money (the projected sum is what "
|
||||||
|
"the save's own BnkEl states, so this is a check without a VM)",
|
||||||
|
agree, fed, worst));
|
||||||
rec.invocations = static_cast<int>(game.sim.players.size());
|
rec.invocations = static_cast<int>(game.sim.players.size());
|
||||||
for (int k = 1; k <= 12; ++k) {
|
for (int k = 1; k <= 12; ++k) {
|
||||||
rec.leafWrites += pt.writes[k];
|
rec.leafWrites += pt.writes[k];
|
||||||
|
|
|
||||||
|
|
@ -389,7 +389,12 @@ OutputSplit SplitOutput(double total, const OutputRates& rates) {
|
||||||
|
|
||||||
int ConstructionPoints(double constructionShare, int stations, const TuningTable& t) {
|
int ConstructionPoints(double constructionShare, int stations, const TuningTable& t) {
|
||||||
// Truncating, not rounding -- this slot goes through the float-to-int helper.
|
// Truncating, not rounding -- this slot goes through the float-to-int helper.
|
||||||
return Ftol(constructionShare * (1.0 + t.STATION_BONUS_SHIPCON * stations));
|
// C3 correction, from the instruction stream of 0x00746830: the bonus is ignored unless
|
||||||
|
// it is STRICTLY positive (the same unloaded-table guard the output term carries), and
|
||||||
|
// the association is `k x (b x cons) + cons`, not `cons x (1 + b x k)`. Both differences
|
||||||
|
// are invisible while no system has a shipyard station, which is the whole corpus.
|
||||||
|
const double b = t.STATION_BONUS_SHIPCON > 0.0 ? t.STATION_BONUS_SHIPCON : 0.0;
|
||||||
|
return Ftol(static_cast<double>(stations) * (b * constructionShare) + constructionShare);
|
||||||
}
|
}
|
||||||
|
|
||||||
OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal,
|
OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal,
|
||||||
|
|
@ -406,7 +411,9 @@ OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAt
|
||||||
wf = rates.terraform;
|
wf = rates.terraform;
|
||||||
wi = rates.infra;
|
wi = rates.infra;
|
||||||
}
|
}
|
||||||
const double sum = wt + wf + wi;
|
// C3 correction: the original accumulates `wi + (wf + wt)` on the x87 stack, in that
|
||||||
|
// association. Reordering it is not free in floating point.
|
||||||
|
const double sum = wi + (wf + wt);
|
||||||
OutputSplit s;
|
OutputSplit s;
|
||||||
if (sum <= 0 || leftover <= 0) {
|
if (sum <= 0 || leftover <= 0) {
|
||||||
s.trade = std::max(0.0, leftover);
|
s.trade = std::max(0.0, leftover);
|
||||||
|
|
@ -488,6 +495,87 @@ int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in) {
|
||||||
return money > 0 ? money : 0;
|
return money > 0 ? money : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double IdealSuitability(const IdealSuitabilityInputs& in) {
|
||||||
|
if (!in.owned) return in.systemSuitability;
|
||||||
|
double v = in.ownerIdealSuitability;
|
||||||
|
if (in.independent) v = in.serverIdealSuitability;
|
||||||
|
// An `!=` against the sentinel, so a NaN override would also win. Nothing in the corpus
|
||||||
|
// exercises either side of that.
|
||||||
|
if (in.systemOverride != kIdealSuitabilityNoOverride) v = in.systemOverride;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
RepairPassResult RepairShipsInOrbit(int points, int repairDemand) {
|
||||||
|
RepairPassResult r;
|
||||||
|
if (points <= 0 || repairDemand <= 0) {
|
||||||
|
r.left = points;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
r.spent = points < repairDemand ? points : repairDemand;
|
||||||
|
r.left = points - r.spent;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemOutput ComputeSystemOutput(const SystemOutputInputs& in, const TuningTable& t) {
|
||||||
|
SystemOutput o;
|
||||||
|
const OutputRates r = NormaliseOutputRates(in.rates, in.suitAtIdeal, in.infraFull);
|
||||||
|
o.normalisedRates = r;
|
||||||
|
|
||||||
|
// One rounding of the total, then one rounding per channel off that same value.
|
||||||
|
const double total = RoundHalfEven(in.totalOutputRaw);
|
||||||
|
o.totalOutput = Ftol(total);
|
||||||
|
const OutputSplit split = SplitOutput(total, r);
|
||||||
|
o.tradePoints = split.trade;
|
||||||
|
|
||||||
|
// --- construction: the queue first, then the repair pass -----------------------------
|
||||||
|
o.construction = ConstructionPoints(split.construction, in.shipyardStations, t);
|
||||||
|
o.constructionToQueue =
|
||||||
|
in.buildQueueDemand < o.construction ? in.buildQueueDemand : o.construction;
|
||||||
|
int rem = o.construction - o.constructionToQueue;
|
||||||
|
if (rem < 0) rem = 0;
|
||||||
|
if (rem > 0) {
|
||||||
|
const RepairPassResult rep = RepairShipsInOrbit(rem, in.repairDemand);
|
||||||
|
o.constructionToRepair = rep.spent;
|
||||||
|
rem = rep.left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the leftover redistribution -----------------------------------------------------
|
||||||
|
// `SplitLeftover`'s `infraFull` argument is the RAW `Infra == 1` test, not the
|
||||||
|
// `Infra + ibon >= 1` one the normaliser used.
|
||||||
|
const OutputSplit left =
|
||||||
|
rem > 0 ? SplitLeftover(static_cast<double>(rem), r, in.suitAtIdeal, in.infraExactlyOne)
|
||||||
|
: OutputSplit{};
|
||||||
|
o.leftoverToTrade = left.trade;
|
||||||
|
|
||||||
|
// --- infrastructure ------------------------------------------------------------------
|
||||||
|
const double infraNeed = std::ceil((1.0 - in.infra) / 3.3e-5);
|
||||||
|
const double poolInfra = left.infra + split.infra;
|
||||||
|
const double spendInfra = poolInfra < infraNeed ? poolInfra : infraNeed;
|
||||||
|
double leftInfra = poolInfra - spendInfra;
|
||||||
|
if (!(leftInfra > 0.0)) leftInfra = 0.0;
|
||||||
|
// Three separate 80-bit steps, not one x3.3e-5.
|
||||||
|
const double infraGain = spendInfra / 500.0 * 0.01 * 1.65;
|
||||||
|
o.infraDelta = F32(infraGain > 0.0 ? infraGain : 0.0);
|
||||||
|
|
||||||
|
// --- terraforming: the infrastructure leftover lands in THIS pool ---------------------
|
||||||
|
const double terraNeed = std::ceil(in.terraformPointsNeeded);
|
||||||
|
const double poolTerra = (left.terraform + split.terraform) + leftInfra;
|
||||||
|
const double spendTerra = poolTerra < terraNeed ? poolTerra : terraNeed;
|
||||||
|
double leftTerra = poolTerra - spendTerra;
|
||||||
|
if (!(leftTerra > 0.0)) leftTerra = 0.0;
|
||||||
|
o.leftoverToMoney = leftTerra;
|
||||||
|
// The same helper the colony pass uses; its sign test is `suit > ideal`, which is the
|
||||||
|
// original's `IdealSuitability() < Suit` with the operands swapped.
|
||||||
|
o.suitabilityDelta = TerraformDelta(spendTerra, in.terraformMod,
|
||||||
|
in.terraformDown ? 1.0 : 0.0, 0.0);
|
||||||
|
|
||||||
|
// --- money ----------------------------------------------------------------------------
|
||||||
|
SystemMoneyInputs m = in.money;
|
||||||
|
m.tradePoints = (o.leftoverToTrade + o.tradePoints) + leftTerra;
|
||||||
|
o.money = SystemMoneyIncome(m);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity,
|
BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity,
|
||||||
std::int64_t& pendingBonus, bool owned, bool homeSystem) {
|
std::int64_t& pendingBonus, bool owned, bool homeSystem) {
|
||||||
BonusApplyResult r;
|
BonusApplyResult r;
|
||||||
|
|
|
||||||
|
|
@ -520,6 +520,126 @@ int SystemMoneyIncome(const SystemMoneyInputs& in);
|
||||||
// for zero science points, which is INFERRED rather than read.
|
// for zero science points, which is INFERRED rather than read.
|
||||||
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in);
|
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// The turn path: `ServerSystem::ComputeOutput`
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// `ComputeBudget` has two modes and they take a system's money from DIFFERENT functions.
|
||||||
|
// Projected mode calls `ComputeMaxIncome` (SystemMaxIncome above). The turn calls
|
||||||
|
// `ComputeOutput`, which runs `ComputeOutputFromRates` with the system's OWN stored sliders,
|
||||||
|
// so the construction, terraform and infrastructure channels are funded and three edges that
|
||||||
|
// are provably dead under the max-income vector are live:
|
||||||
|
//
|
||||||
|
// * the build queue and the ship-repair pass consume construction points;
|
||||||
|
// * whatever they leave over is redistributed across trade / terraform / infrastructure
|
||||||
|
// and the TRADE share is added to the money channel;
|
||||||
|
// * unspent infrastructure points cascade into the terraform pool, and unspent terraform
|
||||||
|
// points cascade into the money channel -- two hops, not one.
|
||||||
|
//
|
||||||
|
// See sots-re findings/subsystems/output-turn-path.md. Note that the field the earlier
|
||||||
|
// income-term note called "science" is the SHIP CONSTRUCTION slider; there is no science
|
||||||
|
// channel in this function (research is bought with money at the empire level).
|
||||||
|
|
||||||
|
// `ServerSystem::IdealSuitability` (0x00745d60) -- the suitability the terraform channel
|
||||||
|
// aims at, and the `==` the rate normaliser tests against.
|
||||||
|
// unowned -> the system's own suitability (so it is "at its ideal")
|
||||||
|
// independent colony -> the SERVER's per-species baseline for `indi->indsp`
|
||||||
|
// otherwise -> the OWNER's own `IdealSuit` field
|
||||||
|
// and a system-level `dsu` override wins over all three when it is not the sentinel.
|
||||||
|
// CONFIDENCE: high on the branch order. The sentinel is FLT_MAX: that is INFERRED from the
|
||||||
|
// corpus (every system carries exactly FLT_MAX there) rather than read out of the data files.
|
||||||
|
constexpr double kIdealSuitabilityNoOverride = 3.4028234663852886e+38; // FLT_MAX
|
||||||
|
|
||||||
|
struct IdealSuitabilityInputs {
|
||||||
|
bool owned = true;
|
||||||
|
double systemSuitability = 0; // sys.Suit
|
||||||
|
double ownerIdealSuitability = 0; // owner's IdealSuit field
|
||||||
|
bool independent = false; // sys.hindi
|
||||||
|
double serverIdealSuitability = 0; // server->IdealSuit[indi.indsp], independent only
|
||||||
|
double systemOverride = kIdealSuitabilityNoOverride; // sys.dsu
|
||||||
|
};
|
||||||
|
double IdealSuitability(const IdealSuitabilityInputs& in);
|
||||||
|
|
||||||
|
// C3 note on `TerraformPointsNeeded` above (0x00746890): the original does NOT round -- the
|
||||||
|
// `ceil` belongs to `ComputeOutputFromRates`, which applies it to the returned double. Our
|
||||||
|
// version folds the `ceil` in, which is harmless because `ceil` is idempotent and every
|
||||||
|
// caller applies it, but the boundary is worth stating. The sign multiply inside the divisor
|
||||||
|
// is cancelled by a `fabs`, so the result is always >= 0, and a zero `TerraMod` yields +inf,
|
||||||
|
// which makes the terraform channel absorb its whole pool with nothing cascading to money.
|
||||||
|
// That branch is UNEXERCISED -- no corpus player carries TerraMod 0.
|
||||||
|
|
||||||
|
// `ServerSystem::RepairShipsInOrbit` (0x00751590) -- **the side effect** that makes
|
||||||
|
// `ComputeOutputFromRates` unsafe to call for its value. It hands each damaged ship of the
|
||||||
|
// owner's fleets at the system a share of the construction points left over after the build
|
||||||
|
// queue, round-robin, and returns what is left.
|
||||||
|
//
|
||||||
|
// The round robin is EQUIVALENT to `points - min(points, demand)` and this is a proof rather
|
||||||
|
// than an observation: the per-pass share is `max(points / shipCount, 1)`, so every ship with
|
||||||
|
// a positive remaining cost takes at least one point per pass, and the loop's only early exit
|
||||||
|
// requires every remaining cost to be zero. So it ends either with the points exhausted or
|
||||||
|
// with the demand met. CONFIDENCE: high; the equivalence is pinned by a test.
|
||||||
|
struct RepairPassResult {
|
||||||
|
int spent = 0;
|
||||||
|
int left = 0;
|
||||||
|
};
|
||||||
|
RepairPassResult RepairShipsInOrbit(int points, int repairDemand);
|
||||||
|
|
||||||
|
struct SystemOutputInputs {
|
||||||
|
// --- the rate vector, exactly as the system stores it (NOT normalised) ---
|
||||||
|
OutputRates rates;
|
||||||
|
// The two suppressions the normaliser applies. `suitAtIdeal` is an exact `==` against
|
||||||
|
// IdealSuitability(); `infraFull` is `float32(Infra + ibon) >= 1`.
|
||||||
|
bool suitAtIdeal = false;
|
||||||
|
bool infraFull = false;
|
||||||
|
// The leftover-weight test reads the RAW `Infra` against 1.0 and does NOT add the pending
|
||||||
|
// bonus, so it is a different predicate from `infraFull` and is carried separately.
|
||||||
|
bool infraExactlyOne = false;
|
||||||
|
|
||||||
|
// --- the output total, unrounded (lane N's TotalSystemOutputRaw) ---
|
||||||
|
double totalOutputRaw = 0;
|
||||||
|
|
||||||
|
// --- construction ---
|
||||||
|
int shipyardStations = 0; // StationCount(sys, owner, 1)
|
||||||
|
int buildQueueDemand = 0; // sum of `conleft` over the system's build queue
|
||||||
|
// Sum of `Ship::RepairCost` over the owner's damaged ships in orbit. NOT modelled from
|
||||||
|
// the wire anywhere yet; a caller that cannot compute it must leave it 0 and say so.
|
||||||
|
int repairDemand = 0;
|
||||||
|
|
||||||
|
// --- infrastructure ---
|
||||||
|
double infra = 0; // sys.Infra, for `ceil((1 - Infra) / 3.3e-5)`
|
||||||
|
|
||||||
|
// --- terraforming ---
|
||||||
|
double terraformPointsNeeded = 0; // TerraformPointsNeeded(...)
|
||||||
|
bool terraformDown = false; // IdealSuitability() < sys.Suit
|
||||||
|
double terraformMod = 1.0; // owner's TerraMod
|
||||||
|
|
||||||
|
// --- money: every field except `tradePoints`, which this function computes ---
|
||||||
|
SystemMoneyInputs money;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SystemOutput {
|
||||||
|
int totalOutput = 0; // out[0], truncated
|
||||||
|
int money = 0; // out[3] <- the ONLY slot ComputeBudget reads
|
||||||
|
int construction = 0; // out[7]
|
||||||
|
int constructionToQueue = 0; // out[8]
|
||||||
|
int constructionToRepair = 0; // out[9]
|
||||||
|
double infraDelta = 0; // out[10], a float32
|
||||||
|
double suitabilityDelta = 0; // out[11], a float32
|
||||||
|
// Reported so a caller can see which edges actually carried anything.
|
||||||
|
double tradePoints = 0; // round(total x SRt)
|
||||||
|
double leftoverToTrade = 0; // the construction leftover's trade share
|
||||||
|
double leftoverToMoney = 0; // the terraform leftover that reached the money channel
|
||||||
|
OutputRates normalisedRates;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `ServerSystem::ComputeOutput` restricted to the channels the campaign has models for.
|
||||||
|
// out[1], out[2], out[4], out[5] and out[6] -- the resource ledger, the trade-route income
|
||||||
|
// pair and the repair demand -- are NOT produced here: none of them feeds `out[3]`, they have
|
||||||
|
// their own inputs, and inventing them would be coverage theatre.
|
||||||
|
// CONFIDENCE: high on the channel algebra and the rounding sites (every one read off the
|
||||||
|
// instruction stream). The repair spend is only as good as `repairDemand`.
|
||||||
|
SystemOutput ComputeSystemOutput(const SystemOutputInputs& in, const TuningTable& t);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// System bonus and build queue
|
// System bonus and build queue
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -559,6 +559,219 @@ static void test_max_income() {
|
||||||
}()));
|
}()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// 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() {
|
static void test_difficulty_table() {
|
||||||
// Level 0 gives the break to the human; levels 1 and 2 give it to the AI.
|
// 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_ai = DifficultyModsFor(0, true, false);
|
||||||
|
|
@ -714,6 +927,10 @@ int main() {
|
||||||
test_system_money();
|
test_system_money();
|
||||||
test_population_income();
|
test_population_income();
|
||||||
test_max_income();
|
test_max_income();
|
||||||
|
test_ideal_suitability();
|
||||||
|
test_terraform_points();
|
||||||
|
test_repair_pass();
|
||||||
|
test_turn_path_output();
|
||||||
test_difficulty_table();
|
test_difficulty_table();
|
||||||
test_bonuses();
|
test_bonuses();
|
||||||
test_build_queue();
|
test_build_queue();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue