merge lane E2: ship census into BuildTurnRecord; phase-granular --commit-blocked; T36 blocked on budget + ship construction
This commit is contained in:
commit
54b0353067
8 changed files with 452 additions and 52 deletions
|
|
@ -11,7 +11,11 @@ add_library(sots_app STATIC
|
|||
turn.cpp
|
||||
report.cpp)
|
||||
target_include_directories(sots_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..)
|
||||
target_link_libraries(sots_app PUBLIC mars_stream mars_rng sots_game_sim)
|
||||
# game_design (and game_data under it) is here for one phase only: the tail's turn record
|
||||
# needs a design's hull size and defence-platform flag, which are recomputed from the section
|
||||
# catalog and are nowhere on the wire. No game data is embedded; the root is supplied at run
|
||||
# time and its absence costs exactly six fields.
|
||||
target_link_libraries(sots_app PUBLIC mars_stream mars_rng sots_game_sim game_design)
|
||||
target_compile_features(sots_app PUBLIC cxx_std_17)
|
||||
if(NOT MSVC)
|
||||
target_compile_options(sots_app PRIVATE -Wall -Wextra -Werror)
|
||||
|
|
|
|||
|
|
@ -5,15 +5,21 @@
|
|||
// published phase order of all three turn drivers, runs the phases we hold, prints every
|
||||
// phase it did NOT run, and writes the result back through the engine's own writer.
|
||||
//
|
||||
// It reads no game data of its own. The save path comes from the command line or, for the
|
||||
// test, from $SOTS_SAVES_DIR; nothing is embedded.
|
||||
// It embeds no game data. The save path comes from the command line or, for the test, from
|
||||
// $SOTS_SAVES_DIR. One phase -- the tail's turn record -- needs the game's own section
|
||||
// catalog, because a design's hull size and its defence-platform flag are recomputed from it
|
||||
// and are nowhere on the wire; that root is named by --data or $SOTS_DATA_DIR, is never
|
||||
// embedded, and its absence costs exactly those six fields and nothing else.
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app/report.h"
|
||||
#include "app/turn.h"
|
||||
#include "game/data/catalog.h"
|
||||
#include "mars/stream/gzip.h"
|
||||
#include "mars/stream/save.h"
|
||||
|
||||
|
|
@ -21,26 +27,53 @@ namespace {
|
|||
|
||||
int Usage() {
|
||||
std::fprintf(stderr,
|
||||
"usage: sots_turn SAVE [--out FILE] [--metric FILE] [--phases] [--verbose]\n"
|
||||
" [--commit-blocked] [--commit-rng] [--roundtrip]\n"
|
||||
"usage: sots_turn SAVE [--out FILE] [--metric FILE] [--data DIR] [--phases]\n"
|
||||
" [--verbose] [--commit-blocked[=IDS]] [--commit-rng]\n"
|
||||
" [--commit-blocked-except=IDS] [--roundtrip]\n"
|
||||
"\n"
|
||||
" SAVE a .sav to load (gzip or already inflated)\n"
|
||||
" --out FILE write the post-turn save here\n"
|
||||
" --metric FILE write the completion metric as JSON here\n"
|
||||
" --data DIR the game's data root (default $SOTS_DATA_DIR); without\n"
|
||||
" it the tail's ship census is not modelled\n"
|
||||
" --phases print the whole phase table with this run's numbers\n"
|
||||
" --verbose add each phase's standing note to the listing\n"
|
||||
" --commit-blocked write results of phases whose inputs are unmodelled\n"
|
||||
" --commit-blocked=IDS only these blocked phases commit, by phase\n"
|
||||
" id, comma-separated (e.g. T36)\n"
|
||||
" --commit-blocked-except=IDS these blocked phases never commit\n"
|
||||
" --commit-rng write the advanced generator state back\n"
|
||||
" --roundtrip re-serialise the UNTOUCHED save and check byte identity\n");
|
||||
" --roundtrip re-serialise the UNTOUCHED save and check byte identity\n"
|
||||
"\n"
|
||||
"The two id lists exist so a blocked phase's own closed and regressed counts\n"
|
||||
"can be measured apart from every other blocked phase's, rather than netted\n"
|
||||
"into one number for all of them.\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
// "T36,P02" -> {"T36", "P02"}. Empty entries are dropped rather than matched against nothing.
|
||||
std::vector<std::string> SplitIds(const std::string& s) {
|
||||
std::vector<std::string> out;
|
||||
std::string cur;
|
||||
for (char c : s) {
|
||||
if (c == ',') {
|
||||
if (!cur.empty()) out.push_back(cur);
|
||||
cur.clear();
|
||||
} else {
|
||||
cur += c;
|
||||
}
|
||||
}
|
||||
if (!cur.empty()) out.push_back(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
std::string in, out, metric;
|
||||
std::string in, out, metric, dataDir;
|
||||
bool phases = false, verbose = false, roundtrip = false;
|
||||
sots::app::TurnOptions opt;
|
||||
if (const char* env = std::getenv("SOTS_DATA_DIR")) dataDir = env;
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string a = argv[i];
|
||||
|
|
@ -53,12 +86,21 @@ int main(int argc, char** argv) {
|
|||
if (!next(out)) return Usage();
|
||||
} else if (a == "--metric") {
|
||||
if (!next(metric)) return Usage();
|
||||
} else if (a == "--data") {
|
||||
if (!next(dataDir)) return Usage();
|
||||
} else if (a == "--phases") {
|
||||
phases = true;
|
||||
} else if (a == "--verbose") {
|
||||
phases = verbose = true;
|
||||
} else if (a == "--commit-blocked") {
|
||||
opt.commitBlocked = true;
|
||||
} else if (a.rfind("--commit-blocked=", 0) == 0) {
|
||||
opt.commitBlocked = true;
|
||||
opt.commitOnly = SplitIds(a.substr(std::strlen("--commit-blocked=")));
|
||||
if (opt.commitOnly.empty()) return Usage();
|
||||
} else if (a.rfind("--commit-blocked-except=", 0) == 0) {
|
||||
opt.commitExcept = SplitIds(a.substr(std::strlen("--commit-blocked-except=")));
|
||||
if (opt.commitExcept.empty()) return Usage();
|
||||
} else if (a == "--commit-rng") {
|
||||
opt.commitRng = true;
|
||||
} else if (a == "--roundtrip") {
|
||||
|
|
@ -107,6 +149,26 @@ int main(int argc, char** argv) {
|
|||
if (!ok) return 1;
|
||||
}
|
||||
|
||||
// The one place the standalone needs the game's own data. Loading is total -- unreadable
|
||||
// files become problems and the rest still loads -- so the catalog is accepted only when
|
||||
// it actually holds sections, and a root that yields none is reported rather than used.
|
||||
game::data::Catalog catalog;
|
||||
if (!dataDir.empty()) {
|
||||
catalog = game::data::load_catalog(dataDir);
|
||||
if (catalog.sections.empty()) {
|
||||
std::printf("data: %s holds no ship sections; the ship census stays unmodelled\n",
|
||||
dataDir.c_str());
|
||||
} else {
|
||||
opt.catalog = &catalog;
|
||||
std::printf("data: %s -- %zu section(s) over %zu race(s), %zu load problem(s)\n",
|
||||
dataDir.c_str(), catalog.sections.size(), catalog.races.size(),
|
||||
catalog.problems.size());
|
||||
}
|
||||
} else {
|
||||
std::printf("data: no root given (--data DIR / $SOTS_DATA_DIR); the tail's ship census "
|
||||
"stays unmodelled\n");
|
||||
}
|
||||
|
||||
const sots::app::TurnResult r = sots::app::RunStrategicTurn(doc.game, opt);
|
||||
|
||||
if (phases) sots::app::PrintPhaseLog(stdout, r, verbose);
|
||||
|
|
|
|||
|
|
@ -217,12 +217,22 @@ constexpr PhaseDesc kTail[] = {
|
|||
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
|
||||
{Driver::Tail, 35, "T35", "RebuildPlayerReports", PhaseStatus::Stub, ""},
|
||||
{Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Blocked,
|
||||
"fills every player's turn record and archives it by turn; must stay last. Seven of its "
|
||||
"fields are recoverable from the wire and are reproduced -- turn, colony count, savings, "
|
||||
"fills every player's turn record and archives it by turn; must stay last. Thirteen of "
|
||||
"its fields are modelled and self-checked every run against the record the input save "
|
||||
"already carries for its own turn: seven from the wire -- turn, colony count, savings, "
|
||||
"the savings delta, the completed-tech count, the summed population and the alliance mask "
|
||||
"phase S04 rebuilt -- and the model is self-checked every run against the record the input "
|
||||
"save already carries for its own turn. Not committed: four further fields of the same "
|
||||
"record are unmodelled, and savings for the NEW turn comes from a blocked phase"},
|
||||
"phase S04 rebuilt -- and, when a data root is given, the six per-hull-class ship counts, "
|
||||
"which need each design's hull size and defence-platform flag from the section catalog "
|
||||
"because neither is on the wire. Only 32 of the 480 census leaves the corpus archives are "
|
||||
"nonzero, so cls1 (cruisers) and cls2's platform count are UNEXERCISED, not verified. "
|
||||
"Still not committed, and now blocked on exactly two named things, neither of them here: "
|
||||
"savings and the income derived from it come from P01/P02, which are blocked on the "
|
||||
"per-system money output; and a ship the turn BUILDS never enters our fleet list, so the "
|
||||
"census carries the pre-construction count. Measured with --commit-blocked=T36 on "
|
||||
"turn1->turn2: 29 leaves closed, 7 regressed -- sav x3, inc x3 and one shpt[0] short by "
|
||||
"exactly the one destroyer that turn completes. The archived record is one struct on the "
|
||||
"wire, so those words cannot be omitted while the rest is written: committing is "
|
||||
"all-or-nothing at the record"},
|
||||
};
|
||||
|
||||
PhaseTally Tally(const PhaseDesc* p, std::size_t n) {
|
||||
|
|
|
|||
133
src/app/turn.cpp
133
src/app/turn.cpp
|
|
@ -17,6 +17,18 @@
|
|||
#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;
|
||||
|
|
@ -114,7 +126,7 @@ void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
|
|||
const int wouldBe = sim::SaturatingAdd(p.sav, b.net);
|
||||
++t.fired[1];
|
||||
if (wouldBe != p.sav) ++t.wouldWrite[2];
|
||||
if (opt.commitBlocked) {
|
||||
if (opt.CommitBlocked("P02")) {
|
||||
p.sav = wouldBe;
|
||||
++t.writes[2];
|
||||
}
|
||||
|
|
@ -271,10 +283,12 @@ struct TurnRecordAudit {
|
|||
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) {
|
||||
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;
|
||||
|
|
@ -290,7 +304,7 @@ TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game) {
|
|||
// 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);
|
||||
inputTurn, i, spineRan, &dangling, census);
|
||||
a.dangling += dangling;
|
||||
const TurnRecordDiff d = CompareTurnRecord(built, *stored);
|
||||
++a.playersChecked;
|
||||
|
|
@ -307,25 +321,38 @@ void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord&
|
|||
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.
|
||||
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 (!opt.commitBlocked) {
|
||||
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);
|
||||
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
|
||||
|
|
@ -336,39 +363,81 @@ void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord&
|
|||
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.
|
||||
// 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 += 7;
|
||||
rec.leafWrites += perPlayerFields;
|
||||
}
|
||||
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 (!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",
|
||||
"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("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");
|
||||
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
|
||||
|
|
@ -416,9 +485,12 @@ 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);
|
||||
// INPUT save already carries for its own turn. None of the fields it reads is written by
|
||||
// a phase below, but the check is taken first so that stays true by construction.
|
||||
ShipCensusIndex inputCensus;
|
||||
if (opt.catalog) inputCensus = ShipCensusIndex(game, *opt.catalog);
|
||||
const TurnRecordAudit recordAudit =
|
||||
AuditTurnRecordsAgainstSave(game, opt.catalog ? &inputCensus : nullptr);
|
||||
|
||||
mars::rng::MT19937 gen(1u);
|
||||
r.rngLoaded = LoadGenerator(game, gen);
|
||||
|
|
@ -555,11 +627,12 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
|||
if (e.player.npc) continue;
|
||||
if (e.player.status == 1) continue;
|
||||
++n;
|
||||
if (opt.commitBlocked) e.player.status = 1;
|
||||
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 = opt.commitBlocked ? n : 0;
|
||||
rec.wouldWrite = opt.commitBlocked ? 0 : n;
|
||||
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 "
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
#include <vector>
|
||||
|
||||
#include "app/phase_catalog.h"
|
||||
#include "game/data/catalog.h"
|
||||
#include "mars/rng/mt19937.h"
|
||||
#include "mars/stream/shapes.h"
|
||||
|
||||
|
|
@ -20,6 +21,16 @@ struct TurnOptions {
|
|||
// a blocked phase writing a confidently-wrong value is worse than a phase that does not
|
||||
// write at all, because it replaces a leaf that may currently agree by construction.
|
||||
bool commitBlocked = false;
|
||||
// Which blocked phases that switch applies to, by phase id ("P02", "S31", "T36").
|
||||
//
|
||||
// `commitBlocked` alone is all-or-nothing, and all-or-nothing hides which phase paid for
|
||||
// which leaf: a run that commits three blocked phases reports one closed count and one
|
||||
// regressed count for all three together, which is the netting the campaign does not do.
|
||||
// These two lists narrow it. `commitOnly` non-empty means only those ids commit;
|
||||
// `commitExcept` names ids that never do. A phase must pass both filters.
|
||||
std::vector<std::string> commitOnly;
|
||||
std::vector<std::string> commitExcept;
|
||||
bool CommitBlocked(const char* phaseId) const;
|
||||
// Write the advanced generator state back into the save. Off by default: the turn
|
||||
// consumes roughly eighteen to twenty words that nothing here models, so an advanced
|
||||
// state is wrong in a different way than an untouched one, and an untouched one at
|
||||
|
|
@ -27,6 +38,12 @@ struct TurnOptions {
|
|||
bool commitRng = false;
|
||||
// Tuning constants were loaded, so formulas that read them may run.
|
||||
bool haveTuning = false;
|
||||
// The game's data root, when the operator supplied one. The standalone reads no game data
|
||||
// of its own and needs none for any other phase; the tail's turn record is the one place
|
||||
// where a save is not enough, because a design's hull size and its defence-platform flag
|
||||
// are recomputed from the section catalog and never written down. Null means the ship
|
||||
// census is not modelled, and the phase says so rather than writing six zeros.
|
||||
const ::game::data::Catalog* catalog = nullptr;
|
||||
};
|
||||
|
||||
// One line of the run log.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include <cstdio>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
|
||||
#include "app/alliance.h"
|
||||
|
||||
|
|
@ -13,9 +14,47 @@ constexpr const char* kUnmodelled[] = {
|
|||
"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",
|
||||
"the four per-class ship LOSS and KILL counters (shpl/shpk/satl/satk) -- zero in every "
|
||||
"save in the corpus, so nothing has ever exercised them and no model stands behind the "
|
||||
"zeros this phase writes",
|
||||
};
|
||||
|
||||
// The order the original's stats pass visits a design's slots in, expressed as indices into
|
||||
// the WIRE's section array. The wire writes command, mission, engine; the in-memory array the
|
||||
// stats pass walks holds mission, command, engine. Hull size is an ASSIGNMENT -- the last
|
||||
// resolved section wins -- so the order is load-bearing in principle. It is not observable in
|
||||
// this corpus: design rule A6 makes every shipped design class-homogeneous, so first-wins and
|
||||
// last-wins agree on all 503 design records. This is therefore a hypothesis carried from the
|
||||
// original's slot order, not a fact the saves can confirm.
|
||||
constexpr int kWireSlotVisitOrder[3] = {1, 0, 2};
|
||||
|
||||
// One save `Des` record, classified. The record names each section as (species index, id in
|
||||
// that race's manifest), which is what the catalog's id lookup takes.
|
||||
game::design::HullClass ClassifySavedDesign(const game::data::Catalog& cat,
|
||||
const mars::stream::shapes::Design& d) {
|
||||
std::vector<const game::data::ShipSectionDef*> found;
|
||||
int unresolved = 0;
|
||||
for (int wire : kWireSlotVisitOrder) {
|
||||
if (static_cast<std::size_t>(wire) >= d.sections.size()) continue;
|
||||
const auto& s = d.sections[static_cast<std::size_t>(wire)];
|
||||
const int species = s.sec.a, id = s.sec.b;
|
||||
if (species == 0 && id == 0) continue; // an empty slot, not a bad reference
|
||||
if (species < 0 || species >= game::data::kSpeciesCount) {
|
||||
++unresolved;
|
||||
continue;
|
||||
}
|
||||
const game::data::ShipSectionDef* def = cat.section_by_id(
|
||||
game::data::species_name(static_cast<game::data::Species>(species)), id);
|
||||
if (!def) {
|
||||
++unresolved;
|
||||
continue;
|
||||
}
|
||||
found.push_back(def);
|
||||
}
|
||||
game::design::HullClass h = game::design::classify_sections(found);
|
||||
h.unresolved_sections = unresolved;
|
||||
return h;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
const char* const* TurnRecord::Unmodelled(std::size_t& count) {
|
||||
|
|
@ -23,10 +62,58 @@ const char* const* TurnRecord::Unmodelled(std::size_t& count) {
|
|||
return kUnmodelled;
|
||||
}
|
||||
|
||||
ShipCensusIndex::ShipCensusIndex(const mars::stream::shapes::SaveGame& game,
|
||||
const game::data::Catalog& cat) {
|
||||
modelled_ = true;
|
||||
byPlayer_.resize(game.sim.players.size());
|
||||
|
||||
// Design ids are unique across the whole game -- the legacy (rider) designs share the id
|
||||
// space -- so one map over every player's lists serves every fleet.
|
||||
std::map<std::int32_t, game::design::HullClass> byId;
|
||||
for (const auto& pe : game.sim.players) {
|
||||
for (const auto* list : {&pe.player.designs, &pe.player.legacyDesigns}) {
|
||||
for (const auto& de : *list) {
|
||||
const game::design::HullClass h = ClassifySavedDesign(cat, de.des);
|
||||
++designsSeen;
|
||||
if (!h.ok()) ++designsUnclassified;
|
||||
byId[de.desID] = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A fleet names its owner by the player's OBJECT id, not by its position in the player
|
||||
// vector; the archive is keyed by the position. The two are different numbers.
|
||||
std::map<std::int32_t, std::size_t> playerIndex;
|
||||
for (std::size_t i = 0; i < game.sim.players.size(); ++i)
|
||||
playerIndex[game.sim.players[i].playerID] = i;
|
||||
|
||||
for (const auto& fe : game.sim.fleets) {
|
||||
auto owner = playerIndex.find(fe.flt.pid);
|
||||
if (owner == playerIndex.end()) {
|
||||
++fleetsWithoutOwner;
|
||||
continue;
|
||||
}
|
||||
game::design::ShipCensus& c = byPlayer_[owner->second];
|
||||
for (const auto& se : fe.flt.ships) {
|
||||
auto d = byId.find(se.ship.desID);
|
||||
if (d == byId.end() || !d->second.ok()) {
|
||||
++shipsWithoutDesign;
|
||||
continue;
|
||||
}
|
||||
c.add(d->second);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const game::design::ShipCensus* ShipCensusIndex::ForPlayer(std::size_t vectorIndex) const {
|
||||
if (!modelled_ || vectorIndex >= byPlayer_.size()) return nullptr;
|
||||
return &byPlayer_[vectorIndex];
|
||||
}
|
||||
|
||||
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
|
||||
const std::vector<mars::stream::shapes::SysEntry>& systems,
|
||||
std::int32_t frame, std::size_t vectorIndex, bool spineRan,
|
||||
int* danglingOwnedSystems) {
|
||||
int* danglingOwnedSystems, const ShipCensusIndex* census) {
|
||||
TurnRecord r;
|
||||
r.turn = frame;
|
||||
// Written by the spine's fourth phase, which the load path does not run.
|
||||
|
|
@ -59,6 +146,17 @@ TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
|
|||
r.population += static_cast<std::int64_t>(sys->pop) + static_cast<std::int64_t>(sys->pbon);
|
||||
}
|
||||
if (danglingOwnedSystems) *danglingOwnedSystems = dangling;
|
||||
|
||||
// The census is not derived from the player at all -- it is a walk of the fleets the
|
||||
// player owns, and the classification behind it needs the section catalog. It arrives
|
||||
// prebuilt or not at all.
|
||||
if (census) {
|
||||
if (const game::design::ShipCensus* c = census->ForPlayer(vectorIndex)) {
|
||||
r.censusModelled = true;
|
||||
r.ships = c->ships;
|
||||
r.platforms = c->platforms;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +192,18 @@ TurnRecordDiff CompareTurnRecord(const TurnRecord& built,
|
|||
cmp("inc", built.income, stored.inc);
|
||||
cmp("tch", built.completedTech, stored.tch);
|
||||
cmp("almem", built.allianceMask, stored.almem);
|
||||
// The six census counters, and only when a catalog produced them: comparing six
|
||||
// unmodelled zeros against a stored record would score whatever the corpus happens to
|
||||
// hold rather than anything this code knows.
|
||||
if (built.censusModelled) {
|
||||
for (std::size_t k = 0; k < stored.classes.size() && k < 3; ++k) {
|
||||
char field[24];
|
||||
std::snprintf(field, sizeof field, "cls%zu.shpt", k);
|
||||
cmp(field, built.ships[k], stored.classes[k].shpt);
|
||||
std::snprintf(field, sizeof field, "cls%zu.satt", k);
|
||||
cmp(field, built.platforms[k], stored.classes[k].satt);
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,18 +7,59 @@
|
|||
// 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.
|
||||
// Seven fields are recoverable from the wire and are reproduced here. Six more -- the
|
||||
// per-hull-class ship census -- are recoverable only with the game's section catalog, because
|
||||
// neither the hull size nor the defence-platform flag is anywhere on the wire; they are filled
|
||||
// when the operator supplies a data root and are reported as unmodelled when they are not.
|
||||
// The rest are not recoverable at all, and each is named with the reason -- they belong to
|
||||
// phases or inputs the standalone does not hold.
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/data/catalog.h"
|
||||
#include "game/design/hull.h"
|
||||
#include "mars/stream/shapes.h"
|
||||
|
||||
namespace sots::app {
|
||||
|
||||
// The per-player ship census, built once for the whole simulation.
|
||||
//
|
||||
// The counts cannot come from a save alone: a ship names a design, and a design's hull size
|
||||
// and defence-platform flag are recomputed from the section catalog whenever it changes and
|
||||
// are never written down. So this needs a data root, and says so when it does not have one.
|
||||
//
|
||||
// Built once rather than per player because design ids are unique across the whole game and
|
||||
// the fleet list is global: classifying every design once and then walking the fleets is the
|
||||
// same shape the original uses, and it keeps the catalog lookups off the per-player path.
|
||||
class ShipCensusIndex {
|
||||
public:
|
||||
// Without a catalog: an index that reports itself unmodelled and counts nothing.
|
||||
ShipCensusIndex() = default;
|
||||
ShipCensusIndex(const mars::stream::shapes::SaveGame& game, const game::data::Catalog& cat);
|
||||
|
||||
bool modelled() const { return modelled_; }
|
||||
// Null when the census is unmodelled or the index is out of range.
|
||||
const game::design::ShipCensus* ForPlayer(std::size_t vectorIndex) const;
|
||||
|
||||
int designsSeen = 0;
|
||||
// A design none of whose sections resolve against the catalog. NOT counted as class 0:
|
||||
// an unclassifiable design is a gap, and a silent class-0 count would hide it.
|
||||
int designsUnclassified = 0;
|
||||
// A ship naming a design id no player's design list carries, or an unclassifiable one.
|
||||
int shipsWithoutDesign = 0;
|
||||
// Fleets whose owner id names no player in the player vector -- the NPC pools. Skipped,
|
||||
// and counted so "skipped" is a measurement rather than an omission.
|
||||
int fleetsWithoutOwner = 0;
|
||||
|
||||
private:
|
||||
bool modelled_ = false;
|
||||
std::vector<game::design::ShipCensus> byPlayer_;
|
||||
};
|
||||
|
||||
// The part of a turn record this model can produce.
|
||||
struct TurnRecord {
|
||||
std::int32_t turn = 0; // the frame counter
|
||||
|
|
@ -29,6 +70,13 @@ struct TurnRecord {
|
|||
std::int32_t completedTech = 0; // tech-tree entries in the completed state
|
||||
std::int32_t allianceMask = 0; // the shared-vision mask the spine's phase 4 rebuilds
|
||||
|
||||
// The census, by hull size 0/1/2. Filled only when `censusModelled`; six zeros otherwise,
|
||||
// which is exactly the value a wrong model would produce, hence the flag rather than a
|
||||
// sentinel.
|
||||
bool censusModelled = false;
|
||||
std::array<int, 3> ships{};
|
||||
std::array<int, 3> platforms{};
|
||||
|
||||
// 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);
|
||||
|
|
@ -49,12 +97,16 @@ constexpr std::int32_t kTechStateCompleted = 4;
|
|||
// alliance mask is written only by the spine. So a record archived by the load path carries
|
||||
// a zero mask, and that is a positive prediction of this model, not an exclusion: the
|
||||
// earliest turn every save carries has `almem == 0` on every player, in all eleven saves.
|
||||
//
|
||||
// `census` may be null, and is null whenever no data root was supplied: the six census
|
||||
// fields then stay zero and `censusModelled` stays false.
|
||||
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
|
||||
const std::vector<mars::stream::shapes::SysEntry>& systems,
|
||||
std::int32_t frame, std::size_t vectorIndex, bool spineRan,
|
||||
// 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);
|
||||
int* danglingOwnedSystems = nullptr,
|
||||
const ShipCensusIndex* census = nullptr);
|
||||
|
||||
// The earliest turn the archive carries for a player. The record for that turn was written
|
||||
// by the new-game / load path, not by a turn.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@
|
|||
// 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.
|
||||
// repo. $SOTS_DATA_DIR is read the same way and adds the six ship-census fields to the
|
||||
// comparison; without it the census is not modelled and the other seven fields are compared
|
||||
// alone. No game data enters this repo either.
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
|
@ -21,6 +24,7 @@
|
|||
|
||||
#include "app/alliance.h"
|
||||
#include "app/turn_record.h"
|
||||
#include "game/data/catalog.h"
|
||||
#include "mars/stream/save.h"
|
||||
|
||||
static int failures = 0;
|
||||
|
|
@ -55,7 +59,28 @@ int main() {
|
|||
return 0;
|
||||
}
|
||||
|
||||
// The section catalog, when the operator has one. It is what turns the six census fields
|
||||
// from unmodelled into compared; the count of compared fields below is asserted against
|
||||
// this flag so a silently-empty catalog cannot pass as a green run over seven fields.
|
||||
game::data::Catalog cat;
|
||||
bool haveCatalog = false;
|
||||
if (const char* dataDir = std::getenv("SOTS_DATA_DIR")) {
|
||||
if (*dataDir) {
|
||||
cat = game::data::load_catalog(dataDir);
|
||||
haveCatalog = !cat.sections.empty();
|
||||
if (!haveCatalog)
|
||||
std::printf("app_test_turn_record: %s holds no ship sections; the census is "
|
||||
"not compared\n", dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
int files = 0, players = 0, fields = 0, dangling = 0, noArchive = 0;
|
||||
// Census coverage, reported as loudly as the verdict (earned rule 15) and as a hypothesis
|
||||
// where nothing exercises it (rule 6): a counter that is zero in every archived record
|
||||
// agrees for free and is not evidence of anything.
|
||||
int censusLeaves = 0, censusNonzero = 0;
|
||||
std::array<int, 6> nonzeroByLeaf{}; // cls0 shpt/satt, cls1 shpt/satt, cls2 shpt/satt
|
||||
int designsSeen = 0, designsUnclassified = 0, shipsWithoutDesign = 0, fleetsWithoutOwner = 0;
|
||||
// Reported rather than assumed: how much of the alliance rule the corpus actually
|
||||
// exercises. A green run over records that are all `alid == -1` would test the self bit
|
||||
// and nothing else, and would look identical to a green run that tested everything.
|
||||
|
|
@ -74,6 +99,14 @@ int main() {
|
|||
}
|
||||
++files;
|
||||
const auto& sim = doc.game.sim;
|
||||
sots::app::ShipCensusIndex census;
|
||||
if (haveCatalog) {
|
||||
census = sots::app::ShipCensusIndex(doc.game, cat);
|
||||
designsSeen += census.designsSeen;
|
||||
designsUnclassified += census.designsUnclassified;
|
||||
shipsWithoutDesign += census.shipsWithoutDesign;
|
||||
fleetsWithoutOwner += census.fleetsWithoutOwner;
|
||||
}
|
||||
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;
|
||||
|
|
@ -90,9 +123,24 @@ int main() {
|
|||
// positive claim the corpus checks on 8 records, not a field skipped.
|
||||
const bool spineRan =
|
||||
sim.frame > sots::app::EarliestArchivedTurn(sim.turnstats.players[i].hist);
|
||||
const sots::app::TurnRecord built = sots::app::BuildTurnRecord(
|
||||
sim.players[i].player, sim.systems, sim.frame, i, spineRan, &dang);
|
||||
const sots::app::TurnRecord built =
|
||||
sots::app::BuildTurnRecord(sim.players[i].player, sim.systems, sim.frame, i,
|
||||
spineRan, &dang, haveCatalog ? &census : nullptr);
|
||||
dangling += dang;
|
||||
CHECK(built.censusModelled == haveCatalog);
|
||||
if (built.censusModelled) {
|
||||
for (std::size_t k = 0; k < stored->classes.size() && k < 3; ++k) {
|
||||
censusLeaves += 2;
|
||||
if (stored->classes[k].shpt != 0) {
|
||||
++censusNonzero;
|
||||
++nonzeroByLeaf[k * 2];
|
||||
}
|
||||
if (stored->classes[k].satt != 0) {
|
||||
++censusNonzero;
|
||||
++nonzeroByLeaf[k * 2 + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
|
|
@ -118,7 +166,11 @@ int main() {
|
|||
// failure mode this campaign has paid for twice.
|
||||
CHECK(files > 0);
|
||||
CHECK(players > 0);
|
||||
CHECK(fields == players * 7);
|
||||
CHECK(fields == players * (haveCatalog ? 13 : 7));
|
||||
// A ship whose design no player's list carries, or a design nothing in the catalog
|
||||
// resolves, would silently drop out of the census rather than diverge. Both are failures.
|
||||
CHECK(designsUnclassified == 0);
|
||||
CHECK(shipsWithoutDesign == 0);
|
||||
|
||||
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), "
|
||||
|
|
@ -129,6 +181,26 @@ int main() {
|
|||
"the load path and are predicted to be zero; %d record(s) have a vector "
|
||||
"position that differs from the player index field\n",
|
||||
alliedRecords, players, loadWrittenRecords, indexDiffers);
|
||||
if (!haveCatalog) {
|
||||
std::printf(" ship census NOT compared: $SOTS_DATA_DIR unset or empty. The six "
|
||||
"counters need each design's hull size and defence-platform flag, and "
|
||||
"neither is on the wire.\n");
|
||||
} else {
|
||||
std::printf(" ship census: %d leaf/leaves compared, %d NONZERO in the archive; per "
|
||||
"leaf (cls0 shpt/satt, cls1 shpt/satt, cls2 shpt/satt) = %d/%d %d/%d %d/%d "
|
||||
"over %d design(s), %d fleet(s) owned by no player in the vector\n",
|
||||
censusLeaves, censusNonzero, nonzeroByLeaf[0], nonzeroByLeaf[1],
|
||||
nonzeroByLeaf[2], nonzeroByLeaf[3], nonzeroByLeaf[4], nonzeroByLeaf[5],
|
||||
designsSeen, fleetsWithoutOwner);
|
||||
std::printf(" a zero leaf agrees for free: any counter with no nonzero observation "
|
||||
"above is UNEXERCISED, not verified.\n");
|
||||
std::printf(" NOT SEPARATED by this corpus: hull size is an assignment in slot order, "
|
||||
"so the original's in-memory visit order (mission, command, engine) and "
|
||||
"the wire's (command, mission, engine) would differ only on a design whose "
|
||||
"sections disagree on class. Design rule A6 forbids one, so the memory "
|
||||
"order used here is carried from the instruction stream, not confirmed by "
|
||||
"these bytes.\n");
|
||||
}
|
||||
if (indexDiffers == 0)
|
||||
std::printf(" NOT SEPARATED by this corpus: every player's vector position equals its "
|
||||
"index field, so no comparison here can tell `1 << position` from "
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue