#include "app/turn.h" #include #include #include #include #include #include #include "app/alliance.h" #include "app/trade_raid.h" #include "app/turn_record.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 { 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) { 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; } // --------------------------------------------------------------------------------------- // 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 }; TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game) { TurnRecordAudit a; 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); 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()); // Six fields per player would be written, plus a new archive element per player. Nothing // is committed by default: five further fields of the same element are unmodelled, and // the two the model DOES hold for the new turn -- savings and the income derived from it // -- are downstream of a blocked phase, so every element written would be wrong in a way // the untouched save is not. `--commit-blocked` writes them anyway, so the claim that // committing makes things worse is a measurement rather than 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 (!opt.commitBlocked) { ++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); 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; the // counts are the unmodelled part, the shape is not. for (std::int32_t c = 0; c < 3; ++c) { mars::stream::shapes::ClassStats cs; cs.cls = c; s.classes.push_back(cs); } hist.stats.push_back(s); ++archived; rec.leafWrites += 7; } rec.committed = rec.leafWrites > 0; if (!opt.commitBlocked) rec.wouldWrite = archived * 7; rec.notes.push_back(fmt("%s %d record(s) for turn %d; 7 modelled field(s) each", opt.commitBlocked ? "ARCHIVED" : "would archive", archived, game.sim.frame)); 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", audit.fieldsCompared, audit.playersChecked, audit.mismatches, audit.dangling ? " (owned-system ids missing from the table!)" : "")); 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)); 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("and the two fields the model does hold for the NEW turn -- savings and " "the income derived from it -- come from a blocked phase, so committing " "this would replace one container-shaped divergence per player with " "several wrong leaves"); } } // 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 six fields it reads is written // by a phase below, but the check is taken first so that stays true by construction. const TurnRecordAudit recordAudit = AuditTurnRecordsAgainstSave(game); 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); } 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 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) e.player.status = 1; } rec.invocations = static_cast(game.sim.players.size()); rec.leafWrites = opt.commitBlocked ? n : 0; rec.wouldWrite = opt.commitBlocked ? 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 == 36) { RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks); } r.records.push_back(rec); } // H01 SaveWriterInvariants. { PhaseRecord rec; rec.desc = &hp[1]; 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( "the research-allocation draw is downstream of the budget, which is blocked on the " "per-system money output (0 or 1 word)"); 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