lane D2: hull size + the defence-platform flag; the ship census reproduces 480/480
The two derived words the per-player turn record's ship census counts by, and the design serializers that three lanes had been told did not exist. HOW DESIGNS PERSIST. Game::ShipDesign derives from Game::ShipDesignDef and reaches IStreamable through adjustor thunks, so a design is written by TWO serializers: the base emits FAIDes/DHide/DWep/DName and exactly three section frames (command, mission, engine on the wire), the derived one appends Dtc, the Dwgv flag and, only when that flag is set, a weapon-group frame. The earlier "the writer makes no stream call at all" note named an address that is in no vftable at all. Corrected in shapes.h. THREE sections, not five. The "two reserved slots" were Dtc and Dwgv swept into the section list by the reference reader's catch-all tail; the constructor builds a three-element array. design.h's comment is corrected and the fixture loader now accepts 3-5 raw_slots so old fixtures still load; the array keeps five inert entries deliberately, since touching the slot enum reaches rules.cpp and another lane's tests for no behavioural gain. DWep and Dwgv are bools, not ints -- both writers call the bool primitive. With four-character tags a bool item and an int item are the same size on the wire and 0/1 the same bytes, so no save can tell them apart. Byte-neutral: the typed round trip is still byte-identical on all 11 saves at 100% named coverage. HULL SIZE is the section_class of the last resolved section in memory slot order, mapped Destroyer/Cruiser/Dreadnought -> 0/1/2 case-insensitively, with absent or unrecognised meaning 0 rather than an error. The DEFENCE-PLATFORM flag is one bit of a 64-bit role-flag word OR-ed across the design's sections. Neither is on the wire; both are rebuilt from the section catalog. MEASURED, not assumed: the new game_design_census test rebuilds the six census counters per player and compares them against the record the game archived for each save's own frame. 11 saves, 503 designs, 480 leaves, 0 mismatched, 0 ships with an unresolvable design, 0 designs where first- and last-resolved section disagree on hull size. COVERAGE IS THIN AND THE TEST SAYS SO: only 32 of the 480 leaves are nonzero, and three of the six census leaves (both cruiser rows and dreadnought platforms) are never exercised by any save in the corpus -- the test prints the per-leaf nonzero counts and names them unexercised rather than verified. Nothing is wired into the turn record: src/app is another lane's this cycle, so this is evaluated and reported, not written. host ctest 43/43 (was 42/42; +1, skips cleanly without the env). With a data root set, game_data_realdata and mars_text_realdata fail identically on main -- both are the absent Locale/EN/Strings.csv, not this change. clean-room OK. Reference readers fixed openly in the RE repo: save_reader 49/49, design rules 32/32, stock_designs.json regenerated (raw_slots 5->3 and dWep int->bool are the only field changes across all 127 designs).
This commit is contained in:
parent
b2bad30f6c
commit
f177a5fdd0
14 changed files with 602 additions and 19 deletions
|
|
@ -46,6 +46,17 @@ SectionClass parse_section_class(std::string_view s) {
|
||||||
return SectionClass::Other;
|
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 {
|
namespace {
|
||||||
|
|
||||||
// `option` entries: scalar pairs and blocks share the key; the normalised
|
// `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.explicit_section = f.opt_bool("explicit_section");
|
||||||
s.autonomous = f.opt_bool("autonomous");
|
s.autonomous = f.opt_bool("autonomous");
|
||||||
s.nodesign = f.opt_bool("nodesign");
|
s.nodesign = f.opt_bool("nodesign");
|
||||||
|
s.defence_platform = f.opt_bool("defence_platform");
|
||||||
|
|
||||||
out.value = std::move(s);
|
out.value = std::move(s);
|
||||||
return out;
|
return out;
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ std::string_view section_class_name(SectionClass c);
|
||||||
SectionType parse_section_type(std::string_view s);
|
SectionType parse_section_type(std::string_view s);
|
||||||
SectionClass parse_section_class(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 {
|
struct OptionGroup {
|
||||||
std::vector<std::string> members; // tech names, file order
|
std::vector<std::string> members; // tech names, file order
|
||||||
bool scalar = false; // written as `option T` (one member)
|
bool scalar = false; // written as `option T` (one member)
|
||||||
|
|
@ -103,6 +111,10 @@ struct ShipSectionDef {
|
||||||
std::vector<std::string> exclude;
|
std::vector<std::string> exclude;
|
||||||
std::string explicit_command_section, explicit_engine_section;
|
std::string explicit_command_section, explicit_engine_section;
|
||||||
std::optional<bool> explicit_section, autonomous, nodesign;
|
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;
|
Block raw;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
# add_subdirectory(src/game/data); link game_design.
|
# add_subdirectory(src/game/data); link game_design.
|
||||||
add_library(game_design STATIC
|
add_library(game_design STATIC
|
||||||
design.cpp
|
design.cpp
|
||||||
|
hull.cpp
|
||||||
rules.cpp
|
rules.cpp
|
||||||
stats.cpp
|
stats.cpp
|
||||||
defaults.cpp
|
defaults.cpp
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
// game::design -- the ship-design record and the validator's report types.
|
// 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
|
// 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
|
// section 1): a name, two flags, and THREE section slots -- command, mission,
|
||||||
// uses three -- command, mission, engine; slots 3 and 4 are reserved and
|
// engine. Each slot names a section as (species index, section id in that
|
||||||
// always empty. Each slot names a section as (species index, section id in
|
// race's `_shipsections.txt`), carries one weapon reference per `bank{}` block
|
||||||
// that race's `_shipsections.txt`), carries one weapon reference per `bank{}`
|
// of the section in file order, and the list of option techs the section
|
||||||
// block of the section in file order, and the list of option techs the
|
// instance was built with.
|
||||||
// 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
|
// 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
|
// 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 : 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<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
|
||||||
88
src/game/design/hull.h
Normal file
88
src/game/design/hull.h
Normal file
|
|
@ -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 <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; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<const data::ShipSectionDef*>& 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<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
|
||||||
|
|
@ -90,6 +90,8 @@ DesignStats derive_stats(const Ruleset& rules, const Design& d) {
|
||||||
st.maintenance_cost += s->maintenance_cost.value_or(0);
|
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 (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);
|
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())
|
for (std::string_view key : capacity_keys())
|
||||||
if (const data::Attr* a = s->raw.find(key)) set_capacity(st, key, a->value);
|
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)) {
|
if (s->section_type == data::SectionType::Engine || (slot == Slot::Mission && !st.ftlspeed && s->ftlspeed)) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ struct SectionStats {
|
||||||
struct DesignStats {
|
struct DesignStats {
|
||||||
std::string race, name;
|
std::string race, name;
|
||||||
std::string hull_class; // section_class of the first resolved section (folded)
|
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
|
std::vector<SectionStats> sections; // command, mission, engine order; only resolved slots
|
||||||
|
|
||||||
double mass = 0;
|
double mass = 0;
|
||||||
|
|
|
||||||
|
|
@ -851,27 +851,42 @@ struct DesignSection { // Game::ShipDesignDef::Section, one DSec of a Des frame
|
||||||
ar.rest(extra);
|
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)
|
struct Design { // on-disk tags are case variants of the reference names (FAIDes, DHide, DWep, DName)
|
||||||
static constexpr const char* kStreamName = "Des";
|
static constexpr const char* kStreamName = "Des";
|
||||||
bool faiDes = false, dHide = false;
|
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;
|
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;
|
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;
|
std::vector<Node> extra;
|
||||||
template <class Ar>
|
template <class Ar>
|
||||||
void io(Ar& ar) {
|
void io(Ar& ar) {
|
||||||
ar.b(R("faiDes", "FAIDes"), faiDes);
|
ar.b(R("faiDes", "FAIDes"), faiDes);
|
||||||
ar.b(R("dHide", "DHide"), dHide);
|
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.str(R("dName", "DName"), dName);
|
||||||
ar.repeat("DSec", sections, [](Ar& a, DesignSection& e) { a.obj(A("DSec"), e); });
|
ar.repeat("DSec", sections, [](Ar& a, DesignSection& e) { a.obj(A("DSec"), e); });
|
||||||
ar.i32(A("Dtc"), dtc);
|
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);
|
ar.rest(extra);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# build_and_run.sh (plain g++). Include from the root with
|
# build_and_run.sh (plain g++). Include from the root with
|
||||||
# add_subdirectory(tests/game_design) after add_subdirectory(src/game/design).
|
# add_subdirectory(tests/game_design) after add_subdirectory(src/game/design).
|
||||||
add_executable(game_design_unit_tests
|
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_link_libraries(game_design_unit_tests PRIVATE game_design)
|
||||||
target_include_directories(game_design_unit_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(game_design_unit_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
add_test(NAME game_design_unit COMMAND game_design_unit_tests)
|
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)
|
add_executable(game_design_dump_designs stock_designs.cpp dump_designs.cpp)
|
||||||
target_link_libraries(game_design_dump_designs PRIVATE game_design)
|
target_link_libraries(game_design_dump_designs PRIVATE game_design)
|
||||||
target_include_directories(game_design_dump_designs PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
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/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/design.cpp" "$ROOT/src/game/design/rules.cpp"
|
||||||
"$ROOT/src/game/design/stats.cpp" "$ROOT/src/game/design/defaults.cpp"
|
"$ROOT/src/game/design/stats.cpp" "$ROOT/src/game/design/defaults.cpp"
|
||||||
|
"$ROOT/src/game/design/hull.cpp"
|
||||||
)
|
)
|
||||||
|
|
||||||
mkdir -p "$BUILD"
|
mkdir -p "$BUILD"
|
||||||
|
|
||||||
echo "== building unit tests"
|
echo "== building unit tests"
|
||||||
"$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/test_main.cpp" "$HERE/mini_catalog.cpp" "$HERE/test_structure.cpp" \
|
"$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"
|
echo "== running unit tests"
|
||||||
"$BUILD/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.default_weapons = d["dWep"].as_int();
|
||||||
des.race = game::data::parse_species(sd.race_text);
|
des.race = game::data::parse_species(sd.race_text);
|
||||||
if (!d["known_techs"].is_null()) des.known_techs = strings(d["known_techs"]);
|
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"];
|
const Value& raw = d["raw_slots"];
|
||||||
if (!raw.is_array() || raw.items.size() != static_cast<std::size_t>(kSlotCount)) {
|
if (!raw.is_array() || raw.items.size() < 3 ||
|
||||||
err = des.name + ": raw_slots must have " + std::to_string(kSlotCount) + " entries";
|
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;
|
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)];
|
const Value& r = raw.items[static_cast<std::size_t>(i)];
|
||||||
SectionUse& u = des.slots[static_cast<std::size_t>(i)];
|
SectionUse& u = des.slots[static_cast<std::size_t>(i)];
|
||||||
int species = r["species"].as_int();
|
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;
|
||||||
|
}
|
||||||
146
tests/game_design/test_hull.cpp
Normal file
146
tests/game_design/test_hull.cpp
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue