From ed6602e9ed4143b8787efa781e52fd21b1985cdf Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 15:52:38 -0400 Subject: [PATCH] S11 civilian growth; fix ComputeBudget's interest literals Reference pair turn1->turn2: 81 leaves closed, 0 regressed (was 78/0). Pair turn2->turn3: 39 closed, 0 regressed (was 36/0). With --commit-blocked=T31 --ai-player 1: 83/0 and 41/0. game/sim/colony: GrowCivilianPopulations models ServerSystem's civilian growth sub-pass. The whole system's delta is clamped to 20,000,000 -- an int64 column of the population-type table, built in the executable from its own literals -- and on both reference pairs that clamp, not the growth curve and not any carrying capacity, is what decides the value: the uncapped delta is 7.5x it and the capacity headroom 25x it. So the pass commits with no tuning table loaded, and says by how much each unmodelled input would have to be wrong before it mattered. The one input genuinely off the wire is the per-species civilian capacity factor. It is handled by running the pass twice, once with the modelled capacity and once with the system's own wire-known dcs limit, and committing only when the two agree. Imperial growth is deliberately NOT committed: it is a no-op on this corpus and would need a capacity the corpus can bound from below but not from above. game/sim/economy: both interest rates in ComputeBudget are WIDENED FLOAT literals, (double)0.01f and (double)0.15f, and are then truncated -- so a treasury of exactly 50,000 earns 499, not 500. This module used the exact decimals, which left the human's savings one money high on the first reference pair and exact on the second. Sixteen hand-computed test expectations moved by one; they were derived from the model, not measured. The live ComputeBudget compare (4,437 calls, 0 divergences) did not catch this because it presented only 20 distinct states and none sat on a rounding boundary. game/sim/colony: ShipRepairCost, the last unmodelled input of the output turn path. The demand is still 0 -- its two design fields are cached stats the save does not carry -- but the zero is now evidenced rather than silent: S13 reports the candidate set, and the independent colony keeps a ten-ship fleet over a colony whose savings close exactly at zero demand. Gates run as separate commands: clean-room OK, host ctest 49/49, and the CT111 shim cross-build exit 0 (required: game/sim is compiled into the shim). The host build and report were also re-run on CT111 and produced identical numbers. docs/G3-civilian-growth.md; notes repo findings/subsystems/population-growth.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ --- docs/G3-civilian-growth.md | 82 ++++++++++ src/app/CMakeLists.txt | 1 + src/app/growth_phase.cpp | 275 ++++++++++++++++++++++++++++++++ src/app/growth_phase.h | 52 ++++++ src/app/phase_catalog.cpp | 38 +++-- src/app/turn.cpp | 54 ++++++- src/game/sim/colony.cpp | 57 +++++++ src/game/sim/colony.h | 106 ++++++++++++ src/game/sim/economy.cpp | 4 +- src/game/sim/economy.h | 11 ++ src/game/sim/species.h | 8 + tests/game_sim/test_colony.cpp | 95 +++++++++++ tests/game_sim/test_economy.cpp | 35 ++-- 13 files changed, 783 insertions(+), 35 deletions(-) create mode 100644 docs/G3-civilian-growth.md create mode 100644 src/app/growth_phase.cpp create mode 100644 src/app/growth_phase.h 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() {