merge lane D2: ship-design persistence, hull class and defence-platform flag; census verified 480 leaves

This commit is contained in:
alex 2026-09-08 12:48:44 -04:00
commit 5a6a3155dc
14 changed files with 635 additions and 19 deletions

View file

@ -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<ShipSectionDef> 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;

View file

@ -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<std::string> members; // tech names, file order
bool scalar = false; // written as `option T` (one member)
@ -103,6 +111,10 @@ struct ShipSectionDef {
std::vector<std::string> exclude;
std::string explicit_command_section, explicit_engine_section;
std::optional<bool> 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<bool> defence_platform;
Block raw;

View file

@ -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

View file

@ -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

54
src/game/design/hull.cpp Normal file
View file

@ -0,0 +1,54 @@
#include "game/design/hull.h"
#include "game/design/rules.h"
namespace game::design {
HullClass classify_sections(const std::vector<const data::ShipSectionDef*>& 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<const data::ShipSectionDef*> found;
HullClass h;
for (Slot slot : kAggregationOrder) {
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<std::size_t>(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

100
src/game/design/hull.h Normal file
View file

@ -0,0 +1,100 @@
// 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 <array>
#include <cstdint>
#include <vector>
#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; }
};
// The order the original's stats pass visits the slots in, which is the design
// record's IN-MEMORY array order and NOT its wire order. The wire writes
// command, mission, engine; the array holds mission, command, engine. The
// distinction only shows on hull size, because that is an assignment: a design
// with an empty engine slot and both other slots filled would take its class
// from the command section here and from the mission section under wire order.
// Rule A3 makes that shape invalid (a mission section without sockets requires
// both partners empty, with sockets requires both filled), so no design in the
// corpus can tell them apart -- which is exactly why the order is spelled out
// rather than left to whichever loop was convenient.
constexpr std::array<Slot, 3> kAggregationOrder{Slot::Mission, Slot::Command, Slot::Engine};
// Classify from already-resolved section definitions, in `kAggregationOrder`.
// Null entries are empty slots and are skipped.
HullClass classify_sections(const std::vector<const data::ShipSectionDef*>& sections);
// Classify a design record against a catalog. Slots are visited in
// `kAggregationOrder`; 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<int, 3> ships{}; // designs WITHOUT the platform flag
std::array<int, 3> 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

View file

@ -1,4 +1,5 @@
#include "game/design/stats.h"
#include "game/design/hull.h"
namespace game::design {
@ -90,6 +91,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);
// The role-flag word is an OR, so slot order does not matter here.
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)) {
@ -100,6 +103,12 @@ DesignStats derive_stats(const Ruleset& rules, const Design& d) {
}
st.sections.push_back(std::move(sec));
}
// Hull size is an ASSIGNMENT in the record's in-memory slot order, so it is
// taken after the loop above rather than inside it -- that loop runs in wire
// order, which is not the same order. See design/hull.h.
for (Slot slot : kAggregationOrder)
for (const SectionStats& sec : st.sections)
if (sec.slot == slot && sec.section) st.hull_size = data::hull_size(sec.section->section_class);
st.total_cost_estimate = st.section_cost_with_options + st.weapon_cost_per_bank;
return st;
}

View file

@ -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<SectionStats> sections; // command, mission, engine order; only resolved slots
double mass = 0;

View file

@ -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<DesignSection> 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<Node> extra;
template <class Ar>
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);
}
};

View file

@ -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)

View file

@ -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"

View file

@ -53,12 +53,18 @@ bool load_stock_designs(const std::string& path, std::vector<StockDesign>& 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<std::size_t>(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<std::size_t>(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<int>(raw.items.size());
for (int i = 0; i < slots; ++i) {
const Value& r = raw.items[static_cast<std::size_t>(i)];
SectionUse& u = des.slots[static_cast<std::size_t>(i)];
int species = r["species"].as_int();

View file

@ -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 <array>
#include <cstdio>
#include <cstdlib>
#include <map>
#include <string>
#include <vector>
#include <dirent.h>
#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<const ShipSectionDef*> 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>(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<std::string> 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<int, 6> 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<std::int32_t, HullClass> 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<Species>(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<std::int32_t, std::size_t> playerIndex;
for (std::size_t i = 0; i < sim.players.size(); ++i) playerIndex[sim.players[i].playerID] = i;
std::vector<ShipCensus> 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<std::size_t>(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;
}

View file

@ -0,0 +1,160 @@
// 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_in_memory_order) {
// Assignment, not first-wins and not max, and the order is the record's
// in-memory array order -- mission, command, engine -- not its wire order.
// Class-mixing is a rule A6 error and an engine-less two-slot design breaks
// rule A3, so this can only be shown on designs the validator rejects,
// which is exactly why it is worth pinning.
CHECK(kAggregationOrder[0] == Slot::Mission);
CHECK(kAggregationOrder[1] == Slot::Command);
CHECK(kAggregationOrder[2] == Slot::Engine);
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, 0); // the destroyer COMMAND 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, 1); // the cruiser command section
// With the engine slot filled, the engine section wins whatever the others say.
Design e;
e.race = Species::Human;
e.command() = use("DECommand", {"bal_gauss"});
e.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"});
e.engine() = use("CRFission", {});
CHECK_EQ(classify_design(mini_catalog(), e).hull_size, 1);
}
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);
}