Compare commits

...

3 commits

29 changed files with 3298 additions and 1 deletions

View file

@ -25,6 +25,7 @@ add_subdirectory(src/mars/vfs) # .gob ZIP reader + native override (lib mars
add_subdirectory(src/mars/stream) # Streamable save format + gzip (lib mars_stream)
add_subdirectory(src/mars/rng) # MT19937 (lib mars_rng)
add_subdirectory(src/game/data) # typed catalogs on mars/parse+text (lib game_data)
add_subdirectory(src/game/design) # ship-design rules + derived stats (lib game_design)
# ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ----
add_library(shim_trace STATIC
@ -60,7 +61,7 @@ else()
add_executable(addr_smoke tests/addr_smoke.cpp)
target_link_libraries(addr_smoke PRIVATE sots_addresses)
add_test(NAME addr_smoke COMMAND addr_smoke)
foreach(_t mars_parse mars_text game_sim mars_vfs mars_stream shim_trace game_data)
foreach(_t mars_parse mars_text game_sim mars_vfs mars_stream shim_trace game_data game_design)
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
add_subdirectory(tests/${_t})
endif()

View file

@ -19,6 +19,7 @@ loads) builds, deploys, hooks, and logs from a real game launch. Engine code acc
- `mars/stream` — the game's self-describing save/serialization format (reader, writer, typed shapes) + gzip; three real saves round-trip byte-identical
- `mars/rng` — MT19937 with save-state load/store; layout confirmed against real saves
- `game/data` — typed catalogs (weapons, ship sections, turrets, id registries, tech tree, strings) with cross-reference checks; 229k values agree with the reference
- `game/design` — ship-design assembly/fit/tech-gating rules and derived stats; validates all 127 stock designs from real saves
Build: `cmake --preset host && cmake --build --preset host && ctest --preset host` (Linux);
`tools/sync-build.sh` cross-builds the shim on the lab box and stages it for deployment.

217
docs/game-design.md Normal file
View file

@ -0,0 +1,217 @@
# `game::design` — ship-design assembly rules
`src/game/design/` decides what a valid `ShipDesignDef` is and what it
weighs, costs and carries, on top of the `game::data` catalogs. Library
target `game_design` (static, links `game_data`), include as
`game/design/<header>.h`. C++17, no dependencies, no exceptions across the
API: every finding is a `Violation`.
Behaviour is specified by the RE findings (`SHIP_DESIGN_RULES.md`, rules
derived from the shipped data and every design in three real saves); the
reference Python validator (`design_rules.py`) is the oracle and this module
is diffed against it design by design (§5).
```
src/game/design/
design.h/.cpp Slot, WeaponRef, SectionUse, Design (the 5-slot save record); Level, Violation
rules.h/.cpp Ruleset::validate; hull_class_tech, applied_techs, options_from_applied,
bank_accepts_class, turret_row, weapon_fits_bank, TechSet
stats.h/.cpp derive_stats -> DesignStats (mass, cost, health, crew, drive, capacities, turrets)
defaults.h/.cpp DefaultWeaponTable (_defaultweapons.txt), fill_empty_banks
tests/game_design/
build_and_run.sh plain g++ build; unit tests; real-data test; dump; oracle comparison
mini_catalog.cpp hand-written miniature data root (4 species, 12 weapons, 19 turret rows, tech tree)
test_structure/banks/tech/stats/json.cpp 43 unit tests, one or more per rule
stock_designs.cpp reads the owner's designs extract (game-derived; never in the repo)
realdata_test.cpp every save design through the rules; the published counts
dump_designs.cpp findings + applied techs + stats per design -> JSON
oracle/compare.py runs design_rules.py on the same designs and diffs
```
## 1. The design record (`design.h`)
The save's `Des` record, kept in its own shape:
| field | save | notes |
|---|---|---|
| `name`, `hidden`, `designer_made`, `default_weapons` | `dName`, `dHide`, `faiDes`, `dWep` | the last two are carried through; their meaning is open |
| `race` | — | the species catalog the design is validated against; `Unknown` → the first filled slot's species (`effective_race()`) |
| `slots[5]` | `DSec[5]` | `Slot::Command 0, Mission 1, Engine 2`; `Reserved3/4` must stay empty (rule A1) |
| `SectionUse::species`, `section_id` | `DSec.species_idx`, `section_id` | `(0, 0)` = empty; `section_id` indexes the race's `_shipsections.txt`; a stem / file name in `section` is accepted instead |
| `SectionUse::weapons` | `DGbnk2` | one `WeaponRef` per `bank{}` in file order: `by_id` (`bID true, wid`), `by_file` (`bID false, wfn`), `by_stem`, `none`; `rider_design_id` = `did` |
| `SectionUse::options` | `DOpts` minus structural techs | the chosen option techs; `Ruleset::strip_applied_techs` turns a raw `DOpts` list into this |
| `known_techs` | player `TechTree` `St == 4` | `nullopt` skips tech gating |
`Violation { rule, level, message, slot, bank }` with `Level::Error`
(invalid), `Warn` (engine accepts it, designer UI would not / only code can
settle it), `Info`. `str()` prints `ERROR B4 [mission bank 2]: …`.
## 2. Rule catalog (`rules.h`)
Ids match the RE document and the reference validator. "Evidence" is what in
the data or the saves forces the rule.
### Structure
| id | rule | evidence |
|---|---|---|
| A1 | slots 3 and 4 are never used | absent in all 127 save designs (our addition; the reference has no such record) |
| A2 | the mission slot is always filled | every design and template has one; standalone hulls sit in the mission slot |
| A3 | mission section **with** `socket_fore/aft` = hull ship: command **and** engine required; **without** = standalone: both must be empty (an unresolvable partner still counts as filled) | 57–61 hull vs 16–21 standalone mission sections per race; `Light Defense Platform` = `[0,0] DEDefencePlatform [0,0]` |
| A4 | `section_type` must match the slot (case-insensitive). A typeless section in the mission slot of a standalone hidden design, or one whose `design_class` is `rider`, is `Info`; anywhere else it is an error | hidden `Default Assault Shuttle` = `[0,0] _AssaultShuttle [0,0]` |
| A5 | one species catalog: the race must have a catalog, every section must resolve in it, and a slot naming another species is an error | per-race `sections/` dirs; the same stem differs per race (`DEFission` requires `DRV_Node` for Human, `DRV_Hyper` for Tarkas); no save design mixes species |
| A6 | all sections share `section_class` | all 127 designs and 87 templates are class-homogeneous — only code can say whether the engine checks or merely never offers a mix |
| A7 | `exclude` list: a section may not be combined with a listed stem | Hiver `DERamScoop exclude {DEFission …}` (ramscoop command sections forbid fission engines) |
| A8 | `explicit_command_section` / `explicit_engine_section` on a mission section pin its partners; `explicit_section true` sections are usable only that way | the six `DNStation*` `_Fore`/`_Aft` triples |
| A9 | `nodesign` = not offered by the designer, **not** an engine rule → `Warn` (silent on hidden designs) | `DNGuard` is a scenario mission section; NPC designs use nodesign sections throughout |
**Sockets are geometry, not a key.** `socket_aft` on a command section and
`socket_fore` on a mission section are mesh node names for snapping the
models; they need not match (`DECommand CommandNode` + `DEColonizer
MissionNode01`). Only their *presence* carries assembly information (A3).
### Banks and weapon fit
| id | rule | evidence |
|---|---|---|
| B1 | exactly one weapon entry per `bank{}` block, in file order | `DGbnk2.n == len(bank{})` on all 197 instances |
| B2 | no bank is left empty | every bank in every save design has a `DW2`; `_defaultweapons.txt` exists to fill them |
| B3 | a bank with `weapon "<file>"` in data is bound to that weapon; an empty entry takes it | 404 NPC banks; NPC designs store the same file in `wfn` |
| B4 | fit = class compatibility **and** a `_turrets.txt` row for `(bank size, weapon size, class)`; a bank without a turret spec takes only its fixed weapon | below |
| B5 | the weapon must exist (id, file or stem); NPC-scope weapons only on NPC hulls | player weapons by id, NPC by path |
| B6 | `exclusive_species` | `bal_grapple`, `bal_disruptorwhip` are Zuul-only |
| B7 | rider weapons (`compatible_section`) need one of those rider sections in the race catalog | `DNCarrier`'s `tarkahunter` bank exists only in the Tarkas catalog |
| B8 | `hidden` weapons are engine-only unless the design is hidden or the bank is fixed | `mis_planet*`, `_mis_*_warhead`, Silicoid guns |
**B4 in detail.** `bank_accepts_class`: `standard ← {standard, missile,
grapple}`, `strafe ← {standard}`, everything else exact. Stock `Armor` puts
`mis` (medium/missile) in `DEArmor`'s medium standard bank; no weapon is
class `strafe` and the NPC `_HeraldDefender` strafe bank carries a
standard-class beamer; grapples have no banks of their own (assumption).
`turret_row` looks the row up by the **weapon's** class first (a missile in
a standard bank gets `turret_m1missile.x`), then by the bank's class (the
strafe rows are the fallback). Consequences: tiny (PD) weapons fit only
small standard/strafe banks; a large standard bank takes 54 player weapons.
### Tech gating
| id | rule | evidence |
|---|---|---|
| C1 | every section `requires` must be researched; `GRP_<g>` is satisfied by any researched tech of `group g` (case-insensitive). A token that is neither a tech nor a group is `Warn` | 970 refs; groups PRJCTR/PD/HVYBEAM/SPINAL/TORPS/MINES/BIOMISSILE/JAMMING/SHIELDS |
| C2 | every weapon `requires` (AND) must be researched; fixed NPC banks are exempt | 189 refs |
| C3 | chosen options must be researched (when the option is a tech node) | turn-1 designs choose none; NPC players (all 293 techs) choose one per group |
| C4 | **hull-class tech**: cruiser sections need `IND_CruisCon`, dreadnought `IND_DreadCon`; `design_class station` sections are exempt; destroyers need nothing | `DOpts` carries the tech first for every CR/DN section and omits it for `_AsteroidMonitor*` |
| C5 | a tech whose every incoming `allows` edge writes `<Race>:0` can never be researched by that race → its sections are unbuildable for it, researched or not | `DRV_Node` Human-only, `DRV_Hyper` Tarkas-only; `_NPC` is never written and defaults to 100 |
**Hidden default rider designs** (code rule the data does not show): the
engine creates `Default Assault Shuttle` for every race at game start; the
Tarkas `_AssaultShuttle` requires `DRN_AdvFrm`, unresearched on turn 1. On
`hidden` designs C1–C4 are reported at `Warn`.
Not a gate: the tech tree's `ship { section X }` lists are the "new section"
UI notice (139 of ~150 stems differ from the sections' own `requires`).
### Options
| id | rule | evidence |
|---|---|---|
| D1 | a chosen option must be a member of one of the section's groups (`option` entries, then `optiondef`); at most one per group | no tech is in two groups of one section; NPC `DOpts` hold one per group |
| D2 | `applied_techs = [hull-class tech] + requires + chosen options in group order` is what the save stores | 197/197 instances |
## 3. API
```cpp
using namespace game::design;
data::Catalog cat = data::load_catalog(root);
Ruleset rules(cat); // precomputes the C5 availability index
Design d; d.race = data::Species::Human; d.known_techs = player_techs;
d.command() = {.., .section = "DECommand", .weapons = {WeaponRef::by_id(8)}};
d.mission().section_id = 37; // by id, like the save
...
std::vector<Violation> v = rules.validate(d); has_errors(v); rules_at(v, Level::Warn);
DesignStats st = derive_stats(rules, d); st.mass, st.total_cost_estimate, st.sections[i].banks[j].turret
applied_techs(section, chosen) // == DOpts
rules.strip_applied_techs(d); // DOpts -> chosen options, in place
weapon_fits_bank(cat.turrets, bank, weapon) // Fit { ok, why, row }
DefaultWeaponTable t = *load_default_weapons(root).value; fill_empty_banks(d, cat, t);
```
Resolution: a section by `(species, section_id)` through the race's id
registry, else by stem / file name; a weapon by id, full path, bare file
name or stem (player catalog wins on a stem clash). Everything folds ASCII
case.
## 4. Derived stats (`stats.h`)
| stat | derivation | status |
|---|---|---|
| `mass`, `health_total`, `crew`, `cpoints` | sums of the section fields (`crew false` → 0) | data; whether weapons add mass is code |
| `section_cost`, `section_cost_with_options` | sum of `cost`, and × the product of the chosen options' `option_cost` | the multiplier is an assumption |
| `weapon_cost_per_bank` / `_per_mount` | weapon `cost` once per bank / × mounts | which one the engine uses is code; `total_cost_estimate` uses per-bank |
| `banks`, `turrets` | bank count, one turret per mount; each bank's `_turrets.txt` row (model, health, track speed) | data |
| `command_cost`, `maintenance_cost`, `command_quota` | sums | data |
| `ftlspeed`, `nodespeed`, `engine_techera`, `netforcelimits` | from the engine section, or the mission section of a standalone hull | the strategic/tactical speed formulas are code |
| `capacities` | role flags and capacities copied from `raw` (text as written) | data |
| `applied_techs` per section | D2 | 197/197 |
## 5. Tests and results
`tests/game_design/build_and_run.sh` (plain `g++ -std=c++17 -Wall -Wextra
-Wpedantic -Werror`; CMake wiring in `tests/game_design/CMakeLists.txt`):
1. **Unit tests** — 43 cases on the hand-written mini root: every rule in
§2 in both directions (valid stock `Armor`, then each way to break it),
resolution by id / species / file name, the class-accept table and the
row lookup order (weapon class first, bank class fallback), hidden-design
downgrades, `GRP_` satisfaction, C5 availability incl. the `_NPC`
default, `applied_techs` order with `optiondef` last, the DOpts round
trip, stats sums, the option multiplier, NPC fixed-weapon costing, the
default-weapon table and `fill_empty_banks`, the JSON reader.
2. **Real-data test** (`SOTS_DATA_DIR` + `SOTS_DESIGNS_JSON`, else SKIP) —
every design of every player in the three saves, built from the raw
`(species, section_id, weapons, DOpts)` record.
3. **Oracle comparison** — `dump_designs` writes findings, applied techs
and stats per design; `oracle/compare.py` runs `design_rules.py` on the
same designs and requires identical finding sets `(rule, level, slot,
bank)`, identical applied techs and options, and equal stats.
### Results (2026-09-07, owner's `gob-extract` + 127-design extract)
| what | result |
|---|---|
| designs clean on structure / banks / fit / options / gating | **127 / 127** |
| section instances whose `DOpts` = `applied_techs()` | **197 / 197** |
| tech gating vs the owning player's researched techs | **121 / 127**; the 6 others are warn-only: the hidden Tarkas `Default Assault Shuttle` in each save for both Tarkas players (`_AssaultShuttle requires DRN_AdvFrm`) |
| oracle: findings | 62 / 62 identical (50 designs carry findings: A4 info on the 12 hidden riders, C1 warn on the 6 Tarkas ones, A9 warn on NPC nodesign hulls) |
| oracle: applied techs, options, stats | 9,673 values, **0 differences** |
Skips cleanly with the variables unset; `tests/game_design/build/` is
git-ignored; nothing from the game is committed (`tools/clean_room_check.sh`
OK).
## 6. Open questions (only code can settle)
1. **A6 class equality** — enforced in validation, or only by the designer
never offering a mix? Implemented as an error.
2. **B4 class-accept table** — `standard ← missile` and `strafe ← standard`
are data-forced; `standard ← grapple` is the weakest assumption (the two
Zuul grapples would otherwise be unmountable). Whether `tiny` really is
small-only, and the row-lookup order.
3. **C4 hull-class tech** — injected from `section_class` with the station
exemption; design-time or build-time check?
4. **Hidden default designs** — where the engine builds them and why
`requires` is skipped (modelled as warn-level gating on `hidden`).
5. **Option semantics** — default pick per group, auto-upgrade, and what
`option_cost` multiplies (section cost assumed; build time?).
6. **Cost / mass / speed** — weapon cost per bank or per mount; whether
weapons add mass; how `netforcelimits` + mass and `ftlspeed`/`nodespeed`
+ `TECHBEN_STRATSPD` become speeds.
7. **Missiles and `WEP_Nukes`** — `mis` has no `requires`; gated in code?
8. **`faiDes`, `dWep`** — carried through as `designer_made`,
`default_weapons`; meaning open.
9. **Empty banks (B2)** — can a design be saved with one, or does the
designer always fill from `_defaultweapons.txt` (`fill_empty_banks`)?
10. **Repeated keys** — first or last value (`game::data` takes last).
11. **Mixed-species records** — the record allows it; modelled as an A5
error for want of any example.

View file

@ -0,0 +1,16 @@
# game_design -- ship-design assembly rules on the game_data catalogs: the
# five-slot design record, the validator (structure / banks & weapon fit /
# tech gating / options), applied_techs (the save's DOpts), derived stats and
# the default-weapon table.
# Include from the root with add_subdirectory(src/game/design) after
# add_subdirectory(src/game/data); link game_design.
add_library(game_design STATIC
design.cpp
rules.cpp
stats.cpp
defaults.cpp
)
target_include_directories(game_design PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../../..)
target_link_libraries(game_design PUBLIC game_data)
target_compile_features(game_design PUBLIC cxx_std_17)
target_compile_options(game_design PRIVATE -Wall -Wextra -Wpedantic -Werror)

View file

@ -0,0 +1,71 @@
#include "game/design/defaults.h"
#include "game/design/rules.h"
#include "mars/text/flat_kv.h"
namespace game::design {
using data::iequals;
const DefaultWeaponRow* DefaultWeaponTable::find(std::string_view turret_class, std::string_view turret_size) const {
const DefaultWeaponRow* hit = nullptr;
for (const DefaultWeaponRow& r : rows_)
if (iequals(r.turret_class, turret_class) && iequals(r.turret_size, turret_size)) hit = &r; // last wins
return hit;
}
data::Loaded<DefaultWeaponTable> parse_default_weapons(std::string_view text, std::string file) {
data::Loaded<DefaultWeaponTable> out;
DefaultWeaponTable table;
auto rows = mars::text::parse_rows(text);
for (const auto& p : rows.problems)
out.problems.push_back({data::Problem::Kind::Unparsed, file, p.line, "", p.message});
for (const mars::text::Row& r : rows.value) {
if (r.tokens.size() != 3) {
out.problems.push_back({data::Problem::Kind::Unparsed, file, r.line, "",
"default-weapon row has " + std::to_string(r.tokens.size()) + " fields, expected 3"});
continue;
}
table.add({r.tokens[0].text, r.tokens[1].text, r.tokens[2].text, r.line});
}
out.value = std::move(table);
return out;
}
data::Loaded<DefaultWeaponTable> load_default_weapons(const std::filesystem::path& root) {
std::string text;
if (!data::read_file(root / "Weapons" / "_defaultweapons.txt", text)) {
data::Loaded<DefaultWeaponTable> out;
out.value.emplace();
out.problems.push_back({data::Problem::Kind::IoError, "Weapons/_defaultweapons.txt", 0, "", "cannot read file"});
return out;
}
return parse_default_weapons(text, "Weapons/_defaultweapons.txt");
}
int fill_empty_banks(Design& d, const data::Catalog& cat, const DefaultWeaponTable& table) {
Ruleset rules(cat);
const data::Species race = d.effective_race();
int filled = 0;
for (Slot slot : kUsedSlots) {
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) continue;
if (use.weapons.size() < s->banks.size()) use.weapons.resize(s->banks.size());
for (std::size_t i = 0; i < s->banks.size(); ++i) {
if (!use.weapons[i].empty()) continue;
const data::BankDef& bank = s->banks[i];
if (!bank.weapon.empty()) {
use.weapons[i] = WeaponRef::by_file(bank.weapon);
++filled;
} else if (const DefaultWeaponRow* row = table.find(bank)) {
use.weapons[i] = WeaponRef::by_file("Weapons/" + row->weapon_file);
++filled;
}
}
}
return filled;
}
} // namespace game::design

View file

@ -0,0 +1,52 @@
// game::design -- Weapons/_defaultweapons.txt: the weapon a bank gets "in the
// absence of tech and player information" (the file's own words), one row per
// (bank turret class, bank turret size):
//
// <mount-class> <mount-size> "<weapon-file>" -- 31 rows
//
// The file name is relative to Weapons/. Whether the designer always fills an
// empty bank from this table (rule B2) is an open question; the table is
// offered so a caller can.
#pragma once
#include <string>
#include <string_view>
#include <vector>
#include "game/data/catalog.h"
#include "game/design/design.h"
namespace game::design {
struct DefaultWeaponRow {
std::string turret_class, turret_size, weapon_file; // as written
int line = 0;
};
class DefaultWeaponTable {
public:
const std::vector<DefaultWeaponRow>& rows() const { return rows_; }
std::size_t size() const { return rows_.size(); }
void add(DefaultWeaponRow r) { rows_.push_back(std::move(r)); }
// Case-insensitive; nullptr when no row matches.
const DefaultWeaponRow* find(std::string_view turret_class, std::string_view turret_size) const;
const DefaultWeaponRow* find(const data::BankDef& bank) const { return find(bank.turret_class, bank.turret_size); }
private:
std::vector<DefaultWeaponRow> rows_;
};
data::Loaded<DefaultWeaponTable> parse_default_weapons(std::string_view text, std::string file = {});
// Reads <root>/Weapons/_defaultweapons.txt; an unreadable file is an IoError
// problem and an empty table.
data::Loaded<DefaultWeaponTable> load_default_weapons(const std::filesystem::path& root);
// Fill every empty bank of every filled slot with the table's weapon (as a
// file reference "Weapons/<file>", or the bank's own fixed weapon when it has
// one). Returns the number of banks filled. Slots whose section cannot be
// resolved are left alone.
int fill_empty_banks(Design& d, const data::Catalog& cat, const DefaultWeaponTable& table);
} // namespace game::design

View file

@ -0,0 +1,84 @@
#include "game/design/design.h"
#include <algorithm>
namespace game::design {
std::string_view slot_name(Slot s) {
switch (s) {
case Slot::Command: return "command";
case Slot::Mission: return "mission";
case Slot::Engine: return "engine";
case Slot::Reserved3: return "slot3";
case Slot::Reserved4: return "slot4";
}
return "?";
}
std::optional<Slot> parse_slot(std::string_view s) {
for (Slot k : kUsedSlots)
if (data::iequals(slot_name(k), s)) return k;
return std::nullopt;
}
std::string WeaponRef::describe() const {
switch (kind) {
case Kind::Empty: return "(none)";
case Kind::Id: return "#" + std::to_string(id);
case Kind::File:
case Kind::Stem: return text;
}
return "?";
}
std::string SectionUse::describe() const {
if (section_id != 0) return "#" + std::to_string(section_id);
return section.empty() ? "(empty)" : section;
}
data::Species Design::effective_race() const {
if (race != data::Species::Unknown) return race;
for (const SectionUse& u : slots)
if (!u.empty()) return u.species;
return data::Species::Unknown;
}
std::string_view level_name(Level l) {
switch (l) {
case Level::Error: return "error";
case Level::Warn: return "warn";
case Level::Info: return "info";
}
return "?";
}
std::string Violation::str() const {
std::string s(level_name(level));
for (char& c : s) c = static_cast<char>(c >= 'a' && c <= 'z' ? c - 32 : c);
while (s.size() < 5) s += ' ';
s += ' ';
s += rule;
if (slot) {
s += " [";
s += slot_name(*slot);
if (bank) s += " bank " + std::to_string(*bank);
s += ']';
}
s += ": ";
s += message;
return s;
}
bool has_errors(const std::vector<Violation>& v) {
return std::any_of(v.begin(), v.end(), [](const Violation& x) { return x.level == Level::Error; });
}
std::vector<std::string> rules_at(const std::vector<Violation>& v, Level level) {
std::vector<std::string> out;
for (const Violation& x : v)
if (x.level == level && std::find(out.begin(), out.end(), x.rule) == out.end()) out.push_back(x.rule);
std::sort(out.begin(), out.end());
return out;
}
} // namespace game::design

108
src/game/design/design.h Normal file
View file

@ -0,0 +1,108 @@
// 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.
//
// 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
// resolve either form through the catalog.
#pragma once
#include <array>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include "game/data/common.h"
namespace game::design {
enum class Slot : int { Command = 0, Mission = 1, Engine = 2, Reserved3 = 3, Reserved4 = 4 };
constexpr int kSlotCount = 5;
constexpr std::array<Slot, 3> kUsedSlots{Slot::Command, Slot::Mission, Slot::Engine};
std::string_view slot_name(Slot s); // "command" / "mission" / "engine" / "slot3" / "slot4"
std::optional<Slot> parse_slot(std::string_view); // case-insensitive; the three used slots only
// One weapon per bank. The save writes a player weapon as its `_weapons.txt`
// id (`bID true, wid`) and an NPC fixed-bank weapon as its file path
// (`bID false, wfn`); tests and tools may also use a bare stem.
struct WeaponRef {
enum class Kind { Empty, Id, File, Stem };
Kind kind = Kind::Empty;
int id = 0;
std::string text; // file path or stem
int rider_design_id = 0; // `did`: the rider design a rider weapon carries (0 = none)
static WeaponRef none() { return {}; }
static WeaponRef by_id(int id) { WeaponRef r; r.kind = Kind::Id; r.id = id; return r; }
static WeaponRef by_file(std::string file) { WeaponRef r; r.kind = Kind::File; r.text = std::move(file); return r; }
static WeaponRef by_stem(std::string stem) { WeaponRef r; r.kind = Kind::Stem; r.text = std::move(stem); return r; }
bool empty() const { return kind == Kind::Empty; }
std::string describe() const; // "#8", "Weapons/mis.weapon", "bal_gauss", "(none)"
};
struct SectionUse {
data::Species species = data::Species::Unknown; // Unknown -> the design's race
int section_id = 0; // id in the race's _shipsections.txt (0 = none)
std::string section; // stem or file path, used when section_id is 0
std::vector<WeaponRef> weapons; // one per bank{} of the section, file order
std::vector<std::string> options; // chosen option techs (one per option group at most)
bool empty() const { return section_id == 0 && section.empty(); }
std::string describe() const; // "#42" or the stem
};
struct Design {
std::string name;
bool hidden = false; // engine-generated hidden design (`dHide`; rider defaults)
bool designer_made = false; // `faiDes` (meaning open; carried through)
int default_weapons = 0; // `dWep` (1 on the hidden defaults; carried through)
data::Species race = data::Species::Unknown; // catalog every section comes from; Unknown -> first filled slot
std::array<SectionUse, kSlotCount> slots{};
// Researched techs of the owning player. nullopt -> tech gating is skipped
// (structure, fit and option rules still apply).
std::optional<std::vector<std::string>> known_techs;
SectionUse& slot(Slot s) { return slots[static_cast<std::size_t>(s)]; }
const SectionUse& slot(Slot s) const { return slots[static_cast<std::size_t>(s)]; }
SectionUse& command() { return slot(Slot::Command); }
SectionUse& mission() { return slot(Slot::Mission); }
SectionUse& engine() { return slot(Slot::Engine); }
const SectionUse& command() const { return slot(Slot::Command); }
const SectionUse& mission() const { return slot(Slot::Mission); }
const SectionUse& engine() const { return slot(Slot::Engine); }
// The species catalog the design is validated against: `race`, else the
// species of the first filled slot, else Unknown.
data::Species effective_race() const;
};
// Severity of a rule finding. Error: the design is invalid. Warn: the engine
// accepts it but the designer UI would not build it, or only code can settle
// the case (hidden default riders past tech gating, `nodesign` sections).
// Info: a fact worth reporting (a typeless rider hull in a hidden design).
enum class Level { Error, Warn, Info };
std::string_view level_name(Level l); // "error" / "warn" / "info"
struct Violation {
std::string rule; // A2 .. D2 (docs/game-design.md)
Level level = Level::Error;
std::string message;
std::optional<Slot> slot;
std::optional<int> bank; // bank index within the slot's section
std::string str() const; // "ERROR B4 [mission bank 2]: ..."
};
bool has_errors(const std::vector<Violation>& v);
std::vector<std::string> rules_at(const std::vector<Violation>& v, Level level); // sorted, distinct
} // namespace game::design

419
src/game/design/rules.cpp Normal file
View file

@ -0,0 +1,419 @@
#include "game/design/rules.h"
#include <algorithm>
#include <map>
#include <utility>
namespace game::design {
using data::fold;
using data::iequals;
using data::ShipSectionDef;
using data::WeaponDef;
namespace {
bool contains_fold(const std::vector<std::string>& v, std::string_view s) {
return std::any_of(v.begin(), v.end(), [&](const std::string& x) { return iequals(x, s); });
}
void add(std::vector<Violation>& v, const char* rule, Level level, std::string msg,
std::optional<Slot> slot = std::nullopt, std::optional<int> bank = std::nullopt) {
v.push_back(Violation{rule, level, std::move(msg), slot, bank});
}
std::string slot_str(Slot s) { return std::string(slot_name(s)); }
} // namespace
// ---------------------------------------------------------------------------
// techs and options
std::optional<std::string> hull_class_tech(const ShipSectionDef& s) {
if (is_station(s)) return std::nullopt;
switch (s.section_class) {
case data::SectionClass::Cruiser: return std::string("IND_CruisCon");
case data::SectionClass::Dreadnought: return std::string("IND_DreadCon");
default: return std::nullopt;
}
}
std::vector<const data::OptionGroup*> option_groups(const ShipSectionDef& s) {
std::vector<const data::OptionGroup*> out;
for (const data::OptionGroup& g : s.options) out.push_back(&g);
if (s.optiondef) out.push_back(&*s.optiondef);
return out;
}
std::vector<std::string> applied_techs(const ShipSectionDef& s, const std::vector<std::string>& chosen) {
std::vector<std::string> out;
if (auto hct = hull_class_tech(s)) out.push_back(*hct);
out.insert(out.end(), s.requires.begin(), s.requires.end());
for (const data::OptionGroup* g : option_groups(s))
for (const std::string& member : g->members)
for (const std::string& c : chosen)
if (iequals(c, member)) {
out.push_back(c);
break;
}
return out;
}
std::vector<std::string> options_from_applied(const ShipSectionDef& s, const std::vector<std::string>& applied) {
std::vector<std::string> out;
auto hct = hull_class_tech(s);
for (const std::string& t : applied) {
if (hct && iequals(t, *hct)) continue;
if (contains_fold(s.requires, t)) continue;
out.push_back(t);
}
return out;
}
TechSet::TechSet(const std::vector<std::string>& names) {
for (const std::string& n : names) names_.insert(fold(n));
}
bool TechSet::satisfies(const data::TechTree& tree, std::string_view requirement) const {
if (data::is_group_ref(requirement)) {
const std::vector<std::string>* members = tree.group_members(requirement);
if (!members) return false;
return std::any_of(members->begin(), members->end(), [&](const std::string& m) { return contains(m); });
}
return contains(requirement);
}
// ---------------------------------------------------------------------------
// fit
bool bank_accepts_class(std::string_view bank_class, std::string_view weapon_class) {
if (iequals(bank_class, weapon_class)) return true;
if (iequals(bank_class, "standard")) return iequals(weapon_class, "missile") || iequals(weapon_class, "grapple");
if (iequals(bank_class, "strafe")) return iequals(weapon_class, "standard");
return false;
}
const data::TurretRow* turret_row(const data::TurretTable& t, const data::BankDef& bank, const WeaponDef& w) {
if (const data::TurretRow* r = t.find(bank.turret_size, w.turret_size, w.turret_class)) return r;
return t.find(bank.turret_size, w.turret_size, bank.turret_class);
}
Fit weapon_fits_bank(const data::TurretTable& t, const data::BankDef& bank, const WeaponDef& w) {
Fit f;
if (bank.turret_size.empty() || bank.turret_class.empty()) {
// Two NPC banks carry a fixed weapon and no turret spec at all.
f.ok = !bank.weapon.empty() && iequals(w.file, bank.weapon);
f.why = "bank has no turretsize/turretclass (fixed NPC weapon only)";
return f;
}
if (!bank_accepts_class(bank.turret_class, w.turret_class)) {
f.why = "turretclass " + fold(w.turret_class) + " not accepted by " + fold(bank.turret_class) + " bank";
return f;
}
f.row = turret_row(t, bank, w);
if (!f.row) {
f.why = "no _turrets.txt row for (" + fold(bank.turret_size) + " bank, " + fold(w.turret_size) + " weapon, " +
fold(w.turret_class) + "/" + fold(bank.turret_class) + ")";
return f;
}
f.ok = true;
f.why = "ok";
return f;
}
// ---------------------------------------------------------------------------
// Ruleset
Ruleset::Ruleset(const data::Catalog& cat) : cat_(&cat) {
// C5 index: for every tech that has incoming edges, the species for which
// every edge writes 0%.
std::map<std::string, std::vector<const data::AllowsEdge*>> incoming;
for (const data::AllowsEdge& e : cat.tech.edges) incoming[fold(e.to)].push_back(&e);
for (const auto& kv : incoming) {
unsigned mask = 0;
for (int i = 0; i < data::kSpeciesCount; ++i) {
bool all_zero = std::all_of(kv.second.begin(), kv.second.end(), [&](const data::AllowsEdge* e) {
return e->pct[static_cast<std::size_t>(i)] == 0;
});
if (all_zero) mask |= 1u << i;
}
if (mask) unobtainable_[kv.first] = mask;
}
}
bool Ruleset::unobtainable(std::string_view tech, data::Species race) const {
if (race == data::Species::Unknown) return false;
auto it = unobtainable_.find(fold(tech));
return it != unobtainable_.end() && (it->second & (1u << static_cast<int>(race))) != 0;
}
const ShipSectionDef* Ruleset::resolve_section(data::Species race, const SectionUse& use) const {
if (race == data::Species::Unknown || use.empty()) return nullptr;
std::string_view rk = data::species_name(race);
if (use.section_id != 0) return cat_->section_by_id(rk, use.section_id);
std::string_view stem = data::basename(use.section);
if (stem.size() > 12 && iequals(stem.substr(stem.size() - 12), ".shipsection")) stem = stem.substr(0, stem.size() - 12);
return cat_->section(rk, stem);
}
const WeaponDef* Ruleset::resolve_weapon_file(std::string_view path) const {
if (path.empty()) return nullptr;
if (const WeaponDef* w = cat_->weapon_by_file(path)) return w;
std::string_view base = data::basename(path);
// A bare file name ("mis.weapon") or a stem.
for (const WeaponDef& w : cat_->weapons)
if (iequals(data::basename(w.file), base)) return &w;
return cat_->weapon(data::strip_extension(base));
}
const WeaponDef* Ruleset::resolve_weapon(const WeaponRef& ref) const {
switch (ref.kind) {
case WeaponRef::Kind::Empty: return nullptr;
case WeaponRef::Kind::Id: return cat_->weapon_by_id(ref.id);
case WeaponRef::Kind::Stem:
if (const WeaponDef* w = cat_->weapon(ref.text)) return w;
return resolve_weapon_file(ref.text);
case WeaponRef::Kind::File: return resolve_weapon_file(ref.text);
}
return nullptr;
}
void Ruleset::strip_applied_techs(Design& d) const {
data::Species race = d.effective_race();
for (SectionUse& u : d.slots) {
if (u.empty()) continue;
data::Species sp = u.species == data::Species::Unknown ? race : u.species;
if (const ShipSectionDef* s = resolve_section(sp, u)) u.options = options_from_applied(*s, u.options);
}
}
// ---------------------------------------------------------------------------
// validate
std::vector<Violation> Ruleset::validate(const Design& d) const {
std::vector<Violation> v;
const data::Catalog& cat = *cat_;
const data::Species race = d.effective_race();
const bool hidden = d.hidden;
std::optional<TechSet> known;
if (d.known_techs) known.emplace(*d.known_techs);
// A1: the two reserved slots are never used.
for (Slot s : {Slot::Reserved3, Slot::Reserved4})
if (!d.slot(s).empty()) add(v, "A1", Level::Error, "reserved slot is filled", s);
if (race == data::Species::Unknown ||
std::find_if(cat.races.begin(), cat.races.end(),
[&](const std::string& r) { return iequals(r, data::species_name(race)); }) == cat.races.end()) {
add(v, "A5", Level::Error, "unknown species catalog '" + std::string(data::species_name(race)) + "'");
return v;
}
const std::string_view rk = data::species_name(race);
// -- resolve the three used slots --------------------------------------
struct Filled {
Slot slot;
const SectionUse* use;
const ShipSectionDef* def;
};
std::array<Filled, 3> f{};
for (std::size_t i = 0; i < 3; ++i) {
Slot s = kUsedSlots[i];
const SectionUse& u = d.slot(s);
f[i] = {s, u.empty() ? nullptr : &u, nullptr};
if (u.empty()) continue;
if (u.species != data::Species::Unknown && u.species != race) {
add(v, "A5", Level::Error,
"section " + u.describe() + " comes from the " + std::string(data::species_name(u.species)) +
" catalog, design is " + std::string(rk),
s);
continue;
}
f[i].def = resolve_section(race, u);
if (!f[i].def) add(v, "A5", Level::Error, "section " + u.describe() + " not in " + std::string(rk) + " catalog", s);
}
const Filled& cmd = f[0];
const Filled& mis = f[1];
const Filled& eng = f[2];
if (!mis.def) {
if (!mis.use) add(v, "A2", Level::Error, "mission slot is empty");
return v;
}
const ShipSectionDef& m = *mis.def;
// A3 hull ship vs standalone; A8 station triples pin their partners.
const bool standalone = !m.has_sockets();
if (!m.explicit_command_section.empty() || !m.explicit_engine_section.empty()) {
for (const auto& [part, want] : {std::pair{&cmd, &m.explicit_command_section}, std::pair{&eng, &m.explicit_engine_section}}) {
if (want->empty()) continue;
if (!part->def || !iequals(part->def->stem, *want))
add(v, "A8", Level::Error,
m.stem + " requires " + slot_str(part->slot) + " section " + *want + ", got " +
(part->def ? part->def->stem : std::string("none")),
part->slot);
}
} else if (standalone) {
for (const Filled* part : {&cmd, &eng})
if (part->use)
add(v, "A3", Level::Error, m.stem + " has no sockets (standalone hull) but " + slot_str(part->slot) + " slot is filled",
part->slot);
} else {
for (const Filled* part : {&cmd, &eng})
if (!part->def) add(v, "A3", Level::Error, m.stem + " has sockets; " + slot_str(part->slot) + " section required", part->slot);
}
std::vector<const Filled*> present;
for (const Filled& x : f)
if (x.def) present.push_back(&x);
// A4 slot/type, A9 nodesign, A8 explicit partner sections
for (const Filled* p : present) {
const ShipSectionDef& s = *p->def;
if (s.section_type_text.empty()) {
if (p->slot == Slot::Mission && standalone && (hidden || iequals(s.design_class, "rider")))
add(v, "A4", Level::Info, s.stem + " has no section_type (rider hull in a hidden design)", p->slot);
else
add(v, "A4", Level::Error, s.stem + " has no section_type and cannot fill the " + slot_str(p->slot) + " slot", p->slot);
} else if (!iequals(s.section_type_text, slot_name(p->slot))) {
add(v, "A4", Level::Error,
s.stem + " is a " + fold(s.section_type_text) + " section, placed in " + slot_str(p->slot) + " slot", p->slot);
}
if (s.nodesign.value_or(false) && !hidden)
add(v, "A9", Level::Warn, s.stem + " is nodesign (not offered by the design UI)", p->slot);
if (s.explicit_section.value_or(false) && p->slot != Slot::Mission) {
const std::string& owner = p->slot == Slot::Command ? m.explicit_command_section : m.explicit_engine_section;
if (owner.empty() || !iequals(owner, s.stem))
add(v, "A8", Level::Error, s.stem + " is an explicit_section usable only with its owning station", p->slot);
}
}
// A6 class equality
{
std::vector<std::string> classes;
for (const Filled* p : present)
if (!p->def->section_class_text.empty() && !contains_fold(classes, p->def->section_class_text))
classes.push_back(fold(p->def->section_class_text));
if (classes.size() > 1) {
std::sort(classes.begin(), classes.end());
std::string list;
for (const std::string& c : classes) list += (list.empty() ? "" : ", ") + c;
add(v, "A6", Level::Error, "mixed section_class: [" + list + "]");
}
}
// A7 exclude lists
for (const Filled* p : present)
for (const std::string& ex : p->def->exclude)
for (const Filled* q : present)
if (iequals(q->def->stem, ex))
add(v, "A7", Level::Error, p->def->stem + " excludes " + ex + " (in " + slot_str(q->slot) + " slot)", p->slot);
// -- banks & weapons ----------------------------------------------------
for (const Filled* p : present) {
const ShipSectionDef& s = *p->def;
const std::vector<WeaponRef>& weapons = p->use->weapons;
if (weapons.size() != s.banks.size())
add(v, "B1", Level::Error,
s.stem + " has " + std::to_string(s.banks.size()) + " banks, design lists " + std::to_string(weapons.size()) + " weapons",
p->slot);
for (std::size_t i = 0; i < s.banks.size(); ++i) {
const data::BankDef& bank = s.banks[i];
const int bi = static_cast<int>(i);
const WeaponRef* ref = i < weapons.size() && !weapons[i].empty() ? &weapons[i] : nullptr;
const WeaponDef* w = ref ? resolve_weapon(*ref) : nullptr;
if (ref && !w) {
add(v, "B5", Level::Error, "unknown weapon " + ref->describe(), p->slot, bi);
continue;
}
if (!bank.weapon.empty()) {
const WeaponDef* fixed = resolve_weapon_file(bank.weapon);
if (!w) {
w = fixed;
} else if (fixed && !iequals(w->file, fixed->file)) {
add(v, "B3", Level::Error,
"bank is bound to " + std::string(data::basename(bank.weapon)) + ", design has " + w->stem, p->slot, bi);
continue;
}
}
if (!w) {
add(v, "B2", Level::Error,
"bank " + std::to_string(i) + " (" + fold(bank.turret_size) + " " + fold(bank.turret_class) + ", " +
std::to_string(bank.mounts.size()) + " mounts) has no weapon",
p->slot, bi);
continue;
}
Fit fit = weapon_fits_bank(cat.turrets, bank, *w);
if (!fit.ok)
add(v, "B4", Level::Error,
w->stem + " (" + fold(w->turret_size) + " " + fold(w->turret_class) + ") does not fit bank " + std::to_string(i) +
" (" + fold(bank.turret_size) + " " + fold(bank.turret_class) + "): " + fit.why,
p->slot, bi);
if (w->scope != data::WeaponScope::Player && race != data::Species::NPC && bank.weapon.empty())
add(v, "B5", Level::Error, w->stem + " is an NPC-only weapon", p->slot, bi);
if (!w->exclusive_species.empty() && !iequals(w->exclusive_species, rk))
add(v, "B6", Level::Error, w->stem + " is exclusive to " + fold(w->exclusive_species), p->slot, bi);
if (w->hidden.value_or(false) && !hidden && bank.weapon.empty())
add(v, "B8", Level::Error, w->stem + " is a hidden (engine-only) weapon", p->slot, bi);
if (!w->compatible_section.empty()) {
bool carrier = std::any_of(w->compatible_section.begin(), w->compatible_section.end(),
[&](const std::string& cs) { return cat.section(rk, cs) != nullptr; });
if (!carrier) {
std::string list;
for (const std::string& cs : w->compatible_section) list += (list.empty() ? "" : ", ") + cs;
add(v, "B7", Level::Error, w->stem + " needs a rider section [" + list + "] that " + std::string(rk) + " lacks",
p->slot, bi);
}
}
if (known && bank.weapon.empty())
for (const std::string& t : w->requires)
if (!known->satisfies(cat.tech, t)) add(v, "C2", Level::Error, w->stem + " requires " + t, p->slot, bi);
}
}
// -- options ------------------------------------------------------------
for (const Filled* p : present) {
const ShipSectionDef& s = *p->def;
std::vector<const data::OptionGroup*> groups = option_groups(s);
std::map<std::size_t, std::string> seen;
for (const std::string& o : p->use->options) {
std::optional<std::size_t> gi;
for (std::size_t g = 0; g < groups.size() && !gi; ++g)
if (contains_fold(groups[g]->members, o)) gi = g;
if (!gi) {
add(v, "D1", Level::Error, o + " is not an option of " + s.stem, p->slot);
} else if (seen.count(*gi)) {
add(v, "D1", Level::Error, o + " and " + seen[*gi] + " are in the same option group of " + s.stem, p->slot);
} else {
seen[*gi] = o;
}
if (known && cat.tech.find(o) && !known->satisfies(cat.tech, o))
add(v, "C3", Level::Error, "option " + o + " is not researched", p->slot);
}
}
// -- tech gating ----------------------------------------------------------
// Engine-generated hidden designs (the per-race "Default Assault Shuttle")
// exist in every save regardless of research: the Tarkas _AssaultShuttle
// requires DRN_AdvFrm and the design is there on turn 1. Gating on hidden
// designs is therefore reported at warn level.
const Level gate = hidden ? Level::Warn : Level::Error;
for (const Filled* p : present) {
const ShipSectionDef& s = *p->def;
for (const std::string& t : s.requires) {
if (!cat.tech.find(t) && !data::is_group_ref(t))
add(v, "C1", Level::Warn, s.stem + " requires unknown tech " + t, p->slot);
else if (known && !known->satisfies(cat.tech, t))
add(v, "C1", gate, s.stem + " requires " + t, p->slot);
if (unobtainable(t, race))
add(v, "C5", Level::Error, s.stem + " requires " + t + ", which " + std::string(rk) + " can never research", p->slot);
}
if (auto hct = hull_class_tech(s); hct && known && !known->satisfies(cat.tech, *hct))
add(v, "C4", gate, s.stem + " is a " + fold(s.section_class_text) + " section; " + *hct + " not researched", p->slot);
}
if (hidden)
for (Violation& x : v)
if ((x.rule == "C2" || x.rule == "C3") && x.level == Level::Error) x.level = Level::Warn;
return v;
}
} // namespace game::design

113
src/game/design/rules.h Normal file
View file

@ -0,0 +1,113 @@
// game::design -- the ship-design assembly rules.
//
// What makes a design valid, derived from the shipped data and every design
// in the real saves (docs/game-design.md carries the evidence per rule id):
//
// A* structure: mission slot mandatory; sockets on the mission section =>
// command + engine required, none => standalone; slot must match
// section_type; one species catalog; one section_class; `exclude`
// lists; station triples pinned by explicit_*_section; `nodesign` is
// UI-only (warn). Sockets are geometry, not a key.
// B* banks: one weapon per bank{}, none empty, fixed NPC banks bound to
// their file, fit = class-accept table + a _turrets.txt row, weapon
// scope / exclusive_species / rider carriers / hidden weapons.
// C* tech gating: section and weapon `requires` (GRP_ = any of the group),
// chosen options, the implicit hull-class tech (IND_CruisCon /
// IND_DreadCon, stations exempt), race availability. Hidden default
// designs are gated at warn level (code rule the data does not show).
// D* options: at most one per group, members only; applied_techs =
// [hull-class tech] + requires + chosen options in group order (D2).
//
// Nothing here throws; every finding is a Violation.
#pragma once
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "game/data/catalog.h"
#include "game/design/design.h"
namespace game::design {
// C4: the tech a section's class implies. Cruisers need IND_CruisCon,
// dreadnoughts IND_DreadCon; destroyers nothing; `design_class station`
// sections are exempt whatever their class.
std::optional<std::string> hull_class_tech(const data::ShipSectionDef& s);
inline bool is_station(const data::ShipSectionDef& s) { return data::iequals(s.design_class, "station"); }
// The section's option groups in DOpts order: every `option` entry, then the
// `optiondef` shield group.
std::vector<const data::OptionGroup*> option_groups(const data::ShipSectionDef& s);
// D2: the techs a section instance is built with -- exactly the save's DOpts
// list: hull-class tech, `requires` in file order, then the chosen option of
// each group in group order (spelled as chosen).
std::vector<std::string> applied_techs(const data::ShipSectionDef& s, const std::vector<std::string>& chosen_options);
// Inverse of applied_techs for a save record: drops the hull-class tech and
// the `requires` from a DOpts list, leaving the chosen options.
std::vector<std::string> options_from_applied(const data::ShipSectionDef& s, const std::vector<std::string>& applied);
// B4: which weapon turret classes a bank turret class takes besides an exact
// match -- standard <- {standard, missile, grapple}, strafe <- {standard}.
bool bank_accepts_class(std::string_view bank_class, std::string_view weapon_class);
// The _turrets.txt row a weapon in a bank uses: keyed by the weapon's class
// first (a missile in a standard bank gets the missile turret), then by the
// bank's class (strafe banks have their own rows; no weapon is class strafe).
const data::TurretRow* turret_row(const data::TurretTable& t, const data::BankDef& bank, const data::WeaponDef& w);
struct Fit {
bool ok = false;
std::string why; // "ok" or the reason
const data::TurretRow* row = nullptr;
};
Fit weapon_fits_bank(const data::TurretTable& t, const data::BankDef& bank, const data::WeaponDef& w);
// A player's researched techs. `satisfies` resolves GRP_<group> through the
// tech tree: satisfied when any member of the group is known.
class TechSet {
public:
TechSet() = default;
explicit TechSet(const std::vector<std::string>& names);
bool contains(std::string_view name) const { return names_.count(data::fold(name)) != 0; }
bool satisfies(const data::TechTree& tree, std::string_view requirement) const;
std::size_t size() const { return names_.size(); }
private:
std::unordered_set<std::string> names_; // folded
};
class Ruleset {
public:
explicit Ruleset(const data::Catalog& cat);
const data::Catalog& catalog() const { return *cat_; }
std::vector<Violation> validate(const Design& d) const;
// Resolution. A section is found by id in the race's manifest when
// section_id is set, else by stem / file name; a weapon by id, file path
// (full or basename) or stem.
const data::ShipSectionDef* resolve_section(data::Species race, const SectionUse& use) const;
const data::WeaponDef* resolve_weapon(const WeaponRef& ref) const;
const data::WeaponDef* resolve_weapon_file(std::string_view path) const;
// C5: a tech every incoming `allows` edge writes as 0% for the species
// can never be researched by it.
bool unobtainable(std::string_view tech, data::Species race) const;
// Save-record helpers: the save's DOpts lists include the structural techs;
// strip them so `options` holds only the chosen ones (per slot, in place).
// Slots whose section cannot be resolved are left untouched.
void strip_applied_techs(Design& d) const;
private:
const data::Catalog* cat_;
std::unordered_map<std::string, unsigned> unobtainable_; // folded tech -> species bit mask
};
} // namespace game::design

107
src/game/design/stats.cpp Normal file
View file

@ -0,0 +1,107 @@
#include "game/design/stats.h"
namespace game::design {
using data::iequals;
const std::vector<std::string_view>& capacity_keys() {
static const std::vector<std::string_view> keys = {
"refueling_capacity", "repair_capacity", "construction_capacity", "mining_capacity", "mining_rate",
"prisoner_capacity", "colonizer_pop", "colonizer_infra", "colonizer_terra", "scanrange",
"tacticalsensorrange", "range", "split_traffic_volume", "refinery", "freighter", "freighterq",
"police", "tradingpost", "monitor", "spy", "spytender", "science", "propaganda", "ewar", "gateship",
"ramscoop", "node_bore", "node_missile", "gravboat_bonus", "protectorate", "huge", "defence_platform",
};
return keys;
}
const std::string* DesignStats::capacity(std::string_view key) const {
for (const auto& kv : capacities)
if (iequals(kv.first, key)) return &kv.second;
return nullptr;
}
namespace {
void set_capacity(DesignStats& st, std::string_view key, std::string value) {
for (auto& kv : st.capacities)
if (iequals(kv.first, key)) {
kv.second = std::move(value);
return;
}
st.capacities.emplace_back(std::string(key), std::move(value));
}
} // namespace
DesignStats derive_stats(const Ruleset& rules, const Design& d) {
const data::Catalog& cat = rules.catalog();
DesignStats st;
st.name = d.name;
const data::Species race = d.effective_race();
st.race = std::string(data::species_name(race));
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) continue;
SectionStats sec;
sec.slot = slot;
sec.section = s;
for (const std::string& o : use.options)
if (const data::TechNode* n = cat.tech.find(o); n && n->option_cost) sec.option_cost_multiplier *= *n->option_cost;
sec.mass = s->mass.value_or(0);
sec.cost = static_cast<double>(s->cost.value_or(0));
sec.health = s->health.value_or(0);
sec.crew = s->crew.value_or(0);
sec.cpoints = s->cpoints.value_or(0);
for (std::size_t i = 0; i < s->banks.size(); ++i) {
const data::BankDef& bank = s->banks[i];
BankStats b;
b.size = bank.turret_size;
b.turret_class = bank.turret_class;
b.mounts = static_cast<int>(bank.mounts.size());
if (i < use.weapons.size() && !use.weapons[i].empty())
b.weapon = rules.resolve_weapon(use.weapons[i]);
else if (!bank.weapon.empty())
b.weapon = rules.resolve_weapon_file(bank.weapon);
if (b.weapon) {
b.weapon_cost = static_cast<double>(b.weapon->cost.value_or(0));
b.turret = turret_row(cat.turrets, bank, *b.weapon);
st.weapon_cost_per_bank += b.weapon_cost;
st.weapon_cost_per_mount += b.weapon_cost * (b.mounts > 1 ? b.mounts : 1);
}
st.banks += 1;
st.turrets += b.mounts;
sec.banks.push_back(std::move(b));
}
sec.applied_techs = applied_techs(*s, use.options);
st.mass += sec.mass;
st.section_cost += sec.cost;
st.section_cost_with_options += sec.cost * sec.option_cost_multiplier;
st.health_total += sec.health;
st.crew += sec.crew;
st.cpoints += sec.cpoints;
st.command_cost += s->command_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 (st.hull_class.empty() && !s->section_class_text.empty()) st.hull_class = data::fold(s->section_class_text);
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)) {
st.ftlspeed = s->ftlspeed;
st.nodespeed = s->nodespeed;
st.engine_techera = s->engine_techera;
st.netforcelimits = s->netforcelimits;
}
st.sections.push_back(std::move(sec));
}
st.total_cost_estimate = st.section_cost_with_options + st.weapon_cost_per_bank;
return st;
}
} // namespace game::design

70
src/game/design/stats.h Normal file
View file

@ -0,0 +1,70 @@
// game::design -- derived stats of a design (SHIP_DESIGN_RULES.md section 6).
//
// Everything here is a sum or a copy of data fields; the parts only code can
// settle (whether weapons add mass, per-bank vs per-mount weapon cost, what
// `option_cost` multiplies, the speed integrators) are reported as the data
// suggests them and flagged in docs/game-design.md.
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "game/design/rules.h"
namespace game::design {
struct BankStats {
std::string size, turret_class; // as written in the section
int mounts = 0;
const data::WeaponDef* weapon = nullptr; // resolved (or the bank's fixed weapon)
double weapon_cost = 0;
const data::TurretRow* turret = nullptr; // model / health / track speed of the turret
};
struct SectionStats {
Slot slot = Slot::Mission;
const data::ShipSectionDef* section = nullptr;
double mass = 0, cost = 0, health = 0;
double option_cost_multiplier = 1; // product of the chosen options' tech option_cost
std::int64_t crew = 0, cpoints = 0;
std::vector<BankStats> banks;
std::vector<std::string> applied_techs; // D2
};
struct DesignStats {
std::string race, name;
std::string hull_class; // section_class of the first resolved section (folded)
std::vector<SectionStats> sections; // command, mission, engine order; only resolved slots
double mass = 0;
double section_cost = 0; // sum of section `cost`
double section_cost_with_options = 0; // sum of cost x option multiplier
double weapon_cost_per_bank = 0; // one weapon cost per bank
double weapon_cost_per_mount = 0; // weapon cost x mounts
double total_cost_estimate = 0; // section_cost_with_options + weapon_cost_per_bank
double health_total = 0; // the engine tracks health per section; this is only a sum
std::int64_t crew = 0, cpoints = 0;
int banks = 0, turrets = 0; // turrets = one per mount
std::int64_t command_cost = 0, maintenance_cost = 0;
std::optional<std::int64_t> command_quota;
// Drive: from the engine section, or the mission section of a standalone hull.
std::optional<double> ftlspeed, nodespeed;
std::string engine_techera;
std::optional<data::NetForceLimits> netforcelimits;
// Role flags and capacities copied through from the sections' raw blocks
// (key -> text as written; later sections override earlier ones).
std::vector<std::pair<std::string, std::string>> capacities;
const std::string* capacity(std::string_view key) const;
};
// The raw keys copied into `capacities`.
const std::vector<std::string_view>& capacity_keys();
DesignStats derive_stats(const Ruleset& rules, const Design& d);
} // namespace game::design

View file

@ -0,0 +1,17 @@
# Optional CMake wiring for the game_design tests; the canonical runner is
# 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)
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)
add_executable(game_design_realdata_test stock_designs.cpp realdata_test.cpp)
target_link_libraries(game_design_realdata_test PRIVATE game_design)
target_include_directories(game_design_realdata_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME game_design_realdata COMMAND game_design_realdata_test) # SKIPs without SOTS_DATA_DIR + SOTS_DESIGNS_JSON
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})

View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Self-contained build + test for src/game/design (plain g++, no CMake needed).
#
# tests/game_design/build_and_run.sh
#
# Always builds and runs the unit tests (hand-written mini catalog). When
# SOTS_DATA_DIR points at an extracted sots.gob tree AND SOTS_DESIGNS_JSON at
# the owner's stock_designs.json (designs decoded from real saves) it also
# runs the real-data test (every save design must validate, DOpts must equal
# applied_techs, tech gating must show exactly the hidden-rider warnings),
# dumps our findings per design and, if the reference validator is reachable
# (SOTS_DESIGN_RULES_DIR, default ~/sots-re/verify/design-rules), diffs them
# design by design with oracle/compare.py.
#
# Env: CXX (g++), PYTHON (python3), BUILD_DIR (tests/game_design/build),
# SOTS_DATA_DIR, SOTS_DESIGNS_JSON, SOTS_DESIGN_RULES_DIR.
set -euo pipefail
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
ROOT=$(cd "$HERE/../.." && pwd)
BUILD=${BUILD_DIR:-$HERE/build}
CXX=${CXX:-g++}
PYTHON=${PYTHON:-python3}
FLAGS=(-std=c++17 -O2 -Wall -Wextra -Wpedantic -Werror -I"$ROOT/src" -I"$HERE")
LIB=(
"$ROOT/src/mars/parse/blocks.cpp" "$ROOT/src/mars/parse/value.cpp"
"$ROOT/src/mars/text/value.cpp" "$ROOT/src/mars/text/flat_kv.cpp"
"$ROOT/src/mars/text/manifest.cpp" "$ROOT/src/mars/text/csv.cpp"
"$ROOT/src/game/data/common.cpp" "$ROOT/src/game/data/block.cpp"
"$ROOT/src/game/data/weapon.cpp" "$ROOT/src/game/data/shipsection.cpp"
"$ROOT/src/game/data/turrets.cpp" "$ROOT/src/game/data/techtree.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/stats.cpp" "$ROOT/src/game/design/defaults.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"
echo "== running unit tests"
"$BUILD/unit_tests"
if [ -z "${SOTS_DATA_DIR:-}" ] || [ -z "${SOTS_DESIGNS_JSON:-}" ]; then
echo "== SOTS_DATA_DIR / SOTS_DESIGNS_JSON not set: skipping real-data tests"
exit 0
fi
if [ ! -d "$SOTS_DATA_DIR" ]; then
echo "== SOTS_DATA_DIR=$SOTS_DATA_DIR is not a directory" >&2
exit 1
fi
if [ ! -f "$SOTS_DESIGNS_JSON" ]; then
echo "== SOTS_DESIGNS_JSON=$SOTS_DESIGNS_JSON is not a file" >&2
exit 1
fi
echo "== building real-data tools"
"$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/stock_designs.cpp" "$HERE/realdata_test.cpp" -o "$BUILD/realdata_test"
"$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/stock_designs.cpp" "$HERE/dump_designs.cpp" -o "$BUILD/dump_designs"
echo "== real-data: every save design through the rules"
"$BUILD/realdata_test"
echo "== dumping findings"
"$BUILD/dump_designs" "$SOTS_DATA_DIR" "$SOTS_DESIGNS_JSON" "$BUILD/designs.json"
ORACLE=${SOTS_DESIGN_RULES_DIR:-$HOME/sots-re/verify/design-rules}
if [ ! -f "$ORACLE/design_rules.py" ]; then
echo "== reference validator not found at $ORACLE (set SOTS_DESIGN_RULES_DIR): skipping comparison"
exit 0
fi
echo "== comparing with the reference validator"
read -ra PY <<< "$PYTHON" # PYTHON may be a command with arguments ("uv run python3")
exec "${PY[@]}" "$HERE/oracle/compare.py" "$BUILD/designs.json" "$SOTS_DESIGNS_JSON" "$ORACLE"

View file

@ -0,0 +1,122 @@
// dump_designs <data-root> <stock_designs.json> <out.json>
//
// Runs every save design through the rules and writes, per design, the
// findings as (rule, level, slot, bank), applied_techs per slot and the
// derived stats -- the input of oracle/compare.py.
#include <cstdio>
#include <fstream>
#include <string>
#include <vector>
#include "game/data/catalog.h"
#include "game/design/rules.h"
#include "game/design/stats.h"
#include "json_min.h"
#include "stock_designs.h"
using namespace game::data;
using namespace game::design;
using json_min::quote;
namespace {
std::string num(double v) {
char buf[64];
std::snprintf(buf, sizeof buf, "%.17g", v);
return buf;
}
std::string str_list(const std::vector<std::string>& v) {
std::string s = "[";
for (std::size_t i = 0; i < v.size(); ++i) s += (i ? "," : "") + quote(v[i]);
return s + "]";
}
std::string opt_num(const std::optional<double>& v) { return v ? num(*v) : "null"; }
std::string opt_int(const std::optional<std::int64_t>& v) { return v ? std::to_string(*v) : "null"; }
} // namespace
int main(int argc, char** argv) {
if (argc != 4) {
std::fprintf(stderr, "usage: dump_designs <data-root> <stock_designs.json> <out.json>\n");
return 2;
}
Catalog cat = load_catalog(argv[1]);
std::vector<StockDesign> designs;
std::string err;
if (!load_stock_designs(argv[2], designs, err)) {
std::fprintf(stderr, "%s\n", err.c_str());
return 1;
}
Ruleset rules(cat);
std::ofstream out(argv[3], std::ios::binary);
if (!out) {
std::fprintf(stderr, "cannot write %s\n", argv[3]);
return 1;
}
out << "[\n";
bool first = true;
for (StockDesign& sd : designs) {
Design& d = sd.design;
rules.strip_applied_techs(d);
std::vector<Violation> v = rules.validate(d);
DesignStats st = derive_stats(rules, d);
out << (first ? "" : ",\n") << "{\"player_index\":" << sd.player_index << ",\"design_index\":" << sd.design_index
<< ",\"save\":" << quote(sd.save) << ",\"player\":" << quote(sd.player) << ",\"name\":" << quote(d.name)
<< ",\"race\":" << quote(std::string(species_name(d.effective_race()))) << ",\"hidden\":" << (d.hidden ? "true" : "false");
out << ",\"violations\":[";
for (std::size_t i = 0; i < v.size(); ++i) {
const Violation& x = v[i];
out << (i ? "," : "") << "{\"rule\":" << quote(x.rule) << ",\"level\":" << quote(std::string(level_name(x.level)))
<< ",\"slot\":" << (x.slot ? quote(std::string(slot_name(*x.slot))) : "null")
<< ",\"bank\":" << (x.bank ? std::to_string(*x.bank) : "null") << ",\"message\":" << quote(x.message) << "}";
}
out << "],\"applied_techs\":{";
bool fa = true;
for (const SectionStats& s : st.sections) {
out << (fa ? "" : ",") << quote(std::string(slot_name(s.slot))) << ":" << str_list(s.applied_techs);
fa = false;
}
out << "},\"options\":{";
fa = true;
for (Slot s : kUsedSlots) {
if (d.slot(s).empty()) continue;
out << (fa ? "" : ",") << quote(std::string(slot_name(s))) << ":" << str_list(d.slot(s).options);
fa = false;
}
out << "},\"stats\":{"
<< "\"hull_class\":" << quote(st.hull_class) << ",\"mass\":" << num(st.mass) << ",\"section_cost\":" << num(st.section_cost)
<< ",\"section_cost_with_options\":" << num(st.section_cost_with_options)
<< ",\"weapon_cost_per_bank\":" << num(st.weapon_cost_per_bank) << ",\"weapon_cost_per_mount\":" << num(st.weapon_cost_per_mount)
<< ",\"total_cost_estimate\":" << num(st.total_cost_estimate) << ",\"health_total\":" << num(st.health_total)
<< ",\"crew\":" << st.crew << ",\"cpoints\":" << st.cpoints << ",\"banks\":" << st.banks << ",\"turrets\":" << st.turrets
<< ",\"command_cost\":" << st.command_cost << ",\"maintenance_cost\":" << st.maintenance_cost
<< ",\"command_quota\":" << opt_int(st.command_quota) << ",\"ftlspeed\":" << opt_num(st.ftlspeed)
<< ",\"nodespeed\":" << opt_num(st.nodespeed) << ",\"engine_techera\":" << quote(st.engine_techera)
<< ",\"capacities\":{";
for (std::size_t i = 0; i < st.capacities.size(); ++i)
out << (i ? "," : "") << quote(st.capacities[i].first) << ":" << quote(st.capacities[i].second);
out << "},\"sections\":{";
fa = true;
for (const SectionStats& s : st.sections) {
out << (fa ? "" : ",") << quote(std::string(slot_name(s.slot))) << ":{\"section\":" << quote(s.section->stem)
<< ",\"mass\":" << num(s.mass) << ",\"cost\":" << num(s.cost) << ",\"option_cost_multiplier\":" << num(s.option_cost_multiplier)
<< ",\"health\":" << num(s.health) << ",\"crew\":" << s.crew << ",\"cpoints\":" << s.cpoints << ",\"banks\":[";
for (std::size_t i = 0; i < s.banks.size(); ++i) {
const BankStats& b = s.banks[i];
out << (i ? "," : "") << "{\"size\":" << quote(fold(b.size)) << ",\"class\":" << quote(fold(b.turret_class))
<< ",\"mounts\":" << b.mounts << ",\"weapon\":" << (b.weapon ? quote(b.weapon->stem) : "null")
<< ",\"weapon_cost\":" << num(b.weapon_cost) << ",\"turret_model\":" << (b.turret ? quote(b.turret->model) : "null")
<< ",\"turret_health\":" << (b.turret ? opt_num(b.turret->health) : "null") << "}";
}
out << "]}";
fa = false;
}
out << "}}}";
first = false;
}
out << "\n]\n";
std::printf("wrote %zu designs to %s\n", designs.size(), argv[3]);
return 0;
}

View file

@ -0,0 +1,236 @@
// A minimal JSON reader for the test tools (reads the owner's designs JSON).
// Header-only, no dependencies, no exceptions: parse() returns false and an
// error message on malformed input. Numbers are kept as double plus the text.
#pragma once
#include <cstdlib>
#include <map>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace json_min {
struct Value {
enum class Kind { Null, Bool, Number, String, Array, Object };
Kind kind = Kind::Null;
bool b = false;
double num = 0;
std::string text; // string value or number text
std::vector<Value> items; // array
std::vector<std::pair<std::string, Value>> members; // object, file order
bool is_null() const { return kind == Kind::Null; }
bool is_string() const { return kind == Kind::String; }
bool is_number() const { return kind == Kind::Number; }
bool is_array() const { return kind == Kind::Array; }
bool is_object() const { return kind == Kind::Object; }
bool is_bool() const { return kind == Kind::Bool; }
const Value* get(std::string_view key) const {
for (const auto& m : members)
if (m.first == key) return &m.second;
return nullptr;
}
const Value& operator[](std::string_view key) const {
static const Value null;
const Value* v = get(key);
return v ? *v : null;
}
const std::string& str() const { return text; }
int as_int() const { return static_cast<int>(num); }
bool as_bool() const { return kind == Kind::Bool ? b : (kind == Kind::Number ? num != 0 : false); }
};
class Parser {
public:
explicit Parser(std::string_view s) : s_(s) {}
bool parse(Value& out, std::string& err) {
skip();
if (!value(out, err)) return false;
skip();
if (pos_ != s_.size()) return fail(err, "trailing characters");
return true;
}
private:
std::string_view s_;
std::size_t pos_ = 0;
bool fail(std::string& err, const char* what) {
err = std::string(what) + " at offset " + std::to_string(pos_);
return false;
}
void skip() {
while (pos_ < s_.size() && (s_[pos_] == ' ' || s_[pos_] == '\n' || s_[pos_] == '\r' || s_[pos_] == '\t')) ++pos_;
}
bool literal(const char* lit) {
std::string_view l(lit);
if (s_.substr(pos_, l.size()) != l) return false;
pos_ += l.size();
return true;
}
static void put_utf8(std::string& out, unsigned cp) {
if (cp < 0x80) {
out += static_cast<char>(cp);
} else if (cp < 0x800) {
out += static_cast<char>(0xC0 | (cp >> 6));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
out += static_cast<char>(0xE0 | (cp >> 12));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else {
out += static_cast<char>(0xF0 | (cp >> 18));
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
}
}
bool hex4(unsigned& cp) {
if (pos_ + 4 > s_.size()) return false;
cp = 0;
for (int i = 0; i < 4; ++i) {
char c = s_[pos_++];
cp <<= 4;
if (c >= '0' && c <= '9') cp |= static_cast<unsigned>(c - '0');
else if (c >= 'a' && c <= 'f') cp |= static_cast<unsigned>(c - 'a' + 10);
else if (c >= 'A' && c <= 'F') cp |= static_cast<unsigned>(c - 'A' + 10);
else return false;
}
return true;
}
bool string(std::string& out, std::string& err) {
if (pos_ >= s_.size() || s_[pos_] != '"') return fail(err, "expected string");
++pos_;
while (pos_ < s_.size()) {
char c = s_[pos_++];
if (c == '"') return true;
if (c != '\\') {
out += c;
continue;
}
if (pos_ >= s_.size()) break;
char e = s_[pos_++];
switch (e) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
unsigned cp;
if (!hex4(cp)) return fail(err, "bad \\u escape");
if (cp >= 0xD800 && cp < 0xDC00 && literal("\\u")) {
unsigned lo;
if (!hex4(lo)) return fail(err, "bad surrogate");
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
}
put_utf8(out, cp);
break;
}
default: return fail(err, "bad escape");
}
}
return fail(err, "unterminated string");
}
bool value(Value& v, std::string& err) {
if (pos_ >= s_.size()) return fail(err, "unexpected end");
char c = s_[pos_];
if (c == '{') {
++pos_;
v.kind = Value::Kind::Object;
skip();
if (literal("}")) return true;
for (;;) {
skip();
std::string key;
if (!string(key, err)) return false;
skip();
if (!literal(":")) return fail(err, "expected ':'");
skip();
Value m;
if (!value(m, err)) return false;
v.members.emplace_back(std::move(key), std::move(m));
skip();
if (literal(",")) continue;
if (literal("}")) return true;
return fail(err, "expected ',' or '}'");
}
}
if (c == '[') {
++pos_;
v.kind = Value::Kind::Array;
skip();
if (literal("]")) return true;
for (;;) {
skip();
Value item;
if (!value(item, err)) return false;
v.items.push_back(std::move(item));
skip();
if (literal(",")) continue;
if (literal("]")) return true;
return fail(err, "expected ',' or ']'");
}
}
if (c == '"') {
v.kind = Value::Kind::String;
return string(v.text, err);
}
if (literal("null")) return true;
if (literal("true")) {
v.kind = Value::Kind::Bool;
v.b = true;
return true;
}
if (literal("false")) {
v.kind = Value::Kind::Bool;
v.b = false;
return true;
}
std::size_t start = pos_;
while (pos_ < s_.size()) {
char d = s_[pos_];
if ((d >= '0' && d <= '9') || d == '-' || d == '+' || d == '.' || d == 'e' || d == 'E') ++pos_;
else break;
}
if (start == pos_) return fail(err, "unexpected character");
v.kind = Value::Kind::Number;
v.text = std::string(s_.substr(start, pos_ - start));
v.num = std::strtod(v.text.c_str(), nullptr);
return true;
}
};
inline bool parse(std::string_view text, Value& out, std::string& err) { return Parser(text).parse(out, err); }
// Writer helpers for the dump tool.
inline std::string quote(std::string_view s) {
std::string out = "\"";
for (unsigned char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof buf, "\\u%04x", c);
out += buf;
} else {
out += static_cast<char>(c);
}
}
}
return out + "\"";
}
} // namespace json_min

View file

@ -0,0 +1,305 @@
#include "mini_catalog.h"
#include <chrono>
#include <filesystem>
#include <fstream>
using namespace game::data;
using namespace game::design;
namespace fs = std::filesystem;
namespace {
void put(const fs::path& p, const std::string& text) {
fs::create_directories(p.parent_path());
std::ofstream(p, std::ios::binary) << text;
}
// --- sections ---------------------------------------------------------------
std::string bank(const char* cls, const char* size, int mounts, const char* fixed = nullptr) {
std::string s = " bank {";
if (fixed) s += std::string(" weapon \"") + fixed + "\"";
if (cls) s += std::string(" turretclass ") + cls;
if (size) s += std::string(" turretsize ") + size;
for (int i = 0; i < mounts; ++i) s += " mount { node N" + std::to_string(i) + " min_azimuth -90 max_azimuth 90 }";
return s + " }\n";
}
const char* kArmorOptions = " option { option IND_PlyAlloy option IND_MagLat option IND_QrkRes } option { option IND_RefCoat option IND_ImpRfCt }\n";
std::string de_command(const char* extra = "") {
return "shipsection { model a.X section_type command section_class destroyer socket_aft CommandNode\n"
" health 300 mass 800 cost 2000 cpoints 500 command_cost 1\n" + std::string(kArmorOptions) + extra +
bank("standard", "small", 1) + "}\n";
}
std::string de_armor(const char* extra = "") {
return "shipsection { model b.X section_type mission section_class destroyer socket_fore MissionNode01 socket_aft MissionNode\n"
" health 600 mass 2000 cost 4000 cpoints 1000\n" + std::string(kArmorOptions) +
" optiondef { option SLD_MkOne option SLD_MkTwo }\n" + extra +
bank("standard", "small", 3) + bank("standard", "small", 3) + bank("standard", "medium", 1) + "}\n";
}
std::string de_fission(const char* drive) {
return "shipsection { model c.X Section_Type Engine section_class Destroyer socket_fore EngineNode\n"
" requires DRV_Fissn requires " + std::string(drive) +
"\n health 450 mass 2500 cost 5000 cpoints 1380 engine_techera fission ftlspeed .2 nodespeed 4 range 9\n" +
std::string(kArmorOptions) + " option DRV_RecFiss\n"
" netforcelimits { force_forward 23000 force_right 23000 force_up 23000 torque_yaw 5 torque_pitch 5 torque_roll 5 speed 40 rotspeed 10 }\n" +
bank("standard", "small", 1) + "}\n";
}
std::string de_platform() {
return "shipsection { model d.X section_type mission section_class destroyer\n"
" health 1500 mass 5000 cost 6000 cpoints 900 defence_platform true scanrange 12 command_quota 20\n"
" ftlspeed 0 nodespeed 0\n" + std::string(kArmorOptions) +
bank("standard", "small", 2) + bank("standard", "small", 2) + bank("standard", "medium", 1) + "}\n";
}
const char* kRider = "shipsection { model r.X section_class destroyer design_class rider entity_class rider health 50 mass 100 cost 100 }\n";
const char* kRiderGated =
"shipsection { model r.X section_class destroyer design_class rider entity_class rider requires DRN_AdvFrm health 50 mass 100 cost 100 }\n";
void write_human(const fs::path& r) {
fs::path s = r / "Species/Human/sections";
put(s / "DECommand.shipsection", de_command());
put(s / "DEArmor.shipsection", de_armor());
put(s / "DEFission.shipsection", de_fission("DRV_Node"));
put(s / "DEHyperTest.shipsection", de_fission("DRV_Hyper")); // C5: Human can never research DRV_Hyper
put(s / "DEOddTech.shipsection", de_fission("XXX_Nowhere")); // C1 warn: unknown tech
put(s / "DEDefencePlatform.shipsection", de_platform());
put(s / "DERamScoop.shipsection", de_command(" exclude \"DEFission\" ramscoop true\n"));
put(s / "DEStrafe.shipsection",
"shipsection { model e.X section_type mission section_class destroyer socket_fore F socket_aft A health 1 mass 1 cost 1\n" +
bank("strafe", "small", 4) + bank("standard", "large", 1) + "}\n");
put(s / "DETorpedo.shipsection",
"shipsection { model e.X section_type mission section_class destroyer socket_fore F socket_aft A health 1 mass 1 cost 1\n"
" requires GRP_Torps\n" + bank("torpedo", "large", 1) + bank("missile", "medium", 1) + "}\n");
put(s / "DEAssaultShuttle.shipsection",
"shipsection { model e.X section_type mission section_class destroyer socket_fore F socket_aft A health 1 mass 1 cost 1\n"
" requires DRN_AdvFrm\n" + bank("assaultshuttlerider", "large", 1) + bank("tarkahunter", "large", 1) + "}\n");
put(s / "DEPlanetMissile.shipsection",
"shipsection { model e.X section_type mission section_class destroyer socket_fore F socket_aft A health 1 mass 1 cost 1\n" +
bank("planetmissile", "large", 1) + "}\n");
put(s / "CRCommand.shipsection",
"shipsection { model f.X section_type command section_class cruiser socket_aft CommandNode health 1 mass 10 cost 100 crew 5\n" +
bank("standard", "medium", 1) + "}\n");
put(s / "CRArmor.shipsection",
"shipsection { model f.X section_type mission section_class cruiser socket_fore F socket_aft A health 1 mass 20 cost 200 crew 10\n" +
bank("standard", "large", 2) + "}\n");
put(s / "CRFission.shipsection",
"shipsection { model f.X section_type engine section_class cruiser socket_fore EngineNode health 1 mass 30 cost 300 crew 15\n"
" requires DRV_Fissn requires DRV_Node engine_techera fission ftlspeed .2 nodespeed 4\n" +
bank("standard", "medium", 1) + "}\n");
// the station triple (design_class station: no hull-class tech; partners pinned)
put(s / "DNStation.shipsection",
"shipsection { model g.X section_type mission section_class dreadnought socket_fore Fore socket_aft Aft\n"
" requires IND_OrbCom health 9000 mass 100000 cost 700000 crew 100 entity_class \"Station\" design_class station command_quota 46\n"
" explicit_command_section \"DNStation_Fore\" explicit_engine_section \"DNStation_Aft\"\n" +
bank("standard", "large", 2) + "}\n");
put(s / "DNStation_Fore.shipsection",
"shipsection { model g.X section_type command section_class dreadnought socket_aft Aft explicit_section true\n"
" design_class station health 1 mass 1 cost 1\n" + bank("standard", "medium", 1) + "}\n");
put(s / "DNStation_Aft.shipsection",
"shipsection { model g.X section_type engine section_class dreadnought socket_fore Fore explicit_section true\n"
" design_class station health 1 mass 1 cost 1\n" + bank("standard", "medium", 1) + "}\n");
put(s / "DNCommand.shipsection",
"shipsection { model g.X section_type command section_class dreadnought socket_aft CommandNode health 1 mass 1 cost 1\n" +
bank("standard", "medium", 1) + "}\n");
put(s / "_AssaultShuttle.shipsection", kRider);
put(s / "_shipsections.txt",
"1 _AssaultShuttle.shipsection\n37 DEArmor.shipsection\n42 DECommand.shipsection\n47 DEFission.shipsection\n"
"50 DEDefencePlatform.shipsection\n51 DERamScoop.shipsection\n52 DEStrafe.shipsection\n53 DETorpedo.shipsection\n"
"54 DEAssaultShuttle.shipsection\n55 DEPlanetMissile.shipsection\n56 DEHyperTest.shipsection\n57 DEOddTech.shipsection\n"
"60 CRCommand.shipsection\n61 CRArmor.shipsection\n62 CRFission.shipsection\n"
"70 DNStation.shipsection\n71 DNStation_Fore.shipsection\n72 DNStation_Aft.shipsection\n73 DNCommand.shipsection\n");
}
void write_tarkas(const fs::path& r) {
fs::path s = r / "Species/Tarkas/sections";
put(s / "DECommand.shipsection", de_command());
put(s / "DEArmor.shipsection", de_armor());
put(s / "DEFission.shipsection", de_fission("DRV_Hyper"));
put(s / "DEDefencePlatform.shipsection", de_platform());
put(s / "_AssaultShuttle.shipsection", kRiderGated);
put(s / "_HunterShip.shipsection", kRider);
put(s / "_shipsections.txt",
"1 _AssaultShuttle.shipsection\n2 _HunterShip.shipsection\n37 DEArmor.shipsection\n42 DECommand.shipsection\n"
"47 DEFission.shipsection\n50 DEDefencePlatform.shipsection\n");
}
void write_zuul(const fs::path& r) {
fs::path s = r / "Species/Zuul/sections";
put(s / "DECommand.shipsection", de_command());
put(s / "DEArmor.shipsection", de_armor());
put(s / "DEFission.shipsection", de_fission("DRV_Node"));
put(s / "_shipsections.txt", "37 DEArmor.shipsection\n42 DECommand.shipsection\n47 DEFission.shipsection\n");
}
void write_npc(const fs::path& r) {
fs::path s = r / "Species/_NPC/sections";
// an NPC hull: typeless, standalone, fixed weapons in every bank, one bank without a turret spec
put(s / "_Herald.shipsection",
"shipsection { model h.X section_class cruiser health 5000 mass 20000 cost 1 crew false nodesign 1\n" +
bank("standard", "medium", 2, "Species/_NPC/weapons/Herald_gun.weapon") +
bank("strafe", "small", 1, "Species/_NPC/weapons/Herald_beam.weapon") +
bank(nullptr, nullptr, 1, "Species/_NPC/weapons/Herald_gun.weapon") + "}\n");
// an NPC destroyer triple (nodesign, typed, sockets present, player-style banks)
put(s / "_DECommand.shipsection", de_command(" nodesign 1\n"));
put(s / "_DEMission.shipsection", de_armor(" nodesign true\n"));
put(s / "_DEEngine.shipsection", de_fission("DRV_Node"));
put(s / "_shipsections.txt", "1 _Herald.shipsection\n2 _DECommand.shipsection\n3 _DEMission.shipsection\n4 _DEEngine.shipsection\n");
put(r / "Species/_NPC/weapons/Herald_gun.weapon",
"weapon { name @WEAPON_HERALD_GUN weaponclass bullet turretsize medium turretclass standard hidden 1 cost 7 }\n");
put(r / "Species/_NPC/weapons/Herald_beam.weapon",
"weapon { name @WEAPON_HERALD_BEAM weaponclass beam turretsize small turretclass standard cost 9 }\n");
}
// --- weapons -----------------------------------------------------------------
std::string weapon(const char* cls, const char* size, const char* tcls, const std::string& extra, int cost) {
return std::string("weapon { name @W weaponclass ") + cls + " turretsize " + size + " turretclass " + tcls + " cost " +
std::to_string(cost) + " " + extra + " }\n";
}
void write_weapons(const fs::path& r) {
fs::path w = r / "Weapons";
put(w / "bal_gauss.weapon", weapon("bullet", "small", "standard", "requires WEP_GsDrvr", 50));
put(w / "las_red.weapon", weapon("beam", "small", "standard", "requires WEP_RedLas", 60));
put(w / "las_pd.weapon", weapon("beam", "tiny", "standard", "requires WEP_PD", 40));
put(w / "can_plasma.weapon", weapon("bullet", "large", "standard", "requires WEP_Plasma", 400));
put(w / "mis.weapon", weapon("missile", "medium", "Missile", "", 3000));
put(w / "mis_defplat.weapon", weapon("missile", "medium", "PlanetMissile", "", 3500));
put(w / "mis_planet.weapon", weapon("missile", "medium", "PlanetMissile", "hidden 1", 1));
put(w / "trp_photon.weapon", weapon("torpedo", "large", "torpedo", "requires WEP_PhotTrp", 900));
put(w / "bal_grapple.weapon", weapon("grapple", "medium", "grapple", "requires WEP_Grapple exclusive_species zuul", 500));
put(w / "brd_shuttle.weapon", weapon("rider", "large", "assaultshuttlerider", "requires DRN_AdvFrm compatible_section _AssaultShuttle", 800));
put(w / "brd_tarkahunter.weapon",
weapon("rider", "large", "tarkahunter", "requires DRN_AdvFrm compatible_section _HunterShip compatible_section _HunterShip_B", 800));
put(w / "hvy_bem_lancer.weapon", weapon("beam", "large", "beam", "requires GRP_HvyBeam", 1200));
put(w / "_weapons.txt",
"8 bal_gauss.weapon\n9 las_red.weapon\n10 las_pd.weapon\n11 can_plasma.weapon\n21 mis.weapon\n22 mis_defplat.weapon\n"
"23 mis_planet.weapon\n30 trp_photon.weapon\n31 bal_grapple.weapon\n32 brd_shuttle.weapon\n33 brd_tarkahunter.weapon\n"
"34 hvy_bem_lancer.weapon\n");
put(w / "_turrets.txt",
"// size weapon-size class health track az inc model\n"
"small tiny standard 100 400 1 1 \"t_s_tiny.x\"\n"
"small small standard 120 320 1 1 \"t_s.x\"\n"
"medium small standard 300 200 1 1 \"t_m_s.x\"\n"
"medium medium standard 500 140 1 1 \"t_m.x\"\n"
"large small standard 600 200 1 1 \"t_l_s.x\"\n"
"large medium standard 700 140 1 1 \"t_l_m.x\"\n"
"Large large standard 900 80 1 1 \"t_l.x\"\n"
"medium medium Missile 400 100 1 0.25 \"turret_m1missile.x\"\n"
"large medium missile 450 100 1 0.25 \"turret_l1missile.x\"\n"
"large large missile 500 100 1 0.25 \"turret_l2missile.x\"\n"
"large medium planetmissile 500 100 1 0.25 \"turret_pm.x\"\n"
"large large torpedo 800 60 1 1 \"t_trp.x\"\n"
"large large beam 1500 80 1 1 \"\"\n"
"medium medium grapple 500 140 1 1 \"t_grap.x\"\n"
"large medium grapple 700 140 1 1 \"t_grap_l.x\"\n"
"small small strafe 120 320 1 1 \"t_s.x\"\n"
"medium medium strafe 500 140 1 1 \"t_m.x\"\n"
"large large assaultshuttlerider 900 80 1 1 \"t_rider.x\"\n"
"large large tarkahunter 900 80 1 1 \"t_rider.x\"\n");
put(w / "_defaultweapons.txt",
"// <mount-class> <mount-size> <weapon-file>\n"
"standard tiny \"las_pd.weapon\"\nstandard small \"las_red.weapon\"\nstandard medium \"las_red.weapon\"\n"
"standard large \"can_plasma.weapon\"\nmissile medium \"mis.weapon\"\nstrafe small \"las_red.weapon\"\n"
"torpedo large \"trp_photon.weapon\"\n");
}
// --- tech tree --------------------------------------------------------------
void write_tech(const fs::path& r) {
put(r / "TechTree/MasterTechList.tech",
"tech { name \"IND_ROOT\" allows \"IND_PlyAlloy RP:1\" allows \"IND_CruisCon RP:1\" allows \"IND_OrbCom RP:1\" allows \"IND_RefCoat RP:1\" }\n"
"tech { name \"IND_PlyAlloy\" option_cost 1.2 allows \"IND_MagLat RP:1\" }\n"
"tech { name \"IND_MagLat\" option_cost 1.4 allows \"IND_QrkRes RP:1\" }\n"
"tech { name \"IND_QrkRes\" option_cost 1.6 }\n"
"tech { name \"IND_RefCoat\" option_cost 1.2 allows \"IND_ImpRfCt RP:1\" }\n"
"tech { name \"IND_ImpRfCt\" option_cost 1.5 }\n"
"tech { name \"IND_CruisCon\" allows \"IND_DreadCon RP:1\" }\n"
"tech { name \"IND_DreadCon\" }\n"
"tech { name \"IND_OrbCom\" }\n"
"tech { name \"DRV_ROOT\" allows \"DRV_Fissn RP:1\" }\n"
"tech { name \"DRV_Fissn\" allows \"DRV_Node RP:1 Human:100 Hiver:0 Tarkas:0 Liir:0 Zuul:100 Morrigi:0\"\n"
" allows \"DRV_Hyper RP:1 Human:0 Hiver:0 Tarkas:100 Liir:0 Zuul:0 Morrigi:0\" allows \"DRV_RecFiss RP:1\" }\n"
"tech { name \"DRV_Node\" }\n"
"tech { name \"DRV_Hyper\" }\n"
"tech { name \"DRV_RecFiss\" option_cost 1.2 }\n"
"tech { name \"WEP_ROOT\" allows \"WEP_GsDrvr RP:1\" allows \"WEP_RedLas RP:1\" allows \"WEP_Plasma RP:1\" allows \"WEP_Grapple RP:1\" }\n"
"tech { name \"WEP_GsDrvr\" }\n"
"tech { name \"WEP_RedLas\" allows \"WEP_PD RP:1\" allows \"WEP_HvyLancer RP:1\" }\n"
"tech { name \"WEP_PD\" group PD }\n"
"tech { name \"WEP_Plasma\" }\n"
"tech { name \"WEP_Grapple\" }\n"
"tech { name \"WEP_HvyLancer\" group HvyBeam }\n"
"tech { name \"TRP_ROOT\" allows \"WEP_PhotTrp RP:1\" allows \"WEP_PlasTrp RP:1\" }\n"
"tech { name \"WEP_PhotTrp\" group TORPS }\n"
"tech { name \"WEP_PlasTrp\" group TORPS }\n"
"tech { name \"DRN_ROOT\" allows \"DRN_AdvFrm RP:1\" }\n"
"tech { name \"DRN_AdvFrm\" }\n"
"tech { name \"SLD_Root\" allows \"SLD_MkOne RP:1\" }\n"
"tech { name \"SLD_MkOne\" option_cost 1.4 allows \"SLD_MkTwo RP:1\" }\n"
"tech { name \"SLD_MkTwo\" option_cost 1.8 }\n");
}
fs::path make_root() {
auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
fs::path root = fs::temp_directory_path() / ("game_design_test_" + std::to_string(static_cast<long long>(stamp)));
fs::remove_all(root);
write_weapons(root);
write_human(root);
write_tarkas(root);
write_zuul(root);
write_npc(root);
write_tech(root);
return root;
}
} // namespace
const Catalog& mini_catalog() {
static const Catalog cat = [] {
fs::path root = make_root();
Catalog c = load_catalog(root);
fs::remove_all(root);
return c;
}();
return cat;
}
const Ruleset& mini_rules() {
static const Ruleset rules(mini_catalog());
return rules;
}
SectionUse use(std::string stem, std::vector<std::string> weapons, std::vector<std::string> options) {
SectionUse u;
u.section = std::move(stem);
for (std::string& w : weapons) u.weapons.push_back(w.empty() ? WeaponRef::none() : WeaponRef::by_stem(std::move(w)));
u.options = std::move(options);
return u;
}
Design armor(Species race) {
Design d;
d.name = "Armor";
d.race = race;
d.command() = use("DECommand", {"bal_gauss"});
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"});
d.engine() = use("DEFission", {"bal_gauss"});
d.known_techs = std::vector<std::string>{"DRV_Fissn", race == Species::Tarkas ? "DRV_Hyper" : "DRV_Node", "WEP_GsDrvr"};
return d;
}
std::vector<std::string> rules_of(const Design& d, Level level) { return rules_at(mini_rules().validate(d), level); }
std::vector<std::string> error_rules(const Design& d) { return rules_of(d, Level::Error); }
std::string join(const std::vector<std::string>& v) {
std::string s;
for (const std::string& x : v) s += (s.empty() ? "" : ",") + x;
return s;
}

View file

@ -0,0 +1,22 @@
// A hand-written miniature data root (four species catalogs, a dozen weapons,
// a turret table and a tech tree) that exercises every design rule. Written
// to a temp directory once and loaded through game::data::load_catalog so the
// tests see exactly what the real loader produces.
#pragma once
#include <string>
#include <vector>
#include "game/data/catalog.h"
#include "game/design/design.h"
#include "game/design/rules.h"
const game::data::Catalog& mini_catalog();
const game::design::Ruleset& mini_rules();
// Design builders on the mini catalog.
game::design::SectionUse use(std::string stem, std::vector<std::string> weapons = {}, std::vector<std::string> options = {});
game::design::Design armor(game::data::Species race = game::data::Species::Human); // the stock starting destroyer, gated
std::vector<std::string> error_rules(const game::design::Design& d);
std::vector<std::string> rules_of(const game::design::Design& d, game::design::Level level);
std::string join(const std::vector<std::string>& v);

View file

@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""compare.py <ours.json> <stock_designs.json> <design-rules-dir>
Runs the reference validator (design_rules.py from the RE repo) on the same
designs the C++ dump was produced from and diffs, design by design:
* the set of findings as (rule, level, slot, bank) -- must be identical;
* applied_techs per slot -- must be identical (case-insensitive);
* the chosen options per slot -- must be identical;
* derived stats: mass, costs, health, crew, cpoints, banks, turrets,
command/maintenance cost, command quota, drive figures, capacities, and
per-section/per-bank figures -- numbers numerically, bools as 0/1.
The reference loads its catalogs from SOTS_CATALOG_DIR (default: the RE
repo's verify/results/data-catalogs). Exit 1 on any difference.
"""
import json
import os
import sys
MAX_DIFFS = 40
def norm_val(v):
if v is None or v == "":
return None # an absent string field is "" on our side, None on the reference's
if isinstance(v, bool):
return 1 if v else 0
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str):
s = v.strip()
if s.lower() in ("true", "false"):
return 1 if s.lower() == "true" else 0
try:
return float(s)
except ValueError:
return s.lower()
return v
def same(a, b):
a, b = norm_val(a), norm_val(b)
if isinstance(a, float) and isinstance(b, float):
return abs(a - b) <= 1e-9 * max(1.0, abs(a), abs(b))
return a == b
def main(argv):
if len(argv) != 4:
print(__doc__)
return 2
ours_path, designs_path, rules_dir = argv[1:]
sys.path.insert(0, rules_dir)
import design_rules # noqa: E402
cat = design_rules.Catalog.load(os.environ.get("SOTS_CATALOG_DIR"))
ours = json.load(open(ours_path))
players = json.load(open(designs_path))
ref = [(pi, di, p, d) for pi, p in enumerate(players) for di, d in enumerate(p["designs"])]
if len(ref) != len(ours):
print(f"design count differs: ours {len(ours)}, reference {len(ref)}")
return 1
diffs = []
n_viol_ours = n_viol_ref = 0
n_designs_with_findings = 0
n_stats = 0
def diff(tag, what, a, b):
diffs.append(f"{tag}: {what}: ours {a!r} vs reference {b!r}")
for o, (pi, di, p, d) in zip(ours, ref):
tag = f"{p['save']} {p['player']} '{d['name']}'"
if (o["player_index"], o["design_index"]) != (pi, di):
diff(tag, "order", (o["player_index"], o["design_index"]), (pi, di))
continue
# findings
ref_v = design_rules.validate(d, cat)
ref_set = {(v.rule, v.level, v.slot, v.bank) for v in ref_v}
our_set = {(v["rule"], v["level"], v["slot"], v["bank"]) for v in o["violations"]}
n_viol_ours += len(our_set)
n_viol_ref += len(ref_set)
if ref_set:
n_designs_with_findings += 1
if ref_set != our_set:
diff(tag, "findings", sorted(our_set - ref_set, key=str), sorted(ref_set - our_set, key=str))
# applied techs / options / stats
st = design_rules.derive_stats(d, cat)
for slot in design_rules.SLOTS:
use = d.get(slot)
if not use:
if slot in o["applied_techs"]:
diff(tag, f"{slot} applied_techs", o["applied_techs"][slot], None)
continue
want = [t.lower() for t in st["applied_techs"].get(slot, [])]
got = [t.lower() for t in o["applied_techs"].get(slot, [])]
if want != got:
diff(tag, f"{slot} applied_techs", got, want)
wopt = [t.lower() for t in use.get("options", [])]
gopt = [t.lower() for t in o["options"].get(slot, [])]
if wopt != gopt:
diff(tag, f"{slot} options", gopt, wopt)
os_ = o["stats"]
for key in ("mass", "section_cost", "section_cost_with_options", "weapon_cost_per_bank", "weapon_cost_per_mount",
"total_cost_estimate", "health_total", "crew", "cpoints", "banks", "turrets", "command_cost",
"maintenance_cost", "command_quota", "ftlspeed", "nodespeed", "engine_techera", "hull_class"):
n_stats += 1
a, b = norm_val(os_.get(key)), norm_val(st.get(key))
if a is None and b is None:
continue
if (a is None) != (b is None) or not same(a, b):
diff(tag, key, a, b)
for key, val in st["capacities"].items():
n_stats += 1
if key not in os_["capacities"]:
diff(tag, f"capacities.{key}", None, val)
elif not same(os_["capacities"][key], val):
diff(tag, f"capacities.{key}", os_["capacities"][key], val)
for key in os_["capacities"]:
if key not in st["capacities"]:
diff(tag, f"capacities.{key}", os_["capacities"][key], None)
for slot, sec in st["sections"].items():
osec = os_["sections"].get(slot)
if osec is None:
diff(tag, f"{slot} section stats", None, sec["section"])
continue
for key in ("section", "mass", "cost", "option_cost_multiplier", "health", "crew", "cpoints"):
n_stats += 1
if not same(osec[key], sec[key]):
diff(tag, f"{slot}.{key}", osec[key], sec[key])
if len(osec["banks"]) != len(sec["banks"]):
diff(tag, f"{slot} bank count", len(osec["banks"]), len(sec["banks"]))
continue
for i, (ob, rb) in enumerate(zip(osec["banks"], sec["banks"])):
for key in ("size", "class", "mounts", "weapon", "weapon_cost", "turret_model", "turret_health"):
n_stats += 1
a, b = norm_val(ob.get(key)), norm_val(rb.get(key))
if a is None and b is None:
continue
if (a is None) != (b is None) or not same(a, b):
diff(tag, f"{slot} bank {i} {key}", a, b)
print(f"{len(ours)} designs compared; findings ours {n_viol_ours} / reference {n_viol_ref} "
f"({n_designs_with_findings} designs with findings); {n_stats} stat values compared")
if diffs:
print(f"{len(diffs)} differences:")
for line in diffs[:MAX_DIFFS]:
print(" " + line)
if len(diffs) > MAX_DIFFS:
print(f" ... {len(diffs) - MAX_DIFFS} more")
return 1
print("no differences")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View file

@ -0,0 +1,170 @@
// Every design in the owner's real saves through the rules.
//
// SOTS_DATA_DIR=<extracted gob> SOTS_DESIGNS_JSON=<stock_designs.json> realdata_test
//
// Skips (exit 0) when either variable is unset. Checks:
// * the (species, section id) record resolves to the section the reference
// reader named, and the save's bank count is the section's bank count;
// * stripping the structural techs from DOpts yields the reference's options
// and applied_techs() rebuilds DOpts exactly (D2) on every instance;
// * no design has an error-level finding (structure, banks, fit, options,
// tech gating against the owning player's researched techs);
// * the only tech-gating findings are warn-level, on hidden designs (the
// per-race default rider design the engine creates without gating).
// With the owner's 127-design extract the counts must be 127/127, 197/197 and
// 121/127 with the 6 hidden Tarkas assault shuttles as the warnings.
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <string>
#include <vector>
#include "game/data/catalog.h"
#include "game/design/rules.h"
#include "game/design/stats.h"
#include "stock_designs.h"
using namespace game::data;
using namespace game::design;
namespace {
std::string join(const std::vector<std::string>& v) {
std::string s;
for (const std::string& x : v) s += (s.empty() ? "" : ",") + x;
return s;
}
bool same_fold(const std::vector<std::string>& a, const std::vector<std::string>& b) {
if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i)
if (!iequals(a[i], b[i])) return false;
return true;
}
bool is_gating(const std::string& rule) { return rule.size() == 2 && rule[0] == 'C'; }
} // namespace
int main() {
const char* data_dir = std::getenv("SOTS_DATA_DIR");
const char* designs_path = std::getenv("SOTS_DESIGNS_JSON");
if (!data_dir || !*data_dir || !designs_path || !*designs_path) {
std::printf("SKIP: SOTS_DATA_DIR / SOTS_DESIGNS_JSON not set\n");
return 0;
}
Catalog cat = load_catalog(data_dir);
if (cat.sections.empty() || cat.weapons.empty()) {
std::printf("FAIL: no catalog at %s (%zu problems)\n", data_dir, cat.problems.size());
return 1;
}
std::vector<StockDesign> designs;
std::string err;
if (!load_stock_designs(designs_path, designs, err)) {
std::printf("FAIL: %s\n", err.c_str());
return 1;
}
Ruleset rules(cat);
int failures = 0;
int n_designs = 0, n_clean = 0, n_instances = 0, n_dopts_ok = 0, n_gated_ok = 0, n_gate_warn_hidden = 0;
std::vector<std::string> gate_warn_designs;
for (StockDesign& sd : designs) {
++n_designs;
Design& d = sd.design;
const std::string tag = sd.save + " " + sd.player + " '" + d.name + "'";
// resolution against the reference view
for (std::size_t i = 0; i < 3; ++i) {
const SectionUse& u = d.slot(kUsedSlots[i]);
const ShipSectionDef* s = u.empty() ? nullptr : rules.resolve_section(u.species, u);
const std::string got = s ? s->stem : "";
if (!iequals(got, sd.expect_stem[i])) {
std::printf("FAIL %s: %s slot resolves to '%s', reference says '%s'\n", tag.c_str(),
std::string(slot_name(kUsedSlots[i])).c_str(), got.c_str(), sd.expect_stem[i].c_str());
++failures;
}
if (s && static_cast<int>(s->banks.size()) != sd.bank_count[i]) {
std::printf("FAIL %s: %s has %zu banks, save stores %d\n", tag.c_str(), s->stem.c_str(), s->banks.size(),
sd.bank_count[i]);
++failures;
}
}
// DOpts -> chosen options -> DOpts (D2)
rules.strip_applied_techs(d);
for (std::size_t i = 0; i < 3; ++i) {
const SectionUse& u = d.slot(kUsedSlots[i]);
if (u.empty()) continue;
const ShipSectionDef* s = rules.resolve_section(u.species, u);
if (!s) continue;
++n_instances;
if (!same_fold(u.options, sd.expect_options[i])) {
std::printf("FAIL %s: %s options [%s], reference [%s]\n", tag.c_str(), s->stem.c_str(), join(u.options).c_str(),
join(sd.expect_options[i]).c_str());
++failures;
}
std::vector<std::string> applied = applied_techs(*s, u.options);
if (same_fold(applied, sd.dopts[i])) {
++n_dopts_ok;
} else {
std::printf("FAIL %s: %s applied_techs [%s], DOpts [%s]\n", tag.c_str(), s->stem.c_str(), join(applied).c_str(),
join(sd.dopts[i]).c_str());
++failures;
}
}
// the rules
std::vector<Violation> v = rules.validate(d);
bool errors = false, gate = false, gate_error = false;
for (const Violation& x : v) {
if (x.level == Level::Error) errors = true;
if (is_gating(x.rule)) {
gate = true;
if (x.level != Level::Warn) gate_error = true;
}
}
if (errors) {
std::printf("FAIL %s:\n", tag.c_str());
for (const Violation& x : v) std::printf(" %s\n", x.str().c_str());
++failures;
} else {
++n_clean;
}
if (!gate) {
++n_gated_ok;
} else if (!gate_error && d.hidden) {
++n_gate_warn_hidden;
gate_warn_designs.push_back(sd.race_text + " '" + d.name + "' (" + sd.save + ", " + sd.player + ")");
} else {
std::printf("FAIL %s: tech gating finding that is not a warn on a hidden design\n", tag.c_str());
for (const Violation& x : v) std::printf(" %s\n", x.str().c_str());
++failures;
}
// derive_stats must run on everything
DesignStats st = derive_stats(rules, d);
if (st.sections.empty()) {
std::printf("FAIL %s: no stats\n", tag.c_str());
++failures;
}
}
std::printf("designs: %d, clean (structure/banks/fit/options/gating): %d/%d\n", n_designs, n_clean, n_designs);
std::printf("section instances: %d, DOpts reproduced by applied_techs: %d/%d\n", n_instances, n_dopts_ok, n_instances);
std::printf("tech gating: %d/%d pass, %d warn-only on hidden designs:\n", n_gated_ok, n_designs, n_gate_warn_hidden);
for (const std::string& s : gate_warn_designs) std::printf(" %s\n", s.c_str());
if (n_designs == 127) { // the owner's three-save extract: the published numbers
if (n_clean != 127 || n_instances != 197 || n_dopts_ok != 197 || n_gated_ok != 121 || n_gate_warn_hidden != 6) {
std::printf("FAIL: expected 127/127 clean, 197/197 DOpts, 121/127 gated + 6 hidden warnings\n");
++failures;
}
for (const std::string& s : gate_warn_designs)
if (s.rfind("Tarkas 'Default Assault Shuttle'", 0) != 0) {
std::printf("FAIL: unexpected gating warning on %s\n", s.c_str());
++failures;
}
}
std::printf("%s\n", failures ? "FAILED" : "OK");
return failures ? 1 : 0;
}

View file

@ -0,0 +1,95 @@
#include "stock_designs.h"
#include <fstream>
#include <sstream>
#include "game/data/common.h"
#include "json_min.h"
using namespace game::design;
using json_min::Value;
namespace {
std::vector<std::string> strings(const Value& v) {
std::vector<std::string> out;
for (const Value& x : v.items)
if (x.is_string()) out.push_back(x.str());
return out;
}
} // namespace
bool load_stock_designs(const std::string& path, std::vector<StockDesign>& out, std::string& err) {
std::ifstream in(path, std::ios::binary);
if (!in) {
err = "cannot read " + path;
return false;
}
std::ostringstream ss;
ss << in.rdbuf();
Value root;
if (!json_min::parse(ss.str(), root, err)) return false;
if (!root.is_array()) {
err = "expected a list of players";
return false;
}
int pi = 0;
for (const Value& player : root.items) {
int di = 0;
for (const Value& d : player["designs"].items) {
StockDesign sd;
sd.player_index = pi;
sd.design_index = di++;
sd.save = player["save"].str();
sd.player = player["player"].str();
sd.npc = player["npc"].as_bool();
sd.race_text = d["race"].str();
sd.list = d["list"].str();
Design& des = sd.design;
des.name = d["name"].str();
des.hidden = d["hidden"].as_bool();
des.designer_made = d["faiDes"].as_bool();
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"]);
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";
return false;
}
for (int i = 0; i < kSlotCount; ++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();
u.section_id = r["section_id"].as_int();
if (species == 0 && u.section_id == 0) continue; // empty slot
u.species = static_cast<game::data::Species>(species);
const Value& weapons = r["weapons"];
const Value& riders = r["rider_design_ids"];
for (std::size_t w = 0; w < weapons.items.size(); ++w) {
const Value& x = weapons.items[w];
WeaponRef ref;
if (x.is_number()) ref = WeaponRef::by_id(x.as_int());
else if (x.is_string()) ref = WeaponRef::by_file(x.str());
if (w < riders.items.size()) ref.rider_design_id = riders.items[w].as_int();
u.weapons.push_back(ref);
}
u.options = strings(r["options"]);
if (i < 3) {
sd.dopts[static_cast<std::size_t>(i)] = u.options;
sd.bank_count[static_cast<std::size_t>(i)] = r["bank_count"].as_int();
}
}
for (std::size_t i = 0; i < 3; ++i) {
const Value& view = d[slot_name(kUsedSlots[i])];
if (view.is_null()) continue;
sd.expect_stem[i] = view["section"].str();
sd.expect_options[i] = strings(view["options"]);
}
out.push_back(std::move(sd));
}
++pi;
}
return true;
}

View file

@ -0,0 +1,27 @@
// Reads the owner's designs extract ($SOTS_DESIGNS_JSON): every design of
// every player in the real saves, decoded into the save-record shape
// (`raw_slots`: species index, section id, one weapon id/file per bank, the
// DOpts list) plus the player's researched techs and the reference reader's
// resolved view (section stems, stripped options) that the test cross-checks.
// The file is game-derived and never enters the repo.
#pragma once
#include <array>
#include <string>
#include <vector>
#include "game/design/design.h"
struct StockDesign {
int player_index = 0, design_index = 0;
std::string save, player, race_text, list;
bool npc = false;
game::design::Design design; // options hold the raw DOpts until stripped
std::array<std::string, 3> expect_stem{}; // reference view: section stem per used slot ("" = empty)
std::array<std::vector<std::string>, 3> expect_options{}; // reference view: chosen options
std::array<std::vector<std::string>, 3> dopts{}; // the save's DOpts per used slot
std::array<int, 3> bank_count{}; // the save's bank count per used slot
};
// False (and `err`) when the file cannot be read or has an unexpected shape.
bool load_stock_designs(const std::string& path, std::vector<StockDesign>& out, std::string& err);

View file

@ -0,0 +1,193 @@
// B* bank and weapon-fit rules on the mini catalog.
#include "game/design/rules.h"
#include "mini_catalog.h"
#include "test_main.h"
using namespace game::data;
using namespace game::design;
namespace {
Fit fit(const char* section, std::size_t bank, const char* weapon) {
const Catalog& c = mini_catalog();
return weapon_fits_bank(c.turrets, c.section("Human", section)->banks[bank], *c.weapon(weapon));
}
std::vector<int> banks_of(const std::vector<Violation>& v, const char* rule) {
std::vector<int> out;
for (const Violation& x : v)
if (x.rule == rule && x.bank) out.push_back(*x.bank);
return out;
}
} // namespace
TEST(B1_one_weapon_per_bank) {
Design d = armor();
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss"});
CHECK_EQ(join(error_rules(d)), "B1,B2");
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis", "mis"});
CHECK_EQ(join(error_rules(d)), "B1");
}
TEST(B2_no_empty_bank) {
Design d = armor();
d.mission() = use("DEArmor", {"bal_gauss", "", "mis"});
std::vector<Violation> v = mini_rules().validate(d);
CHECK_EQ(join(rules_at(v, Level::Error)), "B2");
CHECK_EQ(banks_of(v, "B2").size(), std::size_t(1));
CHECK_EQ(banks_of(v, "B2")[0], 1);
}
TEST(B3_fixed_npc_banks) {
Design n;
n.race = Species::NPC;
n.hidden = true;
n.mission() = use("_Herald", {"", "", ""});
CHECK_EQ(join(error_rules(n)), ""); // empty entries take the bank's fixed weapon
n.mission().weapons = {WeaponRef::by_file("Species/_NPC/weapons/Herald_gun.weapon"),
WeaponRef::by_file("Species/_NPC/weapons/Herald_beam.weapon"),
WeaponRef::by_file("Species/_NPC/weapons/Herald_gun.weapon")};
CHECK_EQ(join(error_rules(n)), ""); // the same weapon spelled out, as NPC designs store it
n.mission().weapons[0] = WeaponRef::by_stem("bal_gauss");
std::vector<Violation> v = mini_rules().validate(n);
CHECK_EQ(join(rules_at(v, Level::Error)), "B3");
CHECK_EQ(banks_of(v, "B3")[0], 0);
// the bank without a turret spec takes only its fixed weapon
n.mission().weapons[0] = WeaponRef::none();
n.mission().weapons[2] = WeaponRef::by_file("Species/_NPC/weapons/Herald_beam.weapon");
CHECK_EQ(join(error_rules(n)), "B3");
}
TEST(B4_class_accept_table) {
CHECK(bank_accepts_class("standard", "standard"));
CHECK(bank_accepts_class("Standard", "Missile"));
CHECK(bank_accepts_class("standard", "grapple"));
CHECK(!bank_accepts_class("standard", "torpedo"));
CHECK(bank_accepts_class("strafe", "standard"));
CHECK(!bank_accepts_class("strafe", "missile"));
CHECK(bank_accepts_class("torpedo", "Torpedo"));
CHECK(!bank_accepts_class("missile", "standard"));
CHECK(!bank_accepts_class("planetmissile", "missile"));
}
TEST(B4_turret_rows) {
// small standard bank: tiny and small weapons only
CHECK(fit("DECommand", 0, "las_pd").ok);
CHECK(fit("DECommand", 0, "bal_gauss").ok);
CHECK(!fit("DECommand", 0, "mis").ok); // medium missile in a small bank: no row
CHECK(!fit("DECommand", 0, "trp_photon").ok); // class
// medium standard bank: small + medium standard, medium missile (missile row supplies the model), grapple
CHECK(fit("DEArmor", 2, "bal_gauss").ok);
CHECK(!fit("DEArmor", 2, "las_pd").ok);
Fit m = fit("DEArmor", 2, "mis");
CHECK(m.ok);
CHECK(m.row != nullptr);
if (m.row) CHECK_EQ(m.row->model, "turret_m1missile.x");
CHECK(fit("DEArmor", 2, "bal_grapple").ok);
// strafe bank: standard weapons; the row is looked up by the weapon's class
// first, so the standard row is used and the strafe rows are the fallback
Fit s = fit("DEStrafe", 0, "bal_gauss");
CHECK(s.ok);
if (s.row) CHECK_EQ(s.row->turret_class, "standard");
CHECK(!fit("DEStrafe", 0, "mis").ok);
{
// fallback to the bank's class when the weapon-class row is missing
const Catalog& c = mini_catalog();
BankDef strafe_medium;
strafe_medium.turret_class = "strafe";
strafe_medium.turret_size = "medium";
WeaponDef odd = *c.weapon("bal_gauss");
odd.turret_size = "medium";
odd.turret_class = "standard";
const TurretRow* row = turret_row(c.turrets, strafe_medium, odd);
CHECK(row != nullptr);
if (row) CHECK_EQ(row->turret_class, "standard"); // (medium, medium, standard) exists
odd.turret_class = "nothing";
CHECK(turret_row(c.turrets, strafe_medium, odd) != nullptr); // (medium, medium, strafe) row
CHECK(!weapon_fits_bank(c.turrets, strafe_medium, odd).ok); // but the class table rejects it
}
// large standard: small/medium/large standard + missiles
CHECK(fit("DEStrafe", 1, "can_plasma").ok);
CHECK(fit("DEStrafe", 1, "mis").ok);
CHECK(!fit("DEStrafe", 1, "las_pd").ok);
CHECK(!fit("DEStrafe", 1, "trp_photon").ok);
// large torpedo: large torpedoes only
CHECK(fit("DETorpedo", 0, "trp_photon").ok);
CHECK(!fit("DETorpedo", 0, "can_plasma").ok);
// medium missile bank: missiles only
CHECK(fit("DETorpedo", 1, "mis").ok);
CHECK(!fit("DETorpedo", 1, "bal_gauss").ok);
// planetmissile: the planet missiles (medium PlanetMissile in a large bank)
CHECK(fit("DEPlanetMissile", 0, "mis_defplat").ok);
CHECK(!fit("DEPlanetMissile", 0, "mis").ok);
}
TEST(B4_in_a_design) {
Design d = armor();
d.known_techs.reset();
d.command() = use("DECommand", {"trp_photon"});
CHECK_EQ(join(error_rules(d)), "B4");
d = armor();
d.known_techs.reset();
d.mission() = use("DEArmor", {"las_pd", "las_pd", "las_pd"});
std::vector<Violation> v = mini_rules().validate(d);
CHECK_EQ(join(rules_at(v, Level::Error)), "B4");
CHECK_EQ(banks_of(v, "B4").size(), std::size_t(1));
CHECK_EQ(banks_of(v, "B4")[0], 2);
d.mission() = use("DEArmor", {"mis", "bal_gauss", "mis"});
CHECK_EQ(join(error_rules(d)), "B4");
}
TEST(B5_weapon_must_exist_and_be_in_scope) {
Design d = armor();
d.command() = use("DECommand", {"bal_nothing"});
CHECK_EQ(join(error_rules(d)), "B5");
d.command().weapons = {WeaponRef::by_id(4242)};
CHECK_EQ(join(error_rules(d)), "B5");
d.command().weapons = {WeaponRef::by_file("Species/_NPC/weapons/Herald_beam.weapon")};
CHECK_EQ(join(error_rules(d)), "B5"); // NPC-only weapon on a player hull
Design n;
n.race = Species::NPC;
n.command() = use("_DECommand", {"Species/_NPC/weapons/Herald_beam.weapon"});
n.mission() = use("_DEMission", {"bal_gauss", "bal_gauss", "mis"});
n.engine() = use("_DEEngine", {"bal_gauss"});
CHECK_EQ(join(error_rules(n)), ""); // fine on an NPC hull
}
TEST(B6_exclusive_species) {
Design d = armor();
d.known_techs.reset();
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "bal_grapple"});
CHECK_EQ(join(error_rules(d)), "B6");
Design z = armor(Species::Zuul);
z.known_techs.reset();
z.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "bal_grapple"});
CHECK_EQ(join(error_rules(z)), "");
}
TEST(B7_rider_weapons_need_their_rider_section) {
Design d = armor();
d.known_techs.reset();
d.mission() = use("DEAssaultShuttle", {"brd_shuttle", "brd_tarkahunter"});
std::vector<Violation> v = mini_rules().validate(d);
CHECK_EQ(join(rules_at(v, Level::Error)), "B7"); // Human has _AssaultShuttle but no _HunterShip
CHECK_EQ(banks_of(v, "B7")[0], 1);
Design t = armor(Species::Tarkas);
t.known_techs.reset();
t.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"});
// Tarkas mini catalog has both riders but no DEAssaultShuttle section; check the weapon rule directly
CHECK(mini_catalog().section("Tarkas", "_HunterShip") != nullptr);
}
TEST(B8_hidden_weapons_are_engine_only) {
Design d = armor();
d.known_techs.reset();
d.mission() = use("DEPlanetMissile", {"mis_planet"});
CHECK_EQ(join(error_rules(d)), "B8");
d.hidden = true;
CHECK_EQ(join(error_rules(d)), "");
d.hidden = false;
d.mission() = use("DEPlanetMissile", {"mis_defplat"});
CHECK_EQ(join(error_rules(d)), "");
}

View file

@ -0,0 +1,26 @@
// The tools' JSON reader.
#include "json_min.h"
#include "test_main.h"
TEST(json_min_reads_the_shapes_the_tools_need) {
json_min::Value v;
std::string err;
CHECK(json_min::parse(" {\"a\": [1, -2.5, \"x\\n\\u00e9\", null, true, false, {}], \"b\": {\"c\": \"d\"}} ", v, err));
CHECK(v.is_object());
CHECK(v["a"].is_array());
CHECK_EQ(v["a"].items.size(), std::size_t(7));
CHECK_EQ(v["a"].items[0].as_int(), 1);
CHECK(v["a"].items[1].num == -2.5);
CHECK_EQ(v["a"].items[2].str(), std::string("x\n\xc3\xa9"));
CHECK(v["a"].items[3].is_null());
CHECK(v["a"].items[4].as_bool());
CHECK(!v["a"].items[5].as_bool());
CHECK(v["a"].items[6].is_object());
CHECK_EQ(v["b"]["c"].str(), "d");
CHECK(v["missing"].is_null());
CHECK(!json_min::parse("[1, 2", v, err));
CHECK(!err.empty());
CHECK(!json_min::parse("{\"a\" 1}", v, err));
CHECK(!json_min::parse("[1] x", v, err));
CHECK_EQ(json_min::quote("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
}

View file

@ -0,0 +1,32 @@
#include "test_main.h"
namespace testing {
std::vector<Case>& registry() {
static std::vector<Case> r;
return r;
}
int& failures() {
static int n = 0;
return n;
}
void report_failure(const char* file, int line, const std::string& expr) {
std::printf(" FAIL %s:%d: %s\n", file, line, expr.c_str());
++failures();
}
} // namespace testing
int main() {
int ran = 0;
for (const auto& c : testing::registry()) {
int before = testing::failures();
c.fn();
++ran;
if (testing::failures() != before) std::printf("[FAILED] %s\n", c.name);
}
std::printf("%d unit tests, %d failed assertions\n", ran, testing::failures());
return testing::failures() ? 1 : 0;
}

View file

@ -0,0 +1,49 @@
// Tiny self-contained test harness (no third-party deps).
#pragma once
#include <cstdio>
#include <functional>
#include <string>
#include <vector>
namespace testing {
struct Case {
const char* name;
std::function<void()> fn;
};
std::vector<Case>& registry();
int& failures();
void report_failure(const char* file, int line, const std::string& expr);
struct Register {
Register(const char* name, std::function<void()> fn) { registry().push_back({name, std::move(fn)}); }
};
} // namespace testing
#define TEST(name) \
static void test_##name(); \
static testing::Register reg_##name(#name, test_##name); \
static void test_##name()
#define CHECK(expr) \
do { \
if (!(expr)) testing::report_failure(__FILE__, __LINE__, #expr); \
} while (0)
#define CHECK_EQ(a, b) \
do { \
if (!((a) == (b))) \
testing::report_failure(__FILE__, __LINE__, \
std::string(#a " == " #b " [got: ") + \
testing_to_string(a) + " vs " + \
testing_to_string(b) + "]"); \
} while (0)
inline std::string testing_to_string(const std::string& s) { return "\"" + s + "\""; }
inline std::string testing_to_string(const char* s) { return std::string("\"") + s + "\""; }
inline std::string testing_to_string(bool b) { return b ? "true" : "false"; }
template <class T>
std::string testing_to_string(const T& v) { return std::to_string(v); }

View file

@ -0,0 +1,114 @@
// derive_stats and the default-weapon table on the mini catalog.
#include "game/design/defaults.h"
#include "game/design/stats.h"
#include "mini_catalog.h"
#include "test_main.h"
using namespace game::data;
using namespace game::design;
TEST(stats_sums_and_counts) {
DesignStats st = derive_stats(mini_rules(), armor());
CHECK_EQ(st.race, "Human");
CHECK_EQ(st.hull_class, "destroyer");
CHECK_EQ(st.sections.size(), std::size_t(3));
CHECK_EQ(st.mass, 800.0 + 2000 + 2500);
CHECK_EQ(st.section_cost, 2000.0 + 4000 + 5000);
CHECK_EQ(st.section_cost_with_options, st.section_cost);
CHECK_EQ(st.health_total, 300.0 + 600 + 450);
CHECK_EQ(st.cpoints, 500 + 1000 + 1380);
CHECK_EQ(st.crew, 0);
CHECK_EQ(st.banks, 5);
CHECK_EQ(st.turrets, 1 + 3 + 3 + 1 + 1);
CHECK_EQ(st.weapon_cost_per_bank, 50.0 * 4 + 3000);
CHECK_EQ(st.weapon_cost_per_mount, 50.0 * (1 + 3 + 3 + 1) + 3000);
CHECK_EQ(st.total_cost_estimate, 11000.0 + 3200);
CHECK_EQ(st.command_cost, 1);
CHECK(!st.command_quota);
CHECK(st.ftlspeed && *st.ftlspeed == 0.2);
CHECK(st.nodespeed && *st.nodespeed == 4);
CHECK_EQ(st.engine_techera, "fission");
CHECK(st.netforcelimits && st.netforcelimits->speed == 40);
CHECK_EQ(join(st.sections[2].applied_techs), "DRV_Fissn,DRV_Node");
CHECK(st.sections[1].banks[2].turret != nullptr);
if (st.sections[1].banks[2].turret) CHECK_EQ(st.sections[1].banks[2].turret->model, "turret_m1missile.x");
CHECK(st.capacity("range") != nullptr);
if (st.capacity("range")) CHECK_EQ(*st.capacity("range"), "9");
}
TEST(stats_option_cost_multiplier) {
Design d = armor();
d.known_techs.reset();
d.mission().options = {"IND_MagLat", "IND_RefCoat"}; // 1.4 x 1.2
DesignStats st = derive_stats(mini_rules(), d);
CHECK(st.sections[1].option_cost_multiplier > 1.679 && st.sections[1].option_cost_multiplier < 1.681);
CHECK(st.section_cost_with_options > 2000 + 4000 * 1.68 + 5000 - 0.01 && st.section_cost_with_options < 2000 + 4000 * 1.68 + 5000 + 0.01);
CHECK_EQ(join(st.sections[1].applied_techs), "IND_MagLat,IND_RefCoat");
}
TEST(stats_standalone_and_missing) {
Design d;
d.race = Species::Human;
d.mission() = use("DEDefencePlatform", {"bal_gauss", "bal_gauss", "mis"});
DesignStats st = derive_stats(mini_rules(), d);
CHECK_EQ(st.sections.size(), std::size_t(1));
CHECK(st.ftlspeed && *st.ftlspeed == 0); // a standalone mission section supplies the drive figures
CHECK(st.command_quota && *st.command_quota == 20);
CHECK(st.capacity("defence_platform") && *st.capacity("defence_platform") == "true");
CHECK(st.capacity("scanrange") && *st.capacity("scanrange") == "12");
// an unresolvable section contributes nothing; empty banks count as banks
d.mission() = use("DENowhere");
d.engine() = use("DEFission", {""});
st = derive_stats(mini_rules(), d);
CHECK_EQ(st.sections.size(), std::size_t(1));
CHECK_EQ(st.banks, 1);
CHECK_EQ(st.weapon_cost_per_bank, 0.0);
// NPC fixed weapons are costed
Design n;
n.race = Species::NPC;
n.mission() = use("_Herald", {"", "", ""});
st = derive_stats(mini_rules(), n);
CHECK_EQ(st.weapon_cost_per_bank, 7.0 + 9 + 7);
CHECK_EQ(st.weapon_cost_per_mount, 7.0 * 2 + 9 + 7);
CHECK_EQ(st.crew, 0); // `crew false` is a BadValue -> 0
}
TEST(default_weapons_table) {
Loaded<DefaultWeaponTable> t = parse_default_weapons(
"// comment\nstandard small \"las_red.weapon\"\nMissile medium \"mis.weapon\"\nbad row\n");
CHECK(t.ok());
CHECK_EQ(t.value->size(), std::size_t(2));
CHECK_EQ(t.problems.size(), std::size_t(1));
CHECK(t.value->find("standard", "Small") != nullptr);
CHECK(t.value->find("missile", "medium") != nullptr);
CHECK(t.value->find("standard", "large") == nullptr);
CHECK_EQ(t.value->find("standard", "small")->weapon_file, "las_red.weapon");
Loaded<DefaultWeaponTable> missing = load_default_weapons("/nonexistent/root");
CHECK(missing.ok());
CHECK_EQ(missing.value->size(), std::size_t(0));
CHECK_EQ(missing.problems.size(), std::size_t(1));
}
TEST(fill_empty_banks_from_defaults) {
Loaded<DefaultWeaponTable> t = parse_default_weapons(
"standard small \"las_red.weapon\"\nstandard medium \"las_red.weapon\"\ntorpedo large \"trp_photon.weapon\"\n");
Design d = armor();
d.known_techs.reset();
d.mission() = use("DETorpedo", {"", ""}); // large torpedo + medium missile (no default row for missile)
d.engine().weapons.clear(); // short list gets extended
int n = fill_empty_banks(d, mini_catalog(), *t.value);
CHECK_EQ(n, 2);
CHECK_EQ(d.mission().weapons[0].text, "Weapons/trp_photon.weapon");
CHECK(d.mission().weapons[1].empty());
CHECK_EQ(d.engine().weapons.size(), std::size_t(1));
CHECK_EQ(d.engine().weapons[0].text, "Weapons/las_red.weapon");
CHECK_EQ(d.command().weapons[0].text, "bal_gauss"); // untouched
std::vector<Violation> v = mini_rules().validate(d);
CHECK_EQ(join(rules_at(v, Level::Error)), "B2"); // only the missile bank is still empty
// NPC fixed banks are filled with their own weapon
Design h;
h.race = Species::NPC;
h.mission() = use("_Herald");
CHECK_EQ(fill_empty_banks(h, mini_catalog(), *t.value), 3);
CHECK_EQ(h.mission().weapons[1].text, "Species/_NPC/weapons/Herald_beam.weapon");
}

View file

@ -0,0 +1,207 @@
// A* structure rules on the mini catalog.
#include "game/design/rules.h"
#include "mini_catalog.h"
#include "test_main.h"
using namespace game::data;
using namespace game::design;
TEST(mini_catalog_loaded) {
const Catalog& c = mini_catalog();
CHECK_EQ(c.races.size(), std::size_t(4));
CHECK(c.section("Human", "DEArmor") != nullptr);
CHECK(c.section_by_id("Human", 37) == c.section("Human", "DEArmor"));
CHECK(c.weapon_by_id(8) == c.weapon("bal_gauss"));
CHECK_EQ(c.turrets.size(), std::size_t(19));
CHECK(c.tech.find("IND_CruisCon") != nullptr);
}
TEST(stock_armor_is_valid) {
std::vector<Violation> v = mini_rules().validate(armor());
CHECK_EQ(v.size(), std::size_t(0));
for (const Violation& x : v) std::printf(" %s\n", x.str().c_str());
CHECK_EQ(join(error_rules(armor(Species::Tarkas))), "");
}
TEST(resolution_by_id_species_and_file_name) {
Design d = armor();
d.race = Species::Unknown; // race comes from the first filled slot
d.command().species = Species::Human;
d.command().section_id = 42;
d.command().section.clear();
d.mission().section = "Species/Human/sections/DEArmor.shipsection";
d.engine().section = "defission";
d.command().weapons = {WeaponRef::by_id(8)};
d.mission().weapons = {WeaponRef::by_file("Weapons/bal_gauss.weapon"), WeaponRef::by_file("bal_gauss.weapon"),
WeaponRef::by_stem("MIS")};
CHECK(d.effective_race() == Species::Human);
CHECK_EQ(join(error_rules(d)), "");
const Ruleset& r = mini_rules();
CHECK(r.resolve_section(Species::Human, d.command()) == mini_catalog().section("Human", "DECommand"));
CHECK(r.resolve_weapon(WeaponRef::by_id(999)) == nullptr);
CHECK(r.resolve_section(Species::Human, use("Nope")) == nullptr);
SectionUse bad_id;
bad_id.section_id = 999;
CHECK(r.resolve_section(Species::Human, bad_id) == nullptr);
}
TEST(A1_reserved_slots_must_be_empty) {
Design d = armor();
d.slot(Slot::Reserved3) = use("DECommand", {"bal_gauss"});
CHECK_EQ(join(error_rules(d)), "A1");
}
TEST(A2_mission_required) {
Design d = armor();
d.mission() = SectionUse{};
CHECK_EQ(join(error_rules(d)), "A2");
// an unresolvable mission section stops at A5 (nothing else can be judged)
d.mission() = use("DENowhere");
CHECK_EQ(join(error_rules(d)), "A5");
}
TEST(A3_hull_needs_command_and_engine) {
Design d = armor();
d.engine() = SectionUse{};
CHECK_EQ(join(error_rules(d)), "A3");
d.command() = SectionUse{};
std::vector<Violation> v = mini_rules().validate(d);
int a3 = 0;
for (const Violation& x : v)
if (x.rule == "A3") ++a3;
CHECK_EQ(a3, 2);
}
TEST(A3_standalone_cannot_take_partners) {
Design d = armor();
d.mission() = use("DEDefencePlatform", {"bal_gauss", "bal_gauss", "mis"});
CHECK_EQ(join(error_rules(d)), "A3");
d.command() = SectionUse{};
d.engine() = SectionUse{};
CHECK_EQ(join(error_rules(d)), "");
// even an unresolvable partner counts as "filled"
d.command() = use("DENowhere");
CHECK_EQ(join(error_rules(d)), "A3,A5");
}
TEST(A4_slot_must_match_section_type) {
Design d = armor();
d.command() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"});
CHECK_EQ(join(error_rules(d)), "A4");
d = armor();
d.engine() = use("DECommand", {"bal_gauss"});
CHECK_EQ(join(error_rules(d)), "A4");
}
TEST(A4_typeless_rider_hull) {
// hidden default rider design: [0,0] _AssaultShuttle [0,0] -> info only
Design d;
d.race = Species::Human;
d.hidden = true;
d.mission() = use("_AssaultShuttle");
d.known_techs = std::vector<std::string>{};
std::vector<Violation> v = mini_rules().validate(d);
CHECK_EQ(join(rules_at(v, Level::Error)), "");
CHECK_EQ(join(rules_at(v, Level::Info)), "A4");
// not hidden but design_class rider: still info
d.hidden = false;
CHECK_EQ(join(rules_of(d, Level::Info)), "A4");
CHECK_EQ(join(error_rules(d)), "");
// a typeless NPC hull placed in the command slot is an error (and _Herald is a cruiser: A6 too)
Design n;
n.race = Species::NPC;
n.command() = use("_Herald", {"", "", ""});
n.mission() = use("_DEMission", {"bal_gauss", "bal_gauss", "mis"});
n.engine() = use("_DEEngine", {"bal_gauss"});
CHECK_EQ(join(error_rules(n)), "A4,A6");
}
TEST(A5_one_species_catalog) {
Design d = armor();
d.race = Species::Unknown;
CHECK_EQ(join(error_rules(d)), "A5"); // no filled slot names a species
d = armor(Species::Liir); // no Liir catalog in the mini root
CHECK_EQ(join(error_rules(d)), "A5");
d = armor(Species::Zuul);
d.known_techs.reset();
d.mission() = use("DEDefencePlatform"); // Zuul has no DEDefencePlatform
CHECK_EQ(join(error_rules(d)), "A5");
d = armor();
d.engine().species = Species::Tarkas; // mixed species record
CHECK_EQ(join(error_rules(d)), "A3,A5");
}
TEST(A6_class_homogeneous) {
Design d = armor();
d.known_techs.reset();
d.engine() = use("CRFission", {"bal_gauss"});
CHECK_EQ(join(error_rules(d)), "A6");
Design cr;
cr.race = Species::Human;
cr.command() = use("CRCommand", {"bal_gauss"});
cr.mission() = use("CRArmor", {"bal_gauss"});
cr.engine() = use("CRFission", {"bal_gauss"});
CHECK_EQ(join(error_rules(cr)), "");
}
TEST(A7_exclude_lists) {
Design d = armor();
d.command() = use("DERamScoop", {"bal_gauss"});
std::vector<Violation> v = mini_rules().validate(d);
CHECK_EQ(join(rules_at(v, Level::Error)), "A7");
bool at_command = false;
for (const Violation& x : v)
if (x.rule == "A7" && x.slot && *x.slot == Slot::Command) at_command = true;
CHECK(at_command);
}
TEST(A8_station_triples) {
Design d;
d.race = Species::Human;
d.command() = use("DNStation_Fore", {"bal_gauss"});
d.mission() = use("DNStation", {"bal_gauss"});
d.engine() = use("DNStation_Aft", {"bal_gauss"});
CHECK_EQ(join(error_rules(d)), "");
d.command() = use("DNCommand", {"bal_gauss"});
CHECK_EQ(join(error_rules(d)), "A8");
d.command() = SectionUse{};
CHECK_EQ(join(error_rules(d)), "A8");
// the explicit_section halves cannot serve another mission section
Design e;
e.race = Species::Human;
e.command() = use("DNStation_Fore", {"bal_gauss"});
e.mission() = use("CRArmor", {"bal_gauss"});
e.engine() = use("CRFission", {"bal_gauss"});
CHECK_EQ(join(error_rules(e)), "A6,A8");
}
TEST(A9_nodesign_is_a_warning) {
Design n;
n.race = Species::NPC;
n.command() = use("_DECommand", {"bal_gauss"});
n.mission() = use("_DEMission", {"bal_gauss", "bal_gauss", "mis"});
n.engine() = use("_DEEngine", {"bal_gauss"});
CHECK_EQ(join(error_rules(n)), "");
CHECK_EQ(join(rules_of(n, Level::Warn)), "A9");
n.hidden = true;
CHECK_EQ(join(rules_of(n, Level::Warn)), "");
}
TEST(sockets_are_geometry_not_a_key) {
// DECommand socket_aft CommandNode + DEArmor socket_fore MissionNode01: names differ, design is fine
const Catalog& c = mini_catalog();
CHECK_EQ(c.section("Human", "DECommand")->socket_aft, "CommandNode");
CHECK_EQ(c.section("Human", "DEArmor")->socket_fore, "MissionNode01");
CHECK_EQ(join(error_rules(armor())), "");
}
TEST(violation_formatting) {
Violation v{"B4", Level::Error, "does not fit", Slot::Mission, 2};
CHECK_EQ(v.str(), "ERROR B4 [mission bank 2]: does not fit");
Violation w{"A6", Level::Warn, "mixed", std::nullopt, std::nullopt};
CHECK_EQ(w.str(), "WARN A6: mixed");
CHECK(parse_slot("Engine") == Slot::Engine);
CHECK(!parse_slot("slot3"));
CHECK_EQ(WeaponRef::by_id(8).describe(), "#8");
CHECK_EQ(WeaponRef::none().describe(), "(none)");
}

View file

@ -0,0 +1,190 @@
// C* tech gating and D* option rules; applied_techs (the save's DOpts).
#include "game/design/rules.h"
#include "mini_catalog.h"
#include "test_main.h"
using namespace game::data;
using namespace game::design;
namespace {
Design with_techs(Design d, std::vector<std::string> techs) {
d.known_techs = std::move(techs);
return d;
}
} // namespace
TEST(C1_section_requires) {
CHECK_EQ(join(error_rules(with_techs(armor(), {"WEP_GsDrvr", "DRV_Node"}))), "C1");
CHECK_EQ(join(error_rules(with_techs(armor(), {"WEP_GsDrvr", "DRV_Fissn"}))), "C1");
CHECK_EQ(join(error_rules(with_techs(armor(), {"wep_gsdrvr", "drv_fissn", "DRV_NODE"}))), ""); // case-insensitive
Design d = armor();
d.known_techs.reset(); // no player information: gating skipped entirely
CHECK_EQ(join(error_rules(d)), "");
}
TEST(C1_group_requirements) {
Design d = armor();
d.mission() = use("DETorpedo", {"trp_photon", "mis"});
d.known_techs = std::vector<std::string>{"DRV_Fissn", "DRV_Node", "WEP_GsDrvr", "WEP_PlasTrp"};
CHECK_EQ(join(error_rules(d)), "C2"); // GRP_Torps satisfied by WEP_PlasTrp; trp_photon itself needs WEP_PhotTrp
d.known_techs->push_back("WEP_PhotTrp");
CHECK_EQ(join(error_rules(d)), "");
d.known_techs = std::vector<std::string>{"DRV_Fissn", "DRV_Node", "WEP_GsDrvr", "WEP_PhotTrp"};
CHECK_EQ(join(error_rules(d)), "");
TechSet none({"WEP_RedLas"});
CHECK(!none.satisfies(mini_catalog().tech, "GRP_Torps"));
CHECK(!none.satisfies(mini_catalog().tech, "GRP_NoSuchGroup"));
CHECK(TechSet({"WEP_HvyLancer"}).satisfies(mini_catalog().tech, "grp_hvybeam"));
}
TEST(C1_unknown_tech_is_a_warning) {
Design d = armor();
d.engine() = use("DEOddTech", {"bal_gauss"});
// DEOddTech requires DRV_Fissn (known) and XXX_Nowhere (not in the tree):
// a token the tree cannot satisfy is reported, never an error.
std::vector<Violation> v = mini_rules().validate(d);
CHECK_EQ(join(rules_at(v, Level::Error)), "");
CHECK_EQ(join(rules_at(v, Level::Warn)), "C1");
}
TEST(C2_weapon_requires) {
CHECK_EQ(join(error_rules(with_techs(armor(), {"DRV_Fissn", "DRV_Node"}))), "C2");
Design d = armor();
d.mission() = use("DEArmor", {"bal_gauss", "las_red", "mis"});
CHECK_EQ(join(error_rules(d)), "C2");
d.known_techs->push_back("WEP_RedLas");
CHECK_EQ(join(error_rules(d)), "");
}
TEST(C3_options_must_be_researched) {
Design d = armor();
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"}, {"IND_PlyAlloy"});
CHECK_EQ(join(error_rules(d)), "C3");
d.known_techs->push_back("IND_PlyAlloy");
CHECK_EQ(join(error_rules(d)), "");
}
TEST(C4_hull_class_tech) {
const Catalog& c = mini_catalog();
CHECK(hull_class_tech(*c.section("Human", "DEArmor")) == std::nullopt);
CHECK(*hull_class_tech(*c.section("Human", "CRArmor")) == "IND_CruisCon");
CHECK(*hull_class_tech(*c.section("Human", "DNCommand")) == "IND_DreadCon");
CHECK(hull_class_tech(*c.section("Human", "DNStation")) == std::nullopt); // station exemption
CHECK(hull_class_tech(*c.section("Human", "_AssaultShuttle")) == std::nullopt);
Design cr;
cr.race = Species::Human;
cr.command() = use("CRCommand", {"bal_gauss"});
cr.mission() = use("CRArmor", {"bal_gauss"});
cr.engine() = use("CRFission", {"bal_gauss"});
cr.known_techs = std::vector<std::string>{"DRV_Fissn", "DRV_Node", "WEP_GsDrvr"};
std::vector<Violation> v = mini_rules().validate(cr);
CHECK_EQ(join(rules_at(v, Level::Error)), "C4");
int n = 0;
for (const Violation& x : v)
if (x.rule == "C4") ++n;
CHECK_EQ(n, 3);
cr.known_techs->push_back("IND_CruisCon");
CHECK_EQ(join(error_rules(cr)), "");
// station: IND_OrbCom is the gate, no IND_DreadCon needed
Design st;
st.race = Species::Human;
st.command() = use("DNStation_Fore", {"bal_gauss"});
st.mission() = use("DNStation", {"bal_gauss"});
st.engine() = use("DNStation_Aft", {"bal_gauss"});
st.known_techs = std::vector<std::string>{"WEP_GsDrvr", "IND_OrbCom"};
CHECK_EQ(join(error_rules(st)), "");
}
TEST(C5_race_can_never_research) {
const Ruleset& r = mini_rules();
CHECK(r.unobtainable("DRV_Hyper", Species::Human));
CHECK(!r.unobtainable("DRV_Hyper", Species::Tarkas));
CHECK(r.unobtainable("DRV_Node", Species::Tarkas));
CHECK(!r.unobtainable("DRV_Node", Species::Human));
CHECK(!r.unobtainable("DRV_Node", Species::NPC)); // never written -> engine default 100
CHECK(!r.unobtainable("DRV_Fissn", Species::Human));
CHECK(!r.unobtainable("NoSuchTech", Species::Human));
Design d = armor();
d.engine() = use("DEHyperTest", {"bal_gauss"});
d.known_techs = std::vector<std::string>{"DRV_Fissn", "DRV_Hyper", "WEP_GsDrvr"};
CHECK_EQ(join(error_rules(d)), "C5"); // researched or not, Human can never have it
CHECK_EQ(join(error_rules(armor(Species::Tarkas))), "");
}
TEST(hidden_designs_gate_at_warn_level) {
// the Tarkas default assault shuttle exists on turn 1 with DRN_AdvFrm unresearched
Design d;
d.race = Species::Tarkas;
d.hidden = true;
d.mission() = use("_AssaultShuttle");
d.known_techs = std::vector<std::string>{"DRV_Fissn"};
std::vector<Violation> v = mini_rules().validate(d);
CHECK_EQ(join(rules_at(v, Level::Error)), "");
CHECK_EQ(join(rules_at(v, Level::Warn)), "C1");
CHECK_EQ(join(rules_at(v, Level::Info)), "A4");
d.hidden = false;
CHECK_EQ(join(error_rules(d)), "C1");
// C2/C3/C4 downgrade too
Design cr;
cr.race = Species::Human;
cr.hidden = true;
cr.command() = use("CRCommand", {"bal_gauss"});
cr.mission() = use("CRArmor", {"las_red"}, {"IND_PlyAlloy"});
cr.engine() = use("CRFission", {"bal_gauss"});
cr.known_techs = std::vector<std::string>{};
v = mini_rules().validate(cr);
CHECK_EQ(join(rules_at(v, Level::Error)), "D1"); // IND_PlyAlloy is not an option of CRArmor: still an error
CHECK_EQ(join(rules_at(v, Level::Warn)), "C1,C2,C3,C4");
}
TEST(D1_options_one_per_group_members_only) {
Design d = armor();
d.known_techs = std::vector<std::string>{"DRV_Fissn", "DRV_Node", "WEP_GsDrvr", "IND_PlyAlloy", "IND_MagLat",
"SLD_MkOne", "SLD_MkTwo", "IND_RefCoat", "DRV_RecFiss"};
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"}, {"IND_PlyAlloy", "IND_MagLat"});
CHECK_EQ(join(error_rules(d)), "D1");
d.mission().options = {"DRV_RecFiss"}; // an option of the engine, not of DEArmor
CHECK_EQ(join(error_rules(d)), "D1");
d.mission().options = {"IND_MagLat", "IND_RefCoat", "SLD_MkOne"}; // one per group incl. optiondef
CHECK_EQ(join(error_rules(d)), "");
d.mission().options = {"SLD_MkOne", "SLD_MkTwo"};
CHECK_EQ(join(error_rules(d)), "D1");
d.mission().options = {"ind_maglat"}; // spelled differently: still a member
CHECK_EQ(join(error_rules(d)), "");
}
TEST(D2_applied_techs_order) {
const Catalog& c = mini_catalog();
const ShipSectionDef& eng = *c.section("Human", "DEFission");
CHECK_EQ(join(applied_techs(eng, {"IND_RefCoat", "DRV_RecFiss", "IND_PlyAlloy"})),
"DRV_Fissn,DRV_Node,IND_PlyAlloy,IND_RefCoat,DRV_RecFiss");
CHECK_EQ(join(applied_techs(eng, {})), "DRV_Fissn,DRV_Node");
const ShipSectionDef& cr = *c.section("Human", "CRFission");
CHECK_EQ(join(applied_techs(cr, {})), "IND_CruisCon,DRV_Fissn,DRV_Node");
const ShipSectionDef& st = *c.section("Human", "DNStation");
CHECK_EQ(join(applied_techs(st, {})), "IND_OrbCom"); // station: no hull-class tech
const ShipSectionDef& arm = *c.section("Human", "DEArmor");
CHECK_EQ(join(applied_techs(arm, {"SLD_MkTwo", "IND_QrkRes"})), "IND_QrkRes,SLD_MkTwo"); // optiondef last
CHECK_EQ(join(applied_techs(arm, {"NotAnOption"})), ""); // non-members are not applied
CHECK_EQ(join(applied_techs(arm, {"ind_qrkres"})), "ind_qrkres"); // spelled as chosen
}
TEST(D2_options_from_applied_round_trip) {
const Catalog& c = mini_catalog();
const ShipSectionDef& cr = *c.section("Human", "CRFission");
std::vector<std::string> dopts = applied_techs(cr, {"IND_PlyAlloy"});
CHECK_EQ(join(dopts), "IND_CruisCon,DRV_Fissn,DRV_Node"); // CRFission has no option groups in the mini root
CHECK_EQ(join(options_from_applied(cr, dopts)), "");
const ShipSectionDef& eng = *c.section("Human", "DEFission");
dopts = applied_techs(eng, {"IND_MagLat", "DRV_RecFiss"});
CHECK_EQ(join(options_from_applied(eng, dopts)), "IND_MagLat,DRV_RecFiss");
// strip on a whole design record (the save's DOpts include the structural techs)
Design d = armor();
d.engine().options = {"DRV_Fissn", "DRV_Node", "IND_RefCoat"};
d.command().options = {"IND_PlyAlloy"};
mini_rules().strip_applied_techs(d);
CHECK_EQ(join(d.engine().options), "IND_RefCoat");
CHECK_EQ(join(d.command().options), "IND_PlyAlloy");
}