diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index 5b83b06..fbb8a58 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -128,26 +128,36 @@ constexpr PhaseDesc kStrategic[] = { // ServerPlayer::ProcessTurn -- 12 phases, 1..12 // --------------------------------------------------------------------------------------- constexpr PhaseDesc kPlayer[] = { - {Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Blocked, - "the formula is verified (0 divergences over 4,284 live calls) but one input is not " - "modelled: the money output of each owned system. NOT the same function T31 sums -- the " - "turn path takes ComputeOutput with the system's OWN rate sliders, so its money channel " - "carries the repair pass (which is not side-effect free) and the unspent-industry and " - "unspent-terraforming cascades, none of which are zero once the other channels are " - "funded. Only ComputeBudget's PROJECTED mode uses the max-income form that is now " - "modelled. Evaluated and reported, not committed"}, - {Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Blocked, - "saturating add of the budget net into savings; blocked behind P01's missing input"}, + {Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Partial, + "the formula is verified (0 divergences over 4,284 live calls) and the per-system money " + "input is now modelled on the TURN path -- ComputeOutput with the system's own rate " + "sliders, so the build queue, the ship-repair pass and the infrastructure -> terraform " + "-> money cascade are all live, none of which is the max-income form T31 sums. What is " + "still missing is upstream, not here: S11's civilian growth is not committed, so a " + "colony that grew this turn is priced from its pre-growth population, and the repair " + "demand of damaged ships in orbit is taken as 0. The phase self-checks every run by " + "running the same colonies through the projected path, which the save's own BnkEl " + "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, "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"}, {Driver::Player, 4, "P04", "ProcessSpecialProjectsSpend", PhaseStatus::Stub, "special-project spend; the project bodies are opaque on the wire"}, {Driver::Player, 5, "P05", "ProcessResearch", PhaseStatus::Blocked, - "the research slice is verified end to end (35 live calls, 0 divergences) but its " - "allocation comes from P01's budget, so it cannot be driven yet"}, + "the research slice is verified end to end (35 live calls, 0 divergences) and P01 now " + "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, - "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, "zeroes the three timed-research accumulators that are on the wire; two further words the " "phase also zeroes are not identified"}, diff --git a/src/app/turn.cpp b/src/app/turn.cpp index 8cef6eb..dc97c37 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -96,16 +96,37 @@ struct PlayerPhaseTotals { 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 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, - PlayerPhaseTotals& t) { - // --- P01 ComputeBudget -- blocked on the per-system money output ----------------- - // The formula is here and is verified; what is missing is `systemIncome`. We build the - // inputs we do hold so the shape of the gap is visible, then stop. + PlayerPhaseTotals& t, const PlayerBudgetFeed& feed) { + // --- P01 ComputeBudget ------------------------------------------------------------ + // The per-system money is `ComputeOutput(s).out[3]`, NOT `ComputeMaxIncome(s)`: the + // 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; in.savings = p.sav; in.ownsSystems = !p.owners.empty(); in.maintenance = p.maint; + in.maintenanceDivisor = feed.difficulty.maintenanceDivisor; + in.researchDifficultyMult = feed.difficulty.researchMult; in.isAI = p.npc; in.researchRate = p.resRate; in.resMod = p.resMod; @@ -122,14 +143,19 @@ void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng, s.fraction = e.xper; 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 int wouldBe = sim::SaturatingAdd(p.sav, b.net); ++t.fired[1]; if (wouldBe != p.sav) ++t.wouldWrite[2]; - if (opt.CommitBlocked("P02")) { - p.sav = wouldBe; - ++t.writes[2]; + // P02 is the write. It is committed by default now that the money channel is + // modelled; a player whose owned systems could not all be resolved is still + // 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& a, int speci return false; } -// `max(ComputeMaxIncome(s), 0)` for one owned system. -int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI, - const MaxIncomeInputs& ctx) { +// The two terms both money paths share: the output total and everything in the money chain +// except the trade points. `ComputeMaxIncome` and the turn's `ComputeOutput` differ only in +// 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 // 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; + out.popSpecies = popSpecies; const auto species = static_cast(owner.species); 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(s.pop) + s.pbon; // --- the output total (lane N's term) --- - double total = 0.0; if (s.rbfl == 0) { sim::BaseOutputInputs b; b.imperialPopulation = imperial; @@ -343,7 +379,7 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI, b.resourcesAvailable = resAvail; b.infra = s.infra; 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.speciesResourceOutput = sim::ConstantsOf(species).resourceOutput; sim::OutputModifiers m; @@ -353,10 +389,10 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI, m.rebOutMod = owner.rebOutMod; m.scOutMod = owner.scOutMod; 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 civRows[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; } - sim::SystemMoneyInputs mi; + sim::SystemMoneyInputs& mi = out.money; mi.popIncomeImperial = sim::PopulationIncome(sim::PopGroup::Imperial, impRows, true, s.hindi, ctx.tuning); mi.popIncomeCivilian = @@ -384,12 +420,113 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI, mi.serverIncomeMod = ctx.serverIncomeMod; mi.difficultyIncomeMult = sim::DifficultyModsFor(owner.aidf, ownerIsAI, owner.npc).incomeMult; - const double ideal = popSpecies >= 0 && popSpecies < static_cast(ctx.idealSuit.size()) - ? ctx.idealSuit[static_cast(popSpecies)] - : owner.idealSuit; - mi.suitCostMod = - sim::SuitabilityCostMod(s.suit, ideal, owner.suitTol, owner.rebAI, true, s.vnh); - return sim::SystemMaxIncome(total, mi); + out.idealSuitability = popSpecies >= 0 && popSpecies < static_cast(ctx.idealSuit.size()) + ? ctx.idealSuit[static_cast(popSpecies)] + : owner.idealSuit; + mi.suitCostMod = sim::SuitabilityCostMod(s.suit, out.idealSuitability, owner.suitTol, + owner.rebAI, true, s.vnh); + 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(s.suit) == ideal; + in.infraFull = sim::F32(static_cast(s.ibon) + s.infra) >= 1.0; + // The leftover split's own infrastructure test reads the RAW Infra against 1. + in.infraExactlyOne = static_cast(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(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 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 byId; + std::vector 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 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) { @@ -788,8 +925,32 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { break; } case 13: { // S13 PlayerTurn -- the nested driver - for (auto& e : game.sim.players) - RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt); + const std::vector feeds = BuildBudgetFeeds(game, opt); + 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(game.sim.players.size()); for (int k = 1; k <= 12; ++k) { rec.leafWrites += pt.writes[k]; diff --git a/src/game/sim/colony.cpp b/src/game/sim/colony.cpp index e917df1..79f94a9 100644 --- a/src/game/sim/colony.cpp +++ b/src/game/sim/colony.cpp @@ -389,7 +389,12 @@ OutputSplit SplitOutput(double total, const OutputRates& rates) { int ConstructionPoints(double constructionShare, int stations, const TuningTable& t) { // 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(stations) * (b * constructionShare) + constructionShare); } 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; 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; if (sum <= 0 || leftover <= 0) { s.trade = std::max(0.0, leftover); @@ -488,6 +495,87 @@ int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in) { 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(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, std::int64_t& pendingBonus, bool owned, bool homeSystem) { BonusApplyResult r; diff --git a/src/game/sim/colony.h b/src/game/sim/colony.h index cdc36de..55d6034 100644 --- a/src/game/sim/colony.h +++ b/src/game/sim/colony.h @@ -520,6 +520,126 @@ int SystemMoneyIncome(const SystemMoneyInputs& in); // for zero science points, which is INFERRED rather than read. 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 // --------------------------------------------------------------------------------------- diff --git a/tests/game_sim/test_colony.cpp b/tests/game_sim/test_colony.cpp index 6f04272..3fb2a66 100644 --- a/tests/game_sim/test_colony.cpp +++ b/tests/game_sim/test_colony.cpp @@ -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(static_cast(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); @@ -714,6 +927,10 @@ int main() { 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();