sots-engine/src/app/turn.cpp
alex 5e409cfa05 lane A2: S04, the alliance mask -- 80 player-records, 560 fields, 0 mismatches
The spine's fourth phase, read byte-for-byte and implemented:

    almem[i] = (1 << i) | (ALid != -1 ? AL : 0)

with i the player's POSITION IN THE PLAYER VECTOR, not its index field. Both
inputs are on the wire and so is the output, through the turn-record archive,
so the phase is checkable against bytes the original wrote:

    app_turn_record: 11 saves, 80 player-records, 560 fields, 0 mismatches
                     (was 480 fields over six fields; almem is the seventh)

The eight zero masks of the corpus's earliest archived turn are PREDICTED, not
excluded: the archiving phase also runs on load, and the load path does not run
the spine. BuildTurnRecord takes spineRan and models it, so all 80 records are
compared.

Three parts of the rule the corpus cannot separate -- the bit index, the OR,
and the ALid guard -- are pinned in app_alliance with the separating inputs no
save provides, and app_turn_record prints that it could not separate them.

Divergence, closed and regressed reported separately:

    turn1->turn2  default          209 -> 204   closed 5, regressed 0
    turn1->turn2  --commit-blocked 209 -> 189   closed 29, regressed 9  (was 17)
    turn2->turn3  default          108 -> 103   closed 5, regressed 0
    turn2->turn3  --commit-blocked 108 -> 106   closed 13, regressed 11 (was 19)

T36 stays blocked: nine leaves would still be wrong (inc x3, sav x3 behind the
budget; three census leaves behind the design catalogue). It now closes all 24
turnstats leaves on the reference pair, so it becomes a clean +24 once those
two land.

Prediction and falsification committed first in 49ae628.
Gates run separately: clean-room OK; host ctest 43/43. No src/shim touched.
2026-09-08 12:44:00 -04:00

683 lines
31 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/turn_record.h"
#include "game/sim/colony.h"
#include "game/sim/economy.h"
#include "game/sim/numeric.h"
#include "game/sim/rng.h"
#include "game/sim/tuning.h"
namespace sots::app {
namespace {
using mars::stream::Node;
using mars::stream::shapes::Player;
using mars::stream::shapes::SaveGame;
using mars::stream::shapes::Sys;
std::string fmt(const char* f, ...) {
char buf[512];
va_list ap;
va_start(ap, f);
std::vsnprintf(buf, sizeof buf, f, ap);
va_end(ap);
return std::string(buf);
}
// A generator wrapper for the strategic-sim interface. Every draw is counted, so a phase
// can report its word cost even when the state is not committed.
class CountingRandom final : public sim::IRandom {
public:
explicit CountingRandom(mars::rng::MT19937& g) : g_(g) {}
float NextFloat() override {
++words_;
return g_.next_float();
}
std::uint32_t NextIntInclusive(std::uint32_t n) override {
// The rejection loop can spend more than one word; count what the generator moved.
const int before = consumed();
const std::uint32_t v = g_.next_int_inclusive(n);
words_ += consumed() - before;
return v;
}
std::uint32_t NextUInt32() override {
++words_;
return g_.next_u32();
}
int words() const { return words_; }
private:
// Words handed out since the block was twisted, monotone within a block; used only to
// count a rejection loop, which never spans more than one twist here.
int consumed() const { return mars::rng::MT19937::N - g_.left(); }
mars::rng::MT19937& g_;
int words_ = 0;
};
// ---------------------------------------------------------------------------------------
// The player driver
// ---------------------------------------------------------------------------------------
// Read a player's current research target out of the tech-state section. The target is
// named on the wire, so the lookup is by name; a player with no target has an empty name.
const mars::stream::shapes::TechState* FindTarget(const Player& p) {
if (p.resTNm.empty()) return nullptr;
for (const auto& st : p.techTree.state)
if (st.tNm == p.resTNm) return &st;
return nullptr;
}
struct PlayerPhaseTotals {
int writes[13] = {}; // indexed by phase number 1..12
int wouldWrite[13] = {};
int rng[13] = {};
int fired[13] = {}; // how many players the phase actually did something for
};
void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
PlayerPhaseTotals& t) {
// --- P01 ComputeBudget -- blocked on the per-system money output -----------------
// The formula is here and is verified; what is missing is `systemIncome`. We build the
// inputs we do hold so the shape of the gap is visible, then stop.
{
sim::BudgetInputs in;
in.savings = p.sav;
in.ownsSystems = !p.owners.empty();
in.maintenance = p.maint;
in.isAI = p.npc;
in.researchRate = p.resRate;
in.resMod = p.resMod;
in.shrm = p.shrm;
in.trm = p.trm;
in.resScl = p.resScl;
in.tra = p.tra;
in.trp = p.trp;
in.hasResearchTarget = !p.resTNm.empty();
for (const auto& e : p.nexp) {
sim::ExpenseSlider s;
s.minimum = e.xmin;
s.maximum = e.xmax;
s.fraction = e.xper;
in.expenses.push_back(s);
}
// in.systemIncome stays empty: unmodelled input.
const sim::Budget b = sim::ComputeBudget(in, /*projected=*/false);
const int wouldBe = sim::SaturatingAdd(p.sav, b.net);
++t.fired[1];
if (wouldBe != p.sav) ++t.wouldWrite[2];
if (opt.commitBlocked) {
p.sav = wouldBe;
++t.writes[2];
}
}
// --- P07 ClearTimedResearchAccumulators -----------------------------------------
if (p.trm != 0.f || p.tra != 0 || p.trp != 0) {
if (p.trm != 0.f) ++t.writes[7];
if (p.tra != 0) ++t.writes[7];
if (p.trp != 0) ++t.writes[7];
p.trm = 0.f;
p.tra = 0;
p.trp = 0;
++t.fired[7];
}
// --- P08 DecayRebellionOutputModifier -------------------------------------------
if (p.rebAI) {
const float before = p.rebOutMod;
float v = p.rebOutMod - 0.04f;
if (v < 1.0f) v = 1.0f;
if (v > 2.0f) v = 2.0f;
p.rebOutMod = v;
++t.fired[8];
if (before != v) ++t.writes[8];
}
// --- P09 AccumulateTimedResearchBonuses -----------------------------------------
// Iterated from the LAST element down to index 0; the order is part of the result
// because float addition is not associative.
if (!p.pr.empty()) {
++t.fired[9];
const float trmBefore = p.trm;
const std::size_t before = p.pr.size();
for (std::size_t i = p.pr.size(); i-- > 0;) {
p.trm = static_cast<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;
}
// ---------------------------------------------------------------------------------------
// 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
};
TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game) {
TurnRecordAudit a;
const std::int32_t inputTurn = game.sim.frame;
for (std::size_t i = 0; i < game.sim.players.size(); ++i) {
if (i >= game.sim.turnstats.players.size()) break;
const auto& hist = game.sim.turnstats.players[i].hist;
const auto* stored = FindArchivedRecord(hist, inputTurn);
if (!stored) {
++a.missingArchive;
continue;
}
int dangling = 0;
// The archiving phase runs both at the end of a turn and on load, and only the
// end-of-turn path has a spine behind it. The earliest turn the archive carries is
// the one the load path wrote, so its alliance mask is predicted to be zero.
const bool spineRan = inputTurn > EarliestArchivedTurn(hist);
const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems,
inputTurn, i, spineRan, &dangling);
a.dangling += dangling;
const TurnRecordDiff d = CompareTurnRecord(built, *stored);
++a.playersChecked;
a.fieldsCompared += d.compared;
a.mismatches += static_cast<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());
// Six fields per player would be written, plus a new archive element per player. Nothing
// is committed by default: five further fields of the same element are unmodelled, and
// the two the model DOES hold for the new turn -- savings and the income derived from it
// -- are downstream of a blocked phase, so every element written would be wrong in a way
// the untouched save is not. `--commit-blocked` writes them anyway, so the claim that
// committing makes things worse is a measurement rather than an argument.
int archived = 0;
for (std::size_t i = 0; i < game.sim.players.size(); ++i) {
if (i >= game.sim.turnstats.players.size()) break;
auto& hist = game.sim.turnstats.players[i].hist;
if (FindArchivedRecord(hist, game.sim.frame)) continue; // the key is the turn
if (!opt.commitBlocked) {
++archived;
continue;
}
// The record being archived is this turn's, and this turn ran the spine, so the
// alliance mask is the one phase S04 rebuilt rather than a zero from the load path.
const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems,
game.sim.frame, i, /*spineRan=*/true);
mars::stream::shapes::PlayerTurnStats s;
s.trn = built.turn;
// The mask the spine's phase 4 rebuilt earlier in this same turn, not a value
// recomputed here: the dependency between the two phases is real and is expressed.
s.almem = i < allianceMasks.size() ? allianceMasks[i] : built.allianceMask;
s.pop = built.population;
s.col = built.colonies;
s.sav = built.savings;
s.inc = built.income;
s.tch = built.completedTech;
// The census is three hull classes wide whether or not the player owns a ship; the
// counts are the unmodelled part, the shape is not.
for (std::int32_t c = 0; c < 3; ++c) {
mars::stream::shapes::ClassStats cs;
cs.cls = c;
s.classes.push_back(cs);
}
hist.stats.push_back(s);
++archived;
rec.leafWrites += 7;
}
rec.committed = rec.leafWrites > 0;
if (!opt.commitBlocked) rec.wouldWrite = archived * 7;
rec.notes.push_back(fmt("%s %d record(s) for turn %d; 7 modelled field(s) each",
opt.commitBlocked ? "ARCHIVED" : "would archive", archived,
game.sim.frame));
if (audit.playersChecked)
rec.notes.push_back(fmt("SELF-CHECK on the input turn, where the save carries the "
"answer: %d field(s) over %d player(s), %d mismatch(es)%s",
audit.fieldsCompared, audit.playersChecked, audit.mismatches,
audit.dangling ? " (owned-system ids missing from the table!)"
: ""));
for (const auto& m : audit.firstMismatches) rec.notes.push_back(m);
if (audit.missingArchive)
rec.notes.push_back(fmt("%d player(s) carry no archive element for the input turn",
audit.missingArchive));
std::size_t nUnmodelled = 0;
const char* const* un = TurnRecord::Unmodelled(nUnmodelled);
for (std::size_t i = 0; i < nUnmodelled; ++i) rec.notes.push_back(fmt("NOT modelled: %s", un[i]));
rec.notes.push_back("and the two fields the model does hold for the NEW turn -- savings and "
"the income derived from it -- come from a blocked phase, so committing "
"this would replace one container-shaped divergence per player with "
"several wrong leaves");
}
} // namespace
// ---------------------------------------------------------------------------------------
// Generator plumbing
// ---------------------------------------------------------------------------------------
bool LoadGenerator(const SaveGame& game, mars::rng::MT19937& out) {
const Node& f = game.sim.rng;
if (!f.is_complex() || f.children.size() != 1) return false;
const Node& blob = f.children[0];
if (blob.kind != mars::stream::Kind::Raw) return false;
return out.load_state(blob.raw.data(), blob.raw.size());
}
bool StoreGenerator(SaveGame& game, const mars::rng::MT19937& gen) {
Node& f = game.sim.rng;
if (!f.is_complex() || f.children.size() != 1) return false;
Node& blob = f.children[0];
if (blob.kind != mars::stream::Kind::Raw || blob.raw.size() < mars::rng::MT19937::kStateBytes)
return false;
// The blob carries three trailing pad bytes past the state; they are preserved.
std::uint8_t tmp[mars::rng::MT19937::kStateBytes];
gen.save_state(tmp);
std::memcpy(blob.raw.data(), tmp, sizeof tmp);
return true;
}
void ApplySaveWriterInvariants(SaveGame& game, TurnResult& r) {
// Observed on every save in the corpus: the summary's turn number equals the
// simulation's frame counter. The summary is rebuilt by the writer, not by a turn
// phase, so it belongs here rather than in the phase catalog.
if (game.summary.turn != game.sim.frame) {
game.summary.turn = game.sim.frame;
++r.leafWrites;
}
}
// ---------------------------------------------------------------------------------------
// The runner
// ---------------------------------------------------------------------------------------
TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
TurnResult r;
// Taken before any phase runs: the turn-record model is checked against the record the
// INPUT save already carries for its own turn. None of the six fields it reads is written
// by a phase below, but the check is taken first so that stays true by construction.
const TurnRecordAudit recordAudit = AuditTurnRecordsAgainstSave(game);
mars::rng::MT19937 gen(1u);
r.rngLoaded = LoadGenerator(game, gen);
if (!r.rngLoaded)
r.warnings.push_back("generator state could not be read from the save; drawing phases "
"will report themselves as unable to draw");
CountingRandom rng(gen);
std::size_t nh = 0, ns = 0, np = 0;
const PhaseDesc* hp = HostPhases(nh);
const PhaseDesc* sp = StrategicPhases(ns);
const PhaseDesc* pp = PlayerPhases(np);
// H00 BeginProcessTurn: the frame counter, which is the turn number the game displays.
{
PhaseRecord rec;
rec.desc = &hp[0];
++game.sim.frame;
rec.invocations = 1;
rec.leafWrites = 1;
rec.committed = true;
rec.notes.push_back(fmt("Frame -> %d", game.sim.frame));
r.records.push_back(rec);
}
PlayerPhaseTotals pt;
SystemTotals st;
bool playerDriverRan = false;
// Filled by S04 and consumed by the tail's archiving phase. Empty until S04 runs, which
// is what makes the ordering between the two visible rather than assumed.
std::vector<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 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) e.player.status = 1;
}
rec.invocations = static_cast<int>(game.sim.players.size());
rec.leafWrites = opt.commitBlocked ? n : 0;
rec.wouldWrite = opt.commitBlocked ? 0 : n;
rec.committed = rec.leafWrites > 0;
rec.notes.push_back(fmt("%d player status word(s) would be restored to 1", n));
rec.notes.push_back("MEASURED: the phase writes 1, the post-turn file carries "
"4, and a load resets it to 0. Writing the 1 REGRESSED two "
"agreeing leaves on the turn2->turn3 pair, so the write is "
"held back until the writer that produces the 4 is found");
break;
}
default:
break; // named no-op
}
r.records.push_back(rec);
// The player driver's own phases are listed immediately after the phase that runs
// them, so the printed log is the turn in execution order.
if (sp[i].index == 13 && playerDriverRan) {
for (std::size_t k = 0; k < np; ++k) {
PhaseRecord pr;
pr.desc = &pp[k];
const int idx = pp[k].index;
pr.invocations = pt.fired[idx];
pr.leafWrites = pt.writes[idx];
pr.wouldWrite = pt.wouldWrite[idx];
pr.rngWords = pt.rng[idx];
pr.committed = pr.leafWrites > 0;
if (idx == 1)
pr.notes.push_back(fmt("budget computed for %d player(s) with an EMPTY "
"system-income vector; nothing committed",
pt.fired[1]));
if (idx == 2 && pt.wouldWrite[2])
pr.notes.push_back(fmt("%d player(s) would have had savings rewritten",
pt.wouldWrite[2]));
if (idx == 10)
pr.notes.push_back(fmt("%d player(s) held both a target and the pending "
"flag; %d roll(s) fired",
pt.fired[10], pt.writes[10]));
if (idx == 11 && pt.fired[11])
pr.notes.push_back(fmt("%d player(s) meet the no-research condition; the "
"event is not posted",
pt.fired[11]));
r.records.push_back(pr);
}
}
}
// The tail is a separate driver reached from a different message. Nothing in it is
// implemented except the counter bump, so it is listed rather than run -- but it is
// listed, because the autosave is written after it and two of its phases draw.
std::size_t nt = 0;
const PhaseDesc* tp = TailPhases(nt);
for (std::size_t i = 0; i < nt; ++i) {
PhaseRecord rec;
rec.desc = &tp[i];
if (tp[i].index == 0) {
++game.sim.modCount;
rec.invocations = 1;
rec.leafWrites = 1;
rec.committed = true;
rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount));
} else if (tp[i].index == 36) {
RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks);
}
r.records.push_back(rec);
}
// H01 SaveWriterInvariants.
{
PhaseRecord rec;
rec.desc = &hp[1];
const int before = game.summary.turn;
ApplySaveWriterInvariants(game, r);
rec.invocations = 1;
rec.leafWrites = game.summary.turn != before ? 1 : 0;
rec.committed = rec.leafWrites > 0;
rec.notes.push_back(fmt("Summary.Turn -> %d", game.summary.turn));
r.records.push_back(rec);
}
// ApplySaveWriterInvariants counts its own write; zero the accumulator before the fold so
// the per-phase records are the single source of the total.
r.leafWrites = 0;
for (const auto& rec : r.records) {
r.leafWrites += rec.leafWrites;
r.wouldWrite += rec.wouldWrite;
r.rngWords += rec.rngWords;
}
// The RNG ledger, stated the way the campaign states divergence: what is accounted and
// what is not, never netted into one number. A measured turn on the reference save costs
// 18-22 words; what is modelled here is the trade-raid block and the research-event roll.
r.rngUnaccounted.push_back(
"encounter detection draws one unit value and one bounded integer per turn on every "
"turn measured (2 words), with no derived rule behind the count -- its bound is the "
"product of the contact and detector counts, so it is left unmodelled");
r.rngUnaccounted.push_back(
"the research-allocation draw is downstream of the budget, which is blocked on the "
"per-system money output (0 or 1 word)");
if (r.rngLoaded && r.rngWords > 0)
r.rngUnaccounted.push_back(
"a successful raid roll may draw one further word to pick its target; no roll "
"succeeded on any measured turn, so the cost of a success is 0 or 1 and undetermined");
if (opt.commitRng && r.rngLoaded) {
if (!StoreGenerator(game, gen))
r.warnings.push_back("generator state could not be written back");
else
r.rngCommitted = true;
} else if (r.rngWords > 0) {
r.warnings.push_back(
"the generator advanced during this run but the save keeps its original state "
"(--commit-rng to write it)");
}
if (r.rngLoaded)
r.warnings.push_back(
"the modelled words are a LOWER BOUND on the turn's cost, so a committed generator "
"is short by the unaccounted sites below and its drawn VALUES are not the game's");
return r;
}
} // namespace sots::app