sots-engine/src/app/turn.cpp
alex 98257b9e82 RB: sots_turn --turn-commands replays a recorded command stream, and ModCount closes
The milestone needs a second input. The AI is a client, not part of the sim: it decides once,
on one machine, and its decisions reach the server as commands. A save carries the board and
half the input, which is why our turn wrote ModCount 14 where the original writes 24 -- the
missing ten ARE the turn's command stream.

  * `game/ai/apply_order` -- the thirty-step schedule the original drains a batch in: twenty-
    seven per-LIST steps (every player's elements of one list before the next list starts) with
    three per-PLAYER gate loops spliced in at step 10, 29 and 30. Neither list order nor member
    order, and both facts are asserted so a port that sorted cannot pass.
  * `game/ai/command_capture` -- a `.tcb` recorded turn: gates, list lengths, elements in wire
    order, per-client seeds, and `?` for a field the instrument could not read. An element count
    that disagrees with its declaration is REJECTED, because a counter quietly one short is
    indistinguishable from a turn that issued one fewer command.
  * `app/command_replay` -- applies it before the drivers, where the End-Turn dispatcher does.
    Every command is CHARGED; only the ones whose subsystem we hold are APPLIED; the rest are
    declined with the named gap, or marked incomplete when the capture itself lacks the payload.
  * `--turn-commands`, `--replay-count-only`, `--replay-recorded-names`, `--ai-seed`.

Measured on a fresh build directory, canonical pair turn2-state -> turn3-state:
  108 -> 62, closed 46, regressed 0  (was 108 -> 63, closed 45) -- /Sim/ModCount now reads the
  original's 24, decomposed as 2 drivers + 4 research-rate gates + build + rates + list 10 +
  two list-14 + fleet move, with the list-23 population element free.

turn1-state replayed against the SAME run's autosave closes 7 (ModCount and all six research
leaves); against the historical turn2-state it closes 6 and leaves player 512's research pick
diverging -- which is correct, because that recording is from a process that picked differently.

One prediction was falsified and it paid for itself: the first run regressed two leaves because
the rates element's MEMORY field order is not its wire order. The converter no longer claims a
mapping it cannot support.

Two new addresses (the second and third gate-loop heads) via ghidra/addresses.d/lane-rb.json;
header regenerated, never hand-resolved.
2026-09-08 18:57:46 -04:00

1428 lines
72 KiB
C++

#include "app/turn.h"
#include <algorithm>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <string>
#include "app/alliance.h"
#include "app/construction_phase.h"
#include "app/event_phase.h"
#include "app/growth_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<int> 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<float>(static_cast<double>(p.trm) + static_cast<double>(p.pr[i].prm));
if (--p.pr[i].prbt <= 0) p.pr.erase(p.pr.begin() + static_cast<long>(i));
}
if (p.trm != trmBefore) ++t.writes[9];
t.writes[9] += static_cast<int>(before - p.pr.size());
t.writes[9] += static_cast<int>(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<float>(static_cast<double>(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<float>(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<float> idealSuit; // the Sim block's ISsu array, indexed by species
double serverIncomeMod = 1.0; // the Sim block's `IncMod`
sim::TuningTable tuning; // unloaded: see above
};
// Sum of `PopC` over the (group, species) rows of one Population node.
std::int64_t PopCount(const mars::stream::shapes::Population& p, int group, int species) {
std::int64_t n = 0;
for (const auto& g : p.groups)
if (g.popT == group && g.popS == species) n += g.popC;
return n;
}
int MoraleOf(const mars::stream::shapes::Morale& m, int species) {
for (const auto& e : m.entries)
if (e.msp == species) return e.mv;
return 0;
}
bool AddictedTo(const std::vector<mars::stream::shapes::AdctEntry>& a, int species) {
for (const auto& e : a)
if (e.ads == species && e.adt != 0) return true;
return false;
}
// 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<sim::Species>(owner.species);
const std::int64_t resAvail =
s.res + (owner.aMine ? static_cast<std::int64_t>(s.mRes) + s.aRes2 : 0);
const std::int64_t imperial = static_cast<std::int64_t>(s.pop) + s.pbon;
// --- the output total (lane N's term) ---
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<int>(ctx.idealSuit.size())
? ctx.idealSuit[static_cast<std::size_t>(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<double>(s.suit) == ideal;
in.infraFull = sim::F32(static_cast<double>(s.ibon) + s.infra) >= 1.0;
// The leftover split's own infrastructure test reads the RAW Infra against 1.
in.infraExactlyOne = static_cast<double>(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<double>(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<PlayerBudgetFeed> 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<const Sys*> byId;
std::vector<std::int32_t> ids;
for (const auto& e : game.sim.systems) {
ids.push_back(e.sysID);
byId.push_back(&e.sys);
}
const auto find = [&](std::int32_t id) -> const Sys* {
for (std::size_t i = 0; i < ids.size(); ++i)
if (ids[i] == id) return byId[i];
return nullptr;
};
std::vector<PlayerBudgetFeed> 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<int>(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<std::int32_t> ids_;
std::vector<const Sys*> 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<sim::DifficultyColumnEvidence> 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<sim::DifficultyColumnEvidence> 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<sim::DifficultyColumnEvidence>& 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<std::string> 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<int>(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<std::int32_t>& allianceMasks) {
rec.invocations = static_cast<int>(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<std::size_t>(c)];
cs.satt = built.platforms[static_cast<std::size_t>(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<sim::DifficultyColumnEvidence> 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<std::int32_t> 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<int>(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<int>(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<PlayerBudgetFeed> 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<int>(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<int>(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<int>(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 == 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