diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 5a6554b..2649df2 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -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 @@ -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* (vector* 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] diff --git a/src/app/main.cpp b/src/app/main.cpp index 33a904d..ee1154f 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -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") { diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index 98daa06..5b83b06 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -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, ""}, diff --git a/src/app/turn.cpp b/src/app/turn.cpp index 7c11de3..8cef6eb 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -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 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& 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(owner.species); + + const std::int64_t resAvail = + s.res + (owner.aMine ? static_cast(s.mRes) + s.aRes2 : 0); + const std::int64_t imperial = static_cast(s.pop) + s.pbon; + + // --- the output total (lane N's term) --- + double total = 0.0; + if (s.rbfl == 0) { + sim::BaseOutputInputs b; + b.imperialPopulation = imperial; + 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(ctx.idealSuit.size()) + ? ctx.idealSuit[static_cast(popSpecies)] + : owner.idealSuit; + mi.suitCostMod = + sim::SuitabilityCostMod(s.suit, ideal, owner.suitTol, owner.rebAI, true, s.vnh); + return sim::SystemMaxIncome(total, mi); +} + +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 byId; + std::vector ids; + for (const auto& e : game.sim.systems) { + ids.push_back(e.sysID); + byId.push_back(&e.sys); + } + const auto find = [&](std::int32_t id) -> const Sys* { + for (std::size_t i = 0; i < ids.size(); ++i) + if (ids[i] == id) return byId[i]; + return nullptr; + }; + + 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 " diff --git a/src/app/turn.h b/src/app/turn.h index 81f444f..2ec459f 100644 --- a/src/app/turn.h +++ b/src/app/turn.h @@ -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 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 diff --git a/src/game/sim/colony.cpp b/src/game/sim/colony.cpp index 1eb0599..e917df1 100644 --- a/src/game/sim/colony.cpp +++ b/src/game/sim/colony.cpp @@ -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(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(in.popIncomeImperial); - t += static_cast(in.popIncomeCivilian); - t += static_cast(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(GroupIncome(group, r.count, t)); + // The SECOND truncation: per species, after both factors. + sum += static_cast(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, diff --git a/src/game/sim/colony.h b/src/game/sim/colony.h index 46904f2..cdc36de 100644 --- a/src/game/sim/colony.h +++ b/src/game/sim/colony.h @@ -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 // --------------------------------------------------------------------------------------- diff --git a/src/game/sim/economy.cpp b/src/game/sim/economy.cpp index 4c791f4..a5bfa9f 100644 --- a/src/game/sim/economy.cpp +++ b/src/game/sim/economy.cpp @@ -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(m[0]), static_cast(m[1]), + static_cast(m[2])}; +} + int SavingsInterest(int savings, bool ownsSystems) { if (savings < 0 || !ownsSystems) return 0; return Ftol(static_cast(savings) * 0.01); diff --git a/src/game/sim/economy.h b/src/game/sim/economy.h index e0c0899..1b5d525 100644 --- a/src/game/sim/economy.h +++ b/src/game/sim/economy.h @@ -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 // --------------------------------------------------------------------------------------- diff --git a/src/game/sim/species.h b/src/game/sim/species.h index 4be4e71..ffd353c 100644 --- a/src/game/sim/species.h +++ b/src/game/sim/species.h @@ -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}; } } diff --git a/src/game/sim/tuning.h b/src/game/sim/tuning.h index a1265ca..3d58486 100644 --- a/src/game/sim/tuning.h +++ b/src/game/sim/tuning.h @@ -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; diff --git a/tests/game_sim/test_colony.cpp b/tests/game_sim/test_colony.cpp index b37771d..6f04272 100644 --- a/tests/game_sim/test_colony.cpp +++ b/tests/game_sim/test_colony.cpp @@ -1,6 +1,9 @@ #include "game/sim/colony.h" +#include + #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(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(ai) / static_cast(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");