merge lane G3: civilian growth; app CMake list and includes union-resolved into their constructs (rule 22)
This commit is contained in:
commit
aabd8a3506
13 changed files with 783 additions and 35 deletions
82
docs/G3-civilian-growth.md
Normal file
82
docs/G3-civilian-growth.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -11,6 +11,7 @@ add_library(sots_app STATIC
|
|||
turn_record.cpp
|
||||
construction_phase.cpp
|
||||
event_phase.cpp
|
||||
growth_phase.cpp
|
||||
visibility_phase.cpp
|
||||
turn.cpp
|
||||
report.cpp)
|
||||
|
|
|
|||
275
src/app/growth_phase.cpp
Normal file
275
src/app/growth_phase.cpp
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
#include "app/growth_phase.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
|
||||
#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<std::int32_t, const Player*> owners;
|
||||
for (const auto& e : game.sim.players) owners[e.playerID] = &e.player;
|
||||
|
||||
std::vector<float> 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<sim::Species>(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<int>(idealSuit.size())
|
||||
? idealSuit[static_cast<std::size_t>(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<sim::Species>(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<sim::Species>(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<double>(modelled[sp].current +
|
||||
std::min<std::int64_t>(delta,
|
||||
sim::kCivilianGrowthStepCap)) /
|
||||
(static_cast<double>(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<long long>(a.committedTotal),
|
||||
static_cast<long long>(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<long long>(worstMarginNumer),
|
||||
static_cast<long long>(sim::kCivilianGrowthStepCap),
|
||||
static_cast<double>(worstMarginNumer) /
|
||||
static_cast<double>(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
|
||||
52
src/app/growth_phase.h
Normal file
52
src/app/growth_phase.h
Normal file
|
|
@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
#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<std::string> 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
|
||||
|
|
@ -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"},
|
||||
|
|
@ -264,10 +276,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, ""},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
#include "app/alliance.h"
|
||||
#include "app/construction_phase.h"
|
||||
#include "app/event_phase.h"
|
||||
#include "app/growth_phase.h"
|
||||
#include "app/trade_raid.h"
|
||||
#include "app/treaty.h"
|
||||
#include "app/turn_record.h"
|
||||
|
|
@ -120,6 +121,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
|
||||
};
|
||||
|
||||
// What P11 needs beyond the player itself: the turn to post into (the frame AFTER H00's
|
||||
|
|
@ -503,7 +512,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<double>(s.suit);
|
||||
in.terraformMod = owner.terraMod;
|
||||
|
|
@ -559,6 +571,13 @@ std::vector<PlayerBudgetFeed> 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<int>(fe.flt.ships.size());
|
||||
}
|
||||
}
|
||||
feeds.push_back(std::move(f));
|
||||
}
|
||||
|
|
@ -996,6 +1015,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
|
||||
|
|
@ -1024,10 +1054,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 "
|
||||
|
|
|
|||
|
|
@ -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<double>(clamped) / static_cast<double>(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<double>(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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<double>(savings) * 0.01);
|
||||
return Ftol(static_cast<double>(savings) * kSavingsInterestRate);
|
||||
}
|
||||
|
||||
int DebtInterest(int savings) {
|
||||
if (savings >= 0) return 0;
|
||||
return Ftol(-static_cast<double>(savings) * 0.15);
|
||||
return Ftol(-static_cast<double>(savings) * kDebtInterestRate);
|
||||
}
|
||||
|
||||
int MaintenanceCost(int maintenance, double difficultyDivisor) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue