merge lane E1: income chain 25/25 on the BnkEl oracle; difficulty multiplier resolved (turn.cpp union-resolved: tail phases 17, 21, 31)

This commit is contained in:
alex 2026-09-08 13:55:49 -04:00
commit 0f1c007f00
12 changed files with 616 additions and 44 deletions

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ d617285, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ bebdee1, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -1469,6 +1469,30 @@ constexpr uint32_t ShipDesign_off_Dtc = 0x00000134;
constexpr uint32_t ShipSectionDef_off_SectionClass = 0x00000260;
// offset unsigned int // low dword of the section's 64-bit role-flag word (high dword at +0x29c), one bit per boolean role key in the .shipsection file [verified]
constexpr uint32_t ShipSectionDef_off_RoleFlagsLow = 0x00000298;
// thiscall double (ServerSystem* sys, int groupType) // `ret 4`, real end 0x0074d8e1. The income analogue of PopOutput 0x0074d8f0, and NOT the same law: it sums, over species 0..6, `(double)ftol( (double)GroupIncome(groupType, count) x moraleMod x addictionMod )` -- so the value truncates TWICE per (group, species) row, once inside GroupIncome and once after both factors. moraleMod is 0x00746910 (the same helper the output term uses) and applies to groupType 1 only; addictionMod is the float behind slot 0x00aeca48 when the system's int[7] addiction table at +0x1e4 has a non-zero entry for that species. For groupType 1 and the owner's own species on a non-independent system the count first gains the capacity surplus from two calls to 0x0074a6d0 (out slot 4, then out slot 6), max(0, B - A) [verified]
constexpr uint32_t ServerSystem_PopIncome = 0x0034d760;
// thiscall double (ServerSystem* sys) // plain `ret`, real end 0x0074b793. groupType 2 of the same loop as PopIncome, over SlaveCount(species) (0x0074b610): no morale factor and no capacity surplus, but the addiction factor still applies. Unexercised: slave counts are 0 on every call in the corpus [verified]
constexpr uint32_t ServerSystem_SlaveIncome = 0x0034b700;
// thiscall int64 (ServerSystem* sys, int species) // the slave-group population of one species; the group-2 counterpart of GroupPopulation 0x00747ba0 [unverified]
constexpr uint32_t ServerSystem_SlaveCount = 0x0034b610;
// thiscall void (ServerSystem* sys, int out[12]) // zeroes `out`, returns immediately when the caller's pointer is null or the system has no owner (+0x100), else calls ComputeOutputFromRates(out, &sys->Rts /*+0x88*/) -- the system's OWN rate sliders, not a max-mods vector. THE DISTINCTION THAT MATTERS: ComputeBudget's real (non-projected) per-system money is this function's out[3], while its projected mode and UpdateBankruptcyLimits use ComputeMaxIncome 0x007521c0. They are different numbers: this path funds the science, construction and terraform channels, so the repair pass runs and the unspent-industry and unspent-terraforming cascades into the money channel are live [verified]
constexpr uint32_t ServerSystem_ComputeOutput = 0x00351fb0;
// thiscall float (StrategyServer* srv, ServerPlayer* p) // `ret 4`, real end 0x0080f49f. Returns float32( float32(DifficultyMods_Select(p->diffMods /*+0x36c*/, p)[1]) x float32(srv->IncMod /*raw base +0xbc, the Sim block's `IncMod` tag*/) ), or just the server modifier when p is null. Every step is stored back through a 4-byte float. `ecx` here is the RAW StrategyServer base (ServerSystem+0x10), four bytes above the base the class's own methods get. THIS IS THE MISSING x1.1: at the difficulty level every corpus save carries (aidf == 1) the AI column of the table is 1.1f [verified]
constexpr uint32_t StrategyServer_IncomeDifficultyMod = 0x0040f470;
// thiscall float* (DifficultyMods* rec, ServerPlayer* p) // `ret 4`, real end 0x0059b4b3. Returns &rec->f[0] (the AI triple, at +0x04) when p is non-null AND p->[0xf9] (is-AI) is set AND p->NPC (+0xfb) is clear; otherwise &rec->f[3] (the non-AI triple, at +0x10). The three consumers read offset +0 (fleet maintenance divisor, ComputeBudget 0x0086338b), +4 (system and trade-route money, 0x0080f470 and 0x00833938) and +8 (research points bought with money, 0x0080e229 and 0x00863618) [verified]
constexpr uint32_t DifficultyMods_Select = 0x0019b490;
// cdecl void (int level, DifficultyMods* out) // real end 0x005a3a53 (Ghidra size 193 stops 2 bytes short). memcpy's the default {id 1, 1.0f x6} into `out` FIRST, then builds the table with BuildDifficultyTable 0x005a3870 and linear-searches it for id == level (stride 0x1c, from the 0x92492493 magic divide), copying the six floats on a hit. An out-of-range level therefore yields all ones rather than failing. Called from ServerPlayer::Read 0x008804d0 at 0x00880fa3, gated on 0 <= aidf < 3, which is also where ServerPlayer+0x368 (`aidf`) is stored [verified]
constexpr uint32_t LoadDifficultyRow = 0x001a3990;
// thiscall vector<DifficultyMods>* (vector<DifficultyMods>* out) // real end 0x005a3989. THE TABLE IS BUILT IN CODE, from .rdata float literals -- no data-file key, no GlobalConst slot, same shape as lane N's pop-type table. Three rows of {int id; float ai[3]; float other[3]} (0x1c): id 0 = ai {1,1,1} / other {1.5,1.5,1.5} (0x00a1b000); id 1 = ai {3.0 (0x00a0451c), 1.1 (0x009f957c), 1.5} / other {1,1,1}; id 2 = ai {1e6 (0x009ebd7c), 1.7 (0x009f9580), 2.0 (0x00a04518)} / other {1,1,1}. Read as: level 0 gives the break to the human player, levels 1 and 2 give it to the AI, and on level 2 the AI's fleet maintenance is divided by a million [verified]
constexpr uint32_t BuildDifficultyTable = 0x001a3870;
// thiscall float (ServerPlayer* p) // seven bytes: `fld DWORD [ecx+0x30c]; ret`. The save's per-player `IncMod` [verified]
constexpr uint32_t ServerPlayer_GetIncMod = 0x0040dd10;
// thiscall float (ServerPlayer* p) // `SpeciesDef(p->Species /*+0x5c*/)->+0x24`, the multiplier on the suitability MONEY cost (Zuul 0.7). A data-file value [verified]
constexpr uint32_t ServerPlayer_GetSpeciesCostFactor = 0x0040dd20;
// thiscall float (StrategyServer* srv, int species) // `ret 4`; one instruction of work: `fld DWORD [ecx + species*4 + 0xf8]`. The per-species ideal-suitability array on the RAW server base. IT IS ON THE WIRE: the Sim block's `ISsp`/`ISsu` pairs are this float[7] in species-index order, and the array is randomised per game by the map generator -- verified against every ServerPlayer's own `IdealSuit` field in all 11 corpus saves. CalcSuitMod reads THIS, not the player's field [verified]
constexpr uint32_t StrategyServer_IdealSuit = 0x0040f4b0;
// thiscall double (ServerSystem* sys) // real end 0x0074690d. Returns 0 with no owner, else float32(|IdealSuit(species) - sys->Suit|) / |owner->TerraMod (+0x134) x [0x00a1f928] / 20000|. The sign term (-1.0 when the planet's suitability is STRICTLY above the ideal) is multiplied in BEFORE the fabs at 0x00746906 and therefore cancels: the result is always >= 0. That is why the unspent-terraform cascade into the money channel is provably zero under the max-income rate vector [verified]
constexpr uint32_t ServerSystem_TerraformPointsNeeded = 0x00346890;
// thiscall void (ServerSystem* this, ServerPlayer* p, int encounterId) // 75 B, ret 8. THE NVE WRITER. if (!p) return; s = (int16)this->owner(+0x10)->Frame(+0x8); rec = NVE_map_at(&this->NVE(+0x284), &p->PlyrIdx(+0x28)); rec[0] = (s<<16)|s; rec[1] = encounterId. The map value is 8 bytes at node+0x10: an UNSERIALISED int16 touch stamp at +0, the saved ETS int16 at +2, the saved Eid int32 at +4 -- so ETS and the touch stamp are both set to the frame here, and only the writer at 0x007536a0 makes them differ. Sole caller is the tail's PlayerView-rebuild phase 0x007cf560, under the gate (AFlags >> PlyrIdx) & 1 [mapped]
constexpr uint32_t ServerSystem_RecordObservation = 0x00356300;
// thiscall void (ServerSystem* this, ServerPlayer* p, NveValue* src) // 70 B, ret 8. Intel sharing. rec = NVE_map_at(&this->NVE, &p->PlyrIdx(+0x28)); rec[0] = (hi16(src[0]) << 16) | (int16)this->owner(+0x10)->Frame(+0x8); rec[1] = src[1]. i.e. the receiver gets the DONOR's sighting turn (ETS) and encounter id unchanged, and only the unserialised touch stamp becomes the current frame. Never executed by any save in the corpus: no save has two players in an alliance [mapped]

View file

@ -43,6 +43,9 @@ int Usage() {
" id, comma-separated (e.g. T36)\n"
" --commit-blocked-except=IDS these blocked phases never commit\n"
" --commit-rng write the advanced generator state back\n"
" --ai-player N player N (by PlyrIdx) is AI-controlled; repeatable.\n"
" The flag is a game-setup input the save does not carry\n"
" and it selects the AI column of the difficulty table\n"
" --roundtrip re-serialise the UNTOUCHED save and check byte identity\n"
"\n"
"The two id lists exist so a blocked phase's own closed and regressed counts\n"
@ -101,6 +104,12 @@ int main(int argc, char** argv) {
} else if (a.rfind("--commit-blocked-except=", 0) == 0) {
opt.commitExcept = SplitIds(a.substr(std::strlen("--commit-blocked-except=")));
if (opt.commitExcept.empty()) return Usage();
} else if (a.rfind("--ai-player=", 0) == 0) {
opt.aiPlayers.push_back(std::atoi(a.c_str() + std::strlen("--ai-player=")));
} else if (a == "--ai-player") {
std::string v;
if (!next(v)) return Usage();
opt.aiPlayers.push_back(std::atoi(v.c_str()));
} else if (a == "--commit-rng") {
opt.commitRng = true;
} else if (a == "--roundtrip") {

View file

@ -130,8 +130,12 @@ constexpr PhaseDesc kStrategic[] = {
constexpr PhaseDesc kPlayer[] = {
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Blocked,
"the formula is verified (0 divergences over 4,284 live calls) but one input is not "
"modelled: the money output of each owned system. That needs the population -> base-output "
"term, which the colony model declares unresolved. Evaluated and reported, not committed"},
"modelled: the money output of each owned system. NOT the same function T31 sums -- the "
"turn path takes ComputeOutput with the system's OWN rate sliders, so its money channel "
"carries the repair pass (which is not side-effect free) and the unspent-industry and "
"unspent-terraforming cascades, none of which are zero once the other channels are "
"funded. Only ComputeBudget's PROJECTED mode uses the max-income form that is now "
"modelled. Evaluated and reported, not committed"},
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Blocked,
"saturating add of the budget net into savings; blocked behind P01's missing input"},
{Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
@ -220,9 +224,17 @@ constexpr PhaseDesc kTail[] = {
{Driver::Tail, 29, "T29", "AbortInvisibleInterceptOrders", PhaseStatus::Stub, ""},
{Driver::Tail, 30, "T30", "RebuildCommunicationMasks", PhaseStatus::Stub, ""},
{Driver::Tail, 31, "T31", "UpdateBankruptcyLimits", PhaseStatus::Blocked,
"the limits formula is modelled in game::sim; its input is the sum of every owned "
"system's MAXIMUM money output, which needs the same unresolved population->output term "
"as P01. Evaluated and reported, not committed"},
"the whole chain is now modelled: sum over owned, non-abandoned systems of "
"max(ComputeMaxIncome, 0), and the phase self-checks it every run against the BnkEl the "
"input save already carries -- 8 of 8 players on turn1-state with the AI flag supplied. "
"Two things keep it blocked and neither is the formula. First, `ServerPlayer+0xf9` (is "
"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"},
{Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""},
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},

View file

@ -269,6 +269,211 @@ void RunSystemTurn(Sys& s, int playerCount, SystemTotals& t) {
(void)playerCount;
}
// ---------------------------------------------------------------------------------------
// T31 UpdateBankruptcyLimits -- the per-player maximum-income roll-up
// ---------------------------------------------------------------------------------------
//
// The input is `sum over owned, non-abandoned systems of max(ComputeMaxIncome(s), 0)`, and
// `ComputeMaxIncome` is the output total run through the money chain with the rate vector
// "all output to trade". Every term of that chain is now modelled in game::sim; what this
// function does is read its inputs off the wire.
//
// Two inputs of the chain are NOT on the wire and are named rather than guessed:
// * `ServerPlayer+0xf9`, the per-player "is AI" flag, which selects the AI column of the
// difficulty table (a x1.1 on the income at difficulty level 1). It is copied from the
// game-setup/network player record and never serialised. `TurnOptions::aiPlayers` is the
// operator's way to supply it; with nothing supplied every player takes the non-AI column
// and an AI empire's limit comes out 1/1.1 low.
// * the three game-setup handicap words (`ServerPlayer+0x224/+0x228/+0x22c`), also copied
// at game creation. `+0x224` multiplies the output total. Taken as 1.0 here, which is
// what the whole 11-save corpus measures.
// The tuning constants the chain can read -- the imperial station output bonus, the two
// morale thresholds, the addiction income modifier and the three slave-row columns -- are
// left at their unloaded zero. Every branch that reads one is UNEXERCISED in the corpus (no
// stations, no slaves, no addiction, and every colony's morale sits strictly between the two
// thresholds), so this is a hypothesis about coverage, not a claim that they do not matter.
struct MaxIncomeInputs {
std::vector<float> idealSuit; // the Sim block's ISsu array, indexed by species
double serverIncomeMod = 1.0; // the Sim block's `IncMod`
sim::TuningTable tuning; // unloaded: see above
};
// Sum of `PopC` over the (group, species) rows of one Population node.
std::int64_t PopCount(const mars::stream::shapes::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;
}
int MoraleOf(const mars::stream::shapes::Morale& m, int species) {
for (const auto& e : m.entries)
if (e.msp == species) return e.mv;
return 0;
}
bool AddictedTo(const std::vector<mars::stream::shapes::AdctEntry>& a, int species) {
for (const auto& e : a)
if (e.ads == species && e.adt != 0) return true;
return false;
}
// `max(ComputeMaxIncome(s), 0)` for one owned system.
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx) {
// The system's population is credited to the independent race's species when the colony
// has one, otherwise to the owner's. `hindi` is the gate; `indi` is written either way.
const int popSpecies = s.hindi ? s.indi.indsp : owner.species;
const auto species = static_cast<sim::Species>(owner.species);
const std::int64_t resAvail =
s.res + (owner.aMine ? static_cast<std::int64_t>(s.mRes) + s.aRes2 : 0);
const std::int64_t imperial = static_cast<std::int64_t>(s.pop) + s.pbon;
// --- the output total (lane N's term) ---
double total = 0.0;
if (s.rbfl == 0) {
sim::BaseOutputInputs b;
b.imperialPopulation = imperial;
b.civilianPopulation = PopCount(s.pop2, 1, popSpecies) + PopCount(s.pbon2, 1, popSpecies);
b.civilianMorale = MoraleOf(s.cm, popSpecies);
b.independent = s.hindi;
b.transitResources = s.tRes;
b.resourcesAvailable = resAvail;
b.infra = s.infra;
b.infraBonus = s.ibon;
b.overHarvestRate = 0.0; // the max-income rate vector puts nothing on over-harvest
b.speciesBaseDemand = sim::ConstantsOf(species).resourceDemand;
b.speciesResourceOutput = sim::ConstantsOf(species).resourceOutput;
sim::OutputModifiers m;
m.baseOutput = sim::SystemBaseOutput(b, ctx.tuning);
m.playerOutMod = owner.outMod;
m.systemOutMod = s.outMod;
m.rebOutMod = owner.rebOutMod;
m.scOutMod = owner.scOutMod;
m.techOutMod = 1.0; // ServerPlayer+0x224, not on the wire
total = sim::TotalSystemOutputRaw(m, ctx.tuning);
}
// --- the money chain (this lane's term) ---
sim::PopIncomeRow impRows[sim::kSpeciesCount] = {};
sim::PopIncomeRow civRows[sim::kSpeciesCount] = {};
sim::PopIncomeRow slvRows[sim::kSpeciesCount] = {};
for (int q = 0; q < sim::kSpeciesCount; ++q) {
// GroupPopulation(imperial) credits the whole colony to ONE species.
impRows[q].count = q == popSpecies ? imperial : 0;
civRows[q].count = PopCount(s.pop2, 1, q) + PopCount(s.pbon2, 1, q);
slvRows[q].count = PopCount(s.pop2, 2, q) + PopCount(s.pbon2, 2, q);
const int mor = MoraleOf(s.cm, q);
const bool add = AddictedTo(s.adct, q);
impRows[q].morale = civRows[q].morale = slvRows[q].morale = mor;
impRows[q].addicted = civRows[q].addicted = slvRows[q].addicted = add;
}
sim::SystemMoneyInputs mi;
mi.popIncomeImperial =
sim::PopulationIncome(sim::PopGroup::Imperial, impRows, true, s.hindi, ctx.tuning);
mi.popIncomeCivilian =
sim::PopulationIncome(sim::PopGroup::Civilian, civRows, true, s.hindi, ctx.tuning);
mi.slaveIncome =
sim::PopulationIncome(sim::PopGroup::Slaves, slvRows, true, s.hindi, ctx.tuning);
mi.speciesIncomeFactor = sim::ConstantsOf(species).incomeFactor;
mi.speciesCostFactor = sim::ConstantsOf(species).hazardCostFactor;
mi.playerIncMod = owner.incMod;
mi.serverIncomeMod = ctx.serverIncomeMod;
mi.difficultyIncomeMult =
sim::DifficultyModsFor(owner.aidf, ownerIsAI, owner.npc).incomeMult;
const double ideal = popSpecies >= 0 && popSpecies < static_cast<int>(ctx.idealSuit.size())
? ctx.idealSuit[static_cast<std::size_t>(popSpecies)]
: owner.idealSuit;
mi.suitCostMod =
sim::SuitabilityCostMod(s.suit, ideal, owner.suitTol, owner.rebAI, true, s.vnh);
return sim::SystemMaxIncome(total, mi);
}
void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) {
MaxIncomeInputs ctx;
ctx.serverIncomeMod = game.sim.incMod;
for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu);
// The system table, keyed by the handle id a player's `OwnId` list carries.
std::vector<const Sys*> byId;
std::vector<std::int32_t> ids;
for (const auto& e : game.sim.systems) {
ids.push_back(e.sysID);
byId.push_back(&e.sys);
}
const auto find = [&](std::int32_t id) -> const Sys* {
for (std::size_t i = 0; i < ids.size(); ++i)
if (ids[i] == id) return byId[i];
return nullptr;
};
int players = 0, dangling = 0, matches = 0, compared = 0, aiOwned = 0;
std::string firstMiss;
for (auto& pe : game.sim.players) {
Player& p = pe.player;
if (p.elim) continue;
++players;
const bool isAI = opt.IsAIPlayer(p.plyrIdx);
if (isAI) ++aiOwned;
int maxIncome = 0;
for (std::int32_t id : p.owners) {
const Sys* s = find(id);
if (!s) {
++dangling;
continue;
}
if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero
maxIncome += SystemMaxIncomeFromWire(*s, p, isAI, ctx);
}
const sim::BankruptcyLimits lim =
sim::ComputeBankruptcyLimits(maxIncome, ctx.tuning);
// The limits the save already carries were computed by the ORIGINAL at the end of the
// previous turn from the same colony state, so comparing against them is a check of
// the whole income chain that needs no running game -- the same "testable on load"
// property the turn-record phase has.
++compared;
if (lim.eliminationFloor == p.bnkEl) {
++matches;
} else if (firstMiss.empty()) {
firstMiss = fmt("player %d: BnkEl ours %d, save %d (maxIncome %d)", p.plyrIdx,
lim.eliminationFloor, p.bnkEl, maxIncome);
}
++rec.invocations;
// BnkPr's factor is a data-file constant; with no tuning table its computed value is
// -0 for every player, which is a confidently wrong leaf rather than a missing one.
// It is therefore only offered when the table is loaded.
int would = lim.eliminationFloor != p.bnkEl ? 1 : 0;
const bool prModelled = opt.haveTuning;
if (prModelled && lim.protectionLimit != p.bnkPr) ++would;
if (opt.CommitBlocked("T31")) {
rec.leafWrites += would;
p.bnkEl = lim.eliminationFloor;
if (prModelled) p.bnkPr = lim.protectionLimit;
rec.committed = true;
} else {
rec.wouldWrite += would;
}
}
rec.notes.push_back(fmt("%d player(s); BnkEl reproduced for %d of %d from the input save's "
"own colony state",
players, matches, compared));
if (!firstMiss.empty()) rec.notes.push_back(firstMiss);
if (dangling) rec.notes.push_back(fmt("%d owned-system id(s) absent from the system table",
dangling));
if (aiOwned == 0)
rec.notes.push_back("no player was declared AI (--ai-player N); every player therefore "
"takes the non-AI difficulty column, which is 1/1.1 low on an AI "
"empire at difficulty level 1");
// BnkPr's factor is a data-file constant (BANKRUPTCY_PROTECTION_LIMIT_FACTOR), so with no
// tuning table loaded the protection limit is not modelled even though BnkEl is.
if (!opt.haveTuning)
rec.notes.push_back("BnkPr needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data "
"files, which is not loaded: only BnkEl is modelled here");
}
// ---------------------------------------------------------------------------------------
// The tail's last phase: the per-player turn record, and its own self-check
// ---------------------------------------------------------------------------------------
@ -714,6 +919,8 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.leafWrites = v.leafWrites;
rec.committed = v.leafWrites > 0;
rec.notes = v.notes;
} else if (tp[i].index == 31) {
RunUpdateBankruptcyLimits(game, opt, rec);
} else if (tp[i].index == 36) {
RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks);
}
@ -749,8 +956,11 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
"turn measured (2 words), with no derived rule behind the count -- its bound is the "
"product of the contact and detector counts, so it is left unmodelled");
r.rngUnaccounted.push_back(
"the research-allocation draw is downstream of the budget, which is blocked on the "
"per-system money output (0 or 1 word)");
"two draws are downstream of the budget's research allocation -- ProcessResearch's "
"completion Chance and the tech-effect callback's own roll (0 or 1 word each). The "
"allocation needs ComputeBudget's per-system money, which is ComputeOutput with the "
"system's OWN rate sliders; the max-income form of that money is now modelled and "
"self-checked (see T31), but it is NOT the one this path takes");
if (r.rngLoaded && r.rngWords > 0)
r.rngUnaccounted.push_back(
"a successful raid roll may draw one further word to pick its target; no roll "

View file

@ -38,6 +38,17 @@ struct TurnOptions {
bool commitRng = false;
// Tuning constants were loaded, so formulas that read them may run.
bool haveTuning = false;
// Which players are AI-controlled, by `PlyrIdx`. `ServerPlayer+0xf9` is a game-setup
// input that the save format does not carry (see the T31 block in turn.cpp), and the
// difficulty table selects a different column for an AI player -- at the corpus'
// difficulty level that is a x1.1 on every AI-owned system's money. Empty means "assume
// nobody is AI", which is a statement about what we were told, not about the game.
std::vector<int> aiPlayers;
bool IsAIPlayer(int playerIndex) const {
for (int i : aiPlayers)
if (i == playerIndex) return true;
return false;
}
// The game's data root, when the operator supplied one. The standalone reads no game data
// of its own and needs none for any other phase; the tail's turn record is the one place
// where a save is not enough, because a design's hull size and its defence-platform flag

View file

@ -419,24 +419,73 @@ OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAt
return s;
}
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
bool rebelAI, bool owned) {
if (rebelAI) return 0.0;
if (!owned) return 20.0;
return std::min(std::fabs(idealSuitability - suitability), suitTolerance);
int GroupIncome(PopGroup group, std::int64_t count, const TuningTable& t) {
// `fld DWORD [row+0x14]` -- the income column is a float32 in the table.
const double mod = F32(PopTypeOf(group, t).incomeMod);
return Ftol(Narrow(mod * (static_cast<double>(count) / kIncomePopulationDivisor)));
}
int SystemMoneyIncome(const SystemMoneyInputs& in) {
// Whole blocks of five trade points, worth five money each.
double t = (in.tradePoints - std::fmod(in.tradePoints, 5.0)) * 5.0;
t += static_cast<double>(in.popIncomeImperial);
t += static_cast<double>(in.popIncomeCivilian);
t += static_cast<double>(in.slaveIncome);
t *= in.speciesIncomeFactor;
t *= in.playerIncMod;
t *= in.serverIncomeMod * in.difficultyIncomeMult;
const double cost = in.speciesCostFactor * in.suitCostMod * 10000.0 * 1.5;
return Ftol(t - cost);
double PopulationIncome(PopGroup group, const PopIncomeRow (&rows)[kSpeciesCount],
bool owned, bool independent, const TuningTable& t) {
double sum = 0.0;
for (int sp = 0; sp < kSpeciesCount; ++sp) {
const PopIncomeRow& r = rows[sp];
if (!(r.count > 0)) continue;
// Only the civilian row takes morale, and it takes it through the same helper the
// output term uses -- including the "no owner / independent / no record" bypass.
double morale = 1.0;
if (group == PopGroup::Civilian && owned && !independent) {
morale = MoraleOutputMultiplier(r.morale, t);
}
const double addiction = r.addicted ? t.ADDICTION_INCOME_MOD : 1.0;
const double base = static_cast<double>(GroupIncome(group, r.count, t));
// The SECOND truncation: per species, after both factors.
sum += static_cast<double>(Ftol(Narrow(Narrow(base * morale) * addiction)));
}
return sum;
}
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
bool rebelAI, bool owned, bool vonNeumann) {
if (vonNeumann) return 0.0; // the original's very first test
if (!owned) return 20.0; // ... and it logs a warning
if (rebelAI) return 0.0;
// All three operands are 4-byte floats in the original (`server->IdealSuit[sp]`,
// `sys->Suit`, `owner->SuitTol`); the subtraction and the compare are then done on the
// x87 with no store back, so nothing is narrowed here. The compare is `<=`: a distance
// exactly at the tolerance is charged as itself, not as the cap.
const double d = std::fabs(idealSuitability - suitability);
return d <= suitTolerance ? d : suitTolerance;
}
double SystemMoneyIncomeRaw(const SystemMoneyInputs& in) {
// Whole blocks of five trade points; the same literal is the modulus and the multiplier.
// The `+ 0.0` is a real instruction (an .rdata zero) and the association below is the
// original's -- x87 addition is not associative, so neither is reorderable.
const double blocks = (in.tradePoints - std::fmod(in.tradePoints, 5.0)) * 5.0;
double t = Narrow(Narrow(blocks + 0.0) + in.popIncomeImperial);
t = Narrow(in.popIncomeCivilian + t);
t = Narrow(in.slaveIncome + t);
// Every per-player multiplier is stored back through a 4-byte float before it is used.
t = Narrow(F32(in.speciesIncomeFactor) * t);
const double diff = F32(F32(in.difficultyIncomeMult) * F32(in.serverIncomeMod));
t = Narrow(diff * Narrow(F32(in.playerIncMod) * t));
const double cost =
Narrow(F32(in.speciesCostFactor) * Narrow(Narrow(in.suitCostMod * 10000.0) * 1.5));
return t - cost;
}
int SystemMoneyIncome(const SystemMoneyInputs& in) { return Ftol(SystemMoneyIncomeRaw(in)); }
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in) {
SystemMoneyInputs m = in;
// The max-income rate vector is trade = 1 and every other channel 0, so the trade
// points are the rounded total and the two cascade channels contribute nothing.
m.tradePoints = RoundHalfEven(totalOutput);
const int money = SystemMoneyIncome(m);
return money > 0 ? money : 0;
}
BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity,

View file

@ -420,37 +420,106 @@ int ConstructionPoints(double constructionShare, int stations, const TuningTable
OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal,
bool infraFull);
// ---------------------------------------------------------------------------------------
// Money: the population income law and a system's money output
// ---------------------------------------------------------------------------------------
//
// The income chain is a SECOND chain off the same three-row population table, and it is not
// the output chain with a different constant: income per head is `typeIncomeMod / 14000`,
// with neither the 1.8 factor nor the 500000 divisor the output law uses, and it truncates
// TWICE per (group, species) row -- once inside the per-row term and once after the morale
// and addiction factors. Summing the species first and truncating once is wrong on any
// colony carrying more than one species or a non-unit morale/addiction factor.
// See sots-re findings/subsystems/income-term.md.
// Money contributed per head, before the type modifier. An .rdata literal.
constexpr double kIncomePopulationDivisor = 14000.0;
// One (group, count) row's money: `ftol( typeIncomeMod x (count / 14000) )`. The type
// modifier is a float32 in the table, so it is narrowed before the multiply.
// CONFIDENCE: high -- read instruction by instruction (the whole function is five
// instructions and a tail-jump into the float-to-int helper).
int GroupIncome(PopGroup group, std::int64_t count, const TuningTable& t);
struct PopIncomeRow {
std::int64_t count = 0; // heads of this species in this group
int morale = 0; // the system's Morale entry for this species
bool addicted = false; // the system's addiction entry for this species is non-zero
};
// The population income of one group type, summed over the seven species rows:
// for each species with count > 0:
// mo = civilian groups only: the morale multiplier (the SAME helper output uses)
// ad = addicted ? ADDICTION_INCOME_MOD : 1
// sum += (double) ftol( (double)GroupIncome(group, count) x mo x ad )
// Slave groups take neither morale nor the civilian capacity surplus.
// CONFIDENCE: high on the loop and both truncations; the civilian capacity-surplus term
// (which the original adds to the owner species' civilian count when the colony is at its
// cap) is the CALLER's business -- fold it into that row's `count`. No colony in the
// corpus is at its cap, so that term is UNEXERCISED either way.
double PopulationIncome(PopGroup group, const PopIncomeRow (&rows)[kSpeciesCount],
bool owned, bool independent, const TuningTable& t);
// Suitability term of the money cost: how far the planet is from the owner species'
// ideal, capped by the owner's SuitTol (so the tolerance techs also cap the cost).
// The rebel AI pays nothing; an unowned system is charged as if 20 away.
// The rebel AI pays nothing, and so does a system carrying a von Neumann machine
// (`vnh`, the very first test in the original); an unowned system is charged as if 20 away.
//
// `idealSuitability` is the SERVER's per-species baseline (`server->IdealSuit[species]`),
// not the owner's own `IdealSuit` field, and the species is the system's population species
// (`indi->indsp` on an independent colony) rather than the owner's. In the 11-save corpus
// the two IdealSuit sources agree for every player, so **which one the original reads is
// not falsifiable here** -- it is read from the instruction stream only.
// CONFIDENCE: high.
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
bool rebelAI, bool owned);
bool rebelAI, bool owned, bool vonNeumann = false);
struct SystemMoneyInputs {
double tradePoints = 0; // the system's trade-channel output this turn
int popIncomeImperial = 0; // income of the imperial population groups
int popIncomeCivilian = 0; // income of the civilian groups (incl. surplus term)
int slaveIncome = 0; // income of the slave groups
double popIncomeImperial = 0; // PopulationIncome(Imperial, ...)
double popIncomeCivilian = 0; // PopulationIncome(Civilian, ...), incl. surplus
double slaveIncome = 0; // PopulationIncome(Slaves, ...)
double speciesIncomeFactor = 1.0; // ConstantsOf(owner).incomeFactor (1 if unowned)
double playerIncMod = 1.0; // IncMod (1 if unowned)
double serverIncomeMod = 1.0; // game-option income modifier
double difficultyIncomeMult = 1.0; // AI difficulty trade/income multiplier
double serverIncomeMod = 1.0; // game-option income modifier (Sim tag `IncMod`)
double difficultyIncomeMult = 1.0; // DifficultyIncomeMod's table entry
double speciesCostFactor = 1.0; // ConstantsOf(owner).hazardCostFactor
double suitCostMod = 0; // from SuitabilityCostMod
};
// Money a system contributes this turn:
// t = (trade - fmod(trade, 5)) x 5 whole 5-point blocks of trade, five each
// t += imperial + civilian + slave income (the per-group income tables are inputs)
// t *= speciesIncomeFactor; t *= IncMod; t *= serverIncomeMod x difficultyIncomeMult
// cost = speciesCostFactor x suitCostMod x 10000 x 1.5
// money = ftol(t - cost)
// CONFIDENCE: high on the chain and constants; the per-group population income terms
// (and the addiction factor inside them) are inputs because their own tables are not
// modelled here.
// Money a system contributes this turn (`TradePointsToMoney`, returning the DOUBLE the
// original returns -- its caller is what truncates):
// t = slaves + (civilian + (((trade - fmod(trade, 5)) x 5 + 0) + imperial))
// t = f32(speciesIncomeFactor) x t
// t = f32(f32(difficultyIncomeMult) x f32(serverIncomeMod)) x (f32(IncMod) x t)
// cost = f32(speciesCostFactor) x (suitCostMod x 10000 x 1.5)
// return t - cost
// Whole five-point blocks of trade are worth FIVE money each after being multiplied by
// five -- i.e. 25 money per block of five points; the same literal is the modulus and the
// multiplier. Every per-player multiplier is narrowed to float32 before it is applied, and
// the additive association above is the original's, which x87 does not let us reorder.
// CONFIDENCE: high -- read instruction by instruction.
double SystemMoneyIncomeRaw(const SystemMoneyInputs& in);
// The truncated form its callers store. CONFIDENCE: high.
int SystemMoneyIncome(const SystemMoneyInputs& in);
// `ServerSystem::ComputeMaxIncome` -- what `ComputeBudget` and `UpdateBankruptcyLimits`
// actually sum. It runs the output total through `ComputeOutputFromRates` with the rate
// vector "all output to trade" and takes the money channel:
// trade = roundHalfEven(totalOutput) (rate 1.0, so the second rounding is a no-op)
// return max( ftol(SystemMoneyIncomeRaw(...)), 0 )
// The `max(.., 0)` is the original's `jg`: a negative-income colony contributes zero to the
// empire total rather than reducing it.
//
// The two cascade terms `ComputeOutputFromRates` can add to the money channel -- unspent
// industry and unspent terraforming points -- are provably ZERO under this rate vector:
// both are `0 - min(0, need)` with `need >= 0` (infra is clamped to 1, and the terraform
// point count takes fabs after its sign multiply). They are therefore not inputs here.
// CONFIDENCE: high; the science cascade being zero rests on the repair helper returning 0
// for zero science points, which is INFERRED rather than read.
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in);
// ---------------------------------------------------------------------------------------
// System bonus and build queue
// ---------------------------------------------------------------------------------------

View file

@ -7,6 +7,23 @@
namespace sots::sim {
DifficultyMods DifficultyModsFor(int level, bool isAI, bool isNpc) {
// The six floats of each row, as the image holds them: {AI triple, non-AI triple}.
// Row 0 gives the break to the player; rows 1 and 2 give it to the AI.
struct Row { float ai[3]; float other[3]; };
static constexpr Row kTable[kDifficultyLevels] = {
{{1.0f, 1.0f, 1.0f}, {1.5f, 1.5f, 1.5f}}, // 0
{{3.0f, 1.1f, 1.5f}, {1.0f, 1.0f, 1.0f}}, // 1
{{1000000.0f, 1.7f, 2.0f}, {1.0f, 1.0f, 1.0f}}, // 2
};
// An out-of-range level keeps the all-ones default the original memcpy's in first.
if (level < 0 || level >= kDifficultyLevels) return DifficultyMods{};
const Row& r = kTable[level];
const float* m = (isAI && !isNpc) ? r.ai : r.other;
return DifficultyMods{static_cast<double>(m[0]), static_cast<double>(m[1]),
static_cast<double>(m[2])};
}
int SavingsInterest(int savings, bool ownsSystems) {
if (savings < 0 || !ownsSystems) return 0;
return Ftol(static_cast<double>(savings) * 0.01);

View file

@ -11,6 +11,33 @@
namespace sots::sim {
// ---------------------------------------------------------------------------------------
// Difficulty
// ---------------------------------------------------------------------------------------
//
// Three multipliers, selected per player from a three-row table that the executable builds
// **in code** from float literals -- there is no data-file key and no tuning-table entry for
// any of them. Each row carries two triples: one used for AI players and one for everybody
// else, so the same row makes the game easier for the human on level 0 and easier for the AI
// on levels 1 and 2. See sots-re findings/subsystems/income-term.md §3.
struct DifficultyMods {
double maintenanceDivisor = 1.0; // fleet upkeep is divided by ftol(this)
double incomeMult = 1.0; // a system's money income, and trade-route income
double researchMult = 1.0; // research points bought with money
};
// The number of rows the table holds. A level outside [0, kDifficultyLevels) selects the
// all-ones default rather than failing -- the original memcpy's that default in first and
// only overwrites it on a hit.
constexpr int kDifficultyLevels = 3;
// `level` is the player's `aidf` save field; `isAI` is the player's AI flag, which is
// **not on the wire** (it is copied from the game-setup/network player record), and an NPC
// player takes the non-AI triple whatever its AI flag says.
// CONFIDENCE: high -- table and selector both read instruction by instruction. The corpus
// carries level 1 on every player and exercises only the AI/non-AI split of that row.
DifficultyMods DifficultyModsFor(int level, bool isAI, bool isNpc);
// ---------------------------------------------------------------------------------------
// Budget
// ---------------------------------------------------------------------------------------

View file

@ -29,14 +29,24 @@ struct SpeciesConstants {
double incomeFactor = 1.0; // multiplies a system's money income (Zuul 1.1, Morrigi 0.8)
double hazardCostFactor = 1.0; // multiplies the suitability money cost (Zuul 0.7)
bool systemBonusEligible = true; // long-stability system bonus accrues (Zuul never)
// The species' base resource demand and resource-output factor -- the two fields the
// output term's harvest summand reads, off the SYSTEM OWNER's species rather than the
// colony's population species. Unlike the three above, these come from the DATA FILES;
// the values here are the ones measured live on VM140 for the three species the corpus
// exercises (Human, Tarkas, Zuul) and are 0/10 for the rest, which is UNVERIFIED for
// Hiver, Liir and Morrigi. Getting the Zuul pair wrong costs a Zuul colony exactly
// 400 output points -- 2000 money -- which is how it was found.
int resourceDemand = 0; // SpeciesDef +0x4c
double resourceOutput = 10.0; // SpeciesDef +0x50
};
// CONFIDENCE: high on the three fields (read with their constants and their consumers).
// CONFIDENCE: high on the first three fields (read with their constants and their
// consumers); the two resource fields are data-file values, measured not read.
constexpr SpeciesConstants ConstantsOf(Species s) {
switch (s) {
case Species::Zuul: return SpeciesConstants{1.1, 0.7, false};
case Species::Morrigi: return SpeciesConstants{0.8, 1.0, true};
default: return SpeciesConstants{1.0, 1.0, true};
case Species::Zuul: return SpeciesConstants{1.1, 0.7, false, 10, 40.0};
case Species::Morrigi: return SpeciesConstants{0.8, 1.0, true, 0, 10.0};
default: return SpeciesConstants{1.0, 1.0, true, 0, 10.0};
}
}

View file

@ -67,6 +67,11 @@ struct TuningTable {
double MORALE_DECREASE_OUTPUT = 0;
double MORALE_DECREASE_OUTPUT_MOD = 0;
double ADDICTION_OUTPUT_MOD = 0;
// The income counterpart, read through its own pointer slot (0x00aeca48 -> 0x00aeca44).
// It is applied per (group, species) row inside the population income term, not to the
// system total the way the output modifier is. Its file-image value is 0.9f, the same
// as ADDICTION_OUTPUT_MOD's, but they are two independent keys.
double ADDICTION_INCOME_MOD = 0;
// ---- movement (globals) ----
double STUTTER_SYSTEM_INFLUENCE_RADIUS = 0;

View file

@ -1,6 +1,9 @@
#include "game/sim/colony.h"
#include <cmath>
#include "check.h"
#include "game/sim/economy.h"
#include "game/sim/numeric.h"
using namespace sots::sim;
@ -22,6 +25,8 @@ static TuningTable tuning() {
t.STATION_BONUS_IMPERIAL_OUTPUT = 0.1;
t.STATION_BONUS_SHIPCON = 0.25;
t.ADDICTION_OUTPUT_MOD = 0.5;
t.ADDICTION_INCOME_MOD = 0.9;
t.SLAVES_INCOME_MOD = 3.0;
t.SYSTEMBONUS_MINTURNS = 10;
t.SYSTEMBONUS_POPBONUS = 0.1;
t.SYSTEMBONUS_POPBONUS_HOME = 0.2;
@ -461,6 +466,127 @@ static void test_system_money() {
CHECK_NEAR(ConstantsOf(Species::Human).incomeFactor, 1.0, 0.0);
CHECK(!ConstantsOf(Species::Zuul).systemBonusEligible);
CHECK(ConstantsOf(Species::Hiver).systemBonusEligible);
// A von Neumann machine at the system zeroes the cost before anything else is looked at.
CHECK_NEAR(SuitabilityCostMod(0.3, 0.5, 0.15, false, true, true), 0.0, 0.0);
CHECK_NEAR(SuitabilityCostMod(0.5, 0.5, 0.15, false, false, true), 0.0, 0.0);
}
static void test_population_income() {
TuningTable t = tuning();
// Income per head is typeIncomeMod / 14000 -- no 1.8, no 500000. An imperial billion
// is 1e9/14000 = 71428.57..., truncated.
CHECK_EQ(GroupIncome(PopGroup::Imperial, 1000000000, t), 71428);
CHECK_EQ(GroupIncome(PopGroup::Imperial, 13999, t), 0);
CHECK_EQ(GroupIncome(PopGroup::Imperial, 14000, t), 1);
// The civilian row's modifier is the float32 0.33, so half a billion civilians give
// ftol(0.33000001311302185 x 35714.2857...) = 11785.
CHECK_EQ(GroupIncome(PopGroup::Civilian, 500000000, t), 11785);
CHECK_EQ(GroupIncome(PopGroup::Slaves, 14000, t), 3); // SLAVES_INCOME_MOD = 3
PopIncomeRow rows[kSpeciesCount] = {};
rows[0].count = 1000000000;
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, rows, true, false, t), 71428.0, 0.0);
// Two species truncate SEPARATELY, so the sum is not the truncation of the sum.
PopIncomeRow two[kSpeciesCount] = {};
two[0].count = 20999; // -> 1
two[2].count = 20999; // -> 1
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, two, true, false, t), 2.0, 0.0);
CHECK_EQ(GroupIncome(PopGroup::Imperial, 41998, t), 2); // ... which happens to agree here
PopIncomeRow three[kSpeciesCount] = {};
three[0].count = 13999; // -> 0
three[2].count = 13999; // -> 0
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, three, true, false, t), 0.0, 0.0);
CHECK_EQ(GroupIncome(PopGroup::Imperial, 27998, t), 1); // ... and here it does NOT
// Morale applies to the civilian row only, and the product truncates again.
PopIncomeRow mor[kSpeciesCount] = {};
mor[0].count = 500000000;
mor[0].morale = 80; // >= MORALE_INCREASE_OUTPUT (75)
CHECK_NEAR(PopulationIncome(PopGroup::Civilian, mor, true, false, t),
std::floor(11785.0 * 1.1), 0.0);
// ... but not to the imperial row.
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, mor, true, false, t),
static_cast<double>(GroupIncome(PopGroup::Imperial, 500000000, t)), 0.0);
// ... and an independent colony bypasses morale entirely.
CHECK_NEAR(PopulationIncome(PopGroup::Civilian, mor, true, true, t), 11785.0, 0.0);
// Addiction multiplies every row, imperial included.
PopIncomeRow add[kSpeciesCount] = {};
add[0].count = 1000000000;
add[0].addicted = true;
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, add, true, false, t),
std::floor(71428.0 * 0.9), 0.0);
}
static void test_max_income() {
// The whole chain, with the numbers a level-1 AI Zuul colony produces: the trade points
// are the rounded output total, blocks of five are worth five each after the x5, the
// species factor is 1.1 and the difficulty income modifier another 1.1.
SystemMoneyInputs m;
m.popIncomeImperial = 71428;
m.speciesIncomeFactor = ConstantsOf(Species::Zuul).incomeFactor;
m.speciesCostFactor = ConstantsOf(Species::Zuul).hazardCostFactor;
m.suitCostMod = 0.0; // a homeworld sits exactly at its ideal
m.difficultyIncomeMult = DifficultyModsFor(1, /*isAI=*/true, /*isNpc=*/false).incomeMult;
const int ai = SystemMaxIncome(12345.0, m);
m.difficultyIncomeMult = DifficultyModsFor(1, /*isAI=*/false, /*isNpc=*/false).incomeMult;
const int human = SystemMaxIncome(12345.0, m);
// The AI's advantage on this row is exactly the 1.1 in the difficulty table.
CHECK(ai > human);
CHECK_NEAR(static_cast<double>(ai) / static_cast<double>(human), 1.1, 1e-5);
// A colony whose money comes out negative contributes ZERO to the empire total rather
// than reducing it -- the `jg` at the end of ComputeMaxIncome.
SystemMoneyInputs bad;
bad.suitCostMod = 20.0; // 20 x 15000 of cost against no income
CHECK_EQ(SystemMoneyIncome(bad), -300000);
CHECK_EQ(SystemMaxIncome(0.0, bad), 0);
// The rate vector is trade = 1, so the trade points are the half-to-even rounded total.
SystemMoneyInputs r;
CHECK_EQ(SystemMaxIncome(20.5, r), SystemMoneyIncome([] {
SystemMoneyInputs x;
x.tradePoints = 20.0; // 20.5 ties to the even neighbour
return x;
}()));
CHECK_EQ(SystemMaxIncome(21.5, r), SystemMoneyIncome([] {
SystemMoneyInputs x;
x.tradePoints = 22.0;
return x;
}()));
}
static void test_difficulty_table() {
// Level 0 gives the break to the human; levels 1 and 2 give it to the AI.
const DifficultyMods e_ai = DifficultyModsFor(0, true, false);
const DifficultyMods e_pl = DifficultyModsFor(0, false, false);
CHECK_NEAR(e_ai.maintenanceDivisor, 1.0, 0.0);
CHECK_NEAR(e_ai.incomeMult, 1.0, 0.0);
CHECK_NEAR(e_pl.maintenanceDivisor, 1.5, 0.0);
CHECK_NEAR(e_pl.incomeMult, 1.5, 0.0);
const DifficultyMods n_ai = DifficultyModsFor(1, true, false);
CHECK_NEAR(n_ai.maintenanceDivisor, 3.0, 0.0);
CHECK_NEAR(n_ai.incomeMult, 1.1, 1e-7);
CHECK_NEAR(n_ai.researchMult, 1.5, 0.0);
CHECK_NEAR(DifficultyModsFor(1, false, false).incomeMult, 1.0, 0.0);
const DifficultyMods h_ai = DifficultyModsFor(2, true, false);
CHECK_NEAR(h_ai.maintenanceDivisor, 1000000.0, 0.0);
CHECK_NEAR(h_ai.incomeMult, 1.7, 1e-7);
CHECK_NEAR(h_ai.researchMult, 2.0, 0.0);
// Hard maintenance really is "divided by a million", i.e. free.
CHECK_EQ(MaintenanceCost(999999, h_ai.maintenanceDivisor), 0);
// An NPC player takes the non-AI triple whatever its AI flag says ...
CHECK_NEAR(DifficultyModsFor(1, true, true).incomeMult, 1.0, 0.0);
// ... and an out-of-range level falls back to all ones rather than failing.
CHECK_NEAR(DifficultyModsFor(-1, true, false).incomeMult, 1.0, 0.0);
CHECK_NEAR(DifficultyModsFor(3, true, false).incomeMult, 1.0, 0.0);
CHECK_NEAR(DifficultyModsFor(3, true, false).maintenanceDivisor, 1.0, 0.0);
}
static void test_bonuses() {
@ -586,6 +712,9 @@ int main() {
test_slaves();
test_output();
test_system_money();
test_population_income();
test_max_income();
test_difficulty_table();
test_bonuses();
test_build_queue();
return simtest::finish("test_colony");