`sots_turn` loads a save through the engine's own reader, walks the published phase order of all three turn drivers, runs what we hold, prints what we do not, and writes the result back through the engine's own writer. The phase catalog carries all 32 + 12 + 37 phases whether or not they are implemented, so an unimplemented phase is a named no-op that appears in the run log rather than a silent absence. 14 of the 44 turn-driver phases are modelled, 7 commit anything, 2 of the 37 tail phases are modelled. Modelled but NOT committed is a first-class state. A phase whose formula we hold and whose inputs we do not is evaluated, reported, and left unwritten unless --commit-blocked is passed. That distinction was earned: committing phase 31's player-status restore regressed two leaves that had agreed with the oracle before the turn, because the phase writes 1 and the file carries 4. Measured against the game's own post-turn saves, leaves localised by state_checksum.py with coverage proved by re-serialisation: turn1-state -> turn2-state 209 -> 204 diverging, closed 5, regressed 0 turn2-state -> turn3-state 108 -> 103 diverging, closed 5, regressed 0 Two tests: app_catalog (the tables stay complete and nothing claims to be verified against a live game) and app_turn (11 saves driven; an untouched load re-serialises byte-identically, a turn leaves the file re-readable, and no blocked or stub phase writes anything). Skips cleanly without SOTS_SAVES_DIR. ctest 38/38, clean-room OK. src/shim untouched. docs/S-standalone.md has the full gap list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
112 lines
4.3 KiB
C++
112 lines
4.3 KiB
C++
// Drive one strategic turn over every save in $SOTS_SAVES_DIR and assert the properties
|
|
// the standalone must hold whatever the phase coverage is:
|
|
//
|
|
// 1. an untouched load re-serialises byte-identically (the foundation the diff rests on);
|
|
// 2. a turn never breaks the file -- the post-turn state still round-trips;
|
|
// 3. the counters that ARE modelled moved, and in the right direction;
|
|
// 4. no phase committed a write while its inputs were declared missing;
|
|
// 5. the generator is left alone unless asked for.
|
|
//
|
|
// Skips cleanly (exit 0) when the variable is unset. No .sav ever enters this repo.
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include <dirent.h>
|
|
|
|
#include "app/report.h"
|
|
#include "app/turn.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: SOTS_SAVES_DIR unset, skipped\n");
|
|
return 0;
|
|
}
|
|
DIR* d = opendir(dir);
|
|
if (!d) {
|
|
std::fprintf(stderr, "app_test_turn: 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: no .sav in %s, skipped\n", dir);
|
|
return 0;
|
|
}
|
|
|
|
int ran = 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;
|
|
}
|
|
++ran;
|
|
|
|
// 1. the foundation
|
|
CHECK(mars::stream::write_save(doc.game) == doc.inflated);
|
|
|
|
const int frame0 = doc.game.sim.frame;
|
|
const int mod0 = doc.game.sim.modCount;
|
|
const mars::stream::Node rng0 = doc.game.sim.rng;
|
|
|
|
sots::app::TurnOptions opt; // defaults: commit nothing that is not fully modelled
|
|
const sots::app::TurnResult r = sots::app::RunStrategicTurn(doc.game, opt);
|
|
|
|
// 2. the turn did not break the file
|
|
mars::stream::Bytes after = mars::stream::write_save(doc.game);
|
|
CHECK(!after.empty());
|
|
mars::stream::SaveDocument again = mars::stream::read_save_bytes(after.data(), after.size());
|
|
CHECK(again.count(mars::stream::Issue::Error) == 0);
|
|
CHECK(mars::stream::write_save(again.game) == after);
|
|
|
|
// 3. modelled counters moved
|
|
CHECK(doc.game.sim.modCount > mod0);
|
|
CHECK(doc.game.summary.turn == doc.game.sim.frame);
|
|
CHECK(doc.game.sim.frame == frame0 + 1);
|
|
|
|
// 4. blocked phases stayed blocked
|
|
for (const auto& rec : r.records) {
|
|
if (rec.desc->status == sots::app::PhaseStatus::Blocked) CHECK(rec.leafWrites == 0);
|
|
if (rec.desc->status == sots::app::PhaseStatus::Stub) {
|
|
CHECK(rec.leafWrites == 0);
|
|
CHECK(rec.rngWords == 0);
|
|
}
|
|
}
|
|
|
|
// 5. the generator is untouched by default
|
|
CHECK(doc.game.sim.rng.children.size() == rng0.children.size());
|
|
if (!doc.game.sim.rng.children.empty() && !rng0.children.empty())
|
|
CHECK(doc.game.sim.rng.children[0].raw == rng0.children[0].raw);
|
|
|
|
std::printf(" %s: %d leaf write(s), %d blocked, %d rng word(s)\n", path.c_str(),
|
|
r.leafWrites, r.wouldWrite, r.rngWords);
|
|
}
|
|
|
|
std::printf("app_test_turn: %d save(s) driven, %d failure(s)\n", ran, failures);
|
|
return failures ? 1 : 0;
|
|
}
|