diff --git a/docs/G3-civilian-growth.md b/docs/G3-civilian-growth.md new file mode 100644 index 0000000..2fcb423 --- /dev/null +++ b/docs/G3-civilian-growth.md @@ -0,0 +1,82 @@ +# Civilian population growth, and a savings-interest correction (lane G3) + +The colony sub-pass that runs between the build queue and the resource ledger inside each +system's turn, plus the per-ship repair-cost term that was the last unmodelled input of the +output turn path, plus a one-money defect in `ComputeBudget` that landing the first of those +exposed. + +## What decides the value + +The whole system's civilian delta is clamped to **20,000,000** per turn. That number is an +int64 column of the three-row population-type table, which the executable builds from its own +literals; it is not a data-file value and it is not a carrying capacity. On both reference +pairs the uncapped delta is 150,000,005 and the capacity headroom is 500,000,000, so the clamp +is what produces the answer and neither the growth curve nor the capacity chain can change it +without being wrong by more than an order of magnitude. + +That is why the pass commits with **no tuning table loaded**: the growth fraction it computes +under an unloaded table (0.25) and under the shipped one (0.30000001192092896) both land on the +same committed value, and so would anything in `[0.04, 1.0]`. + +Ranked by consequence, the float that actually decides the value is neither of those. It is the +**rescale**, `trunc(applied x (clamped / total))`. The relative error of the quotient reaches +`2^-53` and the product's absolute error can exceed half an ulp of 20,000,000, so a +single-species colony landing exactly on the cap is a measurement, not a theorem. It does, at +both 53-bit and x87 64-bit precision, on both pairs; `tests/game_sim/test_colony.cpp` pins it. +One ulp the other way costs a whole person and moves the human's savings. + +## The capacity, and how it is handled without the data files + +`MaxPopGeneric` multiplies `Size x 1e8` by a per-species, per-group factor that lives in the +data files and is nowhere on the wire. Rather than assume it, the phase runs the pass **twice**: +once with the modelled capacity and once with the capacity discarded in favour of the system's +own `dcs` limit, which *is* on the wire, and commits only when the two agree. It also reports +the threshold the factor would have to fall below before a committed value moved (0.260 on the +reference pair), against a lower bound of 0.27 that the observed growth itself establishes. + +For the imperial group the corpus pins the factor exactly, with no data files at all: Gamma +Cephei's pending population bonus of 1e9 never drains, and the bonus apply returns exactly when +`Pop >= MaxPop`, so `MaxPop <= 1e9`; and its `Pop` never shrinks, and the imperial apply shrinks +exactly when `pop > cap`, so `MaxPop >= 1e9`. Two behaviours, one capacity, no assumption. + +**Imperial growth is deliberately not committed.** It is a no-op on this corpus and committing +it would need a capacity the corpus can bound from below but not from above; that trades a +regression risk for nothing. + +## The interest literals + +Landing growth left the human's savings **one money high** on the first reference pair and exact +on the second. The residual was not growth. `ComputeBudget` multiplies a treasury by widened +**float** literals — `(double)0.01f` and `(double)0.15f` — and then truncates, so a treasury of +exactly 50,000 earns 499, not 500. This module used the exact decimals. Corrected, with the +constants named in `game/sim/economy.h`; sixteen hand-computed test expectations moved by one. + +Worth recording why it survived: `ComputeBudget` has been compared live against the original for +**4,437 calls with 0 divergences**. That run presented only 20 distinct states and none of them +sat on a rounding boundary. A green behavioural compare is not coverage. + +## Ship repair cost + +`ShipRepairCost` is `max(0, buildTarget - (buildProgress + (allowance ? designAllowance : 0)))` +— plain 32-bit integers. The ship's `ConCap` word is the *progress*, not a capacity: the repair +apply adds points to it and clamps it to the design's build target. + +The demand is still taken as 0, because the two design fields are cached stats the save does not +carry. What changed is that the zero is now **evidenced**: the phase reports the candidate set, +and the independent colony keeps a ten-ship fleet in orbit over a colony whose savings close +exactly with the demand at zero — which cannot happen if any of those hulls carried a cost. + +## Result + +Reference pair `turn1 -> turn2`: **81 leaves closed, 0 regressed** (was 78/0). Pair +`turn2 -> turn3`: **39 closed, 0 regressed** (was 36/0). The three new leaves per pair are the +two colonies' civilian population and the human's savings. With `--commit-blocked=T31 +--ai-player 1` the counts are **83/0** and **41/0**; the extra two are the human's and the AI's +bankruptcy elimination limits, which that phase could not close before because they move with +the population. + +Every gate ran as a separate command: clean-room OK, host `ctest` 49/49, and the CT111 shim +cross-build (exit 0) — required here because `game/sim` is compiled into the shim. + +The reverse-engineering evidence, the falsification table and the honest list of what no corpus +save exercises are in the notes repo, `findings/subsystems/population-growth.md`. diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 2a1d457..1257e2e 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -10,6 +10,7 @@ add_library(sots_app STATIC treaty.cpp turn_record.cpp construction_phase.cpp + growth_phase.cpp visibility_phase.cpp turn.cpp report.cpp) diff --git a/src/app/growth_phase.cpp b/src/app/growth_phase.cpp new file mode 100644 index 0000000..a5604af --- /dev/null +++ b/src/app/growth_phase.cpp @@ -0,0 +1,275 @@ +#include "app/growth_phase.h" + +#include +#include +#include +#include + +#include "game/sim/colony.h" +#include "game/sim/numeric.h" +#include "game/sim/species.h" + +namespace sots::app { + +namespace { + +using mars::stream::shapes::Player; +using mars::stream::shapes::Population; +using mars::stream::shapes::SaveGame; +using mars::stream::shapes::Sys; + +std::string fmt(const char* f, ...) { + char buf[640]; + va_list ap; + va_start(ap, f); + std::vsnprintf(buf, sizeof buf, f, ap); + va_end(ap); + return std::string(buf); +} + +std::int64_t CountOf(const Population& p, int group, int species) { + std::int64_t n = 0; + for (const auto& g : p.groups) + if (g.popT == group && g.popS == species) n += g.popC; + return n; +} + +bool HaltedFor(const Sys& s, int group) { + for (const auto& h : s.halt) + if (h.haltt == group) return h.haltv; + return false; +} + +// `Population::SetCount(1, sp, n)`: the group row is rewritten in place, and a species that +// has no row is left alone (the corpus never seeds one -- see the seeding note in the finding). +bool SetCivilianCount(Population& p, int species, std::int64_t n) { + for (auto& g : p.groups) { + if (g.popT == 1 && g.popS == species) { + if (g.popC == n) return false; + g.popC = n; + return true; + } + } + return false; +} + +} // namespace + +GrowthPhaseResult RunCivilianGrowth(SaveGame& game, const sim::TuningTable& tuning, + bool haveTuning) { + GrowthPhaseResult r; + + // The system's `PID` is the owner's OBJECT id, not its index in the player vector. + std::map owners; + for (const auto& e : game.sim.players) owners[e.playerID] = &e.player; + + std::vector idealSuit; + for (const auto& sp : game.sim.species) idealSuit.push_back(sp.issu); + + int noOwner = 0, noCivilians = 0, heldBack = 0; + std::int64_t worstMarginNumer = 0; // smallest (limit - current) seen, over the step cap + bool anyMargin = false; + double smallestCapacityFactorThatStillWorks = 0.0; + bool anyFactorBound = false; + + for (auto& e : game.sim.systems) { + Sys& s = e.sys; + auto it = owners.find(s.pid); + if (s.pid == 0 || it == owners.end()) { + // `GrowCivilianPops` returns immediately when the system has no owner. + for (const auto& g : s.pop2.groups) + if (g.popT == 1 && g.popC > 0) { ++noOwner; break; } + continue; + } + const Player& p = *it->second; + + bool anyCivilian = false; + for (const auto& g : s.pop2.groups) + if (g.popT == 1 && g.popC > 0) anyCivilian = true; + if (!anyCivilian) { ++noCivilians; continue; } + ++r.systemsVisited; + + const bool halted = HaltedFor(s, 1); + const auto ownerSpecies = static_cast(p.species); + const bool ownerHasCivilians = ownerSpecies != sim::Species::Zuul; // see below + + sim::CivilianGrowthRow modelled[sim::kSpeciesCount] = {}; + sim::CivilianGrowthRow wireOnly[sim::kSpeciesCount] = {}; + std::int64_t before[sim::kSpeciesCount] = {}; + + for (int sp = 0; sp < sim::kSpeciesCount; ++sp) { + const std::int64_t pop2 = CountOf(s.pop2, 1, sp); + const std::int64_t bonus = CountOf(s.pbon2, 1, sp); + before[sp] = pop2; + + // --- the curve ------------------------------------------------------------- + sim::GrowthInputs gi; + gi.pop = pop2 + bonus; // GroupPopulation(1, sp) = Pop2 + pbon2 + gi.blockaded = halted; + gi.suitability = s.suit; + // The distance helper reads the SERVER's per-species baseline, not the owner's + // own `IdealSuit` field. In this corpus they agree; the difference is read, not + // measured. + gi.idealSuitability = sp < static_cast(idealSuit.size()) + ? idealSuit[static_cast(sp)] + : p.idealSuit; + gi.suitTolerance = p.suitTol; + gi.accommodated = p.rebAI; + gi.playerPopMod = p.popMod; + // The system's `GFlags` carries a per-player bit that swaps the curve's per-call + // factor from 1 to 1.5. UNEXERCISED: zero for the growing player everywhere. + gi.extraFactor = + (p.plyrIdx >= 0 && p.plyrIdx < 32 && + (s.gFlags & (1 << p.plyrIdx)) != 0) + ? sim::kFlaggedSystemGrowthFactor + : 1.0; + gi.groupGrowthMult = sim::kCivilianGrowthMult; + const std::int64_t delta = sim::PopulationGrowthDelta(gi, tuning); + + // --- the capacity ---------------------------------------------------------- + sim::CapacityInputs ci; + ci.planetSize = s.size; + ci.species = static_cast(sp); + // `MaxPopGeneric` gates on the OWNER species carrying this population group at + // all (`SpeciesDef+0x168[1] > 0`), which is how Zuul end up with no civilians. + ci.speciesCanLive = ownerHasCivilians; + ci.group = sim::PopGroup::Civilian; + ci.groupCapacityMult = sim::kCivilianCapacityMult; + ci.speciesGrowthFactor = sim::ConstantsOf(static_cast(sp)).growthFactor; + ci.ownerIsDifferentSpecies = + p.species != 4 && p.species != sp; + ci.crossSpeciesMod = 1.0; // SpeciesDef+0x174[1]; not on the wire, never applied here + ci.hazardMod = p.rebAI ? 1.0 + : sim::HazardModifier(s.suit, gi.idealSuitability, p.suitTol); + ci.arcologyTech = p.harcc; + ci.groupMaxEnabled = true; + ci.groupMax = sim::kGroupPopulationCeiling; + ci.ownerIsNpc = false; // the INDSYS multiplier is imperial-only (group 0) + const std::int64_t capacity = sim::CarryingCapacity(ci, tuning); + + // `CivilianSettleLimit` = min(capacity at the species' IDEAL suitability, dcs). + sim::CapacityInputs ideal = ci; + ideal.hazardMod = 1.0; // the override makes suit == ideal, so the hazard is 1 + const std::int64_t idealCapacity = sim::CarryingCapacity(ideal, tuning); + const std::int64_t dcs = CountOf(s.dcs, 1, sp); + + modelled[sp].delta = delta; + modelled[sp].current = pop2 + bonus; + modelled[sp].capacity = capacity; + modelled[sp].settleLimit = std::min(idealCapacity, dcs); + + // The same pass with the modelled capacity discarded: `dcs` alone is a wire-known + // upper bound on the true limit, so agreement between the two means the capacity + // chain -- the one input that is not on the wire -- did not decide anything. + wireOnly[sp] = modelled[sp]; + wireOnly[sp].capacity = INT64_MAX; + wireOnly[sp].settleLimit = dcs; + + const std::int64_t limit = + modelled[sp].settleLimit < capacity ? modelled[sp].settleLimit : capacity; + const std::int64_t headroom = limit - modelled[sp].current; + // The margin is only meaningful for a species the step cap actually held back: a + // species stopped dead by its settle limit has a headroom of zero by definition + // and reporting it as the margin would say nothing about the capacity chain. + if (delta > 0 && headroom >= sim::kCivilianGrowthStepCap) { + if (!anyMargin || headroom < worstMarginNumer) { + worstMarginNumer = headroom; + anyMargin = true; + } + // The capacity factor would have to fall below this before the committed value + // moved: the capacity is linear in it, and what is needed is + // `current + min(delta, cap) >= current + committed`. + if (s.size > 0) { + const double need = + static_cast(modelled[sp].current + + std::min(delta, + sim::kCivilianGrowthStepCap)) / + (static_cast(s.size) * 1e8 * sim::kCivilianCapacityMult); + if (!anyFactorBound || need > smallestCapacityFactorThatStillWorks) { + smallestCapacityFactorThatStillWorks = need; + anyFactorBound = true; + } + } + } + } + + const sim::CivilianGrowthResult a = sim::GrowCivilianPopulations(modelled, halted); + const sim::CivilianGrowthResult b = sim::GrowCivilianPopulations(wireOnly, halted); + + bool agree = true; + for (int sp = 0; sp < sim::kSpeciesCount; ++sp) + if (a.applied[sp] != b.applied[sp]) agree = false; + + int moved = 0; + for (int sp = 0; sp < sim::kSpeciesCount; ++sp) + if (a.applied[sp] != 0) ++moved; + for (int sp = 0; sp < sim::kSpeciesCount; ++sp) + if (a.hitLimit[sp]) ++r.hitSettleLimit; + if (a.stepCapBound) ++r.stepCapBound; + + if (!agree) { + ++heldBack; + r.wouldWrite += moved; + r.notes.push_back(fmt( + "%s: NOT committed -- the capacity model changes the answer (%lld vs %lld with " + "the wire's dcs limit alone), so the per-species capacity factor is a real input " + "here and is evaluated, not written", + s.name.c_str(), static_cast(a.committedTotal), + static_cast(b.committedTotal))); + continue; + } + + for (int sp = 0; sp < sim::kSpeciesCount; ++sp) { + if (a.applied[sp] == 0) continue; + if (SetCivilianCount(s.pop2, sp, before[sp] + a.applied[sp])) ++r.leafWrites; + } + if (moved) ++r.systemsGrown; + } + + if (r.systemsVisited == 0) { + r.notes.push_back("no owned system carries a civilian population; the pass is faithful " + "and idle"); + return r; + } + + r.notes.push_back(fmt("%d owned system(s) with civilians, %d grew, %d leaf write(s); " + "%d system(s) skipped with no civilians and %d unowned", + r.systemsVisited, r.systemsGrown, r.leafWrites, noCivilians, noOwner)); + r.notes.push_back(fmt( + "%d system(s) had the value decided by the 20,000,000 per-turn step cap " + "(POPTYPE[1]+0x08, a literal in the executable) rather than by the growth curve or any " + "capacity", r.stepCapBound)); + if (anyMargin) + r.notes.push_back(fmt( + "smallest capacity headroom on any growing species: %lld, against a step cap of " + "%lld -- a %.1fx margin, so the capacity chain would have to be wrong by more than " + "that before a committed value moved", + static_cast(worstMarginNumer), + static_cast(sim::kCivilianGrowthStepCap), + static_cast(worstMarginNumer) / + static_cast(sim::kCivilianGrowthStepCap))); + if (anyFactorBound) + r.notes.push_back(fmt( + "the per-species civilian capacity factor (SpeciesDef+0x168[1], a DATA FILE value " + "taken as 1.0) would have to fall below %.3f before any committed value changed", + smallestCapacityFactorThatStillWorks)); + r.notes.push_back( + haveTuning + ? "tuning table loaded: POPULATION_GROWTH_MOD and _EXP are the file's" + : "NO tuning table: POPULATION_GROWTH_MOD and _EXP read 0, the MOD multiply is " + "skipped by its strict >0 gate and the exponent clamps to (double)0.01f. Every " + "corpus colony sits EXACTLY at its species' ideal suitability, so the curve's " + "base is 1.0 and the exponent cannot matter; and the uncapped delta is 7.5x the " + "step cap, so no growth fraction in [0.04, 1.0] changes a committed value"); + if (heldBack) + r.notes.push_back(fmt("%d system(s) held back and reported rather than written", + heldBack)); + if (r.hitSettleLimit) + r.notes.push_back(fmt( + "%d species stopped by their settle limit rather than by their own curve; the " + "original raises a morale event for each and that half is NOT modelled, so a " + "morale leaf may stay open because of it", r.hitSettleLimit)); + return r; +} + +} // namespace sots::app diff --git a/src/app/growth_phase.h b/src/app/growth_phase.h new file mode 100644 index 0000000..1fdd211 --- /dev/null +++ b/src/app/growth_phase.h @@ -0,0 +1,52 @@ +// S11's civilian population growth sub-pass, wired to the save shapes. +// +// WHERE IT SITS +// ------------- +// `StrategyServer::ProcessTurn` phase 11 walks the systems; each system's own turn runs, in +// order, the plague pass, the build queue, imperial growth and then *civilian* growth. That +// last pass is `ServerSystem::GrowCivilianPops`, and it is the one modelled here. It runs +// before the player driver at phase 13, so the money `ComputeBudget` sums is priced from the +// POST-growth colony -- which is why one uncommitted population leaf costs the human's `Sav`, +// `PvSav`, `BnkEl` and `BnkPr` as well. +// +// WHAT DECIDES THE VALUE, AND WHAT DOES NOT +// ----------------------------------------- +// The whole system's civilian delta is clamped to `POPTYPE[1] +0x08` = 20,000,000 -- an int64 +// literal inside the executable, not a data-file value and not a carrying capacity. On both +// reference pairs the uncapped delta is 150,000,005 and the capacity headroom is 500,000,000, +// so the clamp is what decides the answer and neither the growth curve nor the capacity chain +// can change it without being wrong by an order of magnitude. The pass therefore commits +// without a tuning table, and reports the margin by which each unmodelled input would have to +// be wrong before it mattered. +// +// The one input that is genuinely not on the wire is the per-species, per-group capacity +// factor (`SpeciesDef+0x168[group]`). It is handled by running the pass twice -- once with the +// modelled capacity and once with the capacity discarded in favour of the system's own `dcs` +// limit, which IS on the wire -- and committing only when the two agree. +#pragma once + +#include +#include + +#include "game/sim/tuning.h" +#include "mars/stream/shapes.h" + +namespace sots::app { + +struct GrowthPhaseResult { + int systemsVisited = 0; // owned systems with a civilian population + int systemsGrown = 0; // systems whose civilian count actually moved + int leafWrites = 0; + int wouldWrite = 0; // systems held back because the capacity model mattered + int stepCapBound = 0; // systems where the 20,000,000 clamp decided the value + int hitSettleLimit = 0; // species stopped by the settle limit (the morale-event half) + std::vector notes; +}; + +// Run civilian growth over every owned system. `tuning` may be default-constructed (nothing +// loaded); the run log says which of the three tuning states it was given and whether that +// state could have changed any committed value. +GrowthPhaseResult RunCivilianGrowth(mars::stream::shapes::SaveGame& game, + const sim::TuningTable& tuning, bool haveTuning); + +} // namespace sots::app diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index 007044a..cc31133 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -85,8 +85,17 @@ constexpr PhaseDesc kStrategic[] = { "upkeep of population carried aboard colony/slaver hulls in transit"}, {Driver::Strategic, 11, "S11", "SystemTurn", PhaseStatus::Partial, "runs game::sim ProcessColonyTurn per system and commits the parts that need neither the " - "tuning table nor a carrying capacity; plague, growth, resources, slaves and rebellion " - "are the sub-passes the model still declares as its input boundary. The BUILD QUEUE is " + "tuning table nor a carrying capacity. CIVILIAN GROWTH now runs and commits: the whole " + "system's civilian delta is clamped to POPTYPE[1]+0x08 = 20,000,000, an int64 literal in " + "the executable, and on both reference pairs that clamp -- not the growth curve and not " + "any capacity -- is what decides the value (the uncapped delta is 7.5x it and the " + "capacity headroom 25x it). The one input that is genuinely off the wire, the per-species " + "civilian capacity factor, is handled by running the pass twice, once with the modelled " + "capacity and once with the system's own wire-known dcs limit, and committing only when " + "the two agree. Plague, resources, slaves and rebellion remain the model's input " + "boundary, as does IMPERIAL growth -- it is a no-op on this corpus (the homeworld sits " + "exactly at its cap) and committing it would need a capacity the corpus cannot bound " + "from above. The BUILD QUEUE is " "modelled and runs (it is the only writer of the per-class built counter in the whole " "image) but has no points to spend: they come from the per-system output term. It is " "NOT what the missing destroyer waits on -- no build order exists anywhere in either " @@ -143,16 +152,19 @@ constexpr PhaseDesc kPlayer[] = { "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 " + "still missing is upstream, not here: the repair demand of ships in orbit is taken as 0 " + "(its two design fields are cached stats, nowhere on the wire; the phase now reports the " + "candidate set that evidences the zero). S11's civilian growth IS committed as of G3, so " + "a colony that grew this turn is priced from its post-growth population. 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)"}, + "saturating add of the budget net into savings, committed. EXACT for the human and for " + "the independent colony on BOTH reference pairs now that S11 commits civilian growth " + "and the two interest rates are the widened float literals the image holds. Still " + "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"}, @@ -251,10 +263,10 @@ constexpr PhaseDesc kTail[] = { "this player AI?) is a game-setup input the save does not carry, and it selects a " "difficulty column worth x1.1 on an AI empire; --ai-player N supplies it. Second, BnkPr " "needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files, so it is offered only " - "with a tuning table loaded. Committing it closes NOTHING on the reference pair: the " - "limits move between turn1 and turn2 because the CIVILIAN population grows, and that " - "growth is itself not committed, so our value equals the input save's. Measured with " - "--commit-blocked=T31 --ai-player 1: 0 closed, 0 regressed"}, + "with a tuning table loaded. It used to close NOTHING because the limits move with the " + "CIVILIAN population and that growth was not committed; now that S11 commits it, measured " + "with --commit-blocked=T31 --ai-player 1: 2 closed, 0 regressed on EACH reference pair " + "-- BnkEl for the human and for the AI. BnkPr still needs the tuning factor"}, {Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""}, {Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""}, {Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""}, diff --git a/src/app/turn.cpp b/src/app/turn.cpp index 00da8f9..cd9e7e1 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -9,6 +9,7 @@ #include "app/alliance.h" #include "app/construction_phase.h" +#include "app/growth_phase.h" #include "app/trade_raid.h" #include "app/treaty.h" #include "app/turn_record.h" @@ -113,6 +114,14 @@ struct PlayerBudgetFeed { // rounding. A large delta here is the model failing, and it is visible without a VM. int projectedIncome = 0; int turnIncome = 0; + // The ninth input of the output turn path: the repair demand of the owner's ships in + // orbit, `Sum Ship::RepairCost` over `RepairShipsInOrbit`'s candidate set. The per-ship + // arithmetic is now read (sim::ShipRepairCost) but its two design fields are cached stats + // that are nowhere on the wire, so the demand is still taken as 0. What IS on the wire is + // the candidate set, and counting it turns a silent zero into an evidenced one: a colony + // with no ship in orbit cannot have a repair demand at all. + int repairCandidateSystems = 0; // owned colonies with at least one fleet in orbit + int repairCandidateShips = 0; // ships in those fleets }; void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng, @@ -469,7 +478,10 @@ int SystemTurnMoneyFromWire(const Sys& s, const Player& owner, bool ownerIsAI, in.buildQueueDemand = 0; if (s.bq) for (const auto& o : s.bq->orders) in.buildQueueDemand += o.conleft; - in.repairDemand = 0; // see the note above + // The ninth input. `sim::ShipRepairCost` now models the per-ship term, but its two design + // fields (the build target and the allowance) are cached design stats that the save does + // not carry, so the demand stays 0 and S13 reports the candidate set that proves the zero. + in.repairDemand = 0; in.terraformPointsNeeded = sim::TerraformPointsNeeded(s.suit, ideal, owner.terraMod); in.terraformDown = ideal < static_cast(s.suit); in.terraformMod = owner.terraMod; @@ -525,6 +537,13 @@ std::vector BuildBudgetFeeds(const SaveGame& game, const TurnO if (money != 0) f.systemIncome.push_back(money); f.turnIncome += money; f.projectedIncome += SystemMaxIncomeFromWire(*s, p, f.isAI, ctx); + if (!s->fleets.empty()) { + ++f.repairCandidateSystems; + for (std::int32_t fid : s->fleets) + for (const auto& fe : game.sim.fleets) + if (fe.fltID == fid) + f.repairCandidateShips += static_cast(fe.flt.ships.size()); + } } feeds.push_back(std::move(f)); } @@ -956,6 +975,17 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { rec.wouldWrite += b.wouldWrite; for (const auto& n : b.notes) rec.notes.push_back("build queue: " + n); } + // Civilian growth runs after the build queue in the original's colony turn, + // and before the player driver at phase 13 -- so what it writes here is what + // ComputeBudget prices the colony from. + { + sim::TuningTable tuning; + const GrowthPhaseResult g = RunCivilianGrowth(game, tuning, opt.haveTuning); + rec.leafWrites += g.leafWrites; + rec.wouldWrite += g.wouldWrite; + if (g.leafWrites > 0) rec.committed = true; + for (const auto& n : g.notes) rec.notes.push_back("civilian growth: " + n); + } break; } case 13: { // S13 PlayerTurn -- the nested driver @@ -976,10 +1006,26 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { } 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", + "(ComputeOutput, not ComputeMaxIncome); civilian growth is now committed " + "by S11, so a colony that grew this turn is priced from its POST-growth " + "population", fed)); + { + int repSys = 0, repShips = 0; + for (const auto& f2 : feeds) { + repSys += f2.repairCandidateSystems; + repShips += f2.repairCandidateShips; + } + rec.notes.push_back(fmt( + "ship-repair demand taken as 0: %d owned colony(ies) carry a fleet in " + "orbit at all, %d ship(s) between them -- the only input of the nine " + "still unmodelled, and its two design fields are cached stats that are " + "nowhere on the wire. Zero is EVIDENCED rather than assumed on this " + "corpus: the independent colony's savings close exactly on both " + "reference pairs with a fleet parked over Koa'Vo, which cannot happen " + "if any of those hulls had a positive repair cost", + repSys, repShips)); + } 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 " diff --git a/src/game/sim/colony.cpp b/src/game/sim/colony.cpp index 48d65df..cb46893 100644 --- a/src/game/sim/colony.cpp +++ b/src/game/sim/colony.cpp @@ -105,6 +105,53 @@ std::int64_t ApplyImperialGrowth(std::int64_t pop, std::int64_t capacity, std::i return result < 0 ? 0 : result; } +CivilianGrowthResult GrowCivilianPopulations(const CivilianGrowthRow* rows, bool growthHalted) { + CivilianGrowthResult r; + for (int sp = 0; sp < kSpeciesCount; ++sp) { + const CivilianGrowthRow& w = rows[sp]; + std::int64_t limit; + bool hit = false; + if (w.settleLimit < w.capacity) { + limit = w.settleLimit; + // The flag is raised on the pre-clamp intent, not on what is finally applied. + if (w.current + w.delta > w.settleLimit) hit = true; + } else { + limit = w.capacity; + } + const std::int64_t headroom = limit - w.current; + std::int64_t a = w.delta < headroom ? w.delta : headroom; + if (growthHalted && a > 0) { + a = 0; + hit = false; + } + r.applied[sp] = a; + r.hitLimit[sp] = hit; + r.rawTotal += a; + } + + std::int64_t clamped = r.rawTotal; + if (clamped < kCivilianDeclineFloor) clamped = kCivilianDeclineFloor; + if (clamped > kCivilianGrowthStepCap) clamped = kCivilianGrowthStepCap; + r.committedTotal = clamped; + if (clamped == r.rawTotal) return r; + + r.stepCapBound = true; + // The rescale quotient and its product are the floats that decide the answer: the + // relative error of `clamped / total` is up to 2^-53 and the product's absolute error can + // exceed half an ulp of the cap, so it is NOT a theorem that a single-species colony + // lands exactly on the cap. It does on both reference pairs and a test pins it. + const double scale = static_cast(clamped) / static_cast(r.rawTotal); + const bool scaleGains = r.rawTotal > kCivilianGrowthStepCap; + const bool scaleLosses = r.rawTotal < kCivilianDeclineFloor; + for (int sp = 0; sp < kSpeciesCount; ++sp) { + if (scaleGains && !(r.applied[sp] > 0)) continue; + if (!scaleGains && scaleLosses && !(r.applied[sp] < 0)) continue; + r.applied[sp] = Ftoi64(Narrow(static_cast(r.applied[sp]) * scale)); + r.hitLimit[sp] = false; + } + return r; +} + double InfrastructurePointsNeeded(double infra) { // A real ceil() on the double quotient; the result stays a double and is compared // against the (also double) point pool, so nothing is truncated on the way. @@ -505,6 +552,16 @@ double IdealSuitability(const IdealSuitabilityInputs& in) { return v; } +int ShipRepairCost(int designBuildTarget, int shipBuildProgress, int designAllowance, + bool useAllowance) { + // The original loads both design fields unconditionally and adds the allowance to the + // ship's progress only under the flag; the subtraction is plain 32-bit integer. + int have = shipBuildProgress; + if (useAllowance) have += designAllowance; + const int cost = designBuildTarget - have; + return cost > 0 ? cost : 0; +} + RepairPassResult RepairShipsInOrbit(int points, int repairDemand) { RepairPassResult r; if (points <= 0 || repairDemand <= 0) { diff --git a/src/game/sim/colony.h b/src/game/sim/colony.h index 1a0565c..9b1ca9f 100644 --- a/src/game/sim/colony.h +++ b/src/game/sim/colony.h @@ -147,6 +147,86 @@ constexpr std::int64_t kMaxPopulationStep = 50000000; // it. CONFIDENCE: high -- read branch by branch. std::int64_t ApplyImperialGrowth(std::int64_t pop, std::int64_t capacity, std::int64_t delta); +// --------------------------------------------------------------------------------------- +// The per-population-type table's own columns (G3) +// --------------------------------------------------------------------------------------- +// +// The three-row table is built inside the executable and only the three slave modifiers come +// from the data files, so every constant below is a fact about the program, not about the +// shipped content. `+0x08` is an int64 and it is NOT a population ceiling: it is the largest +// step one turn may take, which is how both growth passes use it. `+0x28/+0x2c` -- the group +// ceiling -- is written only by the CRT static initialiser, to INT64_MAX, so the clamp that +// reads it is always a no-op; a reader who opens only the obvious initialiser sees zero there +// and would cap every colony at nothing. CONFIDENCE: high, read twice from the bytes and +// four of the values confirmed live by lane N. +constexpr double kImperialGrowthMult = 1.0; // POPTYPE[0] +0x04 +constexpr double kCivilianGrowthMult = 0.25; // POPTYPE[1] +0x04 +constexpr double kSlaveGrowthMult = 0.0; // POPTYPE[2] +0x04 +constexpr double kImperialCapacityMult = 1.0; // POPTYPE[0] +0x20 +constexpr double kCivilianCapacityMult = 2.0; // POPTYPE[1] +0x20 +constexpr std::int64_t kImperialGrowthStepCap = 50000000; // POPTYPE[0] +0x08 +constexpr std::int64_t kCivilianGrowthStepCap = 20000000; // POPTYPE[1] +0x08 +constexpr std::int64_t kGroupPopulationCeiling = INT64_MAX; // POPTYPE[t] +0x28 + +// The floor the civilian pass puts on a system-wide DECLINE. It is a literal inside +// `GrowCivilianPops` (`0xffffffff_fd050f80`), not the imperial row's step cap, even though +// the two numbers happen to be equal. +constexpr std::int64_t kCivilianDeclineFloor = -50000000; + +// The growth curve's per-call factor when the system's `GFlags` carries the owner's player +// bit: the curve is multiplied by 1.5 instead of 1. It is a BONUS, not a penalty. +// UNEXERCISED: `GFlags` is 0 for the growing player on every corpus system. HYPOTHESIS. +constexpr double kFlaggedSystemGrowthFactor = 1.5; + +// --------------------------------------------------------------------------------------- +// Civilian growth -- `ServerSystem::GrowCivilianPops` +// --------------------------------------------------------------------------------------- +// +// The original loops group types 0,1,2 and does work for **1** only (the imperial pass is +// inlined in `ServerSystem::ProcessTurn`, slaves are `ProcessSlaves`); the loop's back edge +// sits outside every decompiler `if`, which is why the body reads as straight-line code if +// you stop at the first `ret`. + +struct CivilianGrowthRow { + // `PopulationGrowthDelta` for (group 1, this species). + std::int64_t delta = 0; + // `Population::Count(Pop2, 1, sp) + Population::Count(pbon2, 1, sp)`. The PENDING civilian + // bonus pool counts towards the headroom even though the write-back adds to `Pop2` alone, + // so a colony with a pending pool grows less than its `Pop2` headroom would allow. + // UNEXERCISED: `pbon2` is empty on every corpus system. HYPOTHESIS on which of the two + // sides carries the pool. + std::int64_t current = 0; + // `MaxPopGeneric(1, sp, owner, NULL)` -- the capacity at the planet's real suitability. + std::int64_t capacity = 0; + // `CivilianSettleLimit(sp)` = min(capacity at the species' IDEAL suitability, the count + // the system's `dcs` Population holds for (1, sp)). + std::int64_t settleLimit = 0; +}; + +struct CivilianGrowthResult { + std::int64_t applied[kSpeciesCount] = {}; + // Set when the species was stopped by its settle limit rather than by its own curve. + // The original turns this into a morale event; that half is not modelled here. + bool hitLimit[kSpeciesCount] = {}; + std::int64_t rawTotal = 0; // before the system-wide clamp + std::int64_t committedTotal = 0; // after it + bool stepCapBound = false; // the clamp moved the total, so the rescale ran +}; + +// One civilian growth pass over a system. +// +// per species: limit = settleLimit < capacity ? settleLimit : capacity +// applied = min(delta, limit - current) (so a limit BELOW the current +// population is a shrink) +// halted && applied > 0 -> applied = 0 +// system-wide: total clamped into [kCivilianDeclineFloor, kCivilianGrowthStepCap]; when the +// clamp bit, every entry of the same sign as the clamp is rescaled by +// `trunc(applied x (clamped / total))` and the original does NOT renormalise, +// so the shares need not add back up to the clamp. +// CONFIDENCE: high -- read instruction by instruction. The single-species case is the only +// one the corpus exercises, and there the rescale is exact. +CivilianGrowthResult GrowCivilianPopulations(const CivilianGrowthRow* rows, bool growthHalted); + // --------------------------------------------------------------------------------------- // Infrastructure and terraforming // --------------------------------------------------------------------------------------- @@ -584,6 +664,32 @@ struct RepairPassResult { }; RepairPassResult RepairShipsInOrbit(int points, int repairDemand); +// `Ship::RepairCost` 0x00815180 -- the per-ship term the pass above sums, and the ninth and +// last input of the output turn path. It is a two-field integer subtraction with a floor at +// zero and no floating point at all: +// +// cost = max(0, designBuildTarget - (shipBuildProgress + (allowance ? designAllowance : 0))) +// +// `shipBuildProgress` is the ship's `ConCap` word (+0x68). Despite the save-format name it is +// NOT a per-turn capacity: `Ship::ApplyRepair` 0x008151c0 does `ConCap += max(points, 0)` and +// then clamps `ConCap` into `[0, designBuildTarget]`, so it is the construction invested in +// the hull so far and `designBuildTarget` (design +0xcc) is its ceiling. `designAllowance` +// (design +0xd0) is subtracted only when the caller asks for it, and the only caller -- +// `RepairShipsInOrbit` -- always does. CONFIDENCE: high on the arithmetic; the two design +// fields are read but NOT identified, so a caller supplies them. +// +// UNEXERCISED. No corpus save has ever produced a non-zero value, and that is a MEASUREMENT +// rather than an absence: the independent colony's fleet sits in orbit over Koa'Vo on both +// reference pairs and that player's `Sav` closes exactly with the demand taken as zero, which +// it could not do if any of those ships had a positive cost. What no save exercises is a +// hull with `ConCap < designBuildTarget`, so every branch below the floor is a HYPOTHESIS. +// +// Note `Ship::ApplyRepair` silently does nothing unless the ship's cached role word (+0x18, +// not on the wire) carries bit 0x400000, while the pass's candidate filter tests a different +// bit -- so a ship can be charged points that never reach it. Read, not explained; flagged. +int ShipRepairCost(int designBuildTarget, int shipBuildProgress, int designAllowance, + bool useAllowance); + struct SystemOutputInputs { // --- the rate vector, exactly as the system stores it (NOT normalised) --- OutputRates rates; diff --git a/src/game/sim/economy.cpp b/src/game/sim/economy.cpp index a5bfa9f..54f635d 100644 --- a/src/game/sim/economy.cpp +++ b/src/game/sim/economy.cpp @@ -26,12 +26,12 @@ DifficultyMods DifficultyModsFor(int level, bool isAI, bool isNpc) { int SavingsInterest(int savings, bool ownsSystems) { if (savings < 0 || !ownsSystems) return 0; - return Ftol(static_cast(savings) * 0.01); + return Ftol(static_cast(savings) * kSavingsInterestRate); } int DebtInterest(int savings) { if (savings >= 0) return 0; - return Ftol(-static_cast(savings) * 0.15); + return Ftol(-static_cast(savings) * kDebtInterestRate); } int MaintenanceCost(int maintenance, double difficultyDivisor) { diff --git a/src/game/sim/economy.h b/src/game/sim/economy.h index 1b5d525..bfd99cf 100644 --- a/src/game/sim/economy.h +++ b/src/game/sim/economy.h @@ -110,6 +110,17 @@ struct Budget { int net = 0; // change in savings this turn }; +// The two interest rates `ComputeBudget` multiplies by are **widened float literals** in the +// image, not the exact decimals: 0x009e31c0 holds (double)0.01f = 0.009999999776482582 and +// 0x009ed188 holds (double)0.15f = 0.15000000596046448 (the same constant lane E1 already +// carries, negated, as `kBankruptcyInterestDivisor`). Both are then truncated by `_ftol2`, so +// the difference from the exact decimal is not cosmetic: a treasury of exactly 50,000 earns +// 499, not 500. G3 correction -- the module used exact decimals, and the live `ComputeBudget` +// compare (4,437 calls, 0 divergences) did not catch it because only 20 distinct states were +// ever presented and none of them sat on a boundary. +constexpr double kSavingsInterestRate = 0.009999999776482582; // (double)0.01f +constexpr double kDebtInterestRate = 0.15000000596046448; // (double)0.15f + // Savings interest: 1 % of a non-negative treasury, only for players who own systems. // CONFIDENCE: high. int SavingsInterest(int savings, bool ownsSystems); diff --git a/src/game/sim/species.h b/src/game/sim/species.h index ffd353c..00b5035 100644 --- a/src/game/sim/species.h +++ b/src/game/sim/species.h @@ -38,6 +38,14 @@ struct SpeciesConstants { // 400 output points -- 2000 money -- which is how it was found. int resourceDemand = 0; // SpeciesDef +0x4c double resourceOutput = 10.0; // SpeciesDef +0x50 + // The per-species multiplier on every carrying capacity (the helper at 0x0053bb00 off the + // same SpeciesDef). Also a DATA FILE value. 1.0 for Human is MEASURED, not assumed, and + // not from the data files either: Gamma Cephei's imperial `pbon` of 1e9 never drains -- + // and the bonus apply returns exactly when `Pop >= MaxPop` -- while its `Pop` of 1e9 never + // shrinks, and the imperial apply shrinks exactly when `pop > cap`. The two behaviours + // bracket the capacity at exactly `Size x 1e8 x 1.0`, which forces this factor to 1.0 for + // a Human colony at its ideal suitability. UNVERIFIED for every other species. + double growthFactor = 1.0; // SpeciesDef, read by MaxPopGeneric }; // CONFIDENCE: high on the first three fields (read with their constants and their diff --git a/tests/game_sim/test_colony.cpp b/tests/game_sim/test_colony.cpp index 3fb2a66..5392c90 100644 --- a/tests/game_sim/test_colony.cpp +++ b/tests/game_sim/test_colony.cpp @@ -918,6 +918,99 @@ static void test_build_queue() { CHECK_EQ(zero[0].constructionLeft, 100); } + +// --------------------------------------------------------------------------------------- +// G3: civilian growth, the step cap and the rescale +// --------------------------------------------------------------------------------------- + +static void test_civilian_growth() { + // The two reference pairs, from the wire. Gamma Cephei: 500,000,000 civilians of one + // species, a `dcs` settle limit of 1,000,000,000 and a modelled capacity of 2,000,000,000. + // The growth fraction is float32(1.2f x 1.0 x 1.0 x 0.25) = 0.30000001192092896, so the + // uncapped delta is 150,000,005 -- 7.5x the step cap. + CivilianGrowthRow rows[kSpeciesCount] = {}; + rows[0].delta = 150000005; + rows[0].current = 500000000; + rows[0].capacity = 2000000000; + rows[0].settleLimit = 1000000000; + CivilianGrowthResult r = GrowCivilianPopulations(rows, false); + CHECK_EQ(r.rawTotal, 150000005); + CHECK_EQ(r.committedTotal, 20000000); + CHECK(r.stepCapBound); + // THE float that decides the value: trunc(150000005 x (20000000 / 150000005)). The + // product's error can exceed half an ulp of 20,000,000, so this is a measurement, not a + // theorem, and one ulp low would cost a whole person and move the human's savings. + CHECK_EQ(r.applied[0], 20000000); + CHECK(!r.hitLimit[0]); + + // Pair 2: the colony starts 20,000,000 higher and takes the same step again. This is the + // discriminator that proves POPTYPE[1]+0x08 is a per-turn STEP cap and not a population + // ceiling -- a ceiling of 20,000,000 would make the colony collapse instead. + rows[0].delta = 156000006; + rows[0].current = 520000000; + r = GrowCivilianPopulations(rows, false); + CHECK_EQ(r.applied[0], 20000000); + + // Koa'Vo: the settle limit equals the current population exactly, so the headroom is zero + // and nothing grows -- and because the total is then zero the clamp does not bite, so the + // settle-limit flag survives to raise a morale event. + CivilianGrowthRow tight[kSpeciesCount] = {}; + tight[2].delta = 241500000; + tight[2].current = 500000000; + tight[2].capacity = 1000000000; + tight[2].settleLimit = 500000000; + r = GrowCivilianPopulations(tight, false); + CHECK_EQ(r.applied[2], 0); + CHECK_EQ(r.committedTotal, 0); + CHECK(!r.stepCapBound); + CHECK(r.hitLimit[2]); + + // A halted colony: the flag is cleared with the growth. + r = GrowCivilianPopulations(rows, true); + CHECK_EQ(r.applied[0], 0); + CHECK(!r.hitLimit[0]); + + // A limit BELOW the current population is a shrink, floored by the decline clamp at + // -50,000,000 and rescaled the same way. + CivilianGrowthRow over[kSpeciesCount] = {}; + over[0].delta = 0; + over[0].current = 900000000; + over[0].capacity = 2000000000; + over[0].settleLimit = 100000000; + r = GrowCivilianPopulations(over, false); + CHECK_EQ(r.rawTotal, -800000000); + CHECK_EQ(r.committedTotal, -50000000); + CHECK_EQ(r.applied[0], -50000000); + + // Two species over the cap: the rescale is proportional and TRUNCATING, and the original + // does not renormalise, so the shares need not add back up to the cap. + CivilianGrowthRow two[kSpeciesCount] = {}; + two[0].delta = 30000000; two[0].current = 0; two[0].capacity = INT64_MAX; + two[0].settleLimit = INT64_MAX; + two[1].delta = 30000001; two[1].current = 0; two[1].capacity = INT64_MAX; + two[1].settleLimit = INT64_MAX; + r = GrowCivilianPopulations(two, false); + CHECK_EQ(r.rawTotal, 60000001); + CHECK_EQ(r.committedTotal, 20000000); + CHECK(r.applied[0] + r.applied[1] <= 20000000); + CHECK(r.applied[0] > 0); + CHECK(r.applied[1] > 0); + + // The step caps are the population-type table's own int64 column. + CHECK_EQ(kCivilianGrowthStepCap, 20000000); + CHECK_EQ(kImperialGrowthStepCap, 50000000); + CHECK_EQ(kCivilianDeclineFloor, -50000000); +} + +static void test_ship_repair_cost() { + // max(0, target - (progress + allowance)), plain 32-bit integers, floored at zero. + CHECK_EQ(ShipRepairCost(1000, 400, 100, true), 500); + CHECK_EQ(ShipRepairCost(1000, 400, 100, false), 600); + CHECK_EQ(ShipRepairCost(1000, 1000, 0, false), 0); + CHECK_EQ(ShipRepairCost(1000, 1200, 0, false), 0); // the floor, not a negative + CHECK_EQ(ShipRepairCost(1000, 950, 100, true), 0); // the allowance can cross the floor +} + int main() { test_capacity(); test_growth(); @@ -934,5 +1027,7 @@ int main() { test_difficulty_table(); test_bonuses(); test_build_queue(); + test_civilian_growth(); + test_ship_repair_cost(); return simtest::finish("test_colony"); } diff --git a/tests/game_sim/test_economy.cpp b/tests/game_sim/test_economy.cpp index 65c8227..18dbb96 100644 --- a/tests/game_sim/test_economy.cpp +++ b/tests/game_sim/test_economy.cpp @@ -5,8 +5,11 @@ using namespace sots::sim; +// The two interest rates are WIDENED FLOAT literals in the image, so a round treasury earns +// one less than the exact decimal would give. Every expectation below that moved by exactly 1 +// was hand-computed from the exact decimal and is corrected here (G3). static void test_interest() { - CHECK_EQ(SavingsInterest(1000, true), 10); + CHECK_EQ(SavingsInterest(1000, true), 9); // 1000 x (double)0.01f = 9.99999977 -> 9 CHECK_EQ(SavingsInterest(199, true), 1); // 1.99 truncates CHECK_EQ(SavingsInterest(1000, false), 0); // no systems, no interest CHECK_EQ(SavingsInterest(-500, true), 0); @@ -81,22 +84,22 @@ static BudgetInputs base_inputs() { static void test_budget_hand_case() { Budget b = ComputeBudget(base_inputs(), false); - CHECK_EQ(b.savingsInterest, 100); + CHECK_EQ(b.savingsInterest, 99); CHECK_EQ(b.debtInterest, 0); CHECK_EQ(b.systemIncomePositive, 8000); CHECK_EQ(b.systemIncomeNegative, 200); CHECK_EQ(b.maintenance, 2000); CHECK_EQ(b.expenses, 0); // 8000 + 1000 + 100 - 200 - 2000 = 6900 - CHECK_EQ(b.available, 6900); + CHECK_EQ(b.available, 6899); CHECK_EQ(b.construction, 500); // (6900 - 500) x 0.5 = 3200 - CHECK_EQ(b.researchMoney, 3200); + CHECK_EQ(b.researchMoney, 3199); // 3200/50 = 64; x1.15 = 73.6; x0.5 = 36.8; x0.85 = 31.28 -> 31 CHECK_EQ(b.researchPoints, 31); CHECK_EQ(b.totalResearchPoints, 31); CHECK(b.hasResearchAllocation); - CHECK_EQ(b.researchMoneyKept, 3200); + CHECK_EQ(b.researchMoneyKept, 3199); CHECK_EQ(b.bonusIncome, 0); CHECK_EQ(b.savingsGiven, 0); // 6900 - 500 - 3200 @@ -110,20 +113,20 @@ static void test_budget_without_research_target() { BudgetInputs in = base_inputs(); in.hasResearchTarget = false; Budget b = ComputeBudget(in, false); - CHECK_EQ(b.researchMoney, 3200); + CHECK_EQ(b.researchMoney, 3199); CHECK_EQ(b.researchPoints, 31); CHECK_EQ(b.totalResearchPoints, 31); CHECK(!b.hasResearchAllocation); CHECK_EQ(b.researchMoneyKept, 0); // The research money stays in the treasury: 6900 - 500 construction. - CHECK_EQ(b.net, 6400); + CHECK_EQ(b.net, 6399); } static void test_budget_projected() { Budget b = ComputeBudget(base_inputs(), true); CHECK_EQ(b.researchMoney, 0); CHECK_EQ(b.researchPoints, 0); - CHECK_EQ(b.net, 6400); + CHECK_EQ(b.net, 6399); } static void test_budget_debt() { @@ -147,8 +150,8 @@ static void test_budget_aid_and_bonus() { in.tra = 4; in.trp = 5; Budget b = ComputeBudget(in, false); - CHECK_EQ(b.researchMoney, 3200); - CHECK_EQ(b.researchMoneyGiven, 1600); + CHECK_EQ(b.researchMoney, 3199); + CHECK_EQ(b.researchMoneyGiven, 1599); CHECK_EQ(b.researchMoneyKept, 1600); // 31 + 4 + 5 = 40; given 20; kept 20 CHECK_EQ(b.researchPointsGiven, 20); @@ -160,7 +163,7 @@ static void test_budget_aid_and_bonus() { in.aidResearchPercent = 250; // clamps to 100 in.aidSavings = 0; b = ComputeBudget(in, false); - CHECK_EQ(b.researchMoneyGiven, 3200); + CHECK_EQ(b.researchMoneyGiven, 3199); CHECK_EQ(b.totalResearchPoints, 0); in = base_inputs(); @@ -188,7 +191,7 @@ static void test_budget_aid_and_bonus() { b = ComputeBudget(in, false); CHECK_EQ(b.available, 0); CHECK_EQ(b.bonusIncome, 0); - CHECK_EQ(b.net, -11100); + CHECK_EQ(b.net, -11101); // savings aid is capped by the projected treasury, not by the turn net in = base_inputs(); @@ -225,7 +228,7 @@ static void test_budget_edges() { in.isAI = true; Budget b = ComputeBudget(in, false); CHECK_EQ(b.construction, 0); // AI path spends construction elsewhere - CHECK_EQ(b.researchMoney, 3450); // 6900 x 0.5 + CHECK_EQ(b.researchMoney, 3449); // 6900 x 0.5 in = base_inputs(); in.systemIncome = {}; @@ -235,12 +238,12 @@ static void test_budget_edges() { CHECK_EQ(b.available, 0); // floored at zero CHECK_EQ(b.construction, 0); CHECK_EQ(b.researchMoney, 0); - CHECK_EQ(b.net, 100 - 5000); // interest minus maintenance + CHECK_EQ(b.net, 99 - 5000); // interest minus maintenance in = base_inputs(); in.constructionDemand = 100000; b = ComputeBudget(in, false); - CHECK_EQ(b.construction, 6900); // capped at available + CHECK_EQ(b.construction, 6899); // capped at available CHECK_EQ(b.researchMoney, 0); in = base_inputs(); @@ -248,7 +251,7 @@ static void test_budget_edges() { b = ComputeBudget(in, false); // pre-expense avail 6900: request 3450 - 1000 = 2450, room 1000 -> 2000 total CHECK_EQ(b.expenses, 2000); - CHECK_EQ(b.available, 4900); + CHECK_EQ(b.available, 4899); } static void test_trade() {