Y: model the turn's dominant generator cost, and the tail's turn record

The trade-raid block is 16 of a measured turn's 18-22 generator words and it is the
first thing the standalone can model as a COUNT rather than as a formula: two chance
rolls per entry of the player vector, neither site inside a back edge, both
probabilities strictly inside (0,1) so neither early-out fires. src/app/trade_raid
implements it with the word cost reported rather than assumed, so a tuning table that
pushes an odds value to 0 or 1 removes the draw and the ledger says so.

Against lane Z's two calibrated oracle pairs the standalone now consumes 16 words and
lands 4 and 2 short, which is exactly the per-call-site ledger's split for those two
turns. It does NOT match the oracle's state, and the report says which sites are
missing instead of netting them off the total.

The tail's last phase -- the per-player turn record -- is modelled for the six fields
that are recoverable from the wire, and self-checked every run against the record the
input save already carries for its own turn: 480 fields over 80 player-records across
the corpus, 0 mismatches. It is not committed. Under --commit-blocked it closes 24
container-shaped divergences on the reference pair and opens 17 leaf-shaped ones, all
of them in the five fields the model does not hold, so the block is a measurement now
rather than an argument.

phases 14/44 of the two turn drivers (8 committed), 3/37 of the tail.
divergence unchanged: 209->204 and 108->103, 5 closed / 0 regressed on both.
This commit is contained in:
lane Y 2026-09-08 11:33:36 -04:00
parent 20e5112398
commit a98ae6db5b
12 changed files with 702 additions and 13 deletions

View file

@ -5,6 +5,8 @@
add_library(sots_app STATIC
phase_catalog.cpp
trade_raid.cpp
turn_record.cpp
turn.cpp
report.cpp)
target_include_directories(sots_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..)

View file

@ -107,11 +107,14 @@ constexpr PhaseDesc kStrategic[] = {
"draw-free, and the cheapest unimplemented phase in this table"},
{Driver::Strategic, 29, "S29", "SystemTailFixup", PhaseStatus::Stub, ""},
{Driver::Strategic, 30, "S30", "BuildTeamPartition", PhaseStatus::Stub, ""},
{Driver::Strategic, 31, "S31", "EncounterDetectionAndStatusRestore", PhaseStatus::Blocked,
"encounter detection is not modelled. The player-status restore that follows it IS -- it "
"writes 1 -- but the value the file carries is 4, so a further writer between this phase "
"and the autosave is unaccounted. Committing the 1 turned two agreeing leaves into "
"disagreeing ones on the turn2->turn3 pair, so it is evaluated and reported instead"},
{Driver::Strategic, 31, "S31", "EncounterDetectionAndStatusRestore", PhaseStatus::Partial,
"trade-raid generation runs first here and IS modelled: two chances per player, one word "
"each, neither site inside a back edge, so the count is a bound -- it is the turn's "
"dominant generator cost and it is committed under --commit-rng. Detection proper is not "
"modelled and spends two further words. The player-status restore that follows it IS "
"modelled -- it writes 1 -- but the value the file carries is 4, so a further writer "
"between this phase and the autosave is unaccounted. Committing the 1 turned two agreeing "
"leaves into disagreeing ones on the turn2->turn3 pair, so it is evaluated and reported"},
};
// ---------------------------------------------------------------------------------------
@ -209,8 +212,13 @@ constexpr PhaseDesc kTail[] = {
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
{Driver::Tail, 35, "T35", "RebuildPlayerReports", PhaseStatus::Stub, ""},
{Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Stub,
"fills every player's turn record and archives it by turn; must stay last"},
{Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Blocked,
"fills every player's turn record and archives it by turn; must stay last. Six of its "
"fields are recoverable from the wire and are reproduced -- turn, colony count, savings, "
"the savings delta, the completed-tech count and the summed population -- and the model is "
"self-checked every run against the record the input save already carries for its own "
"turn. Not committed: five further fields of the same record are unmodelled, and savings "
"for the NEW turn comes from a blocked phase"},
};
PhaseTally Tally(const PhaseDesc* p, std::size_t n) {

View file

@ -75,8 +75,13 @@ void PrintSummary(std::FILE* out, const TurnResult& r) {
std::fprintf(out, "\nthis run\n");
std::fprintf(out, " leaves written %d\n", r.leafWrites);
std::fprintf(out, " leaves NOT written by a blocked phase %d\n", r.wouldWrite);
std::fprintf(out, " generator words consumed %d (state %s)\n", r.rngWords,
r.rngLoaded ? "loaded" : "UNREADABLE");
std::fprintf(out, " generator words consumed %d (state %s, %s)\n", r.rngWords,
r.rngLoaded ? "loaded" : "UNREADABLE",
r.rngCommitted ? "WRITTEN BACK" : "left untouched");
if (!r.rngUnaccounted.empty()) {
std::fprintf(out, " generator words NOT accounted (never netted off the above):\n");
for (const auto& u : r.rngUnaccounted) std::fprintf(out, " - %s\n", u.c_str());
}
for (const auto& w : r.warnings) std::fprintf(out, " ! %s\n", w.c_str());
}
@ -96,7 +101,11 @@ bool WriteMetricJson(const std::string& path, const TurnResult& r, const std::st
f << ",\n";
f << " \"run\": {\"leafWrites\": " << r.leafWrites << ", \"blockedLeafWrites\": "
<< r.wouldWrite << ", \"rngWords\": " << r.rngWords << ", \"rngLoaded\": "
<< (r.rngLoaded ? "true" : "false") << "},\n";
<< (r.rngLoaded ? "true" : "false") << ", \"rngCommitted\": "
<< (r.rngCommitted ? "true" : "false") << ", \"rngUnaccounted\": [";
for (std::size_t i = 0; i < r.rngUnaccounted.size(); ++i)
f << (i ? ", " : "") << '"' << JsonEscape(r.rngUnaccounted[i]) << '"';
f << "]},\n";
f << " \"phases\": [\n";
bool first = true;
for (const auto& rec : r.records) {

37
src/app/trade_raid.cpp Normal file
View file

@ -0,0 +1,37 @@
#include "app/trade_raid.h"
namespace sots::app {
namespace {
// The chance helper with its word cost reported rather than assumed: the two early-outs
// return WITHOUT drawing, so a probability outside (0, 1) costs nothing. Counting the draws
// instead of the calls is what keeps the ledger honest if a loaded tuning table ever pushes
// one of the three odds to 0 or 1.
bool ChanceCounted(sots::sim::IRandom& rng, float p, int& words) {
if (p <= 0.f) return false;
if (p >= 1.f) return true;
++words;
return p > rng.NextFloat();
}
} // namespace
bool Chance(sots::sim::IRandom& rng, float p) {
int ignored = 0;
return ChanceCounted(rng, p, ignored);
}
TradeRaidResult RollTradeRaids(sots::sim::IRandom& rng, int players, const TradeRaidOdds& odds,
const TradeRaidGates& gates) {
TradeRaidResult r;
if (players <= 0) return r;
r.players = players;
for (int i = 0; i < players; ++i) {
if (ChanceCounted(rng, odds.player, r.words)) ++r.playerRaidHits;
if (gates.npcRaids && ChanceCounted(rng, odds.npc, r.words)) ++r.npcRaidHits;
if (gates.refugeeRaids && ChanceCounted(rng, odds.refugee, r.words)) ++r.refugeeRaidHits;
}
return r;
}
} // namespace sots::app

55
src/app/trade_raid.h Normal file
View file

@ -0,0 +1,55 @@
// Trade-raid encounter generation -- the strategic turn's dominant RNG consumer.
//
// The original runs this from inside encounter detection, over the server's player vector,
// rolling up to three chances per player. Two of the three roll on every turn measured; the
// third is behind a subsystem that was not present. The important property, and the reason
// this is modelled at all while the rest of encounter detection is not, is that NO BACK EDGE
// contains any of the three roll sites: the cost is one word per player per firing site, as a
// bound rather than as an observation.
//
// The probabilities are simulation constants that come from the game data. Their in-image
// defaults are used when no tuning table is loaded; either way all three are strictly inside
// (0, 1), so the chance helper takes neither of its early-outs and every roll is exactly one
// word. That is why the WORD COUNT is independent of the probabilities and the RAID DECISIONS
// are not.
#pragma once
#include "game/sim/rng.h"
namespace sots::app {
// The chance helper's contract, transcribed: p <= 0 is false with NO draw, p >= 1 is true with
// NO draw, otherwise exactly one draw and the test is `p > r` -- equality is a failure. A NaN
// probability falls through both early-outs and draws.
bool Chance(sots::sim::IRandom& rng, float p);
struct TradeRaidOdds {
// In-image defaults. A loaded tuning table overrides them; the word cost does not move.
float player = 0.2f;
float npc = 0.05f;
float refugee = 0.05f;
};
struct TradeRaidGates {
// The NPC-raid roll is behind a single server-level test that does not depend on the
// player, so it is all-or-nothing for the whole turn. Measured open on 8 of 8 turns
// across two saves; it is not on the wire, so this is a HYPOTHESIS with a stated default.
bool npcRaids = true;
// The refugee-raid roll needs a subsystem manager that was absent on every measured turn.
bool refugeeRaids = false;
};
struct TradeRaidResult {
int players = 0; // entries of the player vector the loop visited
int words = 0; // generator words consumed
int playerRaidHits = 0; // rolls that SUCCEEDED -- each one may cost a further word we do
int npcRaidHits = 0; // not model (the target-selection draw), so a non-zero total
int refugeeRaidHits = 0;// here is the signal that the ledger is about to fall short
};
// Roll the raid chances for `players` players, in the original's order: player raid, then NPC
// raid, then refugee raid, per player, before moving to the next player.
TradeRaidResult RollTradeRaids(sots::sim::IRandom& rng, int players, const TradeRaidOdds& odds,
const TradeRaidGates& gates);
} // namespace sots::app

View file

@ -7,6 +7,8 @@
#include <cstring>
#include <string>
#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"
@ -253,6 +255,111 @@ void RunSystemTurn(Sys& s, int playerCount, SystemTotals& t) {
(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;
const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems,
inputTurn, &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) {
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;
}
const TurnRecord built =
BuildTurnRecord(game.sim.players[i].player, game.sim.systems, game.sim.frame);
mars::stream::shapes::PlayerTurnStats s;
s.trn = built.turn;
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 += 6;
}
rec.committed = rec.leafWrites > 0;
if (!opt.commitBlocked) rec.wouldWrite = archived * 6;
rec.notes.push_back(fmt("%s %d record(s) for turn %d; 6 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
// ---------------------------------------------------------------------------------------
@ -297,6 +404,11 @@ void ApplySaveWriterInvariants(SaveGame& game, TurnResult& r) {
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)
@ -374,6 +486,30 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
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.
@ -447,6 +583,8 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
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);
}
r.records.push_back(rec);
}
@ -472,14 +610,35 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
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); the turn's full draw count is not yet attributed");
"(--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;
}

View file

@ -44,8 +44,13 @@ struct TurnResult {
std::vector<PhaseRecord> records; // spine order, with player phases nested after S13
int leafWrites = 0;
int wouldWrite = 0;
int rngWords = 0;
int rngWords = 0; // words this run actually consumed -- a LOWER bound on the turn's
bool rngLoaded = false;
bool rngCommitted = false;
// Draw sites a real turn spends words at that this model does not reproduce. Kept next to
// rngWords and never subtracted from it: an accounted count and an unaccounted list are
// two different facts, and folding them into one hides which is which.
std::vector<std::string> rngUnaccounted;
std::vector<std::string> warnings;
};

87
src/app/turn_record.cpp Normal file
View file

@ -0,0 +1,87 @@
#include "app/turn_record.h"
#include <cstdio>
namespace sots::app {
namespace {
constexpr const char* kUnmodelled[] = {
"alliance/vision mask -- rebuilt by the alliance-mask phase of the spine, which is a stub",
"trade income -- a budget-derived field, blocked behind the per-system money output",
"battles fought -- written by the tail's battle-tally phase, which is a stub",
"systems acquired / lost this turn -- the two counted lists are never non-empty in the "
"corpus, so their element meaning is unobserved",
"the per-hull-class ship census -- needs each design's hull size and its class flag, "
"neither of which is on the wire; both come from the game data",
};
} // namespace
const char* const* TurnRecord::Unmodelled(std::size_t& count) {
count = sizeof(kUnmodelled) / sizeof(kUnmodelled[0]);
return kUnmodelled;
}
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
const std::vector<mars::stream::shapes::SysEntry>& systems,
std::int32_t frame, int* danglingOwnedSystems) {
TurnRecord r;
r.turn = frame;
r.savings = p.sav;
// The turn's income is the change in savings across the turn, not a budget line: the
// previous-turn savings word is on the wire and is stamped before this turn's savings are
// written, so the subtraction is exact.
r.income = p.sav - p.pvSav;
r.colonies = static_cast<std::int32_t>(p.owners.size());
for (const auto& st : p.techTree.state)
if (st.st == kTechStateCompleted) ++r.completedTech;
// The population term is a sum of TWO per-system words: the live population and the
// pending population bonus that has not been folded into it yet. Both are signed 32-bit
// and the accumulator is 64-bit, so the sum is taken in 64-bit and never wraps.
int dangling = 0;
for (std::int32_t id : p.owners) {
const mars::stream::shapes::Sys* sys = nullptr;
for (const auto& e : systems)
if (e.sysID == id) {
sys = &e.sys;
break;
}
if (!sys) {
++dangling;
continue;
}
r.population += static_cast<std::int64_t>(sys->pop) + static_cast<std::int64_t>(sys->pbon);
}
if (danglingOwnedSystems) *danglingOwnedSystems = dangling;
return r;
}
const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord(
const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn) {
for (const auto& s : hist.stats)
if (s.trn == turn) return &s;
return nullptr;
}
TurnRecordDiff CompareTurnRecord(const TurnRecord& built,
const mars::stream::shapes::PlayerTurnStats& stored) {
TurnRecordDiff d;
auto cmp = [&](const char* field, long long a, long long b) {
++d.compared;
if (a != b) {
char buf[160];
std::snprintf(buf, sizeof buf, "%s: built %lld != stored %lld", field, a, b);
d.mismatches.push_back(buf);
}
};
cmp("trn", built.turn, stored.trn);
cmp("pop", built.population, stored.pop);
cmp("col", built.colonies, stored.col);
cmp("sav", built.savings, stored.sav);
cmp("inc", built.income, stored.inc);
cmp("tch", built.completedTech, stored.tch);
return d;
}
} // namespace sots::app

62
src/app/turn_record.h Normal file
View file

@ -0,0 +1,62 @@
// The per-player turn record -- the last phase of the post-combat tail.
//
// Each player carries a small "what my empire looked like this turn" summary that the final
// tail phase fills and then copies into a history archive keyed by the turn number. The
// archive IS on the wire: it is the per-player turn-statistics history, one element per turn,
// and every save carries an element for its own frame. That makes this phase testable without
// a running game: build the record from a save's own state and compare it with the element the
// save already holds for that turn.
//
// Six fields are recoverable from the wire and are reproduced here. Five are not, and each is
// named with the reason -- they belong to phases or inputs the standalone does not hold.
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "mars/stream/shapes.h"
namespace sots::app {
// The part of a turn record this model can produce.
struct TurnRecord {
std::int32_t turn = 0; // the frame counter
std::int64_t population = 0; // summed over owned systems
std::int32_t colonies = 0; // owned-system count
std::int32_t savings = 0;
std::int32_t income = 0; // savings minus previous-turn savings
std::int32_t completedTech = 0; // tech-tree entries in the completed state
// Fields the archive element also carries that this model does NOT fill, kept as a
// published list rather than as silence. Each is blocked on something named.
static const char* const* Unmodelled(std::size_t& count);
};
// The completed state's value in the tech-tree state word. Named rather than spelled inline
// because it is the one magic number in this file.
constexpr std::int32_t kTechStateCompleted = 4;
// Build one player's record from the simulation state. `systemsById` is the save's system
// table indexed the way the player's owned-system ids index it.
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
const std::vector<mars::stream::shapes::SysEntry>& systems,
std::int32_t frame,
// set when an owned-system id is not present in the table, which
// would silently drop a term from the population sum
int* danglingOwnedSystems = nullptr);
// What the archive element for `turn` holds, if the save carries one for that turn.
const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord(
const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn);
// A comparison of a built record against a stored one, over the six modelled fields only.
struct TurnRecordDiff {
int compared = 0;
std::vector<std::string> mismatches; // "field: built != stored"
bool ok() const { return mismatches.empty(); }
};
TurnRecordDiff CompareTurnRecord(const TurnRecord& built,
const mars::stream::shapes::PlayerTurnStats& stored);
} // namespace sots::app

View file

@ -11,7 +11,17 @@ add_executable(app_test_turn test_turn.cpp)
target_link_libraries(app_test_turn PRIVATE sots_app)
add_test(NAME app_turn COMMAND app_test_turn)
foreach(_t app_test_catalog app_test_turn)
# The trade-raid word count is pinned against a scripted generator, so it always runs.
add_executable(app_test_trade_raid test_trade_raid.cpp)
target_link_libraries(app_test_trade_raid PRIVATE sots_app)
add_test(NAME app_trade_raid COMMAND app_test_trade_raid)
# The turn-record model against the record the game itself archived; needs the owner's saves.
add_executable(app_test_turn_record test_turn_record.cpp)
target_link_libraries(app_test_turn_record PRIVATE sots_app)
add_test(NAME app_turn_record COMMAND app_test_turn_record)
foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_turn_record)
target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic)
endforeach()

View file

@ -0,0 +1,142 @@
// The trade-raid block is the strategic turn's dominant generator cost, so its WORD COUNT is
// load-bearing in a way its decisions are not. These tests pin the count, the two no-draw
// early-outs of the chance helper, and the roll order -- all against a scripted generator, so
// nothing here depends on the real MT19937.
#include <cstdio>
#include <vector>
#include "app/trade_raid.h"
#include "mars/rng/mt19937.h"
static int failures = 0;
#define CHECK(c) \
do { \
if (!(c)) { \
std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \
++failures; \
} \
} while (0)
namespace {
// A generator that hands out a scripted sequence and counts what it handed out.
class Scripted final : public sots::sim::IRandom {
public:
explicit Scripted(std::vector<float> f) : f_(std::move(f)) {}
float NextFloat() override {
++draws;
return f_.empty() ? 0.f : f_[(cursor_++) % f_.size()];
}
std::uint32_t NextIntInclusive(std::uint32_t) override {
++draws;
return 0;
}
std::uint32_t NextUInt32() override {
++draws;
return 0;
}
int draws = 0;
private:
std::vector<float> f_;
std::size_t cursor_ = 0;
};
} // namespace
int main() {
using namespace sots::app;
// --- the chance helper's two no-draw early-outs ------------------------------------
{
Scripted g({0.5f});
CHECK(Chance(g, 0.f) == false);
CHECK(Chance(g, -1.f) == false);
CHECK(Chance(g, 1.f) == true);
CHECK(Chance(g, 2.f) == true);
CHECK(g.draws == 0); // four calls, zero words: both early-outs return before drawing
}
// --- the comparison is `p > r`, so equality FAILS and still costs a word -----------
{
Scripted g({0.25f, 0.25f});
CHECK(Chance(g, 0.25f) == false); // p == r -> false
CHECK(g.draws == 1);
CHECK(Chance(g, 0.5f) == true); // p > r -> true
CHECK(g.draws == 2);
}
// --- the word count is two per player when the NPC gate is open -------------------
{
Scripted g({0.9f}); // every roll fails; the count must not depend on that
const TradeRaidResult r = RollTradeRaids(g, 8, TradeRaidOdds{}, TradeRaidGates{});
CHECK(r.players == 8);
CHECK(r.words == 16);
CHECK(g.draws == 16);
CHECK(r.playerRaidHits == 0);
CHECK(r.npcRaidHits == 0);
CHECK(r.refugeeRaidHits == 0);
}
// --- ... and the count is unchanged when every roll succeeds -----------------------
{
Scripted g({0.0f}); // every roll succeeds (p > 0)
const TradeRaidResult r = RollTradeRaids(g, 8, TradeRaidOdds{}, TradeRaidGates{});
CHECK(r.words == 16);
CHECK(r.playerRaidHits == 8);
CHECK(r.npcRaidHits == 8);
}
// --- one per player when the NPC gate is shut, three when the refugee one is open --
{
Scripted g({0.9f});
TradeRaidGates gates;
gates.npcRaids = false;
CHECK(RollTradeRaids(g, 7, TradeRaidOdds{}, gates).words == 7);
gates.npcRaids = true;
gates.refugeeRaids = true;
Scripted g2({0.9f});
CHECK(RollTradeRaids(g2, 7, TradeRaidOdds{}, gates).words == 21);
}
// --- a degenerate player vector costs nothing --------------------------------------
{
Scripted g({0.9f});
CHECK(RollTradeRaids(g, 0, TradeRaidOdds{}, TradeRaidGates{}).words == 0);
CHECK(RollTradeRaids(g, -3, TradeRaidOdds{}, TradeRaidGates{}).words == 0);
CHECK(g.draws == 0);
}
// --- odds pinned at 0 or 1 cost NOTHING, and the counter says so -------------------
// This is the case that would silently break a count-the-calls implementation: a tuning
// table that pushes an odds value out of (0, 1) removes the draw, not just the outcome.
{
Scripted g({0.9f});
TradeRaidOdds odds;
odds.player = 0.f;
odds.npc = 1.f;
const TradeRaidResult r = RollTradeRaids(g, 8, odds, TradeRaidGates{});
CHECK(r.words == 0);
CHECK(g.draws == 0);
CHECK(r.playerRaidHits == 0);
CHECK(r.npcRaidHits == 8);
}
// --- against the real generator: 16 words is 16 words -----------------------------
{
mars::rng::MT19937 gen(12345u);
class Adapt final : public sots::sim::IRandom {
public:
explicit Adapt(mars::rng::MT19937& g) : g_(g) {}
float NextFloat() override { return g_.next_float(); }
std::uint32_t NextIntInclusive(std::uint32_t n) override {
return g_.next_int_inclusive(n);
}
std::uint32_t NextUInt32() override { return g_.next_u32(); }
private:
mars::rng::MT19937& g_;
} a(gen);
const int before = gen.index();
const TradeRaidResult r = RollTradeRaids(a, 8, TradeRaidOdds{}, TradeRaidGates{});
CHECK(r.words == 16);
CHECK(gen.index() - before == 16);
}
std::printf(failures ? "trade_raid: FAILED (%d)\n" : "trade_raid: ok (%d failures)\n",
failures);
return failures ? 1 : 0;
}

View file

@ -0,0 +1,113 @@
// The turn-record model, checked against the record the game itself wrote.
//
// The last phase of the post-combat tail archives a per-player summary keyed by turn, and the
// archive is on the wire: every save carries an element for its own frame. So the model can be
// checked with no running game and no VM -- build the record from the save's own state and
// compare it with the element the save already holds.
//
// This is the strongest check available to a lane that holds no game: it compares against
// bytes the original produced. It is NOT the same as `verified` in the phase catalog, which
// means "compared against the live game", and nothing here is that.
//
// Reads $SOTS_SAVES_DIR at run time and skips cleanly when it is unset. No .sav enters this
// repo.
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
#include <dirent.h>
#include "app/turn_record.h"
#include "mars/stream/save.h"
static int failures = 0;
#define CHECK(c) \
do { \
if (!(c)) { \
std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \
++failures; \
} \
} while (0)
int main() {
const char* dir = std::getenv("SOTS_SAVES_DIR");
if (!dir || !*dir) {
std::printf("app_test_turn_record: SOTS_SAVES_DIR unset, skipped\n");
return 0;
}
DIR* d = opendir(dir);
if (!d) {
std::fprintf(stderr, "app_test_turn_record: cannot open %s\n", dir);
return 1;
}
std::vector<std::string> saves;
while (struct dirent* e = readdir(d)) {
const std::string n = e->d_name;
if (n.size() > 4 && n.compare(n.size() - 4, 4, ".sav") == 0)
saves.push_back(std::string(dir) + "/" + n);
}
closedir(d);
if (saves.empty()) {
std::printf("app_test_turn_record: no .sav in %s, skipped\n", dir);
return 0;
}
int files = 0, players = 0, fields = 0, dangling = 0, noArchive = 0;
for (const std::string& path : saves) {
mars::stream::SaveDocument doc;
try {
doc = mars::stream::read_save_file(path);
} catch (const std::exception& ex) {
std::printf(" %s: unreadable (%s), skipped\n", path.c_str(), ex.what());
continue;
}
if (doc.count(mars::stream::Issue::Error)) {
std::printf(" %s: parse errors, skipped\n", path.c_str());
continue;
}
++files;
const auto& sim = doc.game.sim;
int filePlayers = 0, fileFields = 0, fileBad = 0;
for (std::size_t i = 0; i < sim.players.size(); ++i) {
if (i >= sim.turnstats.players.size()) break;
const auto* stored =
sots::app::FindArchivedRecord(sim.turnstats.players[i].hist, sim.frame);
if (!stored) {
++noArchive;
continue;
}
int dang = 0;
const sots::app::TurnRecord built =
sots::app::BuildTurnRecord(sim.players[i].player, sim.systems, sim.frame, &dang);
dangling += dang;
// An owned-system id the save's table does not carry would drop a term from the
// population sum without any other symptom, so it is a failure, not a note.
CHECK(dang == 0);
const sots::app::TurnRecordDiff diff = sots::app::CompareTurnRecord(built, *stored);
++filePlayers;
fileFields += diff.compared;
for (const auto& m : diff.mismatches) {
std::printf("FAIL %s player %zu: %s\n", path.c_str(), i, m.c_str());
++failures;
++fileBad;
}
}
players += filePlayers;
fields += fileFields;
std::printf(" %s: turn %d, %d player-record(s), %d field(s), %d mismatch(es)\n",
path.c_str(), sim.frame, filePlayers, fileFields, fileBad);
}
// The check is worthless if it compared nothing -- a green run over zero records is the
// failure mode this campaign has paid for twice.
CHECK(files > 0);
CHECK(players > 0);
CHECK(fields == players * 6);
std::printf("app_test_turn_record: %d save(s), %d player-record(s), %d field(s) compared, "
"%d player(s) with no archive element, %d dangling owned-system id(s), "
"%d failure(s)\n",
files, players, fields, noArchive, dangling, failures);
return failures ? 1 : 0;
}