H02 StampTreatyTurns -- the diplomacy ledger's "this treaty was last in force on turn N" stamp, over every ordered pair of players that holds one, creating the entry on demand. It is a host step, not a phase of either turn driver: its sole caller is the command-application step, which runs after the frame counter has advanced and before both drivers. On a turn with no combat and no diplomatic command it is the only writer of these fields, which is why a whole per-player dipstats vector is its output. Read from the instruction stream; three things an earlier reading had wrong are corrected in findings/subsystems/treaty-turn-stamp.md: the stamped value is the TURN and not the modification counter, the relation codes are 3=allied / 2=NAP / 1=cease-fire and not the reverse, and the bit is the player's index field rather than its position in the player vector -- the opposite convention from the shared-vision mask two files away. Measured, closed and regressed reported separately and never netted: turn1 -> turn2 (reference) 209 -> 132 closed 77 (was 51), regressed 0 turn2 -> turn3 108 -> 73 closed 35 (was 21), regressed 0 human-turn2 -> turn3 closed 76 (was 64), regressed 0 zuul-turn15 -> turn16 closed 30 (was 18), regressed 0 zuul-turn16 -> turn17 closed 29 (was 17), regressed 0 The last three are pairs from a different game at turns 2, 15 and 16 that the model was never fitted to, and it closes exactly the twelve ordered treaty pairs each of them holds. The rule also reproduces the ledger of ten of the eleven corpus saves entry for entry, including each entry's order and every stamped value; the eleventh is the turn-1 save whose ledger no turn has yet written, and it is the reference pair's input. app_test_treaty pins the five things the corpus cannot separate: the relation codes (no save exercises cease-fire), the bit's source, the missing alliance-id guard, the -1 initialiser on a fresh entry, and the append-at-the-end order that makes re-running the step idempotent instead of duplicating rows. The betrayal half of the same function needs the turn's diplomatic commands; with no command stream it is provably a no-op and is not modelled.
1012 lines
49 KiB
C++
1012 lines
49 KiB
C++
#include "app/turn.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdarg>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
|
|
#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<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 -- 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<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;
|
|
}
|
|
|
|
// `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<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) ---
|
|
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<int>(ctx.idealSuit.size())
|
|
? ctx.idealSuit[static_cast<std::size_t>(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<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;
|
|
};
|
|
|
|
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<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;
|
|
|
|
// 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<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");
|
|
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));
|
|
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<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 && 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
|