From ab2f58ed63d042c1ae80237df005456c89a686d8 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 7 Sep 2026 22:10:35 -0400 Subject: [PATCH] game/design: assembly/fit/gating rules + derived stats on game/data; 127/127 designs, 197/197 DOpts, oracle-identical --- docs/game-design.md | 217 ++++++++++++++ src/game/design/CMakeLists.txt | 16 + src/game/design/defaults.cpp | 71 +++++ src/game/design/defaults.h | 52 ++++ src/game/design/design.cpp | 84 ++++++ src/game/design/design.h | 108 +++++++ src/game/design/rules.cpp | 419 +++++++++++++++++++++++++++ src/game/design/rules.h | 113 ++++++++ src/game/design/stats.cpp | 107 +++++++ src/game/design/stats.h | 70 +++++ tests/game_design/CMakeLists.txt | 17 ++ tests/game_design/build_and_run.sh | 75 +++++ tests/game_design/dump_designs.cpp | 122 ++++++++ tests/game_design/json_min.h | 236 +++++++++++++++ tests/game_design/mini_catalog.cpp | 305 +++++++++++++++++++ tests/game_design/mini_catalog.h | 22 ++ tests/game_design/oracle/compare.py | 158 ++++++++++ tests/game_design/realdata_test.cpp | 170 +++++++++++ tests/game_design/stock_designs.cpp | 95 ++++++ tests/game_design/stock_designs.h | 27 ++ tests/game_design/test_banks.cpp | 193 ++++++++++++ tests/game_design/test_json.cpp | 26 ++ tests/game_design/test_main.cpp | 32 ++ tests/game_design/test_main.h | 49 ++++ tests/game_design/test_stats.cpp | 114 ++++++++ tests/game_design/test_structure.cpp | 207 +++++++++++++ tests/game_design/test_tech.cpp | 190 ++++++++++++ 27 files changed, 3295 insertions(+) create mode 100644 docs/game-design.md create mode 100644 src/game/design/CMakeLists.txt create mode 100644 src/game/design/defaults.cpp create mode 100644 src/game/design/defaults.h create mode 100644 src/game/design/design.cpp create mode 100644 src/game/design/design.h create mode 100644 src/game/design/rules.cpp create mode 100644 src/game/design/rules.h create mode 100644 src/game/design/stats.cpp create mode 100644 src/game/design/stats.h create mode 100644 tests/game_design/CMakeLists.txt create mode 100755 tests/game_design/build_and_run.sh create mode 100644 tests/game_design/dump_designs.cpp create mode 100644 tests/game_design/json_min.h create mode 100644 tests/game_design/mini_catalog.cpp create mode 100644 tests/game_design/mini_catalog.h create mode 100644 tests/game_design/oracle/compare.py create mode 100644 tests/game_design/realdata_test.cpp create mode 100644 tests/game_design/stock_designs.cpp create mode 100644 tests/game_design/stock_designs.h create mode 100644 tests/game_design/test_banks.cpp create mode 100644 tests/game_design/test_json.cpp create mode 100644 tests/game_design/test_main.cpp create mode 100644 tests/game_design/test_main.h create mode 100644 tests/game_design/test_stats.cpp create mode 100644 tests/game_design/test_structure.cpp create mode 100644 tests/game_design/test_tech.cpp diff --git a/docs/game-design.md b/docs/game-design.md new file mode 100644 index 0000000..d816f3b --- /dev/null +++ b/docs/game-design.md @@ -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/
.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 ""` 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_` 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 `: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 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. diff --git a/src/game/design/CMakeLists.txt b/src/game/design/CMakeLists.txt new file mode 100644 index 0000000..8b83d7b --- /dev/null +++ b/src/game/design/CMakeLists.txt @@ -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) diff --git a/src/game/design/defaults.cpp b/src/game/design/defaults.cpp new file mode 100644 index 0000000..ab76f94 --- /dev/null +++ b/src/game/design/defaults.cpp @@ -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 parse_default_weapons(std::string_view text, std::string file) { + data::Loaded 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 load_default_weapons(const std::filesystem::path& root) { + std::string text; + if (!data::read_file(root / "Weapons" / "_defaultweapons.txt", text)) { + data::Loaded 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 diff --git a/src/game/design/defaults.h b/src/game/design/defaults.h new file mode 100644 index 0000000..2786f36 --- /dev/null +++ b/src/game/design/defaults.h @@ -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): +// +// "" -- 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 +#include +#include + +#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& 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 rows_; +}; + +data::Loaded parse_default_weapons(std::string_view text, std::string file = {}); + +// Reads /Weapons/_defaultweapons.txt; an unreadable file is an IoError +// problem and an empty table. +data::Loaded 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/", 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 diff --git a/src/game/design/design.cpp b/src/game/design/design.cpp new file mode 100644 index 0000000..646c198 --- /dev/null +++ b/src/game/design/design.cpp @@ -0,0 +1,84 @@ +#include "game/design/design.h" + +#include + +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 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(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& v) { + return std::any_of(v.begin(), v.end(), [](const Violation& x) { return x.level == Level::Error; }); +} + +std::vector rules_at(const std::vector& v, Level level) { + std::vector 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 diff --git a/src/game/design/design.h b/src/game/design/design.h new file mode 100644 index 0000000..93f8fc3 --- /dev/null +++ b/src/game/design/design.h @@ -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 +#include +#include +#include +#include + +#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 kUsedSlots{Slot::Command, Slot::Mission, Slot::Engine}; + +std::string_view slot_name(Slot s); // "command" / "mission" / "engine" / "slot3" / "slot4" +std::optional 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 weapons; // one per bank{} of the section, file order + std::vector 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 slots{}; + // Researched techs of the owning player. nullopt -> tech gating is skipped + // (structure, fit and option rules still apply). + std::optional> known_techs; + + SectionUse& slot(Slot s) { return slots[static_cast(s)]; } + const SectionUse& slot(Slot s) const { return slots[static_cast(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; + std::optional bank; // bank index within the slot's section + + std::string str() const; // "ERROR B4 [mission bank 2]: ..." +}; + +bool has_errors(const std::vector& v); +std::vector rules_at(const std::vector& v, Level level); // sorted, distinct + +} // namespace game::design diff --git a/src/game/design/rules.cpp b/src/game/design/rules.cpp new file mode 100644 index 0000000..ee15b59 --- /dev/null +++ b/src/game/design/rules.cpp @@ -0,0 +1,419 @@ +#include "game/design/rules.h" + +#include +#include +#include + +namespace game::design { + +using data::fold; +using data::iequals; +using data::ShipSectionDef; +using data::WeaponDef; + +namespace { + +bool contains_fold(const std::vector& 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& v, const char* rule, Level level, std::string msg, + std::optional slot = std::nullopt, std::optional 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 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 option_groups(const ShipSectionDef& s) { + std::vector out; + for (const data::OptionGroup& g : s.options) out.push_back(&g); + if (s.optiondef) out.push_back(&*s.optiondef); + return out; +} + +std::vector applied_techs(const ShipSectionDef& s, const std::vector& chosen) { + std::vector 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 options_from_applied(const ShipSectionDef& s, const std::vector& applied) { + std::vector 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& 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* 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> 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(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(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 Ruleset::validate(const Design& d) const { + std::vector v; + const data::Catalog& cat = *cat_; + const data::Species race = d.effective_race(); + const bool hidden = d.hidden; + std::optional 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 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 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 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& 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(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 groups = option_groups(s); + std::map seen; + for (const std::string& o : p->use->options) { + std::optional 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 diff --git a/src/game/design/rules.h b/src/game/design/rules.h new file mode 100644 index 0000000..e8cdd9a --- /dev/null +++ b/src/game/design/rules.h @@ -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 +#include +#include +#include +#include +#include + +#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 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 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 applied_techs(const data::ShipSectionDef& s, const std::vector& 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 options_from_applied(const data::ShipSectionDef& s, const std::vector& 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_ through the +// tech tree: satisfied when any member of the group is known. +class TechSet { +public: + TechSet() = default; + explicit TechSet(const std::vector& 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 names_; // folded +}; + +class Ruleset { +public: + explicit Ruleset(const data::Catalog& cat); + const data::Catalog& catalog() const { return *cat_; } + + std::vector 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 unobtainable_; // folded tech -> species bit mask +}; + +} // namespace game::design diff --git a/src/game/design/stats.cpp b/src/game/design/stats.cpp new file mode 100644 index 0000000..0697faf --- /dev/null +++ b/src/game/design/stats.cpp @@ -0,0 +1,107 @@ +#include "game/design/stats.h" + +namespace game::design { + +using data::iequals; + +const std::vector& capacity_keys() { + static const std::vector 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(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(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(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 diff --git a/src/game/design/stats.h b/src/game/design/stats.h new file mode 100644 index 0000000..b14118c --- /dev/null +++ b/src/game/design/stats.h @@ -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 +#include +#include +#include +#include + +#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 banks; + std::vector applied_techs; // D2 +}; + +struct DesignStats { + std::string race, name; + std::string hull_class; // section_class of the first resolved section (folded) + std::vector 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 command_quota; + + // Drive: from the engine section, or the mission section of a standalone hull. + std::optional ftlspeed, nodespeed; + std::string engine_techera; + std::optional netforcelimits; + + // Role flags and capacities copied through from the sections' raw blocks + // (key -> text as written; later sections override earlier ones). + std::vector> capacities; + const std::string* capacity(std::string_view key) const; +}; + +// The raw keys copied into `capacities`. +const std::vector& capacity_keys(); + +DesignStats derive_stats(const Ruleset& rules, const Design& d); + +} // namespace game::design diff --git a/tests/game_design/CMakeLists.txt b/tests/game_design/CMakeLists.txt new file mode 100644 index 0000000..ee6994b --- /dev/null +++ b/tests/game_design/CMakeLists.txt @@ -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}) diff --git a/tests/game_design/build_and_run.sh b/tests/game_design/build_and_run.sh new file mode 100755 index 0000000..b6d3861 --- /dev/null +++ b/tests/game_design/build_and_run.sh @@ -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" diff --git a/tests/game_design/dump_designs.cpp b/tests/game_design/dump_designs.cpp new file mode 100644 index 0000000..382c557 --- /dev/null +++ b/tests/game_design/dump_designs.cpp @@ -0,0 +1,122 @@ +// dump_designs +// +// 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 +#include +#include +#include + +#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& 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& v) { return v ? num(*v) : "null"; } +std::string opt_int(const std::optional& v) { return v ? std::to_string(*v) : "null"; } + +} // namespace + +int main(int argc, char** argv) { + if (argc != 4) { + std::fprintf(stderr, "usage: dump_designs \n"); + return 2; + } + Catalog cat = load_catalog(argv[1]); + std::vector 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 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; +} diff --git a/tests/game_design/json_min.h b/tests/game_design/json_min.h new file mode 100644 index 0000000..03e906e --- /dev/null +++ b/tests/game_design/json_min.h @@ -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 +#include +#include +#include +#include +#include + +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 items; // array + std::vector> 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(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(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xF0 | (cp >> 18)); + out += static_cast(0x80 | ((cp >> 12) & 0x3F)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(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(c - '0'); + else if (c >= 'a' && c <= 'f') cp |= static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') cp |= static_cast(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(c); + } + } + } + return out + "\""; +} + +} // namespace json_min diff --git a/tests/game_design/mini_catalog.cpp b/tests/game_design/mini_catalog.cpp new file mode 100644 index 0000000..9e74680 --- /dev/null +++ b/tests/game_design/mini_catalog.cpp @@ -0,0 +1,305 @@ +#include "mini_catalog.h" + +#include +#include +#include + +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", + "// \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(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 weapons, std::vector 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{"DRV_Fissn", race == Species::Tarkas ? "DRV_Hyper" : "DRV_Node", "WEP_GsDrvr"}; + return d; +} + +std::vector rules_of(const Design& d, Level level) { return rules_at(mini_rules().validate(d), level); } +std::vector error_rules(const Design& d) { return rules_of(d, Level::Error); } + +std::string join(const std::vector& v) { + std::string s; + for (const std::string& x : v) s += (s.empty() ? "" : ",") + x; + return s; +} diff --git a/tests/game_design/mini_catalog.h b/tests/game_design/mini_catalog.h new file mode 100644 index 0000000..996f888 --- /dev/null +++ b/tests/game_design/mini_catalog.h @@ -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 +#include + +#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 weapons = {}, std::vector options = {}); +game::design::Design armor(game::data::Species race = game::data::Species::Human); // the stock starting destroyer, gated +std::vector error_rules(const game::design::Design& d); +std::vector rules_of(const game::design::Design& d, game::design::Level level); +std::string join(const std::vector& v); diff --git a/tests/game_design/oracle/compare.py b/tests/game_design/oracle/compare.py new file mode 100644 index 0000000..7a15963 --- /dev/null +++ b/tests/game_design/oracle/compare.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""compare.py + +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)) diff --git a/tests/game_design/realdata_test.cpp b/tests/game_design/realdata_test.cpp new file mode 100644 index 0000000..41c7230 --- /dev/null +++ b/tests/game_design/realdata_test.cpp @@ -0,0 +1,170 @@ +// Every design in the owner's real saves through the rules. +// +// SOTS_DATA_DIR= SOTS_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 +#include +#include +#include +#include + +#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& v) { + std::string s; + for (const std::string& x : v) s += (s.empty() ? "" : ",") + x; + return s; +} + +bool same_fold(const std::vector& a, const std::vector& 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 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 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(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 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 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; +} diff --git a/tests/game_design/stock_designs.cpp b/tests/game_design/stock_designs.cpp new file mode 100644 index 0000000..ce7123e --- /dev/null +++ b/tests/game_design/stock_designs.cpp @@ -0,0 +1,95 @@ +#include "stock_designs.h" + +#include +#include + +#include "game/data/common.h" +#include "json_min.h" + +using namespace game::design; +using json_min::Value; + +namespace { + +std::vector strings(const Value& v) { + std::vector 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& 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(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(i)]; + SectionUse& u = des.slots[static_cast(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(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(i)] = u.options; + sd.bank_count[static_cast(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; +} diff --git a/tests/game_design/stock_designs.h b/tests/game_design/stock_designs.h new file mode 100644 index 0000000..da14751 --- /dev/null +++ b/tests/game_design/stock_designs.h @@ -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 +#include +#include + +#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 expect_stem{}; // reference view: section stem per used slot ("" = empty) + std::array, 3> expect_options{}; // reference view: chosen options + std::array, 3> dopts{}; // the save's DOpts per used slot + std::array 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& out, std::string& err); diff --git a/tests/game_design/test_banks.cpp b/tests/game_design/test_banks.cpp new file mode 100644 index 0000000..e5af868 --- /dev/null +++ b/tests/game_design/test_banks.cpp @@ -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 banks_of(const std::vector& v, const char* rule) { + std::vector 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 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 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 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 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)), ""); +} diff --git a/tests/game_design/test_json.cpp b/tests/game_design/test_json.cpp new file mode 100644 index 0000000..656ace4 --- /dev/null +++ b/tests/game_design/test_json.cpp @@ -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\""); +} diff --git a/tests/game_design/test_main.cpp b/tests/game_design/test_main.cpp new file mode 100644 index 0000000..ab9b648 --- /dev/null +++ b/tests/game_design/test_main.cpp @@ -0,0 +1,32 @@ +#include "test_main.h" + +namespace testing { + +std::vector& registry() { + static std::vector 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; +} diff --git a/tests/game_design/test_main.h b/tests/game_design/test_main.h new file mode 100644 index 0000000..6c78252 --- /dev/null +++ b/tests/game_design/test_main.h @@ -0,0 +1,49 @@ +// Tiny self-contained test harness (no third-party deps). +#pragma once + +#include +#include +#include +#include + +namespace testing { + +struct Case { + const char* name; + std::function fn; +}; + +std::vector& registry(); +int& failures(); +void report_failure(const char* file, int line, const std::string& expr); + +struct Register { + Register(const char* name, std::function 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 +std::string testing_to_string(const T& v) { return std::to_string(v); } diff --git a/tests/game_design/test_stats.cpp b/tests/game_design/test_stats.cpp new file mode 100644 index 0000000..9a016e9 --- /dev/null +++ b/tests/game_design/test_stats.cpp @@ -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 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 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 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 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"); +} diff --git a/tests/game_design/test_structure.cpp b/tests/game_design/test_structure.cpp new file mode 100644 index 0000000..82d4b17 --- /dev/null +++ b/tests/game_design/test_structure.cpp @@ -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 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 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::vector 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 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)"); +} diff --git a/tests/game_design/test_tech.cpp b/tests/game_design/test_tech.cpp new file mode 100644 index 0000000..2466432 --- /dev/null +++ b/tests/game_design/test_tech.cpp @@ -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 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{"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{"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 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{"DRV_Fissn", "DRV_Node", "WEP_GsDrvr"}; + std::vector 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{"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{"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{"DRV_Fissn"}; + std::vector 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{}; + 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{"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 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"); +}