diff --git a/src/game/data/shipsection.cpp b/src/game/data/shipsection.cpp index fe7db8c..bd5ebbc 100644 --- a/src/game/data/shipsection.cpp +++ b/src/game/data/shipsection.cpp @@ -46,6 +46,17 @@ SectionClass parse_section_class(std::string_view s) { return SectionClass::Other; } +int hull_size(SectionClass c) { + switch (c) { + case SectionClass::Cruiser: return 1; + case SectionClass::Dreadnought: return 2; + case SectionClass::Destroyer: + case SectionClass::None: + case SectionClass::Other: return 0; + } + return 0; +} + namespace { // `option` entries: scalar pairs and blocks share the key; the normalised @@ -189,6 +200,7 @@ Loaded load_shipsection(const mars::parse::Document& doc, std::s s.explicit_section = f.opt_bool("explicit_section"); s.autonomous = f.opt_bool("autonomous"); s.nodesign = f.opt_bool("nodesign"); + s.defence_platform = f.opt_bool("defence_platform"); out.value = std::move(s); return out; diff --git a/src/game/data/shipsection.h b/src/game/data/shipsection.h index 3b3c38f..36dccbe 100644 --- a/src/game/data/shipsection.h +++ b/src/game/data/shipsection.h @@ -38,6 +38,14 @@ std::string_view section_class_name(SectionClass c); SectionType parse_section_type(std::string_view s); SectionClass parse_section_class(std::string_view s); +// The hull-size ordinal the game carries alongside the class: 0 destroyer, +// 1 cruiser, 2 dreadnought. It is what the class-name lookup yields -- the +// original walks the same three names case-insensitively and stores the index +// it stopped at, and anything it does not recognise (including an absent +// `section_class`, which leaves the field at its constructed zero) is 0. So +// None and Other are 0 here too, deliberately, and not an error. +int hull_size(SectionClass c); + struct OptionGroup { std::vector members; // tech names, file order bool scalar = false; // written as `option T` (one member) @@ -103,6 +111,10 @@ struct ShipSectionDef { std::vector exclude; std::string explicit_command_section, explicit_engine_section; std::optional explicit_section, autonomous, nodesign; + // `defence_platform`: one bit of the section's role-flag word. The word is + // OR-ed across a design's sections and read back by the per-hull-class ship + // census, which counts platforms separately from ships. See design/hull.h. + std::optional defence_platform; Block raw; diff --git a/src/game/design/CMakeLists.txt b/src/game/design/CMakeLists.txt index 8b83d7b..60500a5 100644 --- a/src/game/design/CMakeLists.txt +++ b/src/game/design/CMakeLists.txt @@ -6,6 +6,7 @@ # add_subdirectory(src/game/data); link game_design. add_library(game_design STATIC design.cpp + hull.cpp rules.cpp stats.cpp defaults.cpp diff --git a/src/game/design/design.h b/src/game/design/design.h index 93f8fc3..06397d1 100644 --- a/src/game/design/design.h +++ b/src/game/design/design.h @@ -1,12 +1,19 @@ // game::design -- the ship-design record and the validator's report types. // // A design is what the save file stores per `Des` entry (SHIP_DESIGN_RULES.md -// section 1): a name, two flags, and FIVE section slots of which the engine -// uses three -- command, mission, engine; slots 3 and 4 are reserved and -// always empty. Each slot names a section as (species index, section id in -// that race's `_shipsections.txt`), carries one weapon reference per `bank{}` -// block of the section in file order, and the list of option techs the -// section instance was built with. +// section 1): a name, two flags, and THREE section slots -- command, mission, +// engine. Each slot names a section as (species index, section id in that +// race's `_shipsections.txt`), carries one weapon reference per `bank{}` block +// of the section in file order, and the list of option techs the section +// instance was built with. +// +// The array below holds five, and slots 3 and 4 are inert slack belonging to +// THIS engine, not to the game. The record really is three sections: both +// design writers are recovered and the base one emits exactly three section +// frames, and the constructor builds a three-element array. The earlier "five +// slots, two reserved" reading came from the reference Python reader sweeping +// the derived writer's Dtc and Dwgv tail into its section list. The slack is +// kept only so fixtures generated before that was fixed still load. // // This record is deliberately the save's shape; hand-written designs may // name sections by stem and weapons by stem instead of by id, and the rules diff --git a/src/game/design/hull.cpp b/src/game/design/hull.cpp new file mode 100644 index 0000000..ac83033 --- /dev/null +++ b/src/game/design/hull.cpp @@ -0,0 +1,54 @@ +#include "game/design/hull.h" + +#include "game/design/rules.h" + +namespace game::design { + +HullClass classify_sections(const std::vector& sections) { + HullClass h; + for (const data::ShipSectionDef* s : sections) { + if (!s) continue; + ++h.resolved_sections; + // Assignment, not max: the last resolved section in slot order wins, + // which is what the original's stats pass does. + h.hull_size = data::hull_size(s->section_class); + // OR: one section carrying the bit makes the whole design a platform. + if (s->defence_platform.value_or(false)) h.defence_platform = true; + } + return h; +} + +HullClass classify_design(const data::Catalog& cat, const Design& d) { + const Ruleset rules(cat); + const data::Species race = d.effective_race(); + std::vector found; + HullClass h; + for (Slot slot : kUsedSlots) { + const SectionUse& use = d.slot(slot); + if (use.empty()) continue; + const data::ShipSectionDef* s = + rules.resolve_section(use.species == data::Species::Unknown ? race : use.species, use); + if (!s) { + ++h.unresolved_sections; + continue; + } + found.push_back(s); + } + const int unresolved = h.unresolved_sections; + h = classify_sections(found); + h.unresolved_sections = unresolved; + return h; +} + +void ShipCensus::add(const HullClass& h) { + const std::size_t col = static_cast(h.hull_size < 0 ? 0 : (h.hull_size > 2 ? 2 : h.hull_size)); + if (h.defence_platform) { + ++platforms[col]; + ++total_platforms; + } else { + ++ships[col]; + ++total_ships; + } +} + +} // namespace game::design diff --git a/src/game/design/hull.h b/src/game/design/hull.h new file mode 100644 index 0000000..b99e70d --- /dev/null +++ b/src/game/design/hull.h @@ -0,0 +1,88 @@ +// game::design -- hull size and the defence-platform class flag, and the +// per-hull-class ship census built from them. +// +// WHY THIS EXISTS +// --------------- +// The per-player turn record the game archives every turn carries six ship +// counts: ships by hull size 0/1/2, and *defence platforms* by hull size +// 0/1/2, written to the wire as three `cls` groups of `shpt` (ships total) +// and `satt` (satellites total). Neither the hull size nor the platform flag +// is on the wire anywhere -- both are recomputed from the section catalog +// whenever a design changes -- so the census cannot be reproduced from a save +// alone until a reader can classify a design the way the game does. +// +// HOW THE ORIGINAL DOES IT +// ------------------------ +// A design caches two derived words that are recomputed by its stats pass: +// +// * a 64-bit role-flag word, the OR of every resolved section's own flag +// word. `defence_platform` is one bit of it (the low word's 0x400). +// * a hull-size ordinal, assigned -- not OR-ed, not max-ed -- from each +// resolved section's `section_class` in slot order, so the last resolved +// section wins. Every shipped design is class-homogeneous (rule A6), so +// first-wins and last-wins agree on all 503 design records in the save +// corpus; the assignment order is reproduced anyway because it is what +// the original does, and a hand-built mixed-class design would show it. +// +// The census then walks the fleets a player owns, and for every ship takes +// its design's two words: platform bit set -> the platform row, else the ship +// row; hull size picks the column. +// +// NOT THE SAME 0x400 +// ------------------ +// A *fleet* also carries a flag word whose 0x400 bit is set when the retreat +// pipeline creates a fleet, and it appears on the wire as `FtFlg`. It is a +// different word on a different object and has nothing to do with this one. +// Reusing the numeral is a coincidence of two bit layouts. +#pragma once + +#include +#include +#include + +#include "game/data/catalog.h" +#include "game/data/shipsection.h" +#include "game/design/design.h" + +namespace game::design { + +// What the census needs to know about one design. +struct HullClass { + int hull_size = 0; // 0 destroyer, 1 cruiser, 2 dreadnought + bool defence_platform = false; // any resolved section carries the flag + int resolved_sections = 0; // sections that named a section the catalog holds + int unresolved_sections = 0; // named a section the catalog does not hold + + // True when at least one section resolved. A design none of whose sections + // resolve is not classifiable and must not be silently counted as a + // destroyer: callers should treat it as a gap, not as class 0. + bool ok() const { return resolved_sections > 0; } +}; + +// Classify from already-resolved section definitions, in the design's slot +// order. Null entries are empty slots and are skipped. +HullClass classify_sections(const std::vector& sections); + +// Classify a design record against a catalog. Slots are visited in +// command / mission / engine order; an empty slot is skipped, and a slot that +// names a section the catalog does not hold is counted in +// `unresolved_sections` and otherwise ignored. +HullClass classify_design(const data::Catalog& cat, const Design& d); + +// Six counters: ships and defence platforms, each by hull size. +struct ShipCensus { + std::array ships{}; // designs WITHOUT the platform flag + std::array platforms{}; // designs WITH it + + // The original also accumulates the two grand totals and then throws them + // away without storing them; they are kept here because they are free and + // because a test that checks only the six stored counters cannot tell a + // miscounted ship from an unclassified one. + int total_ships = 0, total_platforms = 0; + + void add(const HullClass& h); + int ship_total() const { return total_ships; } + int platform_total() const { return total_platforms; } +}; + +} // namespace game::design diff --git a/src/game/design/stats.cpp b/src/game/design/stats.cpp index 0697faf..8ad48d2 100644 --- a/src/game/design/stats.cpp +++ b/src/game/design/stats.cpp @@ -90,6 +90,8 @@ DesignStats derive_stats(const Ruleset& rules, const Design& d) { st.maintenance_cost += s->maintenance_cost.value_or(0); if (s->command_quota) st.command_quota = st.command_quota.value_or(0) + *s->command_quota; if (st.hull_class.empty() && !s->section_class_text.empty()) st.hull_class = data::fold(s->section_class_text); + st.hull_size = data::hull_size(s->section_class); + if (s->defence_platform.value_or(false)) st.defence_platform = true; for (std::string_view key : capacity_keys()) if (const data::Attr* a = s->raw.find(key)) set_capacity(st, key, a->value); if (s->section_type == data::SectionType::Engine || (slot == Slot::Mission && !st.ftlspeed && s->ftlspeed)) { diff --git a/src/game/design/stats.h b/src/game/design/stats.h index b14118c..a9c0057 100644 --- a/src/game/design/stats.h +++ b/src/game/design/stats.h @@ -37,6 +37,12 @@ struct SectionStats { struct DesignStats { std::string race, name; std::string hull_class; // section_class of the first resolved section (folded) + // The two words the ship census reads. `hull_size` follows the original's + // last-resolved-section-wins assignment, so it can differ from `hull_class` + // (first-wins) on a hand-built mixed-class design; every shipped design is + // class-homogeneous, so they agree on all real data. See design/hull.h. + int hull_size = 0; + bool defence_platform = false; std::vector sections; // command, mission, engine order; only resolved slots double mass = 0; diff --git a/src/mars/stream/shapes.h b/src/mars/stream/shapes.h index 02b90b3..6290aa2 100644 --- a/src/mars/stream/shapes.h +++ b/src/mars/stream/shapes.h @@ -851,27 +851,42 @@ struct DesignSection { // Game::ShipDesignDef::Section, one DSec of a Des frame ar.rest(extra); } }; +// A design is written by TWO serializers, a base and a derived one. The base +// emits FAIDes / DHide / DWep / DName and then EXACTLY THREE section frames; +// the derived one appends Dtc, the Dwgv flag and -- only when that flag is set +// -- the weapon-group frame. An earlier note here said the derived writer made +// no stream call at all; that was a misattributed address, and both writers are +// recovered. See findings/objects/ship-design-catalogue.md in the RE repo. +// +// THREE section frames, not five: the "two extra reserved slots" the reference +// Python reader reports are Dtc and Dwgv swept in by its catch-all tail, and +// the base writer's own loop runs three times over a three-element array. +// The run is still read as a run rather than as a fixed three, so a malformed +// record cannot desynchronise the rest of the player block. struct Design { // on-disk tags are case variants of the reference names (FAIDes, DHide, DWep, DName) static constexpr const char* kStreamName = "Des"; bool faiDes = false, dHide = false; - int32_t dWep = 0; + // DWep and Dwgv are BOOLs, not ints -- both writers call the bool + // primitive. With their four-character tags a bool item and an int item are + // the same size on the wire and the values 0/1 are the same bytes, so the + // corpus cannot tell them apart; the writers can. + bool dWep = false; std::string dName; - // The DSec run is uncounted -- it ends when the next tag stops being DSec. - // Game::ShipDesign::Write makes no stream call at all, so there is no recovered - // serializer for the Des frame itself; the section body is - // Game::ShipDesignDef::Section, which there is, and Dtc/Dwgv are from the saves. std::vector sections; - int32_t dtc = 0, dwgv = 0; + int32_t dtc = 0; + bool dwgv = false; + Node weaponGroups; // Dwg; present only when dwgv, which is false in every save available std::vector extra; template void io(Ar& ar) { ar.b(R("faiDes", "FAIDes"), faiDes); ar.b(R("dHide", "DHide"), dHide); - ar.i32(R("dWep", "DWep"), dWep); + ar.b(R("dWep", "DWep"), dWep); ar.str(R("dName", "DName"), dName); ar.repeat("DSec", sections, [](Ar& a, DesignSection& e) { a.obj(A("DSec"), e); }); ar.i32(A("Dtc"), dtc); - ar.i32(A("Dwgv"), dwgv); + ar.b(A("Dwgv"), dwgv); + ar.when(dwgv, [&](Ar& a) { a.any(A("Dwg"), weaponGroups); }); ar.rest(extra); } }; diff --git a/tests/game_design/CMakeLists.txt b/tests/game_design/CMakeLists.txt index ee6994b..f9ab1d4 100644 --- a/tests/game_design/CMakeLists.txt +++ b/tests/game_design/CMakeLists.txt @@ -2,7 +2,7 @@ # build_and_run.sh (plain g++). Include from the root with # add_subdirectory(tests/game_design) after add_subdirectory(src/game/design). add_executable(game_design_unit_tests - test_main.cpp mini_catalog.cpp test_structure.cpp test_banks.cpp test_tech.cpp test_stats.cpp test_json.cpp) + test_main.cpp mini_catalog.cpp test_structure.cpp test_banks.cpp test_tech.cpp test_stats.cpp test_hull.cpp test_json.cpp) target_link_libraries(game_design_unit_tests PRIVATE game_design) target_include_directories(game_design_unit_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_test(NAME game_design_unit COMMAND game_design_unit_tests) @@ -15,3 +15,11 @@ add_test(NAME game_design_realdata COMMAND game_design_realdata_test) # SKIPs add_executable(game_design_dump_designs stock_designs.cpp dump_designs.cpp) target_link_libraries(game_design_dump_designs PRIVATE game_design) target_include_directories(game_design_dump_designs PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +# The ship census against the census the game archived; needs the owner's saves +# AND a data root, and skips cleanly without either. +add_executable(game_design_census_test test_census_saves.cpp) +target_link_libraries(game_design_census_test PRIVATE game_design mars_stream) +target_include_directories(game_design_census_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_compile_options(game_design_census_test PRIVATE -Wall -Wextra -Wpedantic) +add_test(NAME game_design_census COMMAND game_design_census_test) diff --git a/tests/game_design/build_and_run.sh b/tests/game_design/build_and_run.sh index b6d3861..924e59d 100755 --- a/tests/game_design/build_and_run.sh +++ b/tests/game_design/build_and_run.sh @@ -32,13 +32,14 @@ LIB=( "$ROOT/src/game/data/strings.cpp" "$ROOT/src/game/data/catalog.cpp" "$ROOT/src/game/design/design.cpp" "$ROOT/src/game/design/rules.cpp" "$ROOT/src/game/design/stats.cpp" "$ROOT/src/game/design/defaults.cpp" + "$ROOT/src/game/design/hull.cpp" ) mkdir -p "$BUILD" echo "== building unit tests" "$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/test_main.cpp" "$HERE/mini_catalog.cpp" "$HERE/test_structure.cpp" \ - "$HERE/test_banks.cpp" "$HERE/test_tech.cpp" "$HERE/test_stats.cpp" "$HERE/test_json.cpp" -o "$BUILD/unit_tests" + "$HERE/test_banks.cpp" "$HERE/test_tech.cpp" "$HERE/test_stats.cpp" "$HERE/test_hull.cpp" "$HERE/test_json.cpp" -o "$BUILD/unit_tests" echo "== running unit tests" "$BUILD/unit_tests" diff --git a/tests/game_design/stock_designs.cpp b/tests/game_design/stock_designs.cpp index ce7123e..1a3921e 100644 --- a/tests/game_design/stock_designs.cpp +++ b/tests/game_design/stock_designs.cpp @@ -53,12 +53,18 @@ bool load_stock_designs(const std::string& path, std::vector& out, des.default_weapons = d["dWep"].as_int(); des.race = game::data::parse_species(sd.race_text); if (!d["known_techs"].is_null()) des.known_techs = strings(d["known_techs"]); + // Three entries: command, mission, engine. Fixtures generated + // before the reference reader stopped sweeping the design's Dtc and + // Dwgv tail into the section list carry five, the last two empty; + // both are accepted so an old fixture still loads. const Value& raw = d["raw_slots"]; - if (!raw.is_array() || raw.items.size() != static_cast(kSlotCount)) { - err = des.name + ": raw_slots must have " + std::to_string(kSlotCount) + " entries"; + if (!raw.is_array() || raw.items.size() < 3 || + raw.items.size() > static_cast(kSlotCount)) { + err = des.name + ": raw_slots must have 3 to " + std::to_string(kSlotCount) + " entries"; return false; } - for (int i = 0; i < kSlotCount; ++i) { + const int slots = static_cast(raw.items.size()); + for (int i = 0; i < slots; ++i) { const Value& r = raw.items[static_cast(i)]; SectionUse& u = des.slots[static_cast(i)]; int species = r["species"].as_int(); diff --git a/tests/game_design/test_census_saves.cpp b/tests/game_design/test_census_saves.cpp new file mode 100644 index 0000000..434cd3d --- /dev/null +++ b/tests/game_design/test_census_saves.cpp @@ -0,0 +1,225 @@ +// 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; +} diff --git a/tests/game_design/test_hull.cpp b/tests/game_design/test_hull.cpp new file mode 100644 index 0000000..0e71b81 --- /dev/null +++ b/tests/game_design/test_hull.cpp @@ -0,0 +1,146 @@ +// Hull size, the defence-platform class flag, and the six-counter census. +#include "game/design/hull.h" + +#include "game/design/stats.h" +#include "mini_catalog.h" +#include "test_main.h" + +using namespace game::data; +using namespace game::design; + +namespace { + +Design platform() { + Design d; + d.name = "Light Defense Platform"; + d.race = Species::Human; + d.mission() = use("DEDefencePlatform", {"bal_gauss", "bal_gauss", "bal_gauss", "bal_gauss", "mis"}); + return d; +} + +Design cruiser() { + Design d; + d.name = "Cruiser"; + d.race = Species::Human; + d.command() = use("CRCommand", {}); + d.mission() = use("CRArmor", {}); + d.engine() = use("CRFission", {}); + return d; +} + +} // namespace + +TEST(hull_size_from_section_class) { + CHECK_EQ(hull_size(SectionClass::Destroyer), 0); + CHECK_EQ(hull_size(SectionClass::Cruiser), 1); + CHECK_EQ(hull_size(SectionClass::Dreadnought), 2); + // An absent or unrecognised class is a destroyer, not an error: the + // original's name lookup fails and leaves the field at zero. + CHECK_EQ(hull_size(SectionClass::None), 0); + CHECK_EQ(hull_size(SectionClass::Other), 0); +} + +TEST(hull_class_of_a_stock_destroyer) { + HullClass h = classify_design(mini_catalog(), armor()); + CHECK(h.ok()); + CHECK_EQ(h.hull_size, 0); + CHECK(!h.defence_platform); + CHECK_EQ(h.resolved_sections, 3); + CHECK_EQ(h.unresolved_sections, 0); +} + +TEST(hull_class_of_a_cruiser) { + HullClass h = classify_design(mini_catalog(), cruiser()); + CHECK(h.ok()); + CHECK_EQ(h.hull_size, 1); + CHECK(!h.defence_platform); +} + +TEST(defence_platform_flag_is_read_from_the_section) { + const ShipSectionDef* s = mini_catalog().section("Human", "DEDefencePlatform"); + CHECK(s != nullptr); + if (s) CHECK(s->defence_platform.value_or(false)); + const ShipSectionDef* armorsec = mini_catalog().section("Human", "DEArmor"); + CHECK(armorsec != nullptr); + if (armorsec) CHECK(!armorsec->defence_platform.value_or(false)); + + HullClass h = classify_design(mini_catalog(), platform()); + CHECK(h.ok()); + CHECK(h.defence_platform); + CHECK_EQ(h.hull_size, 0); + CHECK_EQ(h.resolved_sections, 1); +} + +TEST(one_flagged_section_makes_the_whole_design_a_platform) { + // The flag word is OR-ed across sections, so a design that mixes a + // flagged section with unflagged ones is still a platform. No shipped + // design does this -- the platforms are all standalone mission sections -- + // so this is the rule, tested where the data cannot show it. + Design d = armor(); + d.mission() = use("DEDefencePlatform", {"bal_gauss", "bal_gauss", "bal_gauss", "bal_gauss", "mis"}); + HullClass h = classify_design(mini_catalog(), d); + CHECK(h.defence_platform); + CHECK_EQ(h.resolved_sections, 3); +} + +TEST(hull_size_takes_the_last_resolved_section) { + // Assignment in slot order, not first-wins and not max. Class-mixing is a + // rule A6 error, so this can only be shown on a design the validator + // rejects -- which is exactly why it is worth pinning. + Design d; + d.race = Species::Human; + d.command() = use("DECommand", {"bal_gauss"}); + d.mission() = use("CRArmor", {}); + HullClass h = classify_design(mini_catalog(), d); + CHECK_EQ(h.resolved_sections, 2); + CHECK_EQ(h.hull_size, 1); // the cruiser mission section, visited last + + Design r; + r.race = Species::Human; + r.command() = use("CRCommand", {}); + r.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"}); + CHECK_EQ(classify_design(mini_catalog(), r).hull_size, 0); +} + +TEST(an_unresolvable_design_is_reported_not_counted_as_a_destroyer) { + Design d; + d.race = Species::Human; + d.mission() = use("NoSuchSection", {}); + HullClass h = classify_design(mini_catalog(), d); + CHECK(!h.ok()); + CHECK_EQ(h.resolved_sections, 0); + CHECK_EQ(h.unresolved_sections, 1); +} + +TEST(derive_stats_carries_the_two_census_words) { + DesignStats st = derive_stats(mini_rules(), armor()); + CHECK_EQ(st.hull_size, 0); + CHECK(!st.defence_platform); + + DesignStats p = derive_stats(mini_rules(), platform()); + CHECK_EQ(p.hull_size, 0); + CHECK(p.defence_platform); + + DesignStats c = derive_stats(mini_rules(), cruiser()); + CHECK_EQ(c.hull_size, 1); + CHECK_EQ(c.hull_class, "cruiser"); +} + +TEST(census_counts_ships_and_platforms_separately) { + ShipCensus c; + HullClass de; // destroyer ship + HullClass cr; cr.hull_size = 1; cr.resolved_sections = 1; + HullClass dn; dn.hull_size = 2; dn.resolved_sections = 1; + HullClass pl; pl.defence_platform = true; pl.resolved_sections = 1; + de.resolved_sections = 1; + + c.add(de); c.add(de); c.add(cr); c.add(dn); c.add(pl); c.add(pl); c.add(pl); + CHECK_EQ(c.ships[0], 2); + CHECK_EQ(c.ships[1], 1); + CHECK_EQ(c.ships[2], 1); + CHECK_EQ(c.platforms[0], 3); + CHECK_EQ(c.platforms[1], 0); + CHECK_EQ(c.platforms[2], 0); + CHECK_EQ(c.ship_total(), 4); + CHECK_EQ(c.platform_total(), 3); +}