#include "app/turn.h" #include #include #include #include #include #include #include "app/alliance.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/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 }; void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng, PlayerPhaseTotals& t) { // --- P01 ComputeBudget -- blocked on the per-system money output ----------------- // The formula is here and is verified; what is missing is `systemIncome`. We build the // inputs we do hold so the shape of the gap is visible, then stop. { sim::BudgetInputs in; in.savings = p.sav; in.ownsSystems = !p.owners.empty(); in.maintenance = p.maint; 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 stays empty: unmodelled input. 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]; if (opt.CommitBlocked("P02")) { 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 -- condition only ---------------------------------- if (p.resTNm.empty()) { bool anyAvailable = false; for (const auto& st : p.techTree.state) if (st.st == 2) { anyAvailable = true; break; } if (anyAvailable) { ++t.fired[11]; ++t.wouldWrite[11]; } } } // --------------------------------------------------------------------------------------- // 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; } // `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 // --------------------------------------------------------------------------------------- // 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; // 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); 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); } // 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[1]; 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"); 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)); break; } case 13: { // S13 PlayerTurn -- the nested driver for (auto& e : game.sim.players) RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt); 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 && pt.fired[11]) pr.notes.push_back(fmt("%d player(s) meet the no-research condition; the " "event is not posted", pt.fired[11])); 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 == 31) { RunUpdateBankruptcyLimits(game, opt, rec); } else if (tp[i].index == 36) { RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks); } r.records.push_back(rec); } // H01 SaveWriterInvariants. { PhaseRecord rec; rec.desc = &hp[2]; 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