// The per-hull-class ship census, checked against the census the game itself // wrote. // // Every save carries the archived per-player turn record for its own frame, // and six of that record's fields are the ship census: `shpt` and `satt` in // each of the three `cls` groups. Neither the hull size nor the platform flag // that produce those six numbers is on the wire -- both are recomputed from // the section catalog -- so this test rebuilds them from $SOTS_DATA_DIR and // compares against bytes the original produced. // // It is NOT a live comparison against the running game: it is a comparison // against what the game archived. Needs $SOTS_SAVES_DIR and $SOTS_DATA_DIR; // skips cleanly when either is unset. No save and no game data enters the // repo. #include #include #include #include #include #include #include #include "game/data/catalog.h" #include "game/design/hull.h" #include "mars/stream/save.h" using namespace game::data; using namespace game::design; namespace shapes = mars::stream::shapes; static int failures = 0; #define CHECK(c) \ do { \ if (!(c)) { \ std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \ ++failures; \ } \ } while (0) namespace { // One save `Des` record, classified. The record names its sections as // (species index, id in that race's manifest), which is exactly what the // catalog's id lookup takes. HullClass classify_saved_design(const Catalog& cat, const shapes::Design& d) { std::vector found; int unresolved = 0; for (const shapes::DesignSection& s : d.sections) { const int species = s.sec.a, id = s.sec.b; if (species == 0 && id == 0) continue; // empty slot if (species < 0 || species >= kSpeciesCount) { ++unresolved; continue; } const ShipSectionDef* def = cat.section_by_id(species_name(static_cast(species)), id); if (!def) { ++unresolved; continue; } found.push_back(def); } HullClass h = classify_sections(found); h.unresolved_sections = unresolved; return h; } const shapes::PlayerTurnStats* archived(const shapes::PlayerTurnHistory& hist, std::int32_t turn) { for (const shapes::PlayerTurnStats& s : hist.stats) if (s.trn == turn) return &s; return nullptr; } } // namespace int main() { const char* savesDir = std::getenv("SOTS_SAVES_DIR"); const char* dataDir = std::getenv("SOTS_DATA_DIR"); if (!savesDir || !*savesDir || !dataDir || !*dataDir) { std::printf("game_design_census: SOTS_SAVES_DIR / SOTS_DATA_DIR unset, skipped\n"); return 0; } DIR* dir = opendir(savesDir); if (!dir) { std::fprintf(stderr, "game_design_census: cannot open %s\n", savesDir); return 1; } std::vector saves; while (struct dirent* e = readdir(dir)) { const std::string n = e->d_name; if (n.size() > 4 && n.compare(n.size() - 4, 4, ".sav") == 0) saves.push_back(std::string(savesDir) + "/" + n); } closedir(dir); if (saves.empty()) { std::printf("game_design_census: no .sav in %s, skipped\n", savesDir); return 0; } const Catalog cat = load_catalog(dataDir); if (cat.sections.empty()) { std::fprintf(stderr, "game_design_census: no sections under %s\n", dataDir); return 1; } int files = 0, compared = 0, mismatches = 0, nonzero = 0; int designsSeen = 0, designsUnclassified = 0, shipsWithoutDesign = 0; int hullAssignmentDiffers = 0; std::array nonzeroByLeaf{}; // cls0.shpt, cls0.satt, cls1.shpt, ... 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; // Design ids are unique across the whole game, so one map serves every // player's fleets. Legacy (rider) designs are in the same id space. std::map byId; for (const shapes::PlayerEntry& pe : sim.players) { for (const auto* list : {&pe.player.designs, &pe.player.legacyDesigns}) { for (const shapes::DesignEntry& de : *list) { HullClass h = classify_saved_design(cat, de.des); ++designsSeen; if (!h.ok()) ++designsUnclassified; // First-wins vs last-wins: rule A6 makes every shipped // design class-homogeneous, so a difference here would be // news. Counted rather than asserted. int first = -1; for (const shapes::DesignSection& s : de.des.sections) { if (s.sec.a == 0 && s.sec.b == 0) continue; if (s.sec.a < 0 || s.sec.a >= kSpeciesCount) continue; if (const ShipSectionDef* d2 = cat.section_by_id(species_name(static_cast(s.sec.a)), s.sec.b)) { first = hull_size(d2->section_class); break; } } if (h.ok() && first >= 0 && first != h.hull_size) ++hullAssignmentDiffers; byId[de.desID] = h; } } } // Player object id -> index into sim.players, which is the index the // turn-statistics archive is keyed by. std::map playerIndex; for (std::size_t i = 0; i < sim.players.size(); ++i) playerIndex[sim.players[i].playerID] = i; std::vector built(sim.players.size()); for (const shapes::FleetEntry& fe : sim.fleets) { auto it = playerIndex.find(fe.flt.pid); if (it == playerIndex.end()) continue; // unowned / NPC-pool fleet ShipCensus& c = built[it->second]; for (const shapes::ShipEntry& se : fe.flt.ships) { auto d = byId.find(se.ship.desID); if (d == byId.end() || !d->second.ok()) { ++shipsWithoutDesign; continue; } c.add(d->second); } } int fileCompared = 0, fileBad = 0; for (std::size_t i = 0; i < sim.players.size(); ++i) { if (i >= sim.turnstats.players.size()) break; const shapes::PlayerTurnStats* stored = archived(sim.turnstats.players[i].hist, sim.frame); if (!stored) continue; for (std::size_t k = 0; k < stored->classes.size() && k < 3; ++k) { const shapes::ClassStats& cs = stored->classes[k]; const int wantShips = cs.shpt, wantPlat = cs.satt; const int gotShips = built[i].ships[k], gotPlat = built[i].platforms[k]; for (int which = 0; which < 2; ++which) { const int want = which ? wantPlat : wantShips; const int got = which ? gotPlat : gotShips; ++compared; ++fileCompared; if (want != 0) { ++nonzero; ++nonzeroByLeaf[k * 2 + static_cast(which)]; } if (want != got) { ++mismatches; ++fileBad; std::printf(" %s p%zu cls%zu %s: built %d != archived %d\n", path.c_str(), i, k, which ? "satt" : "shpt", got, want); } } } } std::printf(" %-34s turn %3d %3d compared %d mismatched\n", path.c_str(), sim.frame, fileCompared, fileBad); } CHECK(files > 0); CHECK(compared > 0); CHECK(mismatches == 0); CHECK(shipsWithoutDesign == 0); CHECK(designsUnclassified == 0); std::printf("game_design_census: %d saves, %d designs, %d census leaves, %d mismatched\n", files, designsSeen, compared, mismatches); std::printf(" coverage: %d of %d leaves are NONZERO in the archive; per leaf " "(cls0 shpt/satt, cls1 shpt/satt, cls2 shpt/satt) = %d/%d %d/%d %d/%d\n", nonzero, compared, nonzeroByLeaf[0], nonzeroByLeaf[1], nonzeroByLeaf[2], nonzeroByLeaf[3], nonzeroByLeaf[4], nonzeroByLeaf[5]); std::printf(" a zero leaf agrees for free -- the leaves with no nonzero observation are " "UNEXERCISED, not verified\n"); std::printf(" designs whose first- and last-resolved section disagree on hull size: %d " "(rule A6 predicts 0)\n", hullAssignmentDiffers); if (failures) std::printf("game_design_census: %d FAILURES\n", failures); return failures ? 1 : 0; }