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.
1428 lines
72 KiB
C++
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
|