#include "app/turn.h" #include #include #include #include #include #include #include "app/alliance.h" #include "app/construction_phase.h" #include "app/event_phase.h" #include "app/growth_phase.h" #include "app/observed_phase.h" #include "app/script_phase.h" #include "app/trade_raid.h" #include "app/treaty.h" #include "app/turn_record.h" #include "app/visibility_phase.h" #include "game/sim/colony.h" #include "game/sim/economy.h" #include "game/sim/numeric.h" #include "game/sim/player_turn.h" #include "game/sim/rng.h" #include "game/sim/tuning.h" namespace sots::app { bool TurnOptions::CommitBlocked(const char* phaseId) const { if (!commitBlocked) return false; const std::string id(phaseId); for (const auto& e : commitExcept) if (e == id) return false; if (commitOnly.empty()) return true; for (const auto& o : commitOnly) if (o == id) return true; return false; } namespace { using mars::stream::Node; using mars::stream::shapes::Player; using mars::stream::shapes::SaveGame; using mars::stream::shapes::Sys; std::string fmt(const char* f, ...) { char buf[512]; va_list ap; va_start(ap, f); std::vsnprintf(buf, sizeof buf, f, ap); va_end(ap); return std::string(buf); } // A generator wrapper for the strategic-sim interface. Every draw is counted, so a phase // can report its word cost even when the state is not committed. class CountingRandom final : public sim::IRandom { public: explicit CountingRandom(mars::rng::MT19937& g) : g_(g) {} float NextFloat() override { ++words_; return g_.next_float(); } std::uint32_t NextIntInclusive(std::uint32_t n) override { // The rejection loop can spend more than one word; count what the generator moved. const int before = consumed(); const std::uint32_t v = g_.next_int_inclusive(n); words_ += consumed() - before; return v; } std::uint32_t NextUInt32() override { ++words_; return g_.next_u32(); } int words() const { return words_; } private: // Words handed out since the block was twisted, monotone within a block; used only to // count a rejection loop, which never spans more than one twist here. int consumed() const { return mars::rng::MT19937::N - g_.left(); } mars::rng::MT19937& g_; int words_ = 0; }; // --------------------------------------------------------------------------------------- // The player driver // --------------------------------------------------------------------------------------- // Read a player's current research target out of the tech-state section. The target is // named on the wire, so the lookup is by name; a player with no target has an empty name. const mars::stream::shapes::TechState* FindTarget(const Player& p) { if (p.resTNm.empty()) return nullptr; for (const auto& st : p.techTree.state) if (st.tNm == p.resTNm) return &st; return nullptr; } struct PlayerPhaseTotals { int writes[13] = {}; // indexed by phase number 1..12 int wouldWrite[13] = {}; int rng[13] = {}; int fired[13] = {}; // how many players the phase actually did something for // P11's own accounting, kept apart from the generic counters so the run log can say why // the phase did or did not post rather than only how many leaves it moved. int evalNoResearch = 0; // players passing the three save-derivable tests int aiSuppressedNoResearch = 0; // of those, the ones the AI roster suppressed int postedNoResearch = 0; // events actually posted int noTextNoResearch = 0; // would have posted but no string table was supplied }; // What the caller has to hand the player driver for P01: the per-system money of every // owned, non-abandoned system, on the TURN path, and whether the driver may trust it. struct PlayerBudgetFeed { std::vector systemIncome; bool isAI = false; sim::DifficultyMods difficulty; bool held = true; // false when a system the player owns could not be found // The self-check: the same systems run through the PROJECTED path, which is the number // the save's own `BnkEl` states and lane E1 scored 25/25 against. The two paths are not // the same function and need not agree -- but on a corpus where every colony is at its // ideal suitability with full infrastructure and an empty build queue, the whole // construction channel returns to trade and they should agree to within the trade-point // rounding. A large delta here is the model failing, and it is visible without a VM. int projectedIncome = 0; int turnIncome = 0; // The ninth input of the output turn path: the repair demand of the owner's ships in // orbit, `Sum Ship::RepairCost` over `RepairShipsInOrbit`'s candidate set. The per-ship // arithmetic is now read (sim::ShipRepairCost) but its two design fields are cached stats // that are nowhere on the wire, so the demand is still taken as 0. What IS on the wire is // the candidate set, and counting it turns a silent zero into an evidenced one: a colony // with no ship in orbit cannot have a repair demand at all. int repairCandidateSystems = 0; // owned colonies with at least one fleet in orbit int repairCandidateShips = 0; // ships in those fleets }; // What P11 needs beyond the player itself: the turn to post into (the frame AFTER H00's // bump), whether the operator declared this player AI-controlled, and the text lookup. A // null `text` means no string table was supplied, which is not the same as "no event": the // phase still evaluates its condition and reports it. struct PlayerEventFeed { int turn = 0; bool isAI = false; const EventTextTable* text = nullptr; bool commit = false; }; void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng, PlayerPhaseTotals& t, const PlayerBudgetFeed& feed, const PlayerEventFeed& events) { // --- P01 ComputeBudget ------------------------------------------------------------ // The per-system money is `ComputeOutput(s).out[3]`, NOT `ComputeMaxIncome(s)`: the // turn path runs the system's own sliders, so the build queue, the ship-repair pass and // the infrastructure -> terraform -> money cascade are all live. See // sots-re findings/subsystems/output-turn-path.md. { sim::BudgetInputs in; in.savings = p.sav; in.ownsSystems = !p.owners.empty(); in.maintenance = p.maint; in.maintenanceDivisor = feed.difficulty.maintenanceDivisor; in.researchDifficultyMult = feed.difficulty.researchMult; in.isAI = p.npc; in.researchRate = p.resRate; in.resMod = p.resMod; in.shrm = p.shrm; in.trm = p.trm; in.resScl = p.resScl; in.tra = p.tra; in.trp = p.trp; in.hasResearchTarget = !p.resTNm.empty(); for (const auto& e : p.nexp) { sim::ExpenseSlider s; s.minimum = e.xmin; s.maximum = e.xmax; s.fraction = e.xper; in.expenses.push_back(s); } in.systemIncome = feed.systemIncome; const sim::Budget b = sim::ComputeBudget(in, /*projected=*/false); const int wouldBe = sim::SaturatingAdd(p.sav, b.net); ++t.fired[1]; if (wouldBe != p.sav) ++t.wouldWrite[2]; // P02 is the write. It is committed by default now that the money channel is // modelled; a player whose owned systems could not all be resolved is still // evaluate-and-report. if (feed.held || opt.CommitBlocked("P02")) { if (wouldBe != p.sav) { p.sav = wouldBe; ++t.writes[2]; } } } // --- P07 ClearTimedResearchAccumulators ----------------------------------------- if (p.trm != 0.f || p.tra != 0 || p.trp != 0) { if (p.trm != 0.f) ++t.writes[7]; if (p.tra != 0) ++t.writes[7]; if (p.trp != 0) ++t.writes[7]; p.trm = 0.f; p.tra = 0; p.trp = 0; ++t.fired[7]; } // --- P08 DecayRebellionOutputModifier ------------------------------------------- if (p.rebAI) { const float before = p.rebOutMod; float v = p.rebOutMod - 0.04f; if (v < 1.0f) v = 1.0f; if (v > 2.0f) v = 2.0f; p.rebOutMod = v; ++t.fired[8]; if (before != v) ++t.writes[8]; } // --- P09 AccumulateTimedResearchBonuses ----------------------------------------- // Iterated from the LAST element down to index 0; the order is part of the result // because float addition is not associative. if (!p.pr.empty()) { ++t.fired[9]; const float trmBefore = p.trm; const std::size_t before = p.pr.size(); for (std::size_t i = p.pr.size(); i-- > 0;) { p.trm = static_cast(static_cast(p.trm) + static_cast(p.pr[i].prm)); if (--p.pr[i].prbt <= 0) p.pr.erase(p.pr.begin() + static_cast(i)); } if (p.trm != trmBefore) ++t.writes[9]; t.writes[9] += static_cast(before - p.pr.size()); t.writes[9] += static_cast(p.pr.size()); // every surviving entry's counter moved } // --- P10 ConsumeResearchRollPending --------------------------------------------- // Threshold is a STRICT `0.5f < progress/cost`, and the flag clear is INSIDE the // branch: a target below half cost keeps the flag into the next turn. if (const mars::stream::shapes::TechState* target = FindTarget(p)) { if (p.resErrRoll) { ++t.fired[10]; const double cost = target->tResCost; const float ratio = cost > 0 ? static_cast(static_cast(target->tResDone) / cost) : 0.f; if (0.5f < ratio) { if (rng) { const int before = rng->words(); (void)rng->NextFloat(); // the research-event roll t.rng[10] += rng->words() - before; } p.resErrRoll = false; ++t.writes[10]; } } } // --- P11 PostNoResearchEvent ---------------------------------------------------- // Three tests, not the two this used to apply. The one that was missing -- "no tech // finished on this turn or later" -- is what keeps the event off the turn a tech lands, // and it is exercised by the corpus. The fourth term, `aiWouldPick`, is the stand-in for // AI research selection: the save's `ResTNm` is the target the file was written with, and // three of the four real players on the reference pair acquire one mid-turn. See // app/event_phase.h and docs/EV-events.md. { const NoResearchDecision d = DecideNoResearchEvent(p, events.turn, events.isAI); if (d.noTarget && d.noneResearched && d.anyAvailable) ++t.evalNoResearch; if (d.aiWouldPick && d.noTarget && d.noneResearched && d.anyAvailable) ++t.aiSuppressedNoResearch; if (d.fires()) { ++t.fired[11]; const bool haveText = events.text && events.text->available(); if (!haveText) ++t.noTextNoResearch; if (haveText && events.commit) { const NoResearchPost post = PostNoResearchEvent(p, events.turn, events.text->text()); t.writes[11] += post.leafWrites; ++t.postedNoResearch; } else { // One `EvNxID`, one collection count, one bucket, one record. t.wouldWrite[11] += 4; } } } } // --------------------------------------------------------------------------------------- // The per-system pass // --------------------------------------------------------------------------------------- struct SystemTotals { int fired = 0, writes = 0, skippedBonus = 0, skippedCountdown = 0, stable = 0; }; void RunSystemTurn(Sys& s, int playerCount, SystemTotals& t) { ++t.fired; // `IsStable()` is a callee's verdict in the original and an input to the model. The // standalone's stand-in -- owned, not abandoned, not destroyed -- is a HYPOTHESIS, and // it is measurable: it drives the turns-developing counter, which is a named leaf. const bool owned = s.pid != 0; const bool stable = owned && !s.abdn && !s.dstyd; if (stable) ++t.stable; // 1. An unowned system's infrastructure rots. if (!owned) { const float before = s.infra; const float after = static_cast(sim::DecayUnownedInfrastructure(s.infra)); if (after != before) { s.infra = after; ++t.writes; } } // 2. The two pending bonus pools. Draining them needs the imperial carrying capacity, // which needs the population->capacity chain; when either pool is non-empty we do // not touch it and say so. if (s.pbon != 0 || s.ibon != 0.f) ++t.skippedBonus; // 3. Turns-developing. { const int before = s.ntdev; s.ntdev = stable ? s.ntdev + 1 : 0; if (s.ntdev != before) ++t.writes; } // 4. The long-stability accrual reads the tuning table; with no tuning table loaded its // increments and targets are all zero, so it is a no-op and is left out rather than // run with fabricated constants. // 5. The turn's resource total is consumed and reset. if (s.tRes != 0) { s.tRes = 0; ++t.writes; } // 6. Growth halts expire every turn. The halt records are a counted list on the wire // whose element meaning is not settled, so this is reported, not written. // 7. The two per-player countdown words. The sweep is skipped entirely when the counter // word is zero, which is the case throughout the corpus; a non-zero word needs the // companion "someone is counting" mask, which is not identified on the wire. if (s.bats2 != 0 || s.rcex != 0) { ++t.skippedCountdown; } (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; } // The two terms both money paths share: the output total and everything in the money chain // except the trade points. `ComputeMaxIncome` and the turn's `ComputeOutput` differ only in // how they arrive at those trade points. struct SystemIncomeTerms { double totalOutputRaw = 0; sim::SystemMoneyInputs money; int popSpecies = 0; double idealSuitability = 0; // the SERVER's per-species baseline (the money cost's ideal) }; SystemIncomeTerms SystemIncomeTermsFromWire(const Sys& s, const Player& owner, bool ownerIsAI, const MaxIncomeInputs& ctx, double overHarvestRate) { SystemIncomeTerms out; // 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; out.popSpecies = popSpecies; 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) --- 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 = overHarvestRate; 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 out.totalOutputRaw = sim::TotalSystemOutputRaw(m, ctx.tuning); } // --- the money chain (lane E1'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 = out.money; 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; out.idealSuitability = popSpecies >= 0 && popSpecies < static_cast(ctx.idealSuit.size()) ? ctx.idealSuit[static_cast(popSpecies)] : owner.idealSuit; mi.suitCostMod = sim::SuitabilityCostMod(s.suit, out.idealSuitability, owner.suitTol, owner.rebAI, true, s.vnh); return out; } // `ComputeOutput(s).out[3]` -- the money a system contributes to the TURN's budget, as // opposed to the projected maximum T31 sums. Not clamped: `ComputeBudget` splits a negative // system into its expense column itself. // // One input of the nine this needs is not on the wire and is taken as zero here: the repair // demand of the owner's damaged ships in orbit, which would need `Ship::RepairCost` over the // fleets at the system. Every point it would consume is a point that does NOT come back to // the money channel, so a colony with a damaged fleet reads HIGH. int SystemTurnMoneyFromWire(const Sys& s, const Player& owner, bool ownerIsAI, const MaxIncomeInputs& ctx) { const SystemIncomeTerms terms = SystemIncomeTermsFromWire(s, owner, ownerIsAI, ctx, s.rts.sroh); sim::IdealSuitabilityInputs isi; isi.owned = true; isi.systemSuitability = s.suit; isi.ownerIdealSuitability = owner.idealSuit; isi.independent = s.hindi; isi.serverIdealSuitability = terms.idealSuitability; isi.systemOverride = s.dsu; const double ideal = sim::IdealSuitability(isi); sim::SystemOutputInputs in; in.rates.trade = s.rts.srt; in.rates.construction = s.rts.srsc; in.rates.terraform = s.rts.srtf; in.rates.infra = s.rts.sri; // The normaliser's two suppressions, with the predicates the original uses: an EXACT // equality for suitability and `float32(Infra + ibon) >= 1` for infrastructure. in.suitAtIdeal = static_cast(s.suit) == ideal; in.infraFull = sim::F32(static_cast(s.ibon) + s.infra) >= 1.0; // The leftover split's own infrastructure test reads the RAW Infra against 1. in.infraExactlyOne = static_cast(s.infra) == 1.0; in.infra = s.infra; in.totalOutputRaw = terms.totalOutputRaw; in.shipyardStations = 0; // StationCount(sys, owner, 1): no corpus system has a station in.buildQueueDemand = 0; if (s.bq) for (const auto& o : s.bq->orders) in.buildQueueDemand += o.conleft; // The ninth input. `sim::ShipRepairCost` now models the per-ship term, but its two design // fields (the build target and the allowance) are cached design stats that the save does // not carry, so the demand stays 0 and S13 reports the candidate set that proves the zero. in.repairDemand = 0; in.terraformPointsNeeded = sim::TerraformPointsNeeded(s.suit, ideal, owner.terraMod); in.terraformDown = ideal < static_cast(s.suit); in.terraformMod = owner.terraMod; in.money = terms.money; return sim::ComputeSystemOutput(in, ctx.tuning).money; } // `max(ComputeMaxIncome(s), 0)` for one owned system. int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI, const MaxIncomeInputs& ctx) { // The max-income rate vector puts nothing on over-harvest, and the two cascade channels // are provably zero under it, so the trade points are simply the rounded output total. const SystemIncomeTerms terms = SystemIncomeTermsFromWire(s, owner, ownerIsAI, ctx, 0.0); return sim::SystemMaxIncome(terms.totalOutputRaw, terms.money); } // One `PlayerBudgetFeed` per player, in save order. Built once, from the colony state as it // stands when the player driver runs -- which is AFTER the per-system turn (the strategic // driver runs the system turn at phase 11 and the player driver at phase 13), so anything // `S11` fails to commit is missing from these numbers as well. std::vector BuildBudgetFeeds(const SaveGame& game, const TurnOptions& opt) { MaxIncomeInputs ctx; ctx.serverIncomeMod = game.sim.incMod; for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu); 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; }; std::vector feeds; feeds.reserve(game.sim.players.size()); for (const auto& pe : game.sim.players) { const Player& p = pe.player; PlayerBudgetFeed f; f.isAI = opt.IsAIPlayer(p.plyrIdx); f.difficulty = sim::DifficultyModsFor(p.aidf, f.isAI, p.npc); for (std::int32_t id : p.owners) { const Sys* s = find(id); if (!s) { f.held = false; // a dangling owner id: the sum is incomplete, say so continue; } if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero const int money = SystemTurnMoneyFromWire(*s, p, f.isAI, ctx); if (money != 0) f.systemIncome.push_back(money); f.turnIncome += money; f.projectedIncome += SystemMaxIncomeFromWire(*s, p, f.isAI, ctx); if (!s->fleets.empty()) { ++f.repairCandidateSystems; for (std::int32_t fid : s->fleets) for (const auto& fe : game.sim.fleets) if (fe.fltID == fid) f.repairCandidateShips += static_cast(fe.flt.ships.size()); } } feeds.push_back(std::move(f)); } return feeds; } // A system table keyed by the handle id a player's `OwnId` list carries. The list is short // enough that a linear probe is cheaper than a map, and keeping it a value type means both // callers below build it the same way. class SystemIndex { public: explicit SystemIndex(const SaveGame& game) { for (const auto& e : game.sim.systems) { ids_.push_back(e.sysID); sys_.push_back(&e.sys); } } const Sys* Find(std::int32_t id) const { for (std::size_t i = 0; i < ids_.size(); ++i) if (ids_[i] == id) return sys_[i]; return nullptr; } private: std::vector ids_; std::vector sys_; }; // `sum over owned, non-abandoned systems of max(ComputeMaxIncome(s), 0)` for one player. int PlayerMaxIncome(const Player& p, bool isAI, const SystemIndex& index, const MaxIncomeInputs& ctx, int* dangling = nullptr) { int maxIncome = 0; for (std::int32_t id : p.owners) { const Sys* s = index.Find(id); if (!s) { if (dangling) ++*dangling; continue; } if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero maxIncome += SystemMaxIncomeFromWire(*s, p, isAI, ctx); } return maxIncome; } // Which difficulty column each player's income was computed under, recovered from the input // save before any phase mutates it. See `game/sim/player_turn.h` for why this is possible at // all; the short version is that `BnkEl` is a 6.67x-slope function of the max income, so the // 10% the AI column adds cannot hide inside a truncation. // // Taken at load, and it has to be: T31 runs at the end of the turn, by which point the colony // state is the POST-turn one and no longer the state the stored limit was written from. std::vector IdentifyDifficultyColumns(const SaveGame& game) { MaxIncomeInputs ctx; ctx.serverIncomeMod = game.sim.incMod; for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu); const SystemIndex index(game); std::vector out; out.reserve(game.sim.players.size()); for (const auto& pe : game.sim.players) { const Player& p = pe.player; out.push_back(sim::IdentifyDifficultyColumn( p.bnkEl, PlayerMaxIncome(p, false, index, ctx), PlayerMaxIncome(p, true, index, ctx), ctx.tuning)); } return out; } void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec, const std::vector& columns) { MaxIncomeInputs ctx; ctx.serverIncomeMod = game.sim.incMod; for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu); const SystemIndex index(game); int players = 0, dangling = 0, reproduced = 0, compared = 0; int identifiedAI = 0, identifiedNonAI = 0, ambiguous = 0, unidentified = 0, overridden = 0; std::string firstMiss; for (std::size_t i = 0; i < game.sim.players.size(); ++i) { Player& p = game.sim.players[i].player; if (p.elim) continue; ++players; ++compared; // The column, from this player's own record where the save could settle it, and from // the operator's roster only where it could not. const sim::DifficultyColumnEvidence ev = i < columns.size() ? columns[i] : sim::DifficultyColumnEvidence{}; bool isAI = false; switch (ev.column) { case sim::DifficultyColumn::AI: isAI = true; ++identifiedAI; break; case sim::DifficultyColumn::NonAI: ++identifiedNonAI; break; case sim::DifficultyColumn::Ambiguous: ++ambiguous; isAI = opt.IsAIPlayer(p.plyrIdx); break; case sim::DifficultyColumn::Unidentified: ++unidentified; isAI = opt.IsAIPlayer(p.plyrIdx); if (isAI) ++overridden; break; } if (ev.Reproduced()) ++reproduced; else if (firstMiss.empty()) firstMiss = fmt("player %d: the input save's BnkEl is %d and neither column " "reproduces it (non-AI %d, AI %d) -- this player abstains", p.plyrIdx, ev.storedLimit, ev.nonAiLimit, ev.aiLimit); const int maxIncome = PlayerMaxIncome(p, isAI, index, ctx, &dangling); const sim::BankruptcyLimits lim = sim::ComputeBankruptcyLimits(maxIncome, ctx.tuning); ++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. const bool prModelled = opt.haveTuning; int would = lim.eliminationFloor != p.bnkEl ? 1 : 0; if (prModelled && lim.protectionLimit != p.bnkPr) ++would; // Committing is per player, and the gate is this player's own record: write only // where the same chain, run on the state the save was written from, reproduced the // limit the save carries. Where it did not, the phase leaves the leaf alone -- a // pass-through leaf that may still be right beats a computed one that is known to be // wrong. `--commit-blocked=T31` remains the operator's override for the rest. const bool commit = ev.Reproduced() || opt.CommitBlocked("T31"); if (commit) { rec.leafWrites += would; p.bnkEl = lim.eliminationFloor; if (prModelled) p.bnkPr = lim.protectionLimit; if (would) rec.committed = true; } else { rec.wouldWrite += would; } } rec.notes.push_back(fmt("%d player(s); the input save's own BnkEl was reproduced for %d " "of %d from the colony state it carries", players, reproduced, compared)); rec.notes.push_back(fmt("difficulty column recovered from the save: %d AI, %d non-AI, " "%d ambiguous, %d unidentified", identifiedAI, identifiedNonAI, ambiguous, unidentified)); if (ambiguous) rec.notes.push_back("ambiguous means both columns produce the SAME limit, so the flag " "cannot matter for that player -- either the max income is zero, " "or the player is an NPC, and the difficulty table's AI row is " "gated on `isAI && !npc`"); if (overridden) rec.notes.push_back(fmt("%d unidentified player(s) took the operator's --ai-player " "roster instead", overridden)); 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)); // 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 // --------------------------------------------------------------------------------------- // The record this phase would archive for the turn just run, and -- separately -- what the // same model produces for the turn the INPUT save was written at, where the save already // carries the answer. The second is a check of the model that needs no running game: it is // the "testable on load" property of this phase. struct TurnRecordAudit { int playersChecked = 0; // players whose input-turn record could be compared int fieldsCompared = 0; int mismatches = 0; int dangling = 0; // owned-system ids the save's system table does not carry std::vector firstMismatches; int missingArchive = 0; // players with no archive element for the input turn bool censusModelled = false; // a catalog was supplied, so six more fields are compared }; TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game, const ShipCensusIndex* census) { TurnRecordAudit a; a.censusModelled = census && census->modelled(); const std::int32_t inputTurn = game.sim.frame; for (std::size_t i = 0; i < game.sim.players.size(); ++i) { if (i >= game.sim.turnstats.players.size()) break; const auto& hist = game.sim.turnstats.players[i].hist; const auto* stored = FindArchivedRecord(hist, inputTurn); if (!stored) { ++a.missingArchive; continue; } int dangling = 0; // The archiving phase runs both at the end of a turn and on load, and only the // end-of-turn path has a spine behind it. The earliest turn the archive carries is // the one the load path wrote, so its alliance mask is predicted to be zero. const bool spineRan = inputTurn > EarliestArchivedTurn(hist); const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems, inputTurn, i, spineRan, &dangling, census); a.dangling += dangling; const TurnRecordDiff d = CompareTurnRecord(built, *stored); ++a.playersChecked; a.fieldsCompared += d.compared; a.mismatches += static_cast(d.mismatches.size()); for (const auto& m : d.mismatches) if (a.firstMismatches.size() < 8) a.firstMismatches.push_back(fmt("player %zu: %s", i, m.c_str())); } return a; } void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec, const TurnRecordAudit& audit, const std::vector& allianceMasks) { rec.invocations = static_cast(game.sim.players.size()); const bool commits = opt.CommitBlocked("T36"); // Rebuilt here rather than reused from the load-time audit: the record this phase archives // is the state at the END of the turn, and a census taken before the phases ran would be // the wrong one the moment a phase adds or removes a ship. Nothing in the standalone does // that today, which makes the two identical today and not by construction. ShipCensusIndex censusNow; if (opt.catalog) censusNow = ShipCensusIndex(game, *opt.catalog); const ShipCensusIndex* census = opt.catalog ? &censusNow : nullptr; const bool haveCensus = census && census->modelled(); const int perPlayerFields = haveCensus ? 13 : 7; // Thirteen fields per player would be written when a data root is present -- seven from // the wire and the six census counters -- plus a new archive element per player. Nothing // is committed by default, and the reason is now exactly two fields wide: savings and the // income derived from it are downstream of P01/P02, which are blocked on the per-system // money output, so every element written carries two confidently-wrong words. The record // is one struct on the wire, so there is no way to archive the eleven right fields and // leave those two out -- the format has no hole. `--commit-blocked` writes them anyway, // so the claim that committing makes things worse stays a measurement, not an argument. int archived = 0; for (std::size_t i = 0; i < game.sim.players.size(); ++i) { if (i >= game.sim.turnstats.players.size()) break; auto& hist = game.sim.turnstats.players[i].hist; if (FindArchivedRecord(hist, game.sim.frame)) continue; // the key is the turn if (!commits) { ++archived; continue; } // The record being archived is this turn's, and this turn ran the spine, so the // alliance mask is the one phase S04 rebuilt rather than a zero from the load path. const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems, game.sim.frame, i, /*spineRan=*/true, /*danglingOwnedSystems=*/nullptr, census); mars::stream::shapes::PlayerTurnStats s; s.trn = built.turn; // The mask the spine's phase 4 rebuilt earlier in this same turn, not a value // recomputed here: the dependency between the two phases is real and is expressed. s.almem = i < allianceMasks.size() ? allianceMasks[i] : built.allianceMask; s.pop = built.population; s.col = built.colonies; s.sav = built.savings; s.inc = built.income; s.tch = built.completedTech; // The census is three hull classes wide whether or not the player owns a ship, so the // three groups are written either way; with a data root the ship and platform totals // are filled from the fleet walk, without one they stay zero and say so. The four // other words of each group -- losses and kills -- have no model behind them. for (std::int32_t c = 0; c < 3; ++c) { mars::stream::shapes::ClassStats cs; cs.cls = c; if (built.censusModelled) { cs.shpt = built.ships[static_cast(c)]; cs.satt = built.platforms[static_cast(c)]; } s.classes.push_back(cs); } hist.stats.push_back(s); ++archived; rec.leafWrites += perPlayerFields; } rec.committed = rec.leafWrites > 0; if (!commits) rec.wouldWrite = archived * perPlayerFields; rec.notes.push_back(fmt("%s %d record(s) for turn %d; %d modelled field(s) each", commits ? "ARCHIVED" : "would archive", archived, game.sim.frame, perPlayerFields)); if (audit.playersChecked) rec.notes.push_back(fmt("SELF-CHECK on the input turn, where the save carries the " "answer: %d field(s) over %d player(s), %d mismatch(es)%s " "(census %s the comparison)", audit.fieldsCompared, audit.playersChecked, audit.mismatches, audit.dangling ? " (owned-system ids missing from the table!)" : "", audit.censusModelled ? "IN" : "not in")); for (const auto& m : audit.firstMismatches) rec.notes.push_back(m); if (audit.missingArchive) rec.notes.push_back(fmt("%d player(s) carry no archive element for the input turn", audit.missingArchive)); if (haveCensus) { rec.notes.push_back(fmt("ship census MODELLED from the section catalog: %d design(s) " "classified, %d unclassifiable, %d ship(s) whose design is " "unknown, %d fleet(s) owned by no player in the vector", census->designsSeen, census->designsUnclassified, census->shipsWithoutDesign, census->fleetsWithoutOwner)); rec.notes.push_back("census COVERAGE (rule 6): only 32 of the 480 census leaves the " "11-save corpus archives are nonzero anywhere -- per leaf " "(cls0 shpt/satt, cls1 shpt/satt, cls2 shpt/satt) = 18/3, 0/0, " "11/0. cls1 entirely (cruisers, ships and platforms) and satt for " "cls2 have NEVER been observed nonzero: those three counters are " "unexercised hypotheses, not verified. A zero leaf agrees for free"); rec.notes.push_back("census is of the fleet list AS IT STANDS, which is the input " "save's: no phase we run creates a ship. MEASURED on both corpus " "pairs -- the archived count is exactly one destroyer higher than " "ours for the one player whose build queue completes that turn, " "while the self-check on the input turn is exact. So this leaf is " "short by the turn's construction, not wrong about classification"); rec.notes.push_back("census HYPOTHESIS: hull size is an assignment in slot order, and " "the slots are visited in the original's in-memory order (mission, " "command, engine), not the wire's. Design rule A6 makes every " "shipped design class-homogeneous, so no save in the corpus can " "tell the two orders apart"); } else { rec.notes.push_back("NOT modelled: the per-hull-class ship census -- it needs each " "design's hull size and defence-platform flag, neither of which is " "on the wire; both come from the section catalog. Pass --data DIR " "(or set SOTS_DATA_DIR) and the six counters are filled and " "self-checked; without it they are written as zeros with nothing " "behind them"); } std::size_t nUnmodelled = 0; const char* const* un = TurnRecord::Unmodelled(nUnmodelled); for (std::size_t i = 0; i < nUnmodelled; ++i) rec.notes.push_back(fmt("NOT modelled: %s", un[i])); rec.notes.push_back("BLOCKED ON TWO NAMED THINGS, neither of them in this phase: savings " "and the income derived from it come from P01/P02, blocked on the " "per-system money output; and one ship count is short by the ships the " "turn builds, because no phase we run creates one. The archived record " "is one struct on the wire, so those words cannot be left out while the " "rest is written -- committing is all-or-nothing at the record, and " "there is no field-granular knob that could change that"); } } // namespace // --------------------------------------------------------------------------------------- // Generator plumbing // --------------------------------------------------------------------------------------- bool LoadGenerator(const SaveGame& game, mars::rng::MT19937& out) { const Node& f = game.sim.rng; if (!f.is_complex() || f.children.size() != 1) return false; const Node& blob = f.children[0]; if (blob.kind != mars::stream::Kind::Raw) return false; return out.load_state(blob.raw.data(), blob.raw.size()); } bool StoreGenerator(SaveGame& game, const mars::rng::MT19937& gen) { Node& f = game.sim.rng; if (!f.is_complex() || f.children.size() != 1) return false; Node& blob = f.children[0]; if (blob.kind != mars::stream::Kind::Raw || blob.raw.size() < mars::rng::MT19937::kStateBytes) return false; // The blob carries three trailing pad bytes past the state; they are preserved. std::uint8_t tmp[mars::rng::MT19937::kStateBytes]; gen.save_state(tmp); std::memcpy(blob.raw.data(), tmp, sizeof tmp); return true; } void ApplySaveWriterInvariants(SaveGame& game, TurnResult& r) { // Observed on every save in the corpus: the summary's turn number equals the // simulation's frame counter. The summary is rebuilt by the writer, not by a turn // phase, so it belongs here rather than in the phase catalog. if (game.summary.turn != game.sim.frame) { game.summary.turn = game.sim.frame; ++r.leafWrites; } } // --------------------------------------------------------------------------------------- // The runner // --------------------------------------------------------------------------------------- TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { TurnResult r; // The recorded command stream, applied first because that is where the original applies it: // the End-Turn dispatcher drains every submitted block, THEN runs the strategic turn, THEN // runs the post-combat tail. Every leaf a command writes is therefore already in place when // the first phase reads the board. if (opt.turnCommands) { r.commandReplay = ReplayTurnCommands(game, *opt.turnCommands, opt.replayOptions); game.sim.modCount += r.commandReplay.bumps; r.leafWrites += r.commandReplay.leafWrites; for (const auto& w : r.commandReplay.warnings) r.warnings.push_back(w); } // Taken before any phase runs: the turn-record model is checked against the record the // INPUT save already carries for its own turn. None of the fields it reads is written by // a phase below, but the check is taken first so that stays true by construction. ShipCensusIndex inputCensus; if (opt.catalog) inputCensus = ShipCensusIndex(game, *opt.catalog); const TurnRecordAudit recordAudit = AuditTurnRecordsAgainstSave(game, opt.catalog ? &inputCensus : nullptr); // Also taken before any phase runs, and for the same reason: T31's difficulty column is // recovered by recomputing each player's bankruptcy limit from the colony state the save // was written from, which stops existing the moment S11 commits growth. const std::vector difficultyColumns = IdentifyDifficultyColumns(game); // The event text. The engine holds keys; the text comes from the operator's own installed // string table, which arrives with the data root or not at all. const EventTextTable eventText(opt.catalog && opt.catalog->strings_loaded ? &opt.catalog->strings : nullptr); mars::rng::MT19937 gen(1u); r.rngLoaded = LoadGenerator(game, gen); if (!r.rngLoaded) r.warnings.push_back("generator state could not be read from the save; drawing phases " "will report themselves as unable to draw"); CountingRandom rng(gen); std::size_t nh = 0, ns = 0, np = 0; const PhaseDesc* hp = HostPhases(nh); const PhaseDesc* sp = StrategicPhases(ns); const PhaseDesc* pp = PlayerPhases(np); // H00 BeginProcessTurn: the frame counter, which is the turn number the game displays. { PhaseRecord rec; rec.desc = &hp[0]; ++game.sim.frame; rec.invocations = 1; rec.leafWrites = 1; rec.committed = true; rec.notes.push_back(fmt("Frame -> %d", game.sim.frame)); r.records.push_back(rec); } // H03 ScriptHookTurnBegin: the turn's first script-object event delivery, sent from // inside BeginProcessTurn immediately after the frame counter above. The ordering is // load-bearing in the same way H02's is -- both rules this delivery runs read the NEW // frame, and a step placed before H00 would read the old one. { PhaseRecord rec; rec.desc = &hp[1]; const ScriptPhaseResult s = RunScriptTurnBegin(game, opt.CommitBlocked("H03")); rec.invocations = s.objectsVisited; rec.leafWrites = s.leafWrites; rec.committed = s.leafWrites > 0; rec.notes = s.notes; r.records.push_back(rec); } // H02 StampTreatyTurns: the diplomacy ledger's "last in force on turn N" stamp. It runs // in the command-application step, which is AFTER the frame counter above and before // either turn driver -- the ordering is load-bearing, because the value stamped is the // new turn and a step placed before H00 would stamp every entry one turn short. { PhaseRecord rec; rec.desc = &hp[2]; const TreatyStampResult ts = StampTreatyTurns(game.sim.players, game.sim.frame); rec.invocations = ts.pairsStamped; rec.leafWrites = ts.leafWrites; rec.committed = true; rec.notes.push_back(fmt("%d ordered pair(s) hold a treaty; %d entry(s) created, " "%d stamp(s) written at turn %d", ts.pairsStamped, ts.entriesCreated, ts.fieldsWritten, game.sim.frame)); if (ts.pairsStamped == 0) rec.notes.push_back("no player in this save holds any treaty, so this run " "exercises the relation test only and writes nothing"); rec.notes.push_back("the betrayal half of the same step needs the turn's diplomatic " "commands (the alliance/NAP/cease-fire broken masks); with no " "command stream it is provably a no-op and is not modelled"); r.records.push_back(rec); } PlayerPhaseTotals pt; SystemTotals st; bool playerDriverRan = false; // Filled by S04 and consumed by the tail's archiving phase. Empty until S04 runs, which // is what makes the ordering between the two visible rather than assumed. std::vector allianceMasks; for (std::size_t i = 0; i < ns; ++i) { PhaseRecord rec; rec.desc = &sp[i]; switch (sp[i].index) { case 0: { // S00 SnapshotPreviousTurn ++game.sim.modCount; rec.invocations = 1; rec.leafWrites = 1; rec.committed = true; rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount)); rec.notes.push_back("the real turn advances this counter 12-44 times, from " "writers spread across both drivers; only this one is " "modelled, so the leaf will not match yet"); // The treasury's previous-turn shadow. It is a copy of `Sav` taken here, // before any phase of the turn can move it, which is what makes the tail's // `Sav - PvSav` the turn's own net. int stamped = 0, moved = 0; for (auto& pe : game.sim.players) { Player& p = pe.player; if (p.elim) continue; const int was = p.pvSav; p.pvSav = sim::SnapshotPreviousTurn(p.sav); ++stamped; if (p.pvSav != was) ++moved; } rec.invocations += stamped; rec.leafWrites += moved; rec.notes.push_back(fmt("PvSav stamped from Sav on %d player(s); %d leaf(s) " "moved", stamped, moved)); rec.notes.push_back("a player whose treasury is spent between the file being " "written and this phase -- the AI's queue-time build " "deduction -- lands 11,900 high here, and that order is " "not in the input save (Rung B)"); break; } case 4: { // S04 RebuildAllianceMasks // Rebuilt from scratch every turn, before anything in the turn can read it. // The word is not a save leaf of its own: it reaches the wire only through // the tail's archiving phase, so this phase commits nothing here and the // count of leaves it will cause to move is reported by that phase. allianceMasks = RebuildAllianceMasks(game.sim.players); int allied = 0; for (const auto& e : game.sim.players) if (e.player.alliances.alid != kNoAlliance) ++allied; rec.invocations = static_cast(allianceMasks.size()); rec.committed = true; rec.notes.push_back(fmt("%d mask(s) rebuilt; %d player(s) carry an alliance id", rec.invocations, allied)); rec.notes.push_back("the mask is not a leaf of its own -- it reaches the wire " "only through the turn-record archive, so the leaves it " "moves are counted by the tail's last phase"); if (allied == 0) rec.notes.push_back("NO player is in an alliance in this save, so this run " "exercises the self bit only and the alliance term is " "an instruction-stream reading with no evidence behind " "it here"); break; } case 11: { // S11 SystemTurn for (auto& e : game.sim.systems) RunSystemTurn(e.sys, static_cast(game.sim.players.size()), st); rec.invocations = st.fired; rec.leafWrites = st.writes; rec.committed = st.writes > 0; rec.notes.push_back(fmt("%d systems, %d judged stable by the owned/not-abandoned " "stand-in (HYPOTHESIS -- the original asks a callee)", st.fired, st.stable)); if (st.skippedBonus) rec.notes.push_back(fmt("%d system(s) left their pending bonus pool alone: " "draining it needs the imperial carrying capacity", st.skippedBonus)); if (st.skippedCountdown) rec.notes.push_back(fmt("%d system(s) left their countdown words alone: the " "companion active-player mask is not identified", st.skippedCountdown)); // The build-queue sub-pass reports on its own line rather than folding its // numbers into S11's, because what blocks it is not what blocks the rest of // the colony turn. See app/construction_phase.h. { const ConstructionPhaseResult b = RunBuildQueues(game); rec.wouldWrite += b.wouldWrite; for (const auto& n : b.notes) rec.notes.push_back("build queue: " + n); } // Civilian growth runs after the build queue in the original's colony turn, // and before the player driver at phase 13 -- so what it writes here is what // ComputeBudget prices the colony from. { sim::TuningTable tuning; const GrowthPhaseResult g = RunCivilianGrowth(game, tuning, opt.haveTuning); rec.leafWrites += g.leafWrites; rec.wouldWrite += g.wouldWrite; if (g.leafWrites > 0) rec.committed = true; for (const auto& n : g.notes) rec.notes.push_back("civilian growth: " + n); } break; } case 13: { // S13 PlayerTurn -- the nested driver const std::vector feeds = BuildBudgetFeeds(game, opt); std::size_t fi = 0; int fed = 0, agree = 0, worst = 0; for (auto& e : game.sim.players) { const PlayerBudgetFeed& f = feeds[fi++]; // The event bucket a post lands in is the turn AFTER H00's bump, which // is `sim.frame` by the time this driver runs. Measured: a turn run from // a save at turn N posts into bucket N+1 in every corpus save. PlayerEventFeed ef; ef.turn = game.sim.frame; ef.isAI = opt.IsAIPlayer(e.player.plyrIdx); ef.text = &eventText; ef.commit = opt.CommitBlocked("P11"); if (!f.systemIncome.empty()) ++fed; else { RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f, ef); continue; } const int d = f.turnIncome - f.projectedIncome; if (d == 0) ++agree; if (d > worst || -d > worst) worst = d < 0 ? -d : d; RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f, ef); } rec.notes.push_back(fmt( "%d player(s) had a non-empty per-system money roll-up on the TURN path " "(ComputeOutput, not ComputeMaxIncome); civilian growth is now committed " "by S11, so a colony that grew this turn is priced from its POST-growth " "population", fed)); { int repSys = 0, repShips = 0; for (const auto& f2 : feeds) { repSys += f2.repairCandidateSystems; repShips += f2.repairCandidateShips; } rec.notes.push_back(fmt( "ship-repair demand taken as 0: %d owned colony(ies) carry a fleet in " "orbit at all, %d ship(s) between them -- the only input of the nine " "still unmodelled, and its two design fields are cached stats that are " "nowhere on the wire. Zero is EVIDENCED rather than assumed on this " "corpus: the independent colony's savings close exactly on both " "reference pairs with a fleet parked over Koa'Vo, which cannot happen " "if any of those hulls had a positive repair cost", repSys, repShips)); } rec.notes.push_back(fmt( "turn path vs projected path on the same colony state: %d of %d landed " "players agree exactly, worst |delta| %d money (the projected sum is what " "the save's own BnkEl states, so this is a check without a VM)", agree, fed, worst)); rec.invocations = static_cast(game.sim.players.size()); for (int k = 1; k <= 12; ++k) { rec.leafWrites += pt.writes[k]; rec.wouldWrite += pt.wouldWrite[k]; rec.rngWords += pt.rng[k]; } rec.committed = rec.leafWrites > 0; playerDriverRan = true; break; } case 29: { // S29 SystemObservedStamp const VisibilityPhaseResult v = RunSystemObservedStamp(game); rec.invocations = v.systemsVisited; rec.leafWrites = v.leafWrites; rec.committed = v.leafWrites > 0; rec.notes = v.notes; break; } case 31: { // S31 EncounterDetectionAndStatusRestore // Trade-raid generation runs FIRST inside this phase, before detection // proper, and it is the turn's dominant RNG consumer: two chances per entry // of the player vector, neither inside a back edge, so the count is a bound. if (r.rngLoaded) { const int before = rng.words(); const TradeRaidResult tr = RollTradeRaids(rng, static_cast(game.sim.players.size()), TradeRaidOdds{}, TradeRaidGates{}); rec.rngWords = rng.words() - before; rec.notes.push_back(fmt("trade raids: %d player(s) x 2 rolls = %d generator " "word(s), one per roll (neither site sits inside a " "back edge, so this is a bound)", tr.players, rec.rngWords)); rec.notes.push_back("the refugee-raid roll is NOT counted: its subsystem was " "absent on all 8 measured turns. If it is ever present " "the turn costs one more word per player"); if (tr.playerRaidHits || tr.npcRaidHits) rec.notes.push_back(fmt("%d roll(s) succeeded in THIS run -- a success " "may cost a further target-selection word that " "is not modelled. The values drawn here are not " "the game's: earlier unmodelled draws shift the " "stream, so the hit COUNT is not a prediction", tr.playerRaidHits + tr.npcRaidHits)); } // The status restore: every player that is not an AI, or whose secondary AI // flag is set, goes back to status 1. The secondary flag is not on the wire, // so the AI test alone is used and the difference is reported. int n = 0; for (auto& e : game.sim.players) { if (e.player.npc) continue; if (e.player.status == 1) continue; ++n; if (opt.CommitBlocked("S31")) e.player.status = 1; } const bool s31Commits = opt.CommitBlocked("S31"); rec.invocations = static_cast(game.sim.players.size()); rec.leafWrites = s31Commits ? n : 0; rec.wouldWrite = s31Commits ? 0 : n; rec.committed = rec.leafWrites > 0; rec.notes.push_back(fmt("%d player status word(s) would be restored to 1", n)); rec.notes.push_back("MEASURED: the phase writes 1, the post-turn file carries " "4, and a load resets it to 0. Writing the 1 REGRESSED two " "agreeing leaves on the turn2->turn3 pair, so the write is " "held back until the writer that produces the 4 is found"); break; } default: break; // named no-op } r.records.push_back(rec); // The player driver's own phases are listed immediately after the phase that runs // them, so the printed log is the turn in execution order. if (sp[i].index == 13 && playerDriverRan) { for (std::size_t k = 0; k < np; ++k) { PhaseRecord pr; pr.desc = &pp[k]; const int idx = pp[k].index; pr.invocations = pt.fired[idx]; pr.leafWrites = pt.writes[idx]; pr.wouldWrite = pt.wouldWrite[idx]; pr.rngWords = pt.rng[idx]; pr.committed = pr.leafWrites > 0; if (idx == 1) pr.notes.push_back(fmt("budget computed for %d player(s) with an EMPTY " "system-income vector; nothing committed", pt.fired[1])); if (idx == 2 && pt.wouldWrite[2]) pr.notes.push_back(fmt("%d player(s) would have had savings rewritten", pt.wouldWrite[2])); if (idx == 10) pr.notes.push_back(fmt("%d player(s) held both a target and the pending " "flag; %d roll(s) fired", pt.fired[10], pt.writes[10])); if (idx == 11) { pr.notes.push_back(fmt( "%d player(s) pass the three save-derivable tests (no target, nothing " "researched this turn or later, at least one available tech); the AI " "roster suppressed %d of them", pt.evalNoResearch, pt.aiSuppressedNoResearch)); if (pt.postedNoResearch) pr.notes.push_back(fmt("%d event(s) posted into bucket EvTurn=%d", pt.postedNoResearch, game.sim.frame)); if (pt.noTextNoResearch) pr.notes.push_back(fmt( "%d event(s) NOT posted: no string table. The engine carries the " "EVENTSUM_/EVENTMSG_ keys and never the text, so a run without " "--data (or $SOTS_DATA_DIR) cannot write a record the oracle " "would match, and writing an empty one would regress it", pt.noTextNoResearch)); if (pt.aiSuppressedNoResearch) pr.notes.push_back( "HYPOTHESIS: an AI-controlled player with no research target " "acquires one before this check, so it never posts. The real " "input is AI research selection; --ai-player N stands in for it. " "With no roster supplied the phase over-fires by exactly the " "number of AI empires that pick a target this turn"); if (!opt.CommitBlocked("P11") && pt.fired[11]) pr.notes.push_back("evaluated, not committed: pass --commit-blocked=P11"); } r.records.push_back(pr); } } } // The tail is a separate driver reached from a different message. Nothing in it is // implemented except the counter bump, so it is listed rather than run -- but it is // listed, because the autosave is written after it and two of its phases draw. std::size_t nt = 0; const PhaseDesc* tp = TailPhases(nt); for (std::size_t i = 0; i < nt; ++i) { PhaseRecord rec; rec.desc = &tp[i]; if (tp[i].index == 0) { ++game.sim.modCount; rec.invocations = 1; rec.leafWrites = 1; rec.committed = true; rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount)); } else if (tp[i].index == 17) { const VisibilityPhaseResult v = RunObservationRecords(game); rec.invocations = v.systemsVisited; rec.leafWrites = v.leafWrites; rec.committed = v.leafWrites > 0; rec.notes = v.notes; } else if (tp[i].index == 21) { const VisibilityPhaseResult v = RunExploredSweep(game); rec.invocations = v.systemsVisited; rec.leafWrites = v.leafWrites; rec.committed = v.leafWrites > 0; rec.notes = v.notes; } else if (tp[i].index == 20) { const ScriptPhaseResult s = RunScriptTurnEnd(game); rec.invocations = s.objectsVisited; rec.leafWrites = s.leafWrites; rec.committed = s.leafWrites > 0; rec.notes = s.notes; } else if (tp[i].index == 34) { const ObservedDesignsResult o = RunRecordObservedDesigns(game); rec.invocations = o.recordCalls; rec.leafWrites = o.leafWrites; rec.committed = o.leafWrites > 0; rec.notes = o.notes; } else if (tp[i].index == 31) { RunUpdateBankruptcyLimits(game, opt, rec, difficultyColumns); } else if (tp[i].index == 36) { RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks); } r.records.push_back(rec); } // H01 SaveWriterInvariants. { PhaseRecord rec; rec.desc = &hp[3]; const int before = game.summary.turn; ApplySaveWriterInvariants(game, r); rec.invocations = 1; rec.leafWrites = game.summary.turn != before ? 1 : 0; rec.committed = rec.leafWrites > 0; rec.notes.push_back(fmt("Summary.Turn -> %d", game.summary.turn)); r.records.push_back(rec); } // ApplySaveWriterInvariants counts its own write; zero the accumulator before the fold so // the per-phase records are the single source of the total. r.leafWrites = 0; for (const auto& rec : r.records) { r.leafWrites += rec.leafWrites; r.wouldWrite += rec.wouldWrite; r.rngWords += rec.rngWords; } // The RNG ledger, stated the way the campaign states divergence: what is accounted and // what is not, never netted into one number. A measured turn on the reference save costs // 18-22 words; what is modelled here is the trade-raid block and the research-event roll. r.rngUnaccounted.push_back( "encounter detection draws one unit value and one bounded integer per turn on every " "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( "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 " "succeeded on any measured turn, so the cost of a success is 0 or 1 and undetermined"); if (opt.commitRng && r.rngLoaded) { if (!StoreGenerator(game, gen)) r.warnings.push_back("generator state could not be written back"); else r.rngCommitted = true; } else if (r.rngWords > 0) { r.warnings.push_back( "the generator advanced during this run but the save keeps its original state " "(--commit-rng to write it)"); } if (r.rngLoaded) r.warnings.push_back( "the modelled words are a LOWER BOUND on the turn's cost, so a committed generator " "is short by the unaccounted sites below and its drawn VALUES are not the game's"); return r; } } // namespace sots::app