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.
113 lines
4.6 KiB
C++
113 lines
4.6 KiB
C++
// 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;
|
|
}
|