merge game/design
This commit is contained in:
commit
6ea3b5f433
27 changed files with 3295 additions and 0 deletions
217
docs/game-design.md
Normal file
217
docs/game-design.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# `game::design` — ship-design assembly rules
|
||||
|
||||
`src/game/design/` decides what a valid `ShipDesignDef` is and what it
|
||||
weighs, costs and carries, on top of the `game::data` catalogs. Library
|
||||
target `game_design` (static, links `game_data`), include as
|
||||
`game/design/<header>.h`. C++17, no dependencies, no exceptions across the
|
||||
API: every finding is a `Violation`.
|
||||
|
||||
Behaviour is specified by the RE findings (`SHIP_DESIGN_RULES.md`, rules
|
||||
derived from the shipped data and every design in three real saves); the
|
||||
reference Python validator (`design_rules.py`) is the oracle and this module
|
||||
is diffed against it design by design (§5).
|
||||
|
||||
```
|
||||
src/game/design/
|
||||
design.h/.cpp Slot, WeaponRef, SectionUse, Design (the 5-slot save record); Level, Violation
|
||||
rules.h/.cpp Ruleset::validate; hull_class_tech, applied_techs, options_from_applied,
|
||||
bank_accepts_class, turret_row, weapon_fits_bank, TechSet
|
||||
stats.h/.cpp derive_stats -> DesignStats (mass, cost, health, crew, drive, capacities, turrets)
|
||||
defaults.h/.cpp DefaultWeaponTable (_defaultweapons.txt), fill_empty_banks
|
||||
tests/game_design/
|
||||
build_and_run.sh plain g++ build; unit tests; real-data test; dump; oracle comparison
|
||||
mini_catalog.cpp hand-written miniature data root (4 species, 12 weapons, 19 turret rows, tech tree)
|
||||
test_structure/banks/tech/stats/json.cpp 43 unit tests, one or more per rule
|
||||
stock_designs.cpp reads the owner's designs extract (game-derived; never in the repo)
|
||||
realdata_test.cpp every save design through the rules; the published counts
|
||||
dump_designs.cpp findings + applied techs + stats per design -> JSON
|
||||
oracle/compare.py runs design_rules.py on the same designs and diffs
|
||||
```
|
||||
|
||||
## 1. The design record (`design.h`)
|
||||
|
||||
The save's `Des` record, kept in its own shape:
|
||||
|
||||
| field | save | notes |
|
||||
|---|---|---|
|
||||
| `name`, `hidden`, `designer_made`, `default_weapons` | `dName`, `dHide`, `faiDes`, `dWep` | the last two are carried through; their meaning is open |
|
||||
| `race` | — | the species catalog the design is validated against; `Unknown` → the first filled slot's species (`effective_race()`) |
|
||||
| `slots[5]` | `DSec[5]` | `Slot::Command 0, Mission 1, Engine 2`; `Reserved3/4` must stay empty (rule A1) |
|
||||
| `SectionUse::species`, `section_id` | `DSec.species_idx`, `section_id` | `(0, 0)` = empty; `section_id` indexes the race's `_shipsections.txt`; a stem / file name in `section` is accepted instead |
|
||||
| `SectionUse::weapons` | `DGbnk2` | one `WeaponRef` per `bank{}` in file order: `by_id` (`bID true, wid`), `by_file` (`bID false, wfn`), `by_stem`, `none`; `rider_design_id` = `did` |
|
||||
| `SectionUse::options` | `DOpts` minus structural techs | the chosen option techs; `Ruleset::strip_applied_techs` turns a raw `DOpts` list into this |
|
||||
| `known_techs` | player `TechTree` `St == 4` | `nullopt` skips tech gating |
|
||||
|
||||
`Violation { rule, level, message, slot, bank }` with `Level::Error`
|
||||
(invalid), `Warn` (engine accepts it, designer UI would not / only code can
|
||||
settle it), `Info`. `str()` prints `ERROR B4 [mission bank 2]: …`.
|
||||
|
||||
## 2. Rule catalog (`rules.h`)
|
||||
|
||||
Ids match the RE document and the reference validator. "Evidence" is what in
|
||||
the data or the saves forces the rule.
|
||||
|
||||
### Structure
|
||||
|
||||
| id | rule | evidence |
|
||||
|---|---|---|
|
||||
| A1 | slots 3 and 4 are never used | absent in all 127 save designs (our addition; the reference has no such record) |
|
||||
| A2 | the mission slot is always filled | every design and template has one; standalone hulls sit in the mission slot |
|
||||
| A3 | mission section **with** `socket_fore/aft` = hull ship: command **and** engine required; **without** = standalone: both must be empty (an unresolvable partner still counts as filled) | 57–61 hull vs 16–21 standalone mission sections per race; `Light Defense Platform` = `[0,0] DEDefencePlatform [0,0]` |
|
||||
| A4 | `section_type` must match the slot (case-insensitive). A typeless section in the mission slot of a standalone hidden design, or one whose `design_class` is `rider`, is `Info`; anywhere else it is an error | hidden `Default Assault Shuttle` = `[0,0] _AssaultShuttle [0,0]` |
|
||||
| A5 | one species catalog: the race must have a catalog, every section must resolve in it, and a slot naming another species is an error | per-race `sections/` dirs; the same stem differs per race (`DEFission` requires `DRV_Node` for Human, `DRV_Hyper` for Tarkas); no save design mixes species |
|
||||
| A6 | all sections share `section_class` | all 127 designs and 87 templates are class-homogeneous — only code can say whether the engine checks or merely never offers a mix |
|
||||
| A7 | `exclude` list: a section may not be combined with a listed stem | Hiver `DERamScoop exclude {DEFission …}` (ramscoop command sections forbid fission engines) |
|
||||
| A8 | `explicit_command_section` / `explicit_engine_section` on a mission section pin its partners; `explicit_section true` sections are usable only that way | the six `DNStation*` `_Fore`/`_Aft` triples |
|
||||
| A9 | `nodesign` = not offered by the designer, **not** an engine rule → `Warn` (silent on hidden designs) | `DNGuard` is a scenario mission section; NPC designs use nodesign sections throughout |
|
||||
|
||||
**Sockets are geometry, not a key.** `socket_aft` on a command section and
|
||||
`socket_fore` on a mission section are mesh node names for snapping the
|
||||
models; they need not match (`DECommand CommandNode` + `DEColonizer
|
||||
MissionNode01`). Only their *presence* carries assembly information (A3).
|
||||
|
||||
### Banks and weapon fit
|
||||
|
||||
| id | rule | evidence |
|
||||
|---|---|---|
|
||||
| B1 | exactly one weapon entry per `bank{}` block, in file order | `DGbnk2.n == len(bank{})` on all 197 instances |
|
||||
| B2 | no bank is left empty | every bank in every save design has a `DW2`; `_defaultweapons.txt` exists to fill them |
|
||||
| B3 | a bank with `weapon "<file>"` in data is bound to that weapon; an empty entry takes it | 404 NPC banks; NPC designs store the same file in `wfn` |
|
||||
| B4 | fit = class compatibility **and** a `_turrets.txt` row for `(bank size, weapon size, class)`; a bank without a turret spec takes only its fixed weapon | below |
|
||||
| B5 | the weapon must exist (id, file or stem); NPC-scope weapons only on NPC hulls | player weapons by id, NPC by path |
|
||||
| B6 | `exclusive_species` | `bal_grapple`, `bal_disruptorwhip` are Zuul-only |
|
||||
| B7 | rider weapons (`compatible_section`) need one of those rider sections in the race catalog | `DNCarrier`'s `tarkahunter` bank exists only in the Tarkas catalog |
|
||||
| B8 | `hidden` weapons are engine-only unless the design is hidden or the bank is fixed | `mis_planet*`, `_mis_*_warhead`, Silicoid guns |
|
||||
|
||||
**B4 in detail.** `bank_accepts_class`: `standard ← {standard, missile,
|
||||
grapple}`, `strafe ← {standard}`, everything else exact. Stock `Armor` puts
|
||||
`mis` (medium/missile) in `DEArmor`'s medium standard bank; no weapon is
|
||||
class `strafe` and the NPC `_HeraldDefender` strafe bank carries a
|
||||
standard-class beamer; grapples have no banks of their own (assumption).
|
||||
`turret_row` looks the row up by the **weapon's** class first (a missile in
|
||||
a standard bank gets `turret_m1missile.x`), then by the bank's class (the
|
||||
strafe rows are the fallback). Consequences: tiny (PD) weapons fit only
|
||||
small standard/strafe banks; a large standard bank takes 54 player weapons.
|
||||
|
||||
### Tech gating
|
||||
|
||||
| id | rule | evidence |
|
||||
|---|---|---|
|
||||
| C1 | every section `requires` must be researched; `GRP_<g>` is satisfied by any researched tech of `group g` (case-insensitive). A token that is neither a tech nor a group is `Warn` | 970 refs; groups PRJCTR/PD/HVYBEAM/SPINAL/TORPS/MINES/BIOMISSILE/JAMMING/SHIELDS |
|
||||
| C2 | every weapon `requires` (AND) must be researched; fixed NPC banks are exempt | 189 refs |
|
||||
| C3 | chosen options must be researched (when the option is a tech node) | turn-1 designs choose none; NPC players (all 293 techs) choose one per group |
|
||||
| C4 | **hull-class tech**: cruiser sections need `IND_CruisCon`, dreadnought `IND_DreadCon`; `design_class station` sections are exempt; destroyers need nothing | `DOpts` carries the tech first for every CR/DN section and omits it for `_AsteroidMonitor*` |
|
||||
| C5 | a tech whose every incoming `allows` edge writes `<Race>:0` can never be researched by that race → its sections are unbuildable for it, researched or not | `DRV_Node` Human-only, `DRV_Hyper` Tarkas-only; `_NPC` is never written and defaults to 100 |
|
||||
|
||||
**Hidden default rider designs** (code rule the data does not show): the
|
||||
engine creates `Default Assault Shuttle` for every race at game start; the
|
||||
Tarkas `_AssaultShuttle` requires `DRN_AdvFrm`, unresearched on turn 1. On
|
||||
`hidden` designs C1–C4 are reported at `Warn`.
|
||||
|
||||
Not a gate: the tech tree's `ship { section X }` lists are the "new section"
|
||||
UI notice (139 of ~150 stems differ from the sections' own `requires`).
|
||||
|
||||
### Options
|
||||
|
||||
| id | rule | evidence |
|
||||
|---|---|---|
|
||||
| D1 | a chosen option must be a member of one of the section's groups (`option` entries, then `optiondef`); at most one per group | no tech is in two groups of one section; NPC `DOpts` hold one per group |
|
||||
| D2 | `applied_techs = [hull-class tech] + requires + chosen options in group order` is what the save stores | 197/197 instances |
|
||||
|
||||
## 3. API
|
||||
|
||||
```cpp
|
||||
using namespace game::design;
|
||||
data::Catalog cat = data::load_catalog(root);
|
||||
Ruleset rules(cat); // precomputes the C5 availability index
|
||||
|
||||
Design d; d.race = data::Species::Human; d.known_techs = player_techs;
|
||||
d.command() = {.., .section = "DECommand", .weapons = {WeaponRef::by_id(8)}};
|
||||
d.mission().section_id = 37; // by id, like the save
|
||||
...
|
||||
std::vector<Violation> v = rules.validate(d); has_errors(v); rules_at(v, Level::Warn);
|
||||
DesignStats st = derive_stats(rules, d); st.mass, st.total_cost_estimate, st.sections[i].banks[j].turret
|
||||
applied_techs(section, chosen) // == DOpts
|
||||
rules.strip_applied_techs(d); // DOpts -> chosen options, in place
|
||||
weapon_fits_bank(cat.turrets, bank, weapon) // Fit { ok, why, row }
|
||||
DefaultWeaponTable t = *load_default_weapons(root).value; fill_empty_banks(d, cat, t);
|
||||
```
|
||||
|
||||
Resolution: a section by `(species, section_id)` through the race's id
|
||||
registry, else by stem / file name; a weapon by id, full path, bare file
|
||||
name or stem (player catalog wins on a stem clash). Everything folds ASCII
|
||||
case.
|
||||
|
||||
## 4. Derived stats (`stats.h`)
|
||||
|
||||
| stat | derivation | status |
|
||||
|---|---|---|
|
||||
| `mass`, `health_total`, `crew`, `cpoints` | sums of the section fields (`crew false` → 0) | data; whether weapons add mass is code |
|
||||
| `section_cost`, `section_cost_with_options` | sum of `cost`, and × the product of the chosen options' `option_cost` | the multiplier is an assumption |
|
||||
| `weapon_cost_per_bank` / `_per_mount` | weapon `cost` once per bank / × mounts | which one the engine uses is code; `total_cost_estimate` uses per-bank |
|
||||
| `banks`, `turrets` | bank count, one turret per mount; each bank's `_turrets.txt` row (model, health, track speed) | data |
|
||||
| `command_cost`, `maintenance_cost`, `command_quota` | sums | data |
|
||||
| `ftlspeed`, `nodespeed`, `engine_techera`, `netforcelimits` | from the engine section, or the mission section of a standalone hull | the strategic/tactical speed formulas are code |
|
||||
| `capacities` | role flags and capacities copied from `raw` (text as written) | data |
|
||||
| `applied_techs` per section | D2 | 197/197 |
|
||||
|
||||
## 5. Tests and results
|
||||
|
||||
`tests/game_design/build_and_run.sh` (plain `g++ -std=c++17 -Wall -Wextra
|
||||
-Wpedantic -Werror`; CMake wiring in `tests/game_design/CMakeLists.txt`):
|
||||
|
||||
1. **Unit tests** — 43 cases on the hand-written mini root: every rule in
|
||||
§2 in both directions (valid stock `Armor`, then each way to break it),
|
||||
resolution by id / species / file name, the class-accept table and the
|
||||
row lookup order (weapon class first, bank class fallback), hidden-design
|
||||
downgrades, `GRP_` satisfaction, C5 availability incl. the `_NPC`
|
||||
default, `applied_techs` order with `optiondef` last, the DOpts round
|
||||
trip, stats sums, the option multiplier, NPC fixed-weapon costing, the
|
||||
default-weapon table and `fill_empty_banks`, the JSON reader.
|
||||
2. **Real-data test** (`SOTS_DATA_DIR` + `SOTS_DESIGNS_JSON`, else SKIP) —
|
||||
every design of every player in the three saves, built from the raw
|
||||
`(species, section_id, weapons, DOpts)` record.
|
||||
3. **Oracle comparison** — `dump_designs` writes findings, applied techs
|
||||
and stats per design; `oracle/compare.py` runs `design_rules.py` on the
|
||||
same designs and requires identical finding sets `(rule, level, slot,
|
||||
bank)`, identical applied techs and options, and equal stats.
|
||||
|
||||
### Results (2026-09-07, owner's `gob-extract` + 127-design extract)
|
||||
|
||||
| what | result |
|
||||
|---|---|
|
||||
| designs clean on structure / banks / fit / options / gating | **127 / 127** |
|
||||
| section instances whose `DOpts` = `applied_techs()` | **197 / 197** |
|
||||
| tech gating vs the owning player's researched techs | **121 / 127**; the 6 others are warn-only: the hidden Tarkas `Default Assault Shuttle` in each save for both Tarkas players (`_AssaultShuttle requires DRN_AdvFrm`) |
|
||||
| oracle: findings | 62 / 62 identical (50 designs carry findings: A4 info on the 12 hidden riders, C1 warn on the 6 Tarkas ones, A9 warn on NPC nodesign hulls) |
|
||||
| oracle: applied techs, options, stats | 9,673 values, **0 differences** |
|
||||
|
||||
Skips cleanly with the variables unset; `tests/game_design/build/` is
|
||||
git-ignored; nothing from the game is committed (`tools/clean_room_check.sh`
|
||||
OK).
|
||||
|
||||
## 6. Open questions (only code can settle)
|
||||
|
||||
1. **A6 class equality** — enforced in validation, or only by the designer
|
||||
never offering a mix? Implemented as an error.
|
||||
2. **B4 class-accept table** — `standard ← missile` and `strafe ← standard`
|
||||
are data-forced; `standard ← grapple` is the weakest assumption (the two
|
||||
Zuul grapples would otherwise be unmountable). Whether `tiny` really is
|
||||
small-only, and the row-lookup order.
|
||||
3. **C4 hull-class tech** — injected from `section_class` with the station
|
||||
exemption; design-time or build-time check?
|
||||
4. **Hidden default designs** — where the engine builds them and why
|
||||
`requires` is skipped (modelled as warn-level gating on `hidden`).
|
||||
5. **Option semantics** — default pick per group, auto-upgrade, and what
|
||||
`option_cost` multiplies (section cost assumed; build time?).
|
||||
6. **Cost / mass / speed** — weapon cost per bank or per mount; whether
|
||||
weapons add mass; how `netforcelimits` + mass and `ftlspeed`/`nodespeed`
|
||||
+ `TECHBEN_STRATSPD` become speeds.
|
||||
7. **Missiles and `WEP_Nukes`** — `mis` has no `requires`; gated in code?
|
||||
8. **`faiDes`, `dWep`** — carried through as `designer_made`,
|
||||
`default_weapons`; meaning open.
|
||||
9. **Empty banks (B2)** — can a design be saved with one, or does the
|
||||
designer always fill from `_defaultweapons.txt` (`fill_empty_banks`)?
|
||||
10. **Repeated keys** — first or last value (`game::data` takes last).
|
||||
11. **Mixed-species records** — the record allows it; modelled as an A5
|
||||
error for want of any example.
|
||||
16
src/game/design/CMakeLists.txt
Normal file
16
src/game/design/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# game_design -- ship-design assembly rules on the game_data catalogs: the
|
||||
# five-slot design record, the validator (structure / banks & weapon fit /
|
||||
# tech gating / options), applied_techs (the save's DOpts), derived stats and
|
||||
# the default-weapon table.
|
||||
# Include from the root with add_subdirectory(src/game/design) after
|
||||
# add_subdirectory(src/game/data); link game_design.
|
||||
add_library(game_design STATIC
|
||||
design.cpp
|
||||
rules.cpp
|
||||
stats.cpp
|
||||
defaults.cpp
|
||||
)
|
||||
target_include_directories(game_design PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../../..)
|
||||
target_link_libraries(game_design PUBLIC game_data)
|
||||
target_compile_features(game_design PUBLIC cxx_std_17)
|
||||
target_compile_options(game_design PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
71
src/game/design/defaults.cpp
Normal file
71
src/game/design/defaults.cpp
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#include "game/design/defaults.h"
|
||||
|
||||
#include "game/design/rules.h"
|
||||
#include "mars/text/flat_kv.h"
|
||||
|
||||
namespace game::design {
|
||||
|
||||
using data::iequals;
|
||||
|
||||
const DefaultWeaponRow* DefaultWeaponTable::find(std::string_view turret_class, std::string_view turret_size) const {
|
||||
const DefaultWeaponRow* hit = nullptr;
|
||||
for (const DefaultWeaponRow& r : rows_)
|
||||
if (iequals(r.turret_class, turret_class) && iequals(r.turret_size, turret_size)) hit = &r; // last wins
|
||||
return hit;
|
||||
}
|
||||
|
||||
data::Loaded<DefaultWeaponTable> parse_default_weapons(std::string_view text, std::string file) {
|
||||
data::Loaded<DefaultWeaponTable> out;
|
||||
DefaultWeaponTable table;
|
||||
auto rows = mars::text::parse_rows(text);
|
||||
for (const auto& p : rows.problems)
|
||||
out.problems.push_back({data::Problem::Kind::Unparsed, file, p.line, "", p.message});
|
||||
for (const mars::text::Row& r : rows.value) {
|
||||
if (r.tokens.size() != 3) {
|
||||
out.problems.push_back({data::Problem::Kind::Unparsed, file, r.line, "",
|
||||
"default-weapon row has " + std::to_string(r.tokens.size()) + " fields, expected 3"});
|
||||
continue;
|
||||
}
|
||||
table.add({r.tokens[0].text, r.tokens[1].text, r.tokens[2].text, r.line});
|
||||
}
|
||||
out.value = std::move(table);
|
||||
return out;
|
||||
}
|
||||
|
||||
data::Loaded<DefaultWeaponTable> load_default_weapons(const std::filesystem::path& root) {
|
||||
std::string text;
|
||||
if (!data::read_file(root / "Weapons" / "_defaultweapons.txt", text)) {
|
||||
data::Loaded<DefaultWeaponTable> out;
|
||||
out.value.emplace();
|
||||
out.problems.push_back({data::Problem::Kind::IoError, "Weapons/_defaultweapons.txt", 0, "", "cannot read file"});
|
||||
return out;
|
||||
}
|
||||
return parse_default_weapons(text, "Weapons/_defaultweapons.txt");
|
||||
}
|
||||
|
||||
int fill_empty_banks(Design& d, const data::Catalog& cat, const DefaultWeaponTable& table) {
|
||||
Ruleset rules(cat);
|
||||
const data::Species race = d.effective_race();
|
||||
int filled = 0;
|
||||
for (Slot slot : kUsedSlots) {
|
||||
SectionUse& use = d.slot(slot);
|
||||
if (use.empty()) continue;
|
||||
const data::ShipSectionDef* s = rules.resolve_section(use.species == data::Species::Unknown ? race : use.species, use);
|
||||
if (!s) continue;
|
||||
if (use.weapons.size() < s->banks.size()) use.weapons.resize(s->banks.size());
|
||||
for (std::size_t i = 0; i < s->banks.size(); ++i) {
|
||||
if (!use.weapons[i].empty()) continue;
|
||||
const data::BankDef& bank = s->banks[i];
|
||||
if (!bank.weapon.empty()) {
|
||||
use.weapons[i] = WeaponRef::by_file(bank.weapon);
|
||||
++filled;
|
||||
} else if (const DefaultWeaponRow* row = table.find(bank)) {
|
||||
use.weapons[i] = WeaponRef::by_file("Weapons/" + row->weapon_file);
|
||||
++filled;
|
||||
}
|
||||
}
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
} // namespace game::design
|
||||
52
src/game/design/defaults.h
Normal file
52
src/game/design/defaults.h
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// game::design -- Weapons/_defaultweapons.txt: the weapon a bank gets "in the
|
||||
// absence of tech and player information" (the file's own words), one row per
|
||||
// (bank turret class, bank turret size):
|
||||
//
|
||||
// <mount-class> <mount-size> "<weapon-file>" -- 31 rows
|
||||
//
|
||||
// The file name is relative to Weapons/. Whether the designer always fills an
|
||||
// empty bank from this table (rule B2) is an open question; the table is
|
||||
// offered so a caller can.
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "game/data/catalog.h"
|
||||
#include "game/design/design.h"
|
||||
|
||||
namespace game::design {
|
||||
|
||||
struct DefaultWeaponRow {
|
||||
std::string turret_class, turret_size, weapon_file; // as written
|
||||
int line = 0;
|
||||
};
|
||||
|
||||
class DefaultWeaponTable {
|
||||
public:
|
||||
const std::vector<DefaultWeaponRow>& rows() const { return rows_; }
|
||||
std::size_t size() const { return rows_.size(); }
|
||||
void add(DefaultWeaponRow r) { rows_.push_back(std::move(r)); }
|
||||
|
||||
// Case-insensitive; nullptr when no row matches.
|
||||
const DefaultWeaponRow* find(std::string_view turret_class, std::string_view turret_size) const;
|
||||
const DefaultWeaponRow* find(const data::BankDef& bank) const { return find(bank.turret_class, bank.turret_size); }
|
||||
|
||||
private:
|
||||
std::vector<DefaultWeaponRow> rows_;
|
||||
};
|
||||
|
||||
data::Loaded<DefaultWeaponTable> parse_default_weapons(std::string_view text, std::string file = {});
|
||||
|
||||
// Reads <root>/Weapons/_defaultweapons.txt; an unreadable file is an IoError
|
||||
// problem and an empty table.
|
||||
data::Loaded<DefaultWeaponTable> load_default_weapons(const std::filesystem::path& root);
|
||||
|
||||
// Fill every empty bank of every filled slot with the table's weapon (as a
|
||||
// file reference "Weapons/<file>", or the bank's own fixed weapon when it has
|
||||
// one). Returns the number of banks filled. Slots whose section cannot be
|
||||
// resolved are left alone.
|
||||
int fill_empty_banks(Design& d, const data::Catalog& cat, const DefaultWeaponTable& table);
|
||||
|
||||
} // namespace game::design
|
||||
84
src/game/design/design.cpp
Normal file
84
src/game/design/design.cpp
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#include "game/design/design.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace game::design {
|
||||
|
||||
std::string_view slot_name(Slot s) {
|
||||
switch (s) {
|
||||
case Slot::Command: return "command";
|
||||
case Slot::Mission: return "mission";
|
||||
case Slot::Engine: return "engine";
|
||||
case Slot::Reserved3: return "slot3";
|
||||
case Slot::Reserved4: return "slot4";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
std::optional<Slot> parse_slot(std::string_view s) {
|
||||
for (Slot k : kUsedSlots)
|
||||
if (data::iequals(slot_name(k), s)) return k;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string WeaponRef::describe() const {
|
||||
switch (kind) {
|
||||
case Kind::Empty: return "(none)";
|
||||
case Kind::Id: return "#" + std::to_string(id);
|
||||
case Kind::File:
|
||||
case Kind::Stem: return text;
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
std::string SectionUse::describe() const {
|
||||
if (section_id != 0) return "#" + std::to_string(section_id);
|
||||
return section.empty() ? "(empty)" : section;
|
||||
}
|
||||
|
||||
data::Species Design::effective_race() const {
|
||||
if (race != data::Species::Unknown) return race;
|
||||
for (const SectionUse& u : slots)
|
||||
if (!u.empty()) return u.species;
|
||||
return data::Species::Unknown;
|
||||
}
|
||||
|
||||
std::string_view level_name(Level l) {
|
||||
switch (l) {
|
||||
case Level::Error: return "error";
|
||||
case Level::Warn: return "warn";
|
||||
case Level::Info: return "info";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
std::string Violation::str() const {
|
||||
std::string s(level_name(level));
|
||||
for (char& c : s) c = static_cast<char>(c >= 'a' && c <= 'z' ? c - 32 : c);
|
||||
while (s.size() < 5) s += ' ';
|
||||
s += ' ';
|
||||
s += rule;
|
||||
if (slot) {
|
||||
s += " [";
|
||||
s += slot_name(*slot);
|
||||
if (bank) s += " bank " + std::to_string(*bank);
|
||||
s += ']';
|
||||
}
|
||||
s += ": ";
|
||||
s += message;
|
||||
return s;
|
||||
}
|
||||
|
||||
bool has_errors(const std::vector<Violation>& v) {
|
||||
return std::any_of(v.begin(), v.end(), [](const Violation& x) { return x.level == Level::Error; });
|
||||
}
|
||||
|
||||
std::vector<std::string> rules_at(const std::vector<Violation>& v, Level level) {
|
||||
std::vector<std::string> out;
|
||||
for (const Violation& x : v)
|
||||
if (x.level == level && std::find(out.begin(), out.end(), x.rule) == out.end()) out.push_back(x.rule);
|
||||
std::sort(out.begin(), out.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace game::design
|
||||
108
src/game/design/design.h
Normal file
108
src/game/design/design.h
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// game::design -- the ship-design record and the validator's report types.
|
||||
//
|
||||
// A design is what the save file stores per `Des` entry (SHIP_DESIGN_RULES.md
|
||||
// section 1): a name, two flags, and FIVE section slots of which the engine
|
||||
// uses three -- command, mission, engine; slots 3 and 4 are reserved and
|
||||
// always empty. Each slot names a section as (species index, section id in
|
||||
// that race's `_shipsections.txt`), carries one weapon reference per `bank{}`
|
||||
// block of the section in file order, and the list of option techs the
|
||||
// section instance was built with.
|
||||
//
|
||||
// This record is deliberately the save's shape; hand-written designs may
|
||||
// name sections by stem and weapons by stem instead of by id, and the rules
|
||||
// resolve either form through the catalog.
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "game/data/common.h"
|
||||
|
||||
namespace game::design {
|
||||
|
||||
enum class Slot : int { Command = 0, Mission = 1, Engine = 2, Reserved3 = 3, Reserved4 = 4 };
|
||||
constexpr int kSlotCount = 5;
|
||||
constexpr std::array<Slot, 3> kUsedSlots{Slot::Command, Slot::Mission, Slot::Engine};
|
||||
|
||||
std::string_view slot_name(Slot s); // "command" / "mission" / "engine" / "slot3" / "slot4"
|
||||
std::optional<Slot> parse_slot(std::string_view); // case-insensitive; the three used slots only
|
||||
|
||||
// One weapon per bank. The save writes a player weapon as its `_weapons.txt`
|
||||
// id (`bID true, wid`) and an NPC fixed-bank weapon as its file path
|
||||
// (`bID false, wfn`); tests and tools may also use a bare stem.
|
||||
struct WeaponRef {
|
||||
enum class Kind { Empty, Id, File, Stem };
|
||||
Kind kind = Kind::Empty;
|
||||
int id = 0;
|
||||
std::string text; // file path or stem
|
||||
int rider_design_id = 0; // `did`: the rider design a rider weapon carries (0 = none)
|
||||
|
||||
static WeaponRef none() { return {}; }
|
||||
static WeaponRef by_id(int id) { WeaponRef r; r.kind = Kind::Id; r.id = id; return r; }
|
||||
static WeaponRef by_file(std::string file) { WeaponRef r; r.kind = Kind::File; r.text = std::move(file); return r; }
|
||||
static WeaponRef by_stem(std::string stem) { WeaponRef r; r.kind = Kind::Stem; r.text = std::move(stem); return r; }
|
||||
|
||||
bool empty() const { return kind == Kind::Empty; }
|
||||
std::string describe() const; // "#8", "Weapons/mis.weapon", "bal_gauss", "(none)"
|
||||
};
|
||||
|
||||
struct SectionUse {
|
||||
data::Species species = data::Species::Unknown; // Unknown -> the design's race
|
||||
int section_id = 0; // id in the race's _shipsections.txt (0 = none)
|
||||
std::string section; // stem or file path, used when section_id is 0
|
||||
std::vector<WeaponRef> weapons; // one per bank{} of the section, file order
|
||||
std::vector<std::string> options; // chosen option techs (one per option group at most)
|
||||
|
||||
bool empty() const { return section_id == 0 && section.empty(); }
|
||||
std::string describe() const; // "#42" or the stem
|
||||
};
|
||||
|
||||
struct Design {
|
||||
std::string name;
|
||||
bool hidden = false; // engine-generated hidden design (`dHide`; rider defaults)
|
||||
bool designer_made = false; // `faiDes` (meaning open; carried through)
|
||||
int default_weapons = 0; // `dWep` (1 on the hidden defaults; carried through)
|
||||
data::Species race = data::Species::Unknown; // catalog every section comes from; Unknown -> first filled slot
|
||||
std::array<SectionUse, kSlotCount> slots{};
|
||||
// Researched techs of the owning player. nullopt -> tech gating is skipped
|
||||
// (structure, fit and option rules still apply).
|
||||
std::optional<std::vector<std::string>> known_techs;
|
||||
|
||||
SectionUse& slot(Slot s) { return slots[static_cast<std::size_t>(s)]; }
|
||||
const SectionUse& slot(Slot s) const { return slots[static_cast<std::size_t>(s)]; }
|
||||
SectionUse& command() { return slot(Slot::Command); }
|
||||
SectionUse& mission() { return slot(Slot::Mission); }
|
||||
SectionUse& engine() { return slot(Slot::Engine); }
|
||||
const SectionUse& command() const { return slot(Slot::Command); }
|
||||
const SectionUse& mission() const { return slot(Slot::Mission); }
|
||||
const SectionUse& engine() const { return slot(Slot::Engine); }
|
||||
|
||||
// The species catalog the design is validated against: `race`, else the
|
||||
// species of the first filled slot, else Unknown.
|
||||
data::Species effective_race() const;
|
||||
};
|
||||
|
||||
// Severity of a rule finding. Error: the design is invalid. Warn: the engine
|
||||
// accepts it but the designer UI would not build it, or only code can settle
|
||||
// the case (hidden default riders past tech gating, `nodesign` sections).
|
||||
// Info: a fact worth reporting (a typeless rider hull in a hidden design).
|
||||
enum class Level { Error, Warn, Info };
|
||||
std::string_view level_name(Level l); // "error" / "warn" / "info"
|
||||
|
||||
struct Violation {
|
||||
std::string rule; // A2 .. D2 (docs/game-design.md)
|
||||
Level level = Level::Error;
|
||||
std::string message;
|
||||
std::optional<Slot> slot;
|
||||
std::optional<int> bank; // bank index within the slot's section
|
||||
|
||||
std::string str() const; // "ERROR B4 [mission bank 2]: ..."
|
||||
};
|
||||
|
||||
bool has_errors(const std::vector<Violation>& v);
|
||||
std::vector<std::string> rules_at(const std::vector<Violation>& v, Level level); // sorted, distinct
|
||||
|
||||
} // namespace game::design
|
||||
419
src/game/design/rules.cpp
Normal file
419
src/game/design/rules.cpp
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
#include "game/design/rules.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <utility>
|
||||
|
||||
namespace game::design {
|
||||
|
||||
using data::fold;
|
||||
using data::iequals;
|
||||
using data::ShipSectionDef;
|
||||
using data::WeaponDef;
|
||||
|
||||
namespace {
|
||||
|
||||
bool contains_fold(const std::vector<std::string>& v, std::string_view s) {
|
||||
return std::any_of(v.begin(), v.end(), [&](const std::string& x) { return iequals(x, s); });
|
||||
}
|
||||
|
||||
void add(std::vector<Violation>& v, const char* rule, Level level, std::string msg,
|
||||
std::optional<Slot> slot = std::nullopt, std::optional<int> bank = std::nullopt) {
|
||||
v.push_back(Violation{rule, level, std::move(msg), slot, bank});
|
||||
}
|
||||
|
||||
std::string slot_str(Slot s) { return std::string(slot_name(s)); }
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// techs and options
|
||||
|
||||
std::optional<std::string> hull_class_tech(const ShipSectionDef& s) {
|
||||
if (is_station(s)) return std::nullopt;
|
||||
switch (s.section_class) {
|
||||
case data::SectionClass::Cruiser: return std::string("IND_CruisCon");
|
||||
case data::SectionClass::Dreadnought: return std::string("IND_DreadCon");
|
||||
default: return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<const data::OptionGroup*> option_groups(const ShipSectionDef& s) {
|
||||
std::vector<const data::OptionGroup*> out;
|
||||
for (const data::OptionGroup& g : s.options) out.push_back(&g);
|
||||
if (s.optiondef) out.push_back(&*s.optiondef);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::string> applied_techs(const ShipSectionDef& s, const std::vector<std::string>& chosen) {
|
||||
std::vector<std::string> out;
|
||||
if (auto hct = hull_class_tech(s)) out.push_back(*hct);
|
||||
out.insert(out.end(), s.requires.begin(), s.requires.end());
|
||||
for (const data::OptionGroup* g : option_groups(s))
|
||||
for (const std::string& member : g->members)
|
||||
for (const std::string& c : chosen)
|
||||
if (iequals(c, member)) {
|
||||
out.push_back(c);
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::string> options_from_applied(const ShipSectionDef& s, const std::vector<std::string>& applied) {
|
||||
std::vector<std::string> out;
|
||||
auto hct = hull_class_tech(s);
|
||||
for (const std::string& t : applied) {
|
||||
if (hct && iequals(t, *hct)) continue;
|
||||
if (contains_fold(s.requires, t)) continue;
|
||||
out.push_back(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
TechSet::TechSet(const std::vector<std::string>& names) {
|
||||
for (const std::string& n : names) names_.insert(fold(n));
|
||||
}
|
||||
|
||||
bool TechSet::satisfies(const data::TechTree& tree, std::string_view requirement) const {
|
||||
if (data::is_group_ref(requirement)) {
|
||||
const std::vector<std::string>* members = tree.group_members(requirement);
|
||||
if (!members) return false;
|
||||
return std::any_of(members->begin(), members->end(), [&](const std::string& m) { return contains(m); });
|
||||
}
|
||||
return contains(requirement);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fit
|
||||
|
||||
bool bank_accepts_class(std::string_view bank_class, std::string_view weapon_class) {
|
||||
if (iequals(bank_class, weapon_class)) return true;
|
||||
if (iequals(bank_class, "standard")) return iequals(weapon_class, "missile") || iequals(weapon_class, "grapple");
|
||||
if (iequals(bank_class, "strafe")) return iequals(weapon_class, "standard");
|
||||
return false;
|
||||
}
|
||||
|
||||
const data::TurretRow* turret_row(const data::TurretTable& t, const data::BankDef& bank, const WeaponDef& w) {
|
||||
if (const data::TurretRow* r = t.find(bank.turret_size, w.turret_size, w.turret_class)) return r;
|
||||
return t.find(bank.turret_size, w.turret_size, bank.turret_class);
|
||||
}
|
||||
|
||||
Fit weapon_fits_bank(const data::TurretTable& t, const data::BankDef& bank, const WeaponDef& w) {
|
||||
Fit f;
|
||||
if (bank.turret_size.empty() || bank.turret_class.empty()) {
|
||||
// Two NPC banks carry a fixed weapon and no turret spec at all.
|
||||
f.ok = !bank.weapon.empty() && iequals(w.file, bank.weapon);
|
||||
f.why = "bank has no turretsize/turretclass (fixed NPC weapon only)";
|
||||
return f;
|
||||
}
|
||||
if (!bank_accepts_class(bank.turret_class, w.turret_class)) {
|
||||
f.why = "turretclass " + fold(w.turret_class) + " not accepted by " + fold(bank.turret_class) + " bank";
|
||||
return f;
|
||||
}
|
||||
f.row = turret_row(t, bank, w);
|
||||
if (!f.row) {
|
||||
f.why = "no _turrets.txt row for (" + fold(bank.turret_size) + " bank, " + fold(w.turret_size) + " weapon, " +
|
||||
fold(w.turret_class) + "/" + fold(bank.turret_class) + ")";
|
||||
return f;
|
||||
}
|
||||
f.ok = true;
|
||||
f.why = "ok";
|
||||
return f;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ruleset
|
||||
|
||||
Ruleset::Ruleset(const data::Catalog& cat) : cat_(&cat) {
|
||||
// C5 index: for every tech that has incoming edges, the species for which
|
||||
// every edge writes 0%.
|
||||
std::map<std::string, std::vector<const data::AllowsEdge*>> incoming;
|
||||
for (const data::AllowsEdge& e : cat.tech.edges) incoming[fold(e.to)].push_back(&e);
|
||||
for (const auto& kv : incoming) {
|
||||
unsigned mask = 0;
|
||||
for (int i = 0; i < data::kSpeciesCount; ++i) {
|
||||
bool all_zero = std::all_of(kv.second.begin(), kv.second.end(), [&](const data::AllowsEdge* e) {
|
||||
return e->pct[static_cast<std::size_t>(i)] == 0;
|
||||
});
|
||||
if (all_zero) mask |= 1u << i;
|
||||
}
|
||||
if (mask) unobtainable_[kv.first] = mask;
|
||||
}
|
||||
}
|
||||
|
||||
bool Ruleset::unobtainable(std::string_view tech, data::Species race) const {
|
||||
if (race == data::Species::Unknown) return false;
|
||||
auto it = unobtainable_.find(fold(tech));
|
||||
return it != unobtainable_.end() && (it->second & (1u << static_cast<int>(race))) != 0;
|
||||
}
|
||||
|
||||
const ShipSectionDef* Ruleset::resolve_section(data::Species race, const SectionUse& use) const {
|
||||
if (race == data::Species::Unknown || use.empty()) return nullptr;
|
||||
std::string_view rk = data::species_name(race);
|
||||
if (use.section_id != 0) return cat_->section_by_id(rk, use.section_id);
|
||||
std::string_view stem = data::basename(use.section);
|
||||
if (stem.size() > 12 && iequals(stem.substr(stem.size() - 12), ".shipsection")) stem = stem.substr(0, stem.size() - 12);
|
||||
return cat_->section(rk, stem);
|
||||
}
|
||||
|
||||
const WeaponDef* Ruleset::resolve_weapon_file(std::string_view path) const {
|
||||
if (path.empty()) return nullptr;
|
||||
if (const WeaponDef* w = cat_->weapon_by_file(path)) return w;
|
||||
std::string_view base = data::basename(path);
|
||||
// A bare file name ("mis.weapon") or a stem.
|
||||
for (const WeaponDef& w : cat_->weapons)
|
||||
if (iequals(data::basename(w.file), base)) return &w;
|
||||
return cat_->weapon(data::strip_extension(base));
|
||||
}
|
||||
|
||||
const WeaponDef* Ruleset::resolve_weapon(const WeaponRef& ref) const {
|
||||
switch (ref.kind) {
|
||||
case WeaponRef::Kind::Empty: return nullptr;
|
||||
case WeaponRef::Kind::Id: return cat_->weapon_by_id(ref.id);
|
||||
case WeaponRef::Kind::Stem:
|
||||
if (const WeaponDef* w = cat_->weapon(ref.text)) return w;
|
||||
return resolve_weapon_file(ref.text);
|
||||
case WeaponRef::Kind::File: return resolve_weapon_file(ref.text);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void Ruleset::strip_applied_techs(Design& d) const {
|
||||
data::Species race = d.effective_race();
|
||||
for (SectionUse& u : d.slots) {
|
||||
if (u.empty()) continue;
|
||||
data::Species sp = u.species == data::Species::Unknown ? race : u.species;
|
||||
if (const ShipSectionDef* s = resolve_section(sp, u)) u.options = options_from_applied(*s, u.options);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validate
|
||||
|
||||
std::vector<Violation> Ruleset::validate(const Design& d) const {
|
||||
std::vector<Violation> v;
|
||||
const data::Catalog& cat = *cat_;
|
||||
const data::Species race = d.effective_race();
|
||||
const bool hidden = d.hidden;
|
||||
std::optional<TechSet> known;
|
||||
if (d.known_techs) known.emplace(*d.known_techs);
|
||||
|
||||
// A1: the two reserved slots are never used.
|
||||
for (Slot s : {Slot::Reserved3, Slot::Reserved4})
|
||||
if (!d.slot(s).empty()) add(v, "A1", Level::Error, "reserved slot is filled", s);
|
||||
|
||||
if (race == data::Species::Unknown ||
|
||||
std::find_if(cat.races.begin(), cat.races.end(),
|
||||
[&](const std::string& r) { return iequals(r, data::species_name(race)); }) == cat.races.end()) {
|
||||
add(v, "A5", Level::Error, "unknown species catalog '" + std::string(data::species_name(race)) + "'");
|
||||
return v;
|
||||
}
|
||||
const std::string_view rk = data::species_name(race);
|
||||
|
||||
// -- resolve the three used slots --------------------------------------
|
||||
struct Filled {
|
||||
Slot slot;
|
||||
const SectionUse* use;
|
||||
const ShipSectionDef* def;
|
||||
};
|
||||
std::array<Filled, 3> f{};
|
||||
for (std::size_t i = 0; i < 3; ++i) {
|
||||
Slot s = kUsedSlots[i];
|
||||
const SectionUse& u = d.slot(s);
|
||||
f[i] = {s, u.empty() ? nullptr : &u, nullptr};
|
||||
if (u.empty()) continue;
|
||||
if (u.species != data::Species::Unknown && u.species != race) {
|
||||
add(v, "A5", Level::Error,
|
||||
"section " + u.describe() + " comes from the " + std::string(data::species_name(u.species)) +
|
||||
" catalog, design is " + std::string(rk),
|
||||
s);
|
||||
continue;
|
||||
}
|
||||
f[i].def = resolve_section(race, u);
|
||||
if (!f[i].def) add(v, "A5", Level::Error, "section " + u.describe() + " not in " + std::string(rk) + " catalog", s);
|
||||
}
|
||||
const Filled& cmd = f[0];
|
||||
const Filled& mis = f[1];
|
||||
const Filled& eng = f[2];
|
||||
if (!mis.def) {
|
||||
if (!mis.use) add(v, "A2", Level::Error, "mission slot is empty");
|
||||
return v;
|
||||
}
|
||||
const ShipSectionDef& m = *mis.def;
|
||||
|
||||
// A3 hull ship vs standalone; A8 station triples pin their partners.
|
||||
const bool standalone = !m.has_sockets();
|
||||
if (!m.explicit_command_section.empty() || !m.explicit_engine_section.empty()) {
|
||||
for (const auto& [part, want] : {std::pair{&cmd, &m.explicit_command_section}, std::pair{&eng, &m.explicit_engine_section}}) {
|
||||
if (want->empty()) continue;
|
||||
if (!part->def || !iequals(part->def->stem, *want))
|
||||
add(v, "A8", Level::Error,
|
||||
m.stem + " requires " + slot_str(part->slot) + " section " + *want + ", got " +
|
||||
(part->def ? part->def->stem : std::string("none")),
|
||||
part->slot);
|
||||
}
|
||||
} else if (standalone) {
|
||||
for (const Filled* part : {&cmd, &eng})
|
||||
if (part->use)
|
||||
add(v, "A3", Level::Error, m.stem + " has no sockets (standalone hull) but " + slot_str(part->slot) + " slot is filled",
|
||||
part->slot);
|
||||
} else {
|
||||
for (const Filled* part : {&cmd, &eng})
|
||||
if (!part->def) add(v, "A3", Level::Error, m.stem + " has sockets; " + slot_str(part->slot) + " section required", part->slot);
|
||||
}
|
||||
|
||||
std::vector<const Filled*> present;
|
||||
for (const Filled& x : f)
|
||||
if (x.def) present.push_back(&x);
|
||||
|
||||
// A4 slot/type, A9 nodesign, A8 explicit partner sections
|
||||
for (const Filled* p : present) {
|
||||
const ShipSectionDef& s = *p->def;
|
||||
if (s.section_type_text.empty()) {
|
||||
if (p->slot == Slot::Mission && standalone && (hidden || iequals(s.design_class, "rider")))
|
||||
add(v, "A4", Level::Info, s.stem + " has no section_type (rider hull in a hidden design)", p->slot);
|
||||
else
|
||||
add(v, "A4", Level::Error, s.stem + " has no section_type and cannot fill the " + slot_str(p->slot) + " slot", p->slot);
|
||||
} else if (!iequals(s.section_type_text, slot_name(p->slot))) {
|
||||
add(v, "A4", Level::Error,
|
||||
s.stem + " is a " + fold(s.section_type_text) + " section, placed in " + slot_str(p->slot) + " slot", p->slot);
|
||||
}
|
||||
if (s.nodesign.value_or(false) && !hidden)
|
||||
add(v, "A9", Level::Warn, s.stem + " is nodesign (not offered by the design UI)", p->slot);
|
||||
if (s.explicit_section.value_or(false) && p->slot != Slot::Mission) {
|
||||
const std::string& owner = p->slot == Slot::Command ? m.explicit_command_section : m.explicit_engine_section;
|
||||
if (owner.empty() || !iequals(owner, s.stem))
|
||||
add(v, "A8", Level::Error, s.stem + " is an explicit_section usable only with its owning station", p->slot);
|
||||
}
|
||||
}
|
||||
|
||||
// A6 class equality
|
||||
{
|
||||
std::vector<std::string> classes;
|
||||
for (const Filled* p : present)
|
||||
if (!p->def->section_class_text.empty() && !contains_fold(classes, p->def->section_class_text))
|
||||
classes.push_back(fold(p->def->section_class_text));
|
||||
if (classes.size() > 1) {
|
||||
std::sort(classes.begin(), classes.end());
|
||||
std::string list;
|
||||
for (const std::string& c : classes) list += (list.empty() ? "" : ", ") + c;
|
||||
add(v, "A6", Level::Error, "mixed section_class: [" + list + "]");
|
||||
}
|
||||
}
|
||||
|
||||
// A7 exclude lists
|
||||
for (const Filled* p : present)
|
||||
for (const std::string& ex : p->def->exclude)
|
||||
for (const Filled* q : present)
|
||||
if (iequals(q->def->stem, ex))
|
||||
add(v, "A7", Level::Error, p->def->stem + " excludes " + ex + " (in " + slot_str(q->slot) + " slot)", p->slot);
|
||||
|
||||
// -- banks & weapons ----------------------------------------------------
|
||||
for (const Filled* p : present) {
|
||||
const ShipSectionDef& s = *p->def;
|
||||
const std::vector<WeaponRef>& weapons = p->use->weapons;
|
||||
if (weapons.size() != s.banks.size())
|
||||
add(v, "B1", Level::Error,
|
||||
s.stem + " has " + std::to_string(s.banks.size()) + " banks, design lists " + std::to_string(weapons.size()) + " weapons",
|
||||
p->slot);
|
||||
for (std::size_t i = 0; i < s.banks.size(); ++i) {
|
||||
const data::BankDef& bank = s.banks[i];
|
||||
const int bi = static_cast<int>(i);
|
||||
const WeaponRef* ref = i < weapons.size() && !weapons[i].empty() ? &weapons[i] : nullptr;
|
||||
const WeaponDef* w = ref ? resolve_weapon(*ref) : nullptr;
|
||||
if (ref && !w) {
|
||||
add(v, "B5", Level::Error, "unknown weapon " + ref->describe(), p->slot, bi);
|
||||
continue;
|
||||
}
|
||||
if (!bank.weapon.empty()) {
|
||||
const WeaponDef* fixed = resolve_weapon_file(bank.weapon);
|
||||
if (!w) {
|
||||
w = fixed;
|
||||
} else if (fixed && !iequals(w->file, fixed->file)) {
|
||||
add(v, "B3", Level::Error,
|
||||
"bank is bound to " + std::string(data::basename(bank.weapon)) + ", design has " + w->stem, p->slot, bi);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!w) {
|
||||
add(v, "B2", Level::Error,
|
||||
"bank " + std::to_string(i) + " (" + fold(bank.turret_size) + " " + fold(bank.turret_class) + ", " +
|
||||
std::to_string(bank.mounts.size()) + " mounts) has no weapon",
|
||||
p->slot, bi);
|
||||
continue;
|
||||
}
|
||||
Fit fit = weapon_fits_bank(cat.turrets, bank, *w);
|
||||
if (!fit.ok)
|
||||
add(v, "B4", Level::Error,
|
||||
w->stem + " (" + fold(w->turret_size) + " " + fold(w->turret_class) + ") does not fit bank " + std::to_string(i) +
|
||||
" (" + fold(bank.turret_size) + " " + fold(bank.turret_class) + "): " + fit.why,
|
||||
p->slot, bi);
|
||||
if (w->scope != data::WeaponScope::Player && race != data::Species::NPC && bank.weapon.empty())
|
||||
add(v, "B5", Level::Error, w->stem + " is an NPC-only weapon", p->slot, bi);
|
||||
if (!w->exclusive_species.empty() && !iequals(w->exclusive_species, rk))
|
||||
add(v, "B6", Level::Error, w->stem + " is exclusive to " + fold(w->exclusive_species), p->slot, bi);
|
||||
if (w->hidden.value_or(false) && !hidden && bank.weapon.empty())
|
||||
add(v, "B8", Level::Error, w->stem + " is a hidden (engine-only) weapon", p->slot, bi);
|
||||
if (!w->compatible_section.empty()) {
|
||||
bool carrier = std::any_of(w->compatible_section.begin(), w->compatible_section.end(),
|
||||
[&](const std::string& cs) { return cat.section(rk, cs) != nullptr; });
|
||||
if (!carrier) {
|
||||
std::string list;
|
||||
for (const std::string& cs : w->compatible_section) list += (list.empty() ? "" : ", ") + cs;
|
||||
add(v, "B7", Level::Error, w->stem + " needs a rider section [" + list + "] that " + std::string(rk) + " lacks",
|
||||
p->slot, bi);
|
||||
}
|
||||
}
|
||||
if (known && bank.weapon.empty())
|
||||
for (const std::string& t : w->requires)
|
||||
if (!known->satisfies(cat.tech, t)) add(v, "C2", Level::Error, w->stem + " requires " + t, p->slot, bi);
|
||||
}
|
||||
}
|
||||
|
||||
// -- options ------------------------------------------------------------
|
||||
for (const Filled* p : present) {
|
||||
const ShipSectionDef& s = *p->def;
|
||||
std::vector<const data::OptionGroup*> groups = option_groups(s);
|
||||
std::map<std::size_t, std::string> seen;
|
||||
for (const std::string& o : p->use->options) {
|
||||
std::optional<std::size_t> gi;
|
||||
for (std::size_t g = 0; g < groups.size() && !gi; ++g)
|
||||
if (contains_fold(groups[g]->members, o)) gi = g;
|
||||
if (!gi) {
|
||||
add(v, "D1", Level::Error, o + " is not an option of " + s.stem, p->slot);
|
||||
} else if (seen.count(*gi)) {
|
||||
add(v, "D1", Level::Error, o + " and " + seen[*gi] + " are in the same option group of " + s.stem, p->slot);
|
||||
} else {
|
||||
seen[*gi] = o;
|
||||
}
|
||||
if (known && cat.tech.find(o) && !known->satisfies(cat.tech, o))
|
||||
add(v, "C3", Level::Error, "option " + o + " is not researched", p->slot);
|
||||
}
|
||||
}
|
||||
|
||||
// -- tech gating ----------------------------------------------------------
|
||||
// Engine-generated hidden designs (the per-race "Default Assault Shuttle")
|
||||
// exist in every save regardless of research: the Tarkas _AssaultShuttle
|
||||
// requires DRN_AdvFrm and the design is there on turn 1. Gating on hidden
|
||||
// designs is therefore reported at warn level.
|
||||
const Level gate = hidden ? Level::Warn : Level::Error;
|
||||
for (const Filled* p : present) {
|
||||
const ShipSectionDef& s = *p->def;
|
||||
for (const std::string& t : s.requires) {
|
||||
if (!cat.tech.find(t) && !data::is_group_ref(t))
|
||||
add(v, "C1", Level::Warn, s.stem + " requires unknown tech " + t, p->slot);
|
||||
else if (known && !known->satisfies(cat.tech, t))
|
||||
add(v, "C1", gate, s.stem + " requires " + t, p->slot);
|
||||
if (unobtainable(t, race))
|
||||
add(v, "C5", Level::Error, s.stem + " requires " + t + ", which " + std::string(rk) + " can never research", p->slot);
|
||||
}
|
||||
if (auto hct = hull_class_tech(s); hct && known && !known->satisfies(cat.tech, *hct))
|
||||
add(v, "C4", gate, s.stem + " is a " + fold(s.section_class_text) + " section; " + *hct + " not researched", p->slot);
|
||||
}
|
||||
if (hidden)
|
||||
for (Violation& x : v)
|
||||
if ((x.rule == "C2" || x.rule == "C3") && x.level == Level::Error) x.level = Level::Warn;
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace game::design
|
||||
113
src/game/design/rules.h
Normal file
113
src/game/design/rules.h
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// game::design -- the ship-design assembly rules.
|
||||
//
|
||||
// What makes a design valid, derived from the shipped data and every design
|
||||
// in the real saves (docs/game-design.md carries the evidence per rule id):
|
||||
//
|
||||
// A* structure: mission slot mandatory; sockets on the mission section =>
|
||||
// command + engine required, none => standalone; slot must match
|
||||
// section_type; one species catalog; one section_class; `exclude`
|
||||
// lists; station triples pinned by explicit_*_section; `nodesign` is
|
||||
// UI-only (warn). Sockets are geometry, not a key.
|
||||
// B* banks: one weapon per bank{}, none empty, fixed NPC banks bound to
|
||||
// their file, fit = class-accept table + a _turrets.txt row, weapon
|
||||
// scope / exclusive_species / rider carriers / hidden weapons.
|
||||
// C* tech gating: section and weapon `requires` (GRP_ = any of the group),
|
||||
// chosen options, the implicit hull-class tech (IND_CruisCon /
|
||||
// IND_DreadCon, stations exempt), race availability. Hidden default
|
||||
// designs are gated at warn level (code rule the data does not show).
|
||||
// D* options: at most one per group, members only; applied_techs =
|
||||
// [hull-class tech] + requires + chosen options in group order (D2).
|
||||
//
|
||||
// Nothing here throws; every finding is a Violation.
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "game/data/catalog.h"
|
||||
#include "game/design/design.h"
|
||||
|
||||
namespace game::design {
|
||||
|
||||
// C4: the tech a section's class implies. Cruisers need IND_CruisCon,
|
||||
// dreadnoughts IND_DreadCon; destroyers nothing; `design_class station`
|
||||
// sections are exempt whatever their class.
|
||||
std::optional<std::string> hull_class_tech(const data::ShipSectionDef& s);
|
||||
inline bool is_station(const data::ShipSectionDef& s) { return data::iequals(s.design_class, "station"); }
|
||||
|
||||
// The section's option groups in DOpts order: every `option` entry, then the
|
||||
// `optiondef` shield group.
|
||||
std::vector<const data::OptionGroup*> option_groups(const data::ShipSectionDef& s);
|
||||
|
||||
// D2: the techs a section instance is built with -- exactly the save's DOpts
|
||||
// list: hull-class tech, `requires` in file order, then the chosen option of
|
||||
// each group in group order (spelled as chosen).
|
||||
std::vector<std::string> applied_techs(const data::ShipSectionDef& s, const std::vector<std::string>& chosen_options);
|
||||
|
||||
// Inverse of applied_techs for a save record: drops the hull-class tech and
|
||||
// the `requires` from a DOpts list, leaving the chosen options.
|
||||
std::vector<std::string> options_from_applied(const data::ShipSectionDef& s, const std::vector<std::string>& applied);
|
||||
|
||||
// B4: which weapon turret classes a bank turret class takes besides an exact
|
||||
// match -- standard <- {standard, missile, grapple}, strafe <- {standard}.
|
||||
bool bank_accepts_class(std::string_view bank_class, std::string_view weapon_class);
|
||||
|
||||
// The _turrets.txt row a weapon in a bank uses: keyed by the weapon's class
|
||||
// first (a missile in a standard bank gets the missile turret), then by the
|
||||
// bank's class (strafe banks have their own rows; no weapon is class strafe).
|
||||
const data::TurretRow* turret_row(const data::TurretTable& t, const data::BankDef& bank, const data::WeaponDef& w);
|
||||
|
||||
struct Fit {
|
||||
bool ok = false;
|
||||
std::string why; // "ok" or the reason
|
||||
const data::TurretRow* row = nullptr;
|
||||
};
|
||||
Fit weapon_fits_bank(const data::TurretTable& t, const data::BankDef& bank, const data::WeaponDef& w);
|
||||
|
||||
// A player's researched techs. `satisfies` resolves GRP_<group> through the
|
||||
// tech tree: satisfied when any member of the group is known.
|
||||
class TechSet {
|
||||
public:
|
||||
TechSet() = default;
|
||||
explicit TechSet(const std::vector<std::string>& names);
|
||||
bool contains(std::string_view name) const { return names_.count(data::fold(name)) != 0; }
|
||||
bool satisfies(const data::TechTree& tree, std::string_view requirement) const;
|
||||
std::size_t size() const { return names_.size(); }
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> names_; // folded
|
||||
};
|
||||
|
||||
class Ruleset {
|
||||
public:
|
||||
explicit Ruleset(const data::Catalog& cat);
|
||||
const data::Catalog& catalog() const { return *cat_; }
|
||||
|
||||
std::vector<Violation> validate(const Design& d) const;
|
||||
|
||||
// Resolution. A section is found by id in the race's manifest when
|
||||
// section_id is set, else by stem / file name; a weapon by id, file path
|
||||
// (full or basename) or stem.
|
||||
const data::ShipSectionDef* resolve_section(data::Species race, const SectionUse& use) const;
|
||||
const data::WeaponDef* resolve_weapon(const WeaponRef& ref) const;
|
||||
const data::WeaponDef* resolve_weapon_file(std::string_view path) const;
|
||||
|
||||
// C5: a tech every incoming `allows` edge writes as 0% for the species
|
||||
// can never be researched by it.
|
||||
bool unobtainable(std::string_view tech, data::Species race) const;
|
||||
|
||||
// Save-record helpers: the save's DOpts lists include the structural techs;
|
||||
// strip them so `options` holds only the chosen ones (per slot, in place).
|
||||
// Slots whose section cannot be resolved are left untouched.
|
||||
void strip_applied_techs(Design& d) const;
|
||||
|
||||
private:
|
||||
const data::Catalog* cat_;
|
||||
std::unordered_map<std::string, unsigned> unobtainable_; // folded tech -> species bit mask
|
||||
};
|
||||
|
||||
} // namespace game::design
|
||||
107
src/game/design/stats.cpp
Normal file
107
src/game/design/stats.cpp
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#include "game/design/stats.h"
|
||||
|
||||
namespace game::design {
|
||||
|
||||
using data::iequals;
|
||||
|
||||
const std::vector<std::string_view>& capacity_keys() {
|
||||
static const std::vector<std::string_view> keys = {
|
||||
"refueling_capacity", "repair_capacity", "construction_capacity", "mining_capacity", "mining_rate",
|
||||
"prisoner_capacity", "colonizer_pop", "colonizer_infra", "colonizer_terra", "scanrange",
|
||||
"tacticalsensorrange", "range", "split_traffic_volume", "refinery", "freighter", "freighterq",
|
||||
"police", "tradingpost", "monitor", "spy", "spytender", "science", "propaganda", "ewar", "gateship",
|
||||
"ramscoop", "node_bore", "node_missile", "gravboat_bonus", "protectorate", "huge", "defence_platform",
|
||||
};
|
||||
return keys;
|
||||
}
|
||||
|
||||
const std::string* DesignStats::capacity(std::string_view key) const {
|
||||
for (const auto& kv : capacities)
|
||||
if (iequals(kv.first, key)) return &kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
void set_capacity(DesignStats& st, std::string_view key, std::string value) {
|
||||
for (auto& kv : st.capacities)
|
||||
if (iequals(kv.first, key)) {
|
||||
kv.second = std::move(value);
|
||||
return;
|
||||
}
|
||||
st.capacities.emplace_back(std::string(key), std::move(value));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DesignStats derive_stats(const Ruleset& rules, const Design& d) {
|
||||
const data::Catalog& cat = rules.catalog();
|
||||
DesignStats st;
|
||||
st.name = d.name;
|
||||
const data::Species race = d.effective_race();
|
||||
st.race = std::string(data::species_name(race));
|
||||
|
||||
for (Slot slot : kUsedSlots) {
|
||||
const SectionUse& use = d.slot(slot);
|
||||
if (use.empty()) continue;
|
||||
const data::ShipSectionDef* s = rules.resolve_section(use.species == data::Species::Unknown ? race : use.species, use);
|
||||
if (!s) continue;
|
||||
|
||||
SectionStats sec;
|
||||
sec.slot = slot;
|
||||
sec.section = s;
|
||||
for (const std::string& o : use.options)
|
||||
if (const data::TechNode* n = cat.tech.find(o); n && n->option_cost) sec.option_cost_multiplier *= *n->option_cost;
|
||||
sec.mass = s->mass.value_or(0);
|
||||
sec.cost = static_cast<double>(s->cost.value_or(0));
|
||||
sec.health = s->health.value_or(0);
|
||||
sec.crew = s->crew.value_or(0);
|
||||
sec.cpoints = s->cpoints.value_or(0);
|
||||
|
||||
for (std::size_t i = 0; i < s->banks.size(); ++i) {
|
||||
const data::BankDef& bank = s->banks[i];
|
||||
BankStats b;
|
||||
b.size = bank.turret_size;
|
||||
b.turret_class = bank.turret_class;
|
||||
b.mounts = static_cast<int>(bank.mounts.size());
|
||||
if (i < use.weapons.size() && !use.weapons[i].empty())
|
||||
b.weapon = rules.resolve_weapon(use.weapons[i]);
|
||||
else if (!bank.weapon.empty())
|
||||
b.weapon = rules.resolve_weapon_file(bank.weapon);
|
||||
if (b.weapon) {
|
||||
b.weapon_cost = static_cast<double>(b.weapon->cost.value_or(0));
|
||||
b.turret = turret_row(cat.turrets, bank, *b.weapon);
|
||||
st.weapon_cost_per_bank += b.weapon_cost;
|
||||
st.weapon_cost_per_mount += b.weapon_cost * (b.mounts > 1 ? b.mounts : 1);
|
||||
}
|
||||
st.banks += 1;
|
||||
st.turrets += b.mounts;
|
||||
sec.banks.push_back(std::move(b));
|
||||
}
|
||||
sec.applied_techs = applied_techs(*s, use.options);
|
||||
|
||||
st.mass += sec.mass;
|
||||
st.section_cost += sec.cost;
|
||||
st.section_cost_with_options += sec.cost * sec.option_cost_multiplier;
|
||||
st.health_total += sec.health;
|
||||
st.crew += sec.crew;
|
||||
st.cpoints += sec.cpoints;
|
||||
st.command_cost += s->command_cost.value_or(0);
|
||||
st.maintenance_cost += s->maintenance_cost.value_or(0);
|
||||
if (s->command_quota) st.command_quota = st.command_quota.value_or(0) + *s->command_quota;
|
||||
if (st.hull_class.empty() && !s->section_class_text.empty()) st.hull_class = data::fold(s->section_class_text);
|
||||
for (std::string_view key : capacity_keys())
|
||||
if (const data::Attr* a = s->raw.find(key)) set_capacity(st, key, a->value);
|
||||
if (s->section_type == data::SectionType::Engine || (slot == Slot::Mission && !st.ftlspeed && s->ftlspeed)) {
|
||||
st.ftlspeed = s->ftlspeed;
|
||||
st.nodespeed = s->nodespeed;
|
||||
st.engine_techera = s->engine_techera;
|
||||
st.netforcelimits = s->netforcelimits;
|
||||
}
|
||||
st.sections.push_back(std::move(sec));
|
||||
}
|
||||
st.total_cost_estimate = st.section_cost_with_options + st.weapon_cost_per_bank;
|
||||
return st;
|
||||
}
|
||||
|
||||
} // namespace game::design
|
||||
70
src/game/design/stats.h
Normal file
70
src/game/design/stats.h
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// game::design -- derived stats of a design (SHIP_DESIGN_RULES.md section 6).
|
||||
//
|
||||
// Everything here is a sum or a copy of data fields; the parts only code can
|
||||
// settle (whether weapons add mass, per-bank vs per-mount weapon cost, what
|
||||
// `option_cost` multiplies, the speed integrators) are reported as the data
|
||||
// suggests them and flagged in docs/game-design.md.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "game/design/rules.h"
|
||||
|
||||
namespace game::design {
|
||||
|
||||
struct BankStats {
|
||||
std::string size, turret_class; // as written in the section
|
||||
int mounts = 0;
|
||||
const data::WeaponDef* weapon = nullptr; // resolved (or the bank's fixed weapon)
|
||||
double weapon_cost = 0;
|
||||
const data::TurretRow* turret = nullptr; // model / health / track speed of the turret
|
||||
};
|
||||
|
||||
struct SectionStats {
|
||||
Slot slot = Slot::Mission;
|
||||
const data::ShipSectionDef* section = nullptr;
|
||||
double mass = 0, cost = 0, health = 0;
|
||||
double option_cost_multiplier = 1; // product of the chosen options' tech option_cost
|
||||
std::int64_t crew = 0, cpoints = 0;
|
||||
std::vector<BankStats> banks;
|
||||
std::vector<std::string> applied_techs; // D2
|
||||
};
|
||||
|
||||
struct DesignStats {
|
||||
std::string race, name;
|
||||
std::string hull_class; // section_class of the first resolved section (folded)
|
||||
std::vector<SectionStats> sections; // command, mission, engine order; only resolved slots
|
||||
|
||||
double mass = 0;
|
||||
double section_cost = 0; // sum of section `cost`
|
||||
double section_cost_with_options = 0; // sum of cost x option multiplier
|
||||
double weapon_cost_per_bank = 0; // one weapon cost per bank
|
||||
double weapon_cost_per_mount = 0; // weapon cost x mounts
|
||||
double total_cost_estimate = 0; // section_cost_with_options + weapon_cost_per_bank
|
||||
double health_total = 0; // the engine tracks health per section; this is only a sum
|
||||
std::int64_t crew = 0, cpoints = 0;
|
||||
int banks = 0, turrets = 0; // turrets = one per mount
|
||||
std::int64_t command_cost = 0, maintenance_cost = 0;
|
||||
std::optional<std::int64_t> command_quota;
|
||||
|
||||
// Drive: from the engine section, or the mission section of a standalone hull.
|
||||
std::optional<double> ftlspeed, nodespeed;
|
||||
std::string engine_techera;
|
||||
std::optional<data::NetForceLimits> netforcelimits;
|
||||
|
||||
// Role flags and capacities copied through from the sections' raw blocks
|
||||
// (key -> text as written; later sections override earlier ones).
|
||||
std::vector<std::pair<std::string, std::string>> capacities;
|
||||
const std::string* capacity(std::string_view key) const;
|
||||
};
|
||||
|
||||
// The raw keys copied into `capacities`.
|
||||
const std::vector<std::string_view>& capacity_keys();
|
||||
|
||||
DesignStats derive_stats(const Ruleset& rules, const Design& d);
|
||||
|
||||
} // namespace game::design
|
||||
17
tests/game_design/CMakeLists.txt
Normal file
17
tests/game_design/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Optional CMake wiring for the game_design tests; the canonical runner is
|
||||
# build_and_run.sh (plain g++). Include from the root with
|
||||
# add_subdirectory(tests/game_design) after add_subdirectory(src/game/design).
|
||||
add_executable(game_design_unit_tests
|
||||
test_main.cpp mini_catalog.cpp test_structure.cpp test_banks.cpp test_tech.cpp test_stats.cpp test_json.cpp)
|
||||
target_link_libraries(game_design_unit_tests PRIVATE game_design)
|
||||
target_include_directories(game_design_unit_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME game_design_unit COMMAND game_design_unit_tests)
|
||||
|
||||
add_executable(game_design_realdata_test stock_designs.cpp realdata_test.cpp)
|
||||
target_link_libraries(game_design_realdata_test PRIVATE game_design)
|
||||
target_include_directories(game_design_realdata_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME game_design_realdata COMMAND game_design_realdata_test) # SKIPs without SOTS_DATA_DIR + SOTS_DESIGNS_JSON
|
||||
|
||||
add_executable(game_design_dump_designs stock_designs.cpp dump_designs.cpp)
|
||||
target_link_libraries(game_design_dump_designs PRIVATE game_design)
|
||||
target_include_directories(game_design_dump_designs PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
75
tests/game_design/build_and_run.sh
Executable file
75
tests/game_design/build_and_run.sh
Executable file
|
|
@ -0,0 +1,75 @@
|
|||
#!/usr/bin/env bash
|
||||
# Self-contained build + test for src/game/design (plain g++, no CMake needed).
|
||||
#
|
||||
# tests/game_design/build_and_run.sh
|
||||
#
|
||||
# Always builds and runs the unit tests (hand-written mini catalog). When
|
||||
# SOTS_DATA_DIR points at an extracted sots.gob tree AND SOTS_DESIGNS_JSON at
|
||||
# the owner's stock_designs.json (designs decoded from real saves) it also
|
||||
# runs the real-data test (every save design must validate, DOpts must equal
|
||||
# applied_techs, tech gating must show exactly the hidden-rider warnings),
|
||||
# dumps our findings per design and, if the reference validator is reachable
|
||||
# (SOTS_DESIGN_RULES_DIR, default ~/sots-re/verify/design-rules), diffs them
|
||||
# design by design with oracle/compare.py.
|
||||
#
|
||||
# Env: CXX (g++), PYTHON (python3), BUILD_DIR (tests/game_design/build),
|
||||
# SOTS_DATA_DIR, SOTS_DESIGNS_JSON, SOTS_DESIGN_RULES_DIR.
|
||||
set -euo pipefail
|
||||
|
||||
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
ROOT=$(cd "$HERE/../.." && pwd)
|
||||
BUILD=${BUILD_DIR:-$HERE/build}
|
||||
CXX=${CXX:-g++}
|
||||
PYTHON=${PYTHON:-python3}
|
||||
FLAGS=(-std=c++17 -O2 -Wall -Wextra -Wpedantic -Werror -I"$ROOT/src" -I"$HERE")
|
||||
LIB=(
|
||||
"$ROOT/src/mars/parse/blocks.cpp" "$ROOT/src/mars/parse/value.cpp"
|
||||
"$ROOT/src/mars/text/value.cpp" "$ROOT/src/mars/text/flat_kv.cpp"
|
||||
"$ROOT/src/mars/text/manifest.cpp" "$ROOT/src/mars/text/csv.cpp"
|
||||
"$ROOT/src/game/data/common.cpp" "$ROOT/src/game/data/block.cpp"
|
||||
"$ROOT/src/game/data/weapon.cpp" "$ROOT/src/game/data/shipsection.cpp"
|
||||
"$ROOT/src/game/data/turrets.cpp" "$ROOT/src/game/data/techtree.cpp"
|
||||
"$ROOT/src/game/data/strings.cpp" "$ROOT/src/game/data/catalog.cpp"
|
||||
"$ROOT/src/game/design/design.cpp" "$ROOT/src/game/design/rules.cpp"
|
||||
"$ROOT/src/game/design/stats.cpp" "$ROOT/src/game/design/defaults.cpp"
|
||||
)
|
||||
|
||||
mkdir -p "$BUILD"
|
||||
|
||||
echo "== building unit tests"
|
||||
"$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/test_main.cpp" "$HERE/mini_catalog.cpp" "$HERE/test_structure.cpp" \
|
||||
"$HERE/test_banks.cpp" "$HERE/test_tech.cpp" "$HERE/test_stats.cpp" "$HERE/test_json.cpp" -o "$BUILD/unit_tests"
|
||||
echo "== running unit tests"
|
||||
"$BUILD/unit_tests"
|
||||
|
||||
if [ -z "${SOTS_DATA_DIR:-}" ] || [ -z "${SOTS_DESIGNS_JSON:-}" ]; then
|
||||
echo "== SOTS_DATA_DIR / SOTS_DESIGNS_JSON not set: skipping real-data tests"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -d "$SOTS_DATA_DIR" ]; then
|
||||
echo "== SOTS_DATA_DIR=$SOTS_DATA_DIR is not a directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$SOTS_DESIGNS_JSON" ]; then
|
||||
echo "== SOTS_DESIGNS_JSON=$SOTS_DESIGNS_JSON is not a file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== building real-data tools"
|
||||
"$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/stock_designs.cpp" "$HERE/realdata_test.cpp" -o "$BUILD/realdata_test"
|
||||
"$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/stock_designs.cpp" "$HERE/dump_designs.cpp" -o "$BUILD/dump_designs"
|
||||
|
||||
echo "== real-data: every save design through the rules"
|
||||
"$BUILD/realdata_test"
|
||||
|
||||
echo "== dumping findings"
|
||||
"$BUILD/dump_designs" "$SOTS_DATA_DIR" "$SOTS_DESIGNS_JSON" "$BUILD/designs.json"
|
||||
|
||||
ORACLE=${SOTS_DESIGN_RULES_DIR:-$HOME/sots-re/verify/design-rules}
|
||||
if [ ! -f "$ORACLE/design_rules.py" ]; then
|
||||
echo "== reference validator not found at $ORACLE (set SOTS_DESIGN_RULES_DIR): skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
echo "== comparing with the reference validator"
|
||||
read -ra PY <<< "$PYTHON" # PYTHON may be a command with arguments ("uv run python3")
|
||||
exec "${PY[@]}" "$HERE/oracle/compare.py" "$BUILD/designs.json" "$SOTS_DESIGNS_JSON" "$ORACLE"
|
||||
122
tests/game_design/dump_designs.cpp
Normal file
122
tests/game_design/dump_designs.cpp
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// dump_designs <data-root> <stock_designs.json> <out.json>
|
||||
//
|
||||
// Runs every save design through the rules and writes, per design, the
|
||||
// findings as (rule, level, slot, bank), applied_techs per slot and the
|
||||
// derived stats -- the input of oracle/compare.py.
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/data/catalog.h"
|
||||
#include "game/design/rules.h"
|
||||
#include "game/design/stats.h"
|
||||
#include "json_min.h"
|
||||
#include "stock_designs.h"
|
||||
|
||||
using namespace game::data;
|
||||
using namespace game::design;
|
||||
using json_min::quote;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string num(double v) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof buf, "%.17g", v);
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string str_list(const std::vector<std::string>& v) {
|
||||
std::string s = "[";
|
||||
for (std::size_t i = 0; i < v.size(); ++i) s += (i ? "," : "") + quote(v[i]);
|
||||
return s + "]";
|
||||
}
|
||||
|
||||
std::string opt_num(const std::optional<double>& v) { return v ? num(*v) : "null"; }
|
||||
std::string opt_int(const std::optional<std::int64_t>& v) { return v ? std::to_string(*v) : "null"; }
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 4) {
|
||||
std::fprintf(stderr, "usage: dump_designs <data-root> <stock_designs.json> <out.json>\n");
|
||||
return 2;
|
||||
}
|
||||
Catalog cat = load_catalog(argv[1]);
|
||||
std::vector<StockDesign> designs;
|
||||
std::string err;
|
||||
if (!load_stock_designs(argv[2], designs, err)) {
|
||||
std::fprintf(stderr, "%s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
Ruleset rules(cat);
|
||||
std::ofstream out(argv[3], std::ios::binary);
|
||||
if (!out) {
|
||||
std::fprintf(stderr, "cannot write %s\n", argv[3]);
|
||||
return 1;
|
||||
}
|
||||
out << "[\n";
|
||||
bool first = true;
|
||||
for (StockDesign& sd : designs) {
|
||||
Design& d = sd.design;
|
||||
rules.strip_applied_techs(d);
|
||||
std::vector<Violation> v = rules.validate(d);
|
||||
DesignStats st = derive_stats(rules, d);
|
||||
out << (first ? "" : ",\n") << "{\"player_index\":" << sd.player_index << ",\"design_index\":" << sd.design_index
|
||||
<< ",\"save\":" << quote(sd.save) << ",\"player\":" << quote(sd.player) << ",\"name\":" << quote(d.name)
|
||||
<< ",\"race\":" << quote(std::string(species_name(d.effective_race()))) << ",\"hidden\":" << (d.hidden ? "true" : "false");
|
||||
out << ",\"violations\":[";
|
||||
for (std::size_t i = 0; i < v.size(); ++i) {
|
||||
const Violation& x = v[i];
|
||||
out << (i ? "," : "") << "{\"rule\":" << quote(x.rule) << ",\"level\":" << quote(std::string(level_name(x.level)))
|
||||
<< ",\"slot\":" << (x.slot ? quote(std::string(slot_name(*x.slot))) : "null")
|
||||
<< ",\"bank\":" << (x.bank ? std::to_string(*x.bank) : "null") << ",\"message\":" << quote(x.message) << "}";
|
||||
}
|
||||
out << "],\"applied_techs\":{";
|
||||
bool fa = true;
|
||||
for (const SectionStats& s : st.sections) {
|
||||
out << (fa ? "" : ",") << quote(std::string(slot_name(s.slot))) << ":" << str_list(s.applied_techs);
|
||||
fa = false;
|
||||
}
|
||||
out << "},\"options\":{";
|
||||
fa = true;
|
||||
for (Slot s : kUsedSlots) {
|
||||
if (d.slot(s).empty()) continue;
|
||||
out << (fa ? "" : ",") << quote(std::string(slot_name(s))) << ":" << str_list(d.slot(s).options);
|
||||
fa = false;
|
||||
}
|
||||
out << "},\"stats\":{"
|
||||
<< "\"hull_class\":" << quote(st.hull_class) << ",\"mass\":" << num(st.mass) << ",\"section_cost\":" << num(st.section_cost)
|
||||
<< ",\"section_cost_with_options\":" << num(st.section_cost_with_options)
|
||||
<< ",\"weapon_cost_per_bank\":" << num(st.weapon_cost_per_bank) << ",\"weapon_cost_per_mount\":" << num(st.weapon_cost_per_mount)
|
||||
<< ",\"total_cost_estimate\":" << num(st.total_cost_estimate) << ",\"health_total\":" << num(st.health_total)
|
||||
<< ",\"crew\":" << st.crew << ",\"cpoints\":" << st.cpoints << ",\"banks\":" << st.banks << ",\"turrets\":" << st.turrets
|
||||
<< ",\"command_cost\":" << st.command_cost << ",\"maintenance_cost\":" << st.maintenance_cost
|
||||
<< ",\"command_quota\":" << opt_int(st.command_quota) << ",\"ftlspeed\":" << opt_num(st.ftlspeed)
|
||||
<< ",\"nodespeed\":" << opt_num(st.nodespeed) << ",\"engine_techera\":" << quote(st.engine_techera)
|
||||
<< ",\"capacities\":{";
|
||||
for (std::size_t i = 0; i < st.capacities.size(); ++i)
|
||||
out << (i ? "," : "") << quote(st.capacities[i].first) << ":" << quote(st.capacities[i].second);
|
||||
out << "},\"sections\":{";
|
||||
fa = true;
|
||||
for (const SectionStats& s : st.sections) {
|
||||
out << (fa ? "" : ",") << quote(std::string(slot_name(s.slot))) << ":{\"section\":" << quote(s.section->stem)
|
||||
<< ",\"mass\":" << num(s.mass) << ",\"cost\":" << num(s.cost) << ",\"option_cost_multiplier\":" << num(s.option_cost_multiplier)
|
||||
<< ",\"health\":" << num(s.health) << ",\"crew\":" << s.crew << ",\"cpoints\":" << s.cpoints << ",\"banks\":[";
|
||||
for (std::size_t i = 0; i < s.banks.size(); ++i) {
|
||||
const BankStats& b = s.banks[i];
|
||||
out << (i ? "," : "") << "{\"size\":" << quote(fold(b.size)) << ",\"class\":" << quote(fold(b.turret_class))
|
||||
<< ",\"mounts\":" << b.mounts << ",\"weapon\":" << (b.weapon ? quote(b.weapon->stem) : "null")
|
||||
<< ",\"weapon_cost\":" << num(b.weapon_cost) << ",\"turret_model\":" << (b.turret ? quote(b.turret->model) : "null")
|
||||
<< ",\"turret_health\":" << (b.turret ? opt_num(b.turret->health) : "null") << "}";
|
||||
}
|
||||
out << "]}";
|
||||
fa = false;
|
||||
}
|
||||
out << "}}}";
|
||||
first = false;
|
||||
}
|
||||
out << "\n]\n";
|
||||
std::printf("wrote %zu designs to %s\n", designs.size(), argv[3]);
|
||||
return 0;
|
||||
}
|
||||
236
tests/game_design/json_min.h
Normal file
236
tests/game_design/json_min.h
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// A minimal JSON reader for the test tools (reads the owner's designs JSON).
|
||||
// Header-only, no dependencies, no exceptions: parse() returns false and an
|
||||
// error message on malformed input. Numbers are kept as double plus the text.
|
||||
#pragma once
|
||||
|
||||
#include <cstdlib>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace json_min {
|
||||
|
||||
struct Value {
|
||||
enum class Kind { Null, Bool, Number, String, Array, Object };
|
||||
Kind kind = Kind::Null;
|
||||
bool b = false;
|
||||
double num = 0;
|
||||
std::string text; // string value or number text
|
||||
std::vector<Value> items; // array
|
||||
std::vector<std::pair<std::string, Value>> members; // object, file order
|
||||
|
||||
bool is_null() const { return kind == Kind::Null; }
|
||||
bool is_string() const { return kind == Kind::String; }
|
||||
bool is_number() const { return kind == Kind::Number; }
|
||||
bool is_array() const { return kind == Kind::Array; }
|
||||
bool is_object() const { return kind == Kind::Object; }
|
||||
bool is_bool() const { return kind == Kind::Bool; }
|
||||
|
||||
const Value* get(std::string_view key) const {
|
||||
for (const auto& m : members)
|
||||
if (m.first == key) return &m.second;
|
||||
return nullptr;
|
||||
}
|
||||
const Value& operator[](std::string_view key) const {
|
||||
static const Value null;
|
||||
const Value* v = get(key);
|
||||
return v ? *v : null;
|
||||
}
|
||||
const std::string& str() const { return text; }
|
||||
int as_int() const { return static_cast<int>(num); }
|
||||
bool as_bool() const { return kind == Kind::Bool ? b : (kind == Kind::Number ? num != 0 : false); }
|
||||
};
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(std::string_view s) : s_(s) {}
|
||||
|
||||
bool parse(Value& out, std::string& err) {
|
||||
skip();
|
||||
if (!value(out, err)) return false;
|
||||
skip();
|
||||
if (pos_ != s_.size()) return fail(err, "trailing characters");
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string_view s_;
|
||||
std::size_t pos_ = 0;
|
||||
|
||||
bool fail(std::string& err, const char* what) {
|
||||
err = std::string(what) + " at offset " + std::to_string(pos_);
|
||||
return false;
|
||||
}
|
||||
void skip() {
|
||||
while (pos_ < s_.size() && (s_[pos_] == ' ' || s_[pos_] == '\n' || s_[pos_] == '\r' || s_[pos_] == '\t')) ++pos_;
|
||||
}
|
||||
bool literal(const char* lit) {
|
||||
std::string_view l(lit);
|
||||
if (s_.substr(pos_, l.size()) != l) return false;
|
||||
pos_ += l.size();
|
||||
return true;
|
||||
}
|
||||
static void put_utf8(std::string& out, unsigned cp) {
|
||||
if (cp < 0x80) {
|
||||
out += static_cast<char>(cp);
|
||||
} else if (cp < 0x800) {
|
||||
out += static_cast<char>(0xC0 | (cp >> 6));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
out += static_cast<char>(0xE0 | (cp >> 12));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
out += static_cast<char>(0xF0 | (cp >> 18));
|
||||
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
}
|
||||
}
|
||||
bool hex4(unsigned& cp) {
|
||||
if (pos_ + 4 > s_.size()) return false;
|
||||
cp = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
char c = s_[pos_++];
|
||||
cp <<= 4;
|
||||
if (c >= '0' && c <= '9') cp |= static_cast<unsigned>(c - '0');
|
||||
else if (c >= 'a' && c <= 'f') cp |= static_cast<unsigned>(c - 'a' + 10);
|
||||
else if (c >= 'A' && c <= 'F') cp |= static_cast<unsigned>(c - 'A' + 10);
|
||||
else return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool string(std::string& out, std::string& err) {
|
||||
if (pos_ >= s_.size() || s_[pos_] != '"') return fail(err, "expected string");
|
||||
++pos_;
|
||||
while (pos_ < s_.size()) {
|
||||
char c = s_[pos_++];
|
||||
if (c == '"') return true;
|
||||
if (c != '\\') {
|
||||
out += c;
|
||||
continue;
|
||||
}
|
||||
if (pos_ >= s_.size()) break;
|
||||
char e = s_[pos_++];
|
||||
switch (e) {
|
||||
case '"': out += '"'; break;
|
||||
case '\\': out += '\\'; break;
|
||||
case '/': out += '/'; break;
|
||||
case 'b': out += '\b'; break;
|
||||
case 'f': out += '\f'; break;
|
||||
case 'n': out += '\n'; break;
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
unsigned cp;
|
||||
if (!hex4(cp)) return fail(err, "bad \\u escape");
|
||||
if (cp >= 0xD800 && cp < 0xDC00 && literal("\\u")) {
|
||||
unsigned lo;
|
||||
if (!hex4(lo)) return fail(err, "bad surrogate");
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
|
||||
}
|
||||
put_utf8(out, cp);
|
||||
break;
|
||||
}
|
||||
default: return fail(err, "bad escape");
|
||||
}
|
||||
}
|
||||
return fail(err, "unterminated string");
|
||||
}
|
||||
bool value(Value& v, std::string& err) {
|
||||
if (pos_ >= s_.size()) return fail(err, "unexpected end");
|
||||
char c = s_[pos_];
|
||||
if (c == '{') {
|
||||
++pos_;
|
||||
v.kind = Value::Kind::Object;
|
||||
skip();
|
||||
if (literal("}")) return true;
|
||||
for (;;) {
|
||||
skip();
|
||||
std::string key;
|
||||
if (!string(key, err)) return false;
|
||||
skip();
|
||||
if (!literal(":")) return fail(err, "expected ':'");
|
||||
skip();
|
||||
Value m;
|
||||
if (!value(m, err)) return false;
|
||||
v.members.emplace_back(std::move(key), std::move(m));
|
||||
skip();
|
||||
if (literal(",")) continue;
|
||||
if (literal("}")) return true;
|
||||
return fail(err, "expected ',' or '}'");
|
||||
}
|
||||
}
|
||||
if (c == '[') {
|
||||
++pos_;
|
||||
v.kind = Value::Kind::Array;
|
||||
skip();
|
||||
if (literal("]")) return true;
|
||||
for (;;) {
|
||||
skip();
|
||||
Value item;
|
||||
if (!value(item, err)) return false;
|
||||
v.items.push_back(std::move(item));
|
||||
skip();
|
||||
if (literal(",")) continue;
|
||||
if (literal("]")) return true;
|
||||
return fail(err, "expected ',' or ']'");
|
||||
}
|
||||
}
|
||||
if (c == '"') {
|
||||
v.kind = Value::Kind::String;
|
||||
return string(v.text, err);
|
||||
}
|
||||
if (literal("null")) return true;
|
||||
if (literal("true")) {
|
||||
v.kind = Value::Kind::Bool;
|
||||
v.b = true;
|
||||
return true;
|
||||
}
|
||||
if (literal("false")) {
|
||||
v.kind = Value::Kind::Bool;
|
||||
v.b = false;
|
||||
return true;
|
||||
}
|
||||
std::size_t start = pos_;
|
||||
while (pos_ < s_.size()) {
|
||||
char d = s_[pos_];
|
||||
if ((d >= '0' && d <= '9') || d == '-' || d == '+' || d == '.' || d == 'e' || d == 'E') ++pos_;
|
||||
else break;
|
||||
}
|
||||
if (start == pos_) return fail(err, "unexpected character");
|
||||
v.kind = Value::Kind::Number;
|
||||
v.text = std::string(s_.substr(start, pos_ - start));
|
||||
v.num = std::strtod(v.text.c_str(), nullptr);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
inline bool parse(std::string_view text, Value& out, std::string& err) { return Parser(text).parse(out, err); }
|
||||
|
||||
// Writer helpers for the dump tool.
|
||||
inline std::string quote(std::string_view s) {
|
||||
std::string out = "\"";
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof buf, "\\u%04x", c);
|
||||
out += buf;
|
||||
} else {
|
||||
out += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out + "\"";
|
||||
}
|
||||
|
||||
} // namespace json_min
|
||||
305
tests/game_design/mini_catalog.cpp
Normal file
305
tests/game_design/mini_catalog.cpp
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
#include "mini_catalog.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
using namespace game::data;
|
||||
using namespace game::design;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
void put(const fs::path& p, const std::string& text) {
|
||||
fs::create_directories(p.parent_path());
|
||||
std::ofstream(p, std::ios::binary) << text;
|
||||
}
|
||||
|
||||
// --- sections ---------------------------------------------------------------
|
||||
|
||||
std::string bank(const char* cls, const char* size, int mounts, const char* fixed = nullptr) {
|
||||
std::string s = " bank {";
|
||||
if (fixed) s += std::string(" weapon \"") + fixed + "\"";
|
||||
if (cls) s += std::string(" turretclass ") + cls;
|
||||
if (size) s += std::string(" turretsize ") + size;
|
||||
for (int i = 0; i < mounts; ++i) s += " mount { node N" + std::to_string(i) + " min_azimuth -90 max_azimuth 90 }";
|
||||
return s + " }\n";
|
||||
}
|
||||
|
||||
const char* kArmorOptions = " option { option IND_PlyAlloy option IND_MagLat option IND_QrkRes } option { option IND_RefCoat option IND_ImpRfCt }\n";
|
||||
|
||||
std::string de_command(const char* extra = "") {
|
||||
return "shipsection { model a.X section_type command section_class destroyer socket_aft CommandNode\n"
|
||||
" health 300 mass 800 cost 2000 cpoints 500 command_cost 1\n" + std::string(kArmorOptions) + extra +
|
||||
bank("standard", "small", 1) + "}\n";
|
||||
}
|
||||
|
||||
std::string de_armor(const char* extra = "") {
|
||||
return "shipsection { model b.X section_type mission section_class destroyer socket_fore MissionNode01 socket_aft MissionNode\n"
|
||||
" health 600 mass 2000 cost 4000 cpoints 1000\n" + std::string(kArmorOptions) +
|
||||
" optiondef { option SLD_MkOne option SLD_MkTwo }\n" + extra +
|
||||
bank("standard", "small", 3) + bank("standard", "small", 3) + bank("standard", "medium", 1) + "}\n";
|
||||
}
|
||||
|
||||
std::string de_fission(const char* drive) {
|
||||
return "shipsection { model c.X Section_Type Engine section_class Destroyer socket_fore EngineNode\n"
|
||||
" requires DRV_Fissn requires " + std::string(drive) +
|
||||
"\n health 450 mass 2500 cost 5000 cpoints 1380 engine_techera fission ftlspeed .2 nodespeed 4 range 9\n" +
|
||||
std::string(kArmorOptions) + " option DRV_RecFiss\n"
|
||||
" netforcelimits { force_forward 23000 force_right 23000 force_up 23000 torque_yaw 5 torque_pitch 5 torque_roll 5 speed 40 rotspeed 10 }\n" +
|
||||
bank("standard", "small", 1) + "}\n";
|
||||
}
|
||||
|
||||
std::string de_platform() {
|
||||
return "shipsection { model d.X section_type mission section_class destroyer\n"
|
||||
" health 1500 mass 5000 cost 6000 cpoints 900 defence_platform true scanrange 12 command_quota 20\n"
|
||||
" ftlspeed 0 nodespeed 0\n" + std::string(kArmorOptions) +
|
||||
bank("standard", "small", 2) + bank("standard", "small", 2) + bank("standard", "medium", 1) + "}\n";
|
||||
}
|
||||
|
||||
const char* kRider = "shipsection { model r.X section_class destroyer design_class rider entity_class rider health 50 mass 100 cost 100 }\n";
|
||||
const char* kRiderGated =
|
||||
"shipsection { model r.X section_class destroyer design_class rider entity_class rider requires DRN_AdvFrm health 50 mass 100 cost 100 }\n";
|
||||
|
||||
void write_human(const fs::path& r) {
|
||||
fs::path s = r / "Species/Human/sections";
|
||||
put(s / "DECommand.shipsection", de_command());
|
||||
put(s / "DEArmor.shipsection", de_armor());
|
||||
put(s / "DEFission.shipsection", de_fission("DRV_Node"));
|
||||
put(s / "DEHyperTest.shipsection", de_fission("DRV_Hyper")); // C5: Human can never research DRV_Hyper
|
||||
put(s / "DEOddTech.shipsection", de_fission("XXX_Nowhere")); // C1 warn: unknown tech
|
||||
put(s / "DEDefencePlatform.shipsection", de_platform());
|
||||
put(s / "DERamScoop.shipsection", de_command(" exclude \"DEFission\" ramscoop true\n"));
|
||||
put(s / "DEStrafe.shipsection",
|
||||
"shipsection { model e.X section_type mission section_class destroyer socket_fore F socket_aft A health 1 mass 1 cost 1\n" +
|
||||
bank("strafe", "small", 4) + bank("standard", "large", 1) + "}\n");
|
||||
put(s / "DETorpedo.shipsection",
|
||||
"shipsection { model e.X section_type mission section_class destroyer socket_fore F socket_aft A health 1 mass 1 cost 1\n"
|
||||
" requires GRP_Torps\n" + bank("torpedo", "large", 1) + bank("missile", "medium", 1) + "}\n");
|
||||
put(s / "DEAssaultShuttle.shipsection",
|
||||
"shipsection { model e.X section_type mission section_class destroyer socket_fore F socket_aft A health 1 mass 1 cost 1\n"
|
||||
" requires DRN_AdvFrm\n" + bank("assaultshuttlerider", "large", 1) + bank("tarkahunter", "large", 1) + "}\n");
|
||||
put(s / "DEPlanetMissile.shipsection",
|
||||
"shipsection { model e.X section_type mission section_class destroyer socket_fore F socket_aft A health 1 mass 1 cost 1\n" +
|
||||
bank("planetmissile", "large", 1) + "}\n");
|
||||
put(s / "CRCommand.shipsection",
|
||||
"shipsection { model f.X section_type command section_class cruiser socket_aft CommandNode health 1 mass 10 cost 100 crew 5\n" +
|
||||
bank("standard", "medium", 1) + "}\n");
|
||||
put(s / "CRArmor.shipsection",
|
||||
"shipsection { model f.X section_type mission section_class cruiser socket_fore F socket_aft A health 1 mass 20 cost 200 crew 10\n" +
|
||||
bank("standard", "large", 2) + "}\n");
|
||||
put(s / "CRFission.shipsection",
|
||||
"shipsection { model f.X section_type engine section_class cruiser socket_fore EngineNode health 1 mass 30 cost 300 crew 15\n"
|
||||
" requires DRV_Fissn requires DRV_Node engine_techera fission ftlspeed .2 nodespeed 4\n" +
|
||||
bank("standard", "medium", 1) + "}\n");
|
||||
// the station triple (design_class station: no hull-class tech; partners pinned)
|
||||
put(s / "DNStation.shipsection",
|
||||
"shipsection { model g.X section_type mission section_class dreadnought socket_fore Fore socket_aft Aft\n"
|
||||
" requires IND_OrbCom health 9000 mass 100000 cost 700000 crew 100 entity_class \"Station\" design_class station command_quota 46\n"
|
||||
" explicit_command_section \"DNStation_Fore\" explicit_engine_section \"DNStation_Aft\"\n" +
|
||||
bank("standard", "large", 2) + "}\n");
|
||||
put(s / "DNStation_Fore.shipsection",
|
||||
"shipsection { model g.X section_type command section_class dreadnought socket_aft Aft explicit_section true\n"
|
||||
" design_class station health 1 mass 1 cost 1\n" + bank("standard", "medium", 1) + "}\n");
|
||||
put(s / "DNStation_Aft.shipsection",
|
||||
"shipsection { model g.X section_type engine section_class dreadnought socket_fore Fore explicit_section true\n"
|
||||
" design_class station health 1 mass 1 cost 1\n" + bank("standard", "medium", 1) + "}\n");
|
||||
put(s / "DNCommand.shipsection",
|
||||
"shipsection { model g.X section_type command section_class dreadnought socket_aft CommandNode health 1 mass 1 cost 1\n" +
|
||||
bank("standard", "medium", 1) + "}\n");
|
||||
put(s / "_AssaultShuttle.shipsection", kRider);
|
||||
put(s / "_shipsections.txt",
|
||||
"1 _AssaultShuttle.shipsection\n37 DEArmor.shipsection\n42 DECommand.shipsection\n47 DEFission.shipsection\n"
|
||||
"50 DEDefencePlatform.shipsection\n51 DERamScoop.shipsection\n52 DEStrafe.shipsection\n53 DETorpedo.shipsection\n"
|
||||
"54 DEAssaultShuttle.shipsection\n55 DEPlanetMissile.shipsection\n56 DEHyperTest.shipsection\n57 DEOddTech.shipsection\n"
|
||||
"60 CRCommand.shipsection\n61 CRArmor.shipsection\n62 CRFission.shipsection\n"
|
||||
"70 DNStation.shipsection\n71 DNStation_Fore.shipsection\n72 DNStation_Aft.shipsection\n73 DNCommand.shipsection\n");
|
||||
}
|
||||
|
||||
void write_tarkas(const fs::path& r) {
|
||||
fs::path s = r / "Species/Tarkas/sections";
|
||||
put(s / "DECommand.shipsection", de_command());
|
||||
put(s / "DEArmor.shipsection", de_armor());
|
||||
put(s / "DEFission.shipsection", de_fission("DRV_Hyper"));
|
||||
put(s / "DEDefencePlatform.shipsection", de_platform());
|
||||
put(s / "_AssaultShuttle.shipsection", kRiderGated);
|
||||
put(s / "_HunterShip.shipsection", kRider);
|
||||
put(s / "_shipsections.txt",
|
||||
"1 _AssaultShuttle.shipsection\n2 _HunterShip.shipsection\n37 DEArmor.shipsection\n42 DECommand.shipsection\n"
|
||||
"47 DEFission.shipsection\n50 DEDefencePlatform.shipsection\n");
|
||||
}
|
||||
|
||||
void write_zuul(const fs::path& r) {
|
||||
fs::path s = r / "Species/Zuul/sections";
|
||||
put(s / "DECommand.shipsection", de_command());
|
||||
put(s / "DEArmor.shipsection", de_armor());
|
||||
put(s / "DEFission.shipsection", de_fission("DRV_Node"));
|
||||
put(s / "_shipsections.txt", "37 DEArmor.shipsection\n42 DECommand.shipsection\n47 DEFission.shipsection\n");
|
||||
}
|
||||
|
||||
void write_npc(const fs::path& r) {
|
||||
fs::path s = r / "Species/_NPC/sections";
|
||||
// an NPC hull: typeless, standalone, fixed weapons in every bank, one bank without a turret spec
|
||||
put(s / "_Herald.shipsection",
|
||||
"shipsection { model h.X section_class cruiser health 5000 mass 20000 cost 1 crew false nodesign 1\n" +
|
||||
bank("standard", "medium", 2, "Species/_NPC/weapons/Herald_gun.weapon") +
|
||||
bank("strafe", "small", 1, "Species/_NPC/weapons/Herald_beam.weapon") +
|
||||
bank(nullptr, nullptr, 1, "Species/_NPC/weapons/Herald_gun.weapon") + "}\n");
|
||||
// an NPC destroyer triple (nodesign, typed, sockets present, player-style banks)
|
||||
put(s / "_DECommand.shipsection", de_command(" nodesign 1\n"));
|
||||
put(s / "_DEMission.shipsection", de_armor(" nodesign true\n"));
|
||||
put(s / "_DEEngine.shipsection", de_fission("DRV_Node"));
|
||||
put(s / "_shipsections.txt", "1 _Herald.shipsection\n2 _DECommand.shipsection\n3 _DEMission.shipsection\n4 _DEEngine.shipsection\n");
|
||||
put(r / "Species/_NPC/weapons/Herald_gun.weapon",
|
||||
"weapon { name @WEAPON_HERALD_GUN weaponclass bullet turretsize medium turretclass standard hidden 1 cost 7 }\n");
|
||||
put(r / "Species/_NPC/weapons/Herald_beam.weapon",
|
||||
"weapon { name @WEAPON_HERALD_BEAM weaponclass beam turretsize small turretclass standard cost 9 }\n");
|
||||
}
|
||||
|
||||
// --- weapons -----------------------------------------------------------------
|
||||
|
||||
std::string weapon(const char* cls, const char* size, const char* tcls, const std::string& extra, int cost) {
|
||||
return std::string("weapon { name @W weaponclass ") + cls + " turretsize " + size + " turretclass " + tcls + " cost " +
|
||||
std::to_string(cost) + " " + extra + " }\n";
|
||||
}
|
||||
|
||||
void write_weapons(const fs::path& r) {
|
||||
fs::path w = r / "Weapons";
|
||||
put(w / "bal_gauss.weapon", weapon("bullet", "small", "standard", "requires WEP_GsDrvr", 50));
|
||||
put(w / "las_red.weapon", weapon("beam", "small", "standard", "requires WEP_RedLas", 60));
|
||||
put(w / "las_pd.weapon", weapon("beam", "tiny", "standard", "requires WEP_PD", 40));
|
||||
put(w / "can_plasma.weapon", weapon("bullet", "large", "standard", "requires WEP_Plasma", 400));
|
||||
put(w / "mis.weapon", weapon("missile", "medium", "Missile", "", 3000));
|
||||
put(w / "mis_defplat.weapon", weapon("missile", "medium", "PlanetMissile", "", 3500));
|
||||
put(w / "mis_planet.weapon", weapon("missile", "medium", "PlanetMissile", "hidden 1", 1));
|
||||
put(w / "trp_photon.weapon", weapon("torpedo", "large", "torpedo", "requires WEP_PhotTrp", 900));
|
||||
put(w / "bal_grapple.weapon", weapon("grapple", "medium", "grapple", "requires WEP_Grapple exclusive_species zuul", 500));
|
||||
put(w / "brd_shuttle.weapon", weapon("rider", "large", "assaultshuttlerider", "requires DRN_AdvFrm compatible_section _AssaultShuttle", 800));
|
||||
put(w / "brd_tarkahunter.weapon",
|
||||
weapon("rider", "large", "tarkahunter", "requires DRN_AdvFrm compatible_section _HunterShip compatible_section _HunterShip_B", 800));
|
||||
put(w / "hvy_bem_lancer.weapon", weapon("beam", "large", "beam", "requires GRP_HvyBeam", 1200));
|
||||
put(w / "_weapons.txt",
|
||||
"8 bal_gauss.weapon\n9 las_red.weapon\n10 las_pd.weapon\n11 can_plasma.weapon\n21 mis.weapon\n22 mis_defplat.weapon\n"
|
||||
"23 mis_planet.weapon\n30 trp_photon.weapon\n31 bal_grapple.weapon\n32 brd_shuttle.weapon\n33 brd_tarkahunter.weapon\n"
|
||||
"34 hvy_bem_lancer.weapon\n");
|
||||
put(w / "_turrets.txt",
|
||||
"// size weapon-size class health track az inc model\n"
|
||||
"small tiny standard 100 400 1 1 \"t_s_tiny.x\"\n"
|
||||
"small small standard 120 320 1 1 \"t_s.x\"\n"
|
||||
"medium small standard 300 200 1 1 \"t_m_s.x\"\n"
|
||||
"medium medium standard 500 140 1 1 \"t_m.x\"\n"
|
||||
"large small standard 600 200 1 1 \"t_l_s.x\"\n"
|
||||
"large medium standard 700 140 1 1 \"t_l_m.x\"\n"
|
||||
"Large large standard 900 80 1 1 \"t_l.x\"\n"
|
||||
"medium medium Missile 400 100 1 0.25 \"turret_m1missile.x\"\n"
|
||||
"large medium missile 450 100 1 0.25 \"turret_l1missile.x\"\n"
|
||||
"large large missile 500 100 1 0.25 \"turret_l2missile.x\"\n"
|
||||
"large medium planetmissile 500 100 1 0.25 \"turret_pm.x\"\n"
|
||||
"large large torpedo 800 60 1 1 \"t_trp.x\"\n"
|
||||
"large large beam 1500 80 1 1 \"\"\n"
|
||||
"medium medium grapple 500 140 1 1 \"t_grap.x\"\n"
|
||||
"large medium grapple 700 140 1 1 \"t_grap_l.x\"\n"
|
||||
"small small strafe 120 320 1 1 \"t_s.x\"\n"
|
||||
"medium medium strafe 500 140 1 1 \"t_m.x\"\n"
|
||||
"large large assaultshuttlerider 900 80 1 1 \"t_rider.x\"\n"
|
||||
"large large tarkahunter 900 80 1 1 \"t_rider.x\"\n");
|
||||
put(w / "_defaultweapons.txt",
|
||||
"// <mount-class> <mount-size> <weapon-file>\n"
|
||||
"standard tiny \"las_pd.weapon\"\nstandard small \"las_red.weapon\"\nstandard medium \"las_red.weapon\"\n"
|
||||
"standard large \"can_plasma.weapon\"\nmissile medium \"mis.weapon\"\nstrafe small \"las_red.weapon\"\n"
|
||||
"torpedo large \"trp_photon.weapon\"\n");
|
||||
}
|
||||
|
||||
// --- tech tree --------------------------------------------------------------
|
||||
|
||||
void write_tech(const fs::path& r) {
|
||||
put(r / "TechTree/MasterTechList.tech",
|
||||
"tech { name \"IND_ROOT\" allows \"IND_PlyAlloy RP:1\" allows \"IND_CruisCon RP:1\" allows \"IND_OrbCom RP:1\" allows \"IND_RefCoat RP:1\" }\n"
|
||||
"tech { name \"IND_PlyAlloy\" option_cost 1.2 allows \"IND_MagLat RP:1\" }\n"
|
||||
"tech { name \"IND_MagLat\" option_cost 1.4 allows \"IND_QrkRes RP:1\" }\n"
|
||||
"tech { name \"IND_QrkRes\" option_cost 1.6 }\n"
|
||||
"tech { name \"IND_RefCoat\" option_cost 1.2 allows \"IND_ImpRfCt RP:1\" }\n"
|
||||
"tech { name \"IND_ImpRfCt\" option_cost 1.5 }\n"
|
||||
"tech { name \"IND_CruisCon\" allows \"IND_DreadCon RP:1\" }\n"
|
||||
"tech { name \"IND_DreadCon\" }\n"
|
||||
"tech { name \"IND_OrbCom\" }\n"
|
||||
"tech { name \"DRV_ROOT\" allows \"DRV_Fissn RP:1\" }\n"
|
||||
"tech { name \"DRV_Fissn\" allows \"DRV_Node RP:1 Human:100 Hiver:0 Tarkas:0 Liir:0 Zuul:100 Morrigi:0\"\n"
|
||||
" allows \"DRV_Hyper RP:1 Human:0 Hiver:0 Tarkas:100 Liir:0 Zuul:0 Morrigi:0\" allows \"DRV_RecFiss RP:1\" }\n"
|
||||
"tech { name \"DRV_Node\" }\n"
|
||||
"tech { name \"DRV_Hyper\" }\n"
|
||||
"tech { name \"DRV_RecFiss\" option_cost 1.2 }\n"
|
||||
"tech { name \"WEP_ROOT\" allows \"WEP_GsDrvr RP:1\" allows \"WEP_RedLas RP:1\" allows \"WEP_Plasma RP:1\" allows \"WEP_Grapple RP:1\" }\n"
|
||||
"tech { name \"WEP_GsDrvr\" }\n"
|
||||
"tech { name \"WEP_RedLas\" allows \"WEP_PD RP:1\" allows \"WEP_HvyLancer RP:1\" }\n"
|
||||
"tech { name \"WEP_PD\" group PD }\n"
|
||||
"tech { name \"WEP_Plasma\" }\n"
|
||||
"tech { name \"WEP_Grapple\" }\n"
|
||||
"tech { name \"WEP_HvyLancer\" group HvyBeam }\n"
|
||||
"tech { name \"TRP_ROOT\" allows \"WEP_PhotTrp RP:1\" allows \"WEP_PlasTrp RP:1\" }\n"
|
||||
"tech { name \"WEP_PhotTrp\" group TORPS }\n"
|
||||
"tech { name \"WEP_PlasTrp\" group TORPS }\n"
|
||||
"tech { name \"DRN_ROOT\" allows \"DRN_AdvFrm RP:1\" }\n"
|
||||
"tech { name \"DRN_AdvFrm\" }\n"
|
||||
"tech { name \"SLD_Root\" allows \"SLD_MkOne RP:1\" }\n"
|
||||
"tech { name \"SLD_MkOne\" option_cost 1.4 allows \"SLD_MkTwo RP:1\" }\n"
|
||||
"tech { name \"SLD_MkTwo\" option_cost 1.8 }\n");
|
||||
}
|
||||
|
||||
fs::path make_root() {
|
||||
auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
|
||||
fs::path root = fs::temp_directory_path() / ("game_design_test_" + std::to_string(static_cast<long long>(stamp)));
|
||||
fs::remove_all(root);
|
||||
write_weapons(root);
|
||||
write_human(root);
|
||||
write_tarkas(root);
|
||||
write_zuul(root);
|
||||
write_npc(root);
|
||||
write_tech(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const Catalog& mini_catalog() {
|
||||
static const Catalog cat = [] {
|
||||
fs::path root = make_root();
|
||||
Catalog c = load_catalog(root);
|
||||
fs::remove_all(root);
|
||||
return c;
|
||||
}();
|
||||
return cat;
|
||||
}
|
||||
|
||||
const Ruleset& mini_rules() {
|
||||
static const Ruleset rules(mini_catalog());
|
||||
return rules;
|
||||
}
|
||||
|
||||
SectionUse use(std::string stem, std::vector<std::string> weapons, std::vector<std::string> options) {
|
||||
SectionUse u;
|
||||
u.section = std::move(stem);
|
||||
for (std::string& w : weapons) u.weapons.push_back(w.empty() ? WeaponRef::none() : WeaponRef::by_stem(std::move(w)));
|
||||
u.options = std::move(options);
|
||||
return u;
|
||||
}
|
||||
|
||||
Design armor(Species race) {
|
||||
Design d;
|
||||
d.name = "Armor";
|
||||
d.race = race;
|
||||
d.command() = use("DECommand", {"bal_gauss"});
|
||||
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"});
|
||||
d.engine() = use("DEFission", {"bal_gauss"});
|
||||
d.known_techs = std::vector<std::string>{"DRV_Fissn", race == Species::Tarkas ? "DRV_Hyper" : "DRV_Node", "WEP_GsDrvr"};
|
||||
return d;
|
||||
}
|
||||
|
||||
std::vector<std::string> rules_of(const Design& d, Level level) { return rules_at(mini_rules().validate(d), level); }
|
||||
std::vector<std::string> error_rules(const Design& d) { return rules_of(d, Level::Error); }
|
||||
|
||||
std::string join(const std::vector<std::string>& v) {
|
||||
std::string s;
|
||||
for (const std::string& x : v) s += (s.empty() ? "" : ",") + x;
|
||||
return s;
|
||||
}
|
||||
22
tests/game_design/mini_catalog.h
Normal file
22
tests/game_design/mini_catalog.h
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// A hand-written miniature data root (four species catalogs, a dozen weapons,
|
||||
// a turret table and a tech tree) that exercises every design rule. Written
|
||||
// to a temp directory once and loaded through game::data::load_catalog so the
|
||||
// tests see exactly what the real loader produces.
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/data/catalog.h"
|
||||
#include "game/design/design.h"
|
||||
#include "game/design/rules.h"
|
||||
|
||||
const game::data::Catalog& mini_catalog();
|
||||
const game::design::Ruleset& mini_rules();
|
||||
|
||||
// Design builders on the mini catalog.
|
||||
game::design::SectionUse use(std::string stem, std::vector<std::string> weapons = {}, std::vector<std::string> options = {});
|
||||
game::design::Design armor(game::data::Species race = game::data::Species::Human); // the stock starting destroyer, gated
|
||||
std::vector<std::string> error_rules(const game::design::Design& d);
|
||||
std::vector<std::string> rules_of(const game::design::Design& d, game::design::Level level);
|
||||
std::string join(const std::vector<std::string>& v);
|
||||
158
tests/game_design/oracle/compare.py
Normal file
158
tests/game_design/oracle/compare.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env python3
|
||||
"""compare.py <ours.json> <stock_designs.json> <design-rules-dir>
|
||||
|
||||
Runs the reference validator (design_rules.py from the RE repo) on the same
|
||||
designs the C++ dump was produced from and diffs, design by design:
|
||||
|
||||
* the set of findings as (rule, level, slot, bank) -- must be identical;
|
||||
* applied_techs per slot -- must be identical (case-insensitive);
|
||||
* the chosen options per slot -- must be identical;
|
||||
* derived stats: mass, costs, health, crew, cpoints, banks, turrets,
|
||||
command/maintenance cost, command quota, drive figures, capacities, and
|
||||
per-section/per-bank figures -- numbers numerically, bools as 0/1.
|
||||
|
||||
The reference loads its catalogs from SOTS_CATALOG_DIR (default: the RE
|
||||
repo's verify/results/data-catalogs). Exit 1 on any difference.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
MAX_DIFFS = 40
|
||||
|
||||
|
||||
def norm_val(v):
|
||||
if v is None or v == "":
|
||||
return None # an absent string field is "" on our side, None on the reference's
|
||||
if isinstance(v, bool):
|
||||
return 1 if v else 0
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if s.lower() in ("true", "false"):
|
||||
return 1 if s.lower() == "true" else 0
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return s.lower()
|
||||
return v
|
||||
|
||||
|
||||
def same(a, b):
|
||||
a, b = norm_val(a), norm_val(b)
|
||||
if isinstance(a, float) and isinstance(b, float):
|
||||
return abs(a - b) <= 1e-9 * max(1.0, abs(a), abs(b))
|
||||
return a == b
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 4:
|
||||
print(__doc__)
|
||||
return 2
|
||||
ours_path, designs_path, rules_dir = argv[1:]
|
||||
sys.path.insert(0, rules_dir)
|
||||
import design_rules # noqa: E402
|
||||
|
||||
cat = design_rules.Catalog.load(os.environ.get("SOTS_CATALOG_DIR"))
|
||||
ours = json.load(open(ours_path))
|
||||
players = json.load(open(designs_path))
|
||||
ref = [(pi, di, p, d) for pi, p in enumerate(players) for di, d in enumerate(p["designs"])]
|
||||
if len(ref) != len(ours):
|
||||
print(f"design count differs: ours {len(ours)}, reference {len(ref)}")
|
||||
return 1
|
||||
|
||||
diffs = []
|
||||
n_viol_ours = n_viol_ref = 0
|
||||
n_designs_with_findings = 0
|
||||
n_stats = 0
|
||||
|
||||
def diff(tag, what, a, b):
|
||||
diffs.append(f"{tag}: {what}: ours {a!r} vs reference {b!r}")
|
||||
|
||||
for o, (pi, di, p, d) in zip(ours, ref):
|
||||
tag = f"{p['save']} {p['player']} '{d['name']}'"
|
||||
if (o["player_index"], o["design_index"]) != (pi, di):
|
||||
diff(tag, "order", (o["player_index"], o["design_index"]), (pi, di))
|
||||
continue
|
||||
# findings
|
||||
ref_v = design_rules.validate(d, cat)
|
||||
ref_set = {(v.rule, v.level, v.slot, v.bank) for v in ref_v}
|
||||
our_set = {(v["rule"], v["level"], v["slot"], v["bank"]) for v in o["violations"]}
|
||||
n_viol_ours += len(our_set)
|
||||
n_viol_ref += len(ref_set)
|
||||
if ref_set:
|
||||
n_designs_with_findings += 1
|
||||
if ref_set != our_set:
|
||||
diff(tag, "findings", sorted(our_set - ref_set, key=str), sorted(ref_set - our_set, key=str))
|
||||
# applied techs / options / stats
|
||||
st = design_rules.derive_stats(d, cat)
|
||||
for slot in design_rules.SLOTS:
|
||||
use = d.get(slot)
|
||||
if not use:
|
||||
if slot in o["applied_techs"]:
|
||||
diff(tag, f"{slot} applied_techs", o["applied_techs"][slot], None)
|
||||
continue
|
||||
want = [t.lower() for t in st["applied_techs"].get(slot, [])]
|
||||
got = [t.lower() for t in o["applied_techs"].get(slot, [])]
|
||||
if want != got:
|
||||
diff(tag, f"{slot} applied_techs", got, want)
|
||||
wopt = [t.lower() for t in use.get("options", [])]
|
||||
gopt = [t.lower() for t in o["options"].get(slot, [])]
|
||||
if wopt != gopt:
|
||||
diff(tag, f"{slot} options", gopt, wopt)
|
||||
os_ = o["stats"]
|
||||
for key in ("mass", "section_cost", "section_cost_with_options", "weapon_cost_per_bank", "weapon_cost_per_mount",
|
||||
"total_cost_estimate", "health_total", "crew", "cpoints", "banks", "turrets", "command_cost",
|
||||
"maintenance_cost", "command_quota", "ftlspeed", "nodespeed", "engine_techera", "hull_class"):
|
||||
n_stats += 1
|
||||
a, b = norm_val(os_.get(key)), norm_val(st.get(key))
|
||||
if a is None and b is None:
|
||||
continue
|
||||
if (a is None) != (b is None) or not same(a, b):
|
||||
diff(tag, key, a, b)
|
||||
for key, val in st["capacities"].items():
|
||||
n_stats += 1
|
||||
if key not in os_["capacities"]:
|
||||
diff(tag, f"capacities.{key}", None, val)
|
||||
elif not same(os_["capacities"][key], val):
|
||||
diff(tag, f"capacities.{key}", os_["capacities"][key], val)
|
||||
for key in os_["capacities"]:
|
||||
if key not in st["capacities"]:
|
||||
diff(tag, f"capacities.{key}", os_["capacities"][key], None)
|
||||
for slot, sec in st["sections"].items():
|
||||
osec = os_["sections"].get(slot)
|
||||
if osec is None:
|
||||
diff(tag, f"{slot} section stats", None, sec["section"])
|
||||
continue
|
||||
for key in ("section", "mass", "cost", "option_cost_multiplier", "health", "crew", "cpoints"):
|
||||
n_stats += 1
|
||||
if not same(osec[key], sec[key]):
|
||||
diff(tag, f"{slot}.{key}", osec[key], sec[key])
|
||||
if len(osec["banks"]) != len(sec["banks"]):
|
||||
diff(tag, f"{slot} bank count", len(osec["banks"]), len(sec["banks"]))
|
||||
continue
|
||||
for i, (ob, rb) in enumerate(zip(osec["banks"], sec["banks"])):
|
||||
for key in ("size", "class", "mounts", "weapon", "weapon_cost", "turret_model", "turret_health"):
|
||||
n_stats += 1
|
||||
a, b = norm_val(ob.get(key)), norm_val(rb.get(key))
|
||||
if a is None and b is None:
|
||||
continue
|
||||
if (a is None) != (b is None) or not same(a, b):
|
||||
diff(tag, f"{slot} bank {i} {key}", a, b)
|
||||
|
||||
print(f"{len(ours)} designs compared; findings ours {n_viol_ours} / reference {n_viol_ref} "
|
||||
f"({n_designs_with_findings} designs with findings); {n_stats} stat values compared")
|
||||
if diffs:
|
||||
print(f"{len(diffs)} differences:")
|
||||
for line in diffs[:MAX_DIFFS]:
|
||||
print(" " + line)
|
||||
if len(diffs) > MAX_DIFFS:
|
||||
print(f" ... {len(diffs) - MAX_DIFFS} more")
|
||||
return 1
|
||||
print("no differences")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
170
tests/game_design/realdata_test.cpp
Normal file
170
tests/game_design/realdata_test.cpp
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// Every design in the owner's real saves through the rules.
|
||||
//
|
||||
// SOTS_DATA_DIR=<extracted gob> SOTS_DESIGNS_JSON=<stock_designs.json> realdata_test
|
||||
//
|
||||
// Skips (exit 0) when either variable is unset. Checks:
|
||||
// * the (species, section id) record resolves to the section the reference
|
||||
// reader named, and the save's bank count is the section's bank count;
|
||||
// * stripping the structural techs from DOpts yields the reference's options
|
||||
// and applied_techs() rebuilds DOpts exactly (D2) on every instance;
|
||||
// * no design has an error-level finding (structure, banks, fit, options,
|
||||
// tech gating against the owning player's researched techs);
|
||||
// * the only tech-gating findings are warn-level, on hidden designs (the
|
||||
// per-race default rider design the engine creates without gating).
|
||||
// With the owner's 127-design extract the counts must be 127/127, 197/197 and
|
||||
// 121/127 with the 6 hidden Tarkas assault shuttles as the warnings.
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/data/catalog.h"
|
||||
#include "game/design/rules.h"
|
||||
#include "game/design/stats.h"
|
||||
#include "stock_designs.h"
|
||||
|
||||
using namespace game::data;
|
||||
using namespace game::design;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string join(const std::vector<std::string>& v) {
|
||||
std::string s;
|
||||
for (const std::string& x : v) s += (s.empty() ? "" : ",") + x;
|
||||
return s;
|
||||
}
|
||||
|
||||
bool same_fold(const std::vector<std::string>& a, const std::vector<std::string>& b) {
|
||||
if (a.size() != b.size()) return false;
|
||||
for (std::size_t i = 0; i < a.size(); ++i)
|
||||
if (!iequals(a[i], b[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool is_gating(const std::string& rule) { return rule.size() == 2 && rule[0] == 'C'; }
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const char* data_dir = std::getenv("SOTS_DATA_DIR");
|
||||
const char* designs_path = std::getenv("SOTS_DESIGNS_JSON");
|
||||
if (!data_dir || !*data_dir || !designs_path || !*designs_path) {
|
||||
std::printf("SKIP: SOTS_DATA_DIR / SOTS_DESIGNS_JSON not set\n");
|
||||
return 0;
|
||||
}
|
||||
Catalog cat = load_catalog(data_dir);
|
||||
if (cat.sections.empty() || cat.weapons.empty()) {
|
||||
std::printf("FAIL: no catalog at %s (%zu problems)\n", data_dir, cat.problems.size());
|
||||
return 1;
|
||||
}
|
||||
std::vector<StockDesign> designs;
|
||||
std::string err;
|
||||
if (!load_stock_designs(designs_path, designs, err)) {
|
||||
std::printf("FAIL: %s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
Ruleset rules(cat);
|
||||
|
||||
int failures = 0;
|
||||
int n_designs = 0, n_clean = 0, n_instances = 0, n_dopts_ok = 0, n_gated_ok = 0, n_gate_warn_hidden = 0;
|
||||
std::vector<std::string> gate_warn_designs;
|
||||
for (StockDesign& sd : designs) {
|
||||
++n_designs;
|
||||
Design& d = sd.design;
|
||||
const std::string tag = sd.save + " " + sd.player + " '" + d.name + "'";
|
||||
|
||||
// resolution against the reference view
|
||||
for (std::size_t i = 0; i < 3; ++i) {
|
||||
const SectionUse& u = d.slot(kUsedSlots[i]);
|
||||
const ShipSectionDef* s = u.empty() ? nullptr : rules.resolve_section(u.species, u);
|
||||
const std::string got = s ? s->stem : "";
|
||||
if (!iequals(got, sd.expect_stem[i])) {
|
||||
std::printf("FAIL %s: %s slot resolves to '%s', reference says '%s'\n", tag.c_str(),
|
||||
std::string(slot_name(kUsedSlots[i])).c_str(), got.c_str(), sd.expect_stem[i].c_str());
|
||||
++failures;
|
||||
}
|
||||
if (s && static_cast<int>(s->banks.size()) != sd.bank_count[i]) {
|
||||
std::printf("FAIL %s: %s has %zu banks, save stores %d\n", tag.c_str(), s->stem.c_str(), s->banks.size(),
|
||||
sd.bank_count[i]);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
// DOpts -> chosen options -> DOpts (D2)
|
||||
rules.strip_applied_techs(d);
|
||||
for (std::size_t i = 0; i < 3; ++i) {
|
||||
const SectionUse& u = d.slot(kUsedSlots[i]);
|
||||
if (u.empty()) continue;
|
||||
const ShipSectionDef* s = rules.resolve_section(u.species, u);
|
||||
if (!s) continue;
|
||||
++n_instances;
|
||||
if (!same_fold(u.options, sd.expect_options[i])) {
|
||||
std::printf("FAIL %s: %s options [%s], reference [%s]\n", tag.c_str(), s->stem.c_str(), join(u.options).c_str(),
|
||||
join(sd.expect_options[i]).c_str());
|
||||
++failures;
|
||||
}
|
||||
std::vector<std::string> applied = applied_techs(*s, u.options);
|
||||
if (same_fold(applied, sd.dopts[i])) {
|
||||
++n_dopts_ok;
|
||||
} else {
|
||||
std::printf("FAIL %s: %s applied_techs [%s], DOpts [%s]\n", tag.c_str(), s->stem.c_str(), join(applied).c_str(),
|
||||
join(sd.dopts[i]).c_str());
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
// the rules
|
||||
std::vector<Violation> v = rules.validate(d);
|
||||
bool errors = false, gate = false, gate_error = false;
|
||||
for (const Violation& x : v) {
|
||||
if (x.level == Level::Error) errors = true;
|
||||
if (is_gating(x.rule)) {
|
||||
gate = true;
|
||||
if (x.level != Level::Warn) gate_error = true;
|
||||
}
|
||||
}
|
||||
if (errors) {
|
||||
std::printf("FAIL %s:\n", tag.c_str());
|
||||
for (const Violation& x : v) std::printf(" %s\n", x.str().c_str());
|
||||
++failures;
|
||||
} else {
|
||||
++n_clean;
|
||||
}
|
||||
if (!gate) {
|
||||
++n_gated_ok;
|
||||
} else if (!gate_error && d.hidden) {
|
||||
++n_gate_warn_hidden;
|
||||
gate_warn_designs.push_back(sd.race_text + " '" + d.name + "' (" + sd.save + ", " + sd.player + ")");
|
||||
} else {
|
||||
std::printf("FAIL %s: tech gating finding that is not a warn on a hidden design\n", tag.c_str());
|
||||
for (const Violation& x : v) std::printf(" %s\n", x.str().c_str());
|
||||
++failures;
|
||||
}
|
||||
// derive_stats must run on everything
|
||||
DesignStats st = derive_stats(rules, d);
|
||||
if (st.sections.empty()) {
|
||||
std::printf("FAIL %s: no stats\n", tag.c_str());
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
std::printf("designs: %d, clean (structure/banks/fit/options/gating): %d/%d\n", n_designs, n_clean, n_designs);
|
||||
std::printf("section instances: %d, DOpts reproduced by applied_techs: %d/%d\n", n_instances, n_dopts_ok, n_instances);
|
||||
std::printf("tech gating: %d/%d pass, %d warn-only on hidden designs:\n", n_gated_ok, n_designs, n_gate_warn_hidden);
|
||||
for (const std::string& s : gate_warn_designs) std::printf(" %s\n", s.c_str());
|
||||
|
||||
if (n_designs == 127) { // the owner's three-save extract: the published numbers
|
||||
if (n_clean != 127 || n_instances != 197 || n_dopts_ok != 197 || n_gated_ok != 121 || n_gate_warn_hidden != 6) {
|
||||
std::printf("FAIL: expected 127/127 clean, 197/197 DOpts, 121/127 gated + 6 hidden warnings\n");
|
||||
++failures;
|
||||
}
|
||||
for (const std::string& s : gate_warn_designs)
|
||||
if (s.rfind("Tarkas 'Default Assault Shuttle'", 0) != 0) {
|
||||
std::printf("FAIL: unexpected gating warning on %s\n", s.c_str());
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
std::printf("%s\n", failures ? "FAILED" : "OK");
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
95
tests/game_design/stock_designs.cpp
Normal file
95
tests/game_design/stock_designs.cpp
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
#include "stock_designs.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
#include "game/data/common.h"
|
||||
#include "json_min.h"
|
||||
|
||||
using namespace game::design;
|
||||
using json_min::Value;
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<std::string> strings(const Value& v) {
|
||||
std::vector<std::string> out;
|
||||
for (const Value& x : v.items)
|
||||
if (x.is_string()) out.push_back(x.str());
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool load_stock_designs(const std::string& path, std::vector<StockDesign>& out, std::string& err) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in) {
|
||||
err = "cannot read " + path;
|
||||
return false;
|
||||
}
|
||||
std::ostringstream ss;
|
||||
ss << in.rdbuf();
|
||||
Value root;
|
||||
if (!json_min::parse(ss.str(), root, err)) return false;
|
||||
if (!root.is_array()) {
|
||||
err = "expected a list of players";
|
||||
return false;
|
||||
}
|
||||
int pi = 0;
|
||||
for (const Value& player : root.items) {
|
||||
int di = 0;
|
||||
for (const Value& d : player["designs"].items) {
|
||||
StockDesign sd;
|
||||
sd.player_index = pi;
|
||||
sd.design_index = di++;
|
||||
sd.save = player["save"].str();
|
||||
sd.player = player["player"].str();
|
||||
sd.npc = player["npc"].as_bool();
|
||||
sd.race_text = d["race"].str();
|
||||
sd.list = d["list"].str();
|
||||
Design& des = sd.design;
|
||||
des.name = d["name"].str();
|
||||
des.hidden = d["hidden"].as_bool();
|
||||
des.designer_made = d["faiDes"].as_bool();
|
||||
des.default_weapons = d["dWep"].as_int();
|
||||
des.race = game::data::parse_species(sd.race_text);
|
||||
if (!d["known_techs"].is_null()) des.known_techs = strings(d["known_techs"]);
|
||||
const Value& raw = d["raw_slots"];
|
||||
if (!raw.is_array() || raw.items.size() != static_cast<std::size_t>(kSlotCount)) {
|
||||
err = des.name + ": raw_slots must have " + std::to_string(kSlotCount) + " entries";
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < kSlotCount; ++i) {
|
||||
const Value& r = raw.items[static_cast<std::size_t>(i)];
|
||||
SectionUse& u = des.slots[static_cast<std::size_t>(i)];
|
||||
int species = r["species"].as_int();
|
||||
u.section_id = r["section_id"].as_int();
|
||||
if (species == 0 && u.section_id == 0) continue; // empty slot
|
||||
u.species = static_cast<game::data::Species>(species);
|
||||
const Value& weapons = r["weapons"];
|
||||
const Value& riders = r["rider_design_ids"];
|
||||
for (std::size_t w = 0; w < weapons.items.size(); ++w) {
|
||||
const Value& x = weapons.items[w];
|
||||
WeaponRef ref;
|
||||
if (x.is_number()) ref = WeaponRef::by_id(x.as_int());
|
||||
else if (x.is_string()) ref = WeaponRef::by_file(x.str());
|
||||
if (w < riders.items.size()) ref.rider_design_id = riders.items[w].as_int();
|
||||
u.weapons.push_back(ref);
|
||||
}
|
||||
u.options = strings(r["options"]);
|
||||
if (i < 3) {
|
||||
sd.dopts[static_cast<std::size_t>(i)] = u.options;
|
||||
sd.bank_count[static_cast<std::size_t>(i)] = r["bank_count"].as_int();
|
||||
}
|
||||
}
|
||||
for (std::size_t i = 0; i < 3; ++i) {
|
||||
const Value& view = d[slot_name(kUsedSlots[i])];
|
||||
if (view.is_null()) continue;
|
||||
sd.expect_stem[i] = view["section"].str();
|
||||
sd.expect_options[i] = strings(view["options"]);
|
||||
}
|
||||
out.push_back(std::move(sd));
|
||||
}
|
||||
++pi;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
27
tests/game_design/stock_designs.h
Normal file
27
tests/game_design/stock_designs.h
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Reads the owner's designs extract ($SOTS_DESIGNS_JSON): every design of
|
||||
// every player in the real saves, decoded into the save-record shape
|
||||
// (`raw_slots`: species index, section id, one weapon id/file per bank, the
|
||||
// DOpts list) plus the player's researched techs and the reference reader's
|
||||
// resolved view (section stems, stripped options) that the test cross-checks.
|
||||
// The file is game-derived and never enters the repo.
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/design/design.h"
|
||||
|
||||
struct StockDesign {
|
||||
int player_index = 0, design_index = 0;
|
||||
std::string save, player, race_text, list;
|
||||
bool npc = false;
|
||||
game::design::Design design; // options hold the raw DOpts until stripped
|
||||
std::array<std::string, 3> expect_stem{}; // reference view: section stem per used slot ("" = empty)
|
||||
std::array<std::vector<std::string>, 3> expect_options{}; // reference view: chosen options
|
||||
std::array<std::vector<std::string>, 3> dopts{}; // the save's DOpts per used slot
|
||||
std::array<int, 3> bank_count{}; // the save's bank count per used slot
|
||||
};
|
||||
|
||||
// False (and `err`) when the file cannot be read or has an unexpected shape.
|
||||
bool load_stock_designs(const std::string& path, std::vector<StockDesign>& out, std::string& err);
|
||||
193
tests/game_design/test_banks.cpp
Normal file
193
tests/game_design/test_banks.cpp
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// B* bank and weapon-fit rules on the mini catalog.
|
||||
#include "game/design/rules.h"
|
||||
#include "mini_catalog.h"
|
||||
#include "test_main.h"
|
||||
|
||||
using namespace game::data;
|
||||
using namespace game::design;
|
||||
|
||||
namespace {
|
||||
|
||||
Fit fit(const char* section, std::size_t bank, const char* weapon) {
|
||||
const Catalog& c = mini_catalog();
|
||||
return weapon_fits_bank(c.turrets, c.section("Human", section)->banks[bank], *c.weapon(weapon));
|
||||
}
|
||||
|
||||
std::vector<int> banks_of(const std::vector<Violation>& v, const char* rule) {
|
||||
std::vector<int> out;
|
||||
for (const Violation& x : v)
|
||||
if (x.rule == rule && x.bank) out.push_back(*x.bank);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(B1_one_weapon_per_bank) {
|
||||
Design d = armor();
|
||||
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(d)), "B1,B2");
|
||||
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis", "mis"});
|
||||
CHECK_EQ(join(error_rules(d)), "B1");
|
||||
}
|
||||
|
||||
TEST(B2_no_empty_bank) {
|
||||
Design d = armor();
|
||||
d.mission() = use("DEArmor", {"bal_gauss", "", "mis"});
|
||||
std::vector<Violation> v = mini_rules().validate(d);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "B2");
|
||||
CHECK_EQ(banks_of(v, "B2").size(), std::size_t(1));
|
||||
CHECK_EQ(banks_of(v, "B2")[0], 1);
|
||||
}
|
||||
|
||||
TEST(B3_fixed_npc_banks) {
|
||||
Design n;
|
||||
n.race = Species::NPC;
|
||||
n.hidden = true;
|
||||
n.mission() = use("_Herald", {"", "", ""});
|
||||
CHECK_EQ(join(error_rules(n)), ""); // empty entries take the bank's fixed weapon
|
||||
n.mission().weapons = {WeaponRef::by_file("Species/_NPC/weapons/Herald_gun.weapon"),
|
||||
WeaponRef::by_file("Species/_NPC/weapons/Herald_beam.weapon"),
|
||||
WeaponRef::by_file("Species/_NPC/weapons/Herald_gun.weapon")};
|
||||
CHECK_EQ(join(error_rules(n)), ""); // the same weapon spelled out, as NPC designs store it
|
||||
n.mission().weapons[0] = WeaponRef::by_stem("bal_gauss");
|
||||
std::vector<Violation> v = mini_rules().validate(n);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "B3");
|
||||
CHECK_EQ(banks_of(v, "B3")[0], 0);
|
||||
// the bank without a turret spec takes only its fixed weapon
|
||||
n.mission().weapons[0] = WeaponRef::none();
|
||||
n.mission().weapons[2] = WeaponRef::by_file("Species/_NPC/weapons/Herald_beam.weapon");
|
||||
CHECK_EQ(join(error_rules(n)), "B3");
|
||||
}
|
||||
|
||||
TEST(B4_class_accept_table) {
|
||||
CHECK(bank_accepts_class("standard", "standard"));
|
||||
CHECK(bank_accepts_class("Standard", "Missile"));
|
||||
CHECK(bank_accepts_class("standard", "grapple"));
|
||||
CHECK(!bank_accepts_class("standard", "torpedo"));
|
||||
CHECK(bank_accepts_class("strafe", "standard"));
|
||||
CHECK(!bank_accepts_class("strafe", "missile"));
|
||||
CHECK(bank_accepts_class("torpedo", "Torpedo"));
|
||||
CHECK(!bank_accepts_class("missile", "standard"));
|
||||
CHECK(!bank_accepts_class("planetmissile", "missile"));
|
||||
}
|
||||
|
||||
TEST(B4_turret_rows) {
|
||||
// small standard bank: tiny and small weapons only
|
||||
CHECK(fit("DECommand", 0, "las_pd").ok);
|
||||
CHECK(fit("DECommand", 0, "bal_gauss").ok);
|
||||
CHECK(!fit("DECommand", 0, "mis").ok); // medium missile in a small bank: no row
|
||||
CHECK(!fit("DECommand", 0, "trp_photon").ok); // class
|
||||
// medium standard bank: small + medium standard, medium missile (missile row supplies the model), grapple
|
||||
CHECK(fit("DEArmor", 2, "bal_gauss").ok);
|
||||
CHECK(!fit("DEArmor", 2, "las_pd").ok);
|
||||
Fit m = fit("DEArmor", 2, "mis");
|
||||
CHECK(m.ok);
|
||||
CHECK(m.row != nullptr);
|
||||
if (m.row) CHECK_EQ(m.row->model, "turret_m1missile.x");
|
||||
CHECK(fit("DEArmor", 2, "bal_grapple").ok);
|
||||
// strafe bank: standard weapons; the row is looked up by the weapon's class
|
||||
// first, so the standard row is used and the strafe rows are the fallback
|
||||
Fit s = fit("DEStrafe", 0, "bal_gauss");
|
||||
CHECK(s.ok);
|
||||
if (s.row) CHECK_EQ(s.row->turret_class, "standard");
|
||||
CHECK(!fit("DEStrafe", 0, "mis").ok);
|
||||
{
|
||||
// fallback to the bank's class when the weapon-class row is missing
|
||||
const Catalog& c = mini_catalog();
|
||||
BankDef strafe_medium;
|
||||
strafe_medium.turret_class = "strafe";
|
||||
strafe_medium.turret_size = "medium";
|
||||
WeaponDef odd = *c.weapon("bal_gauss");
|
||||
odd.turret_size = "medium";
|
||||
odd.turret_class = "standard";
|
||||
const TurretRow* row = turret_row(c.turrets, strafe_medium, odd);
|
||||
CHECK(row != nullptr);
|
||||
if (row) CHECK_EQ(row->turret_class, "standard"); // (medium, medium, standard) exists
|
||||
odd.turret_class = "nothing";
|
||||
CHECK(turret_row(c.turrets, strafe_medium, odd) != nullptr); // (medium, medium, strafe) row
|
||||
CHECK(!weapon_fits_bank(c.turrets, strafe_medium, odd).ok); // but the class table rejects it
|
||||
}
|
||||
// large standard: small/medium/large standard + missiles
|
||||
CHECK(fit("DEStrafe", 1, "can_plasma").ok);
|
||||
CHECK(fit("DEStrafe", 1, "mis").ok);
|
||||
CHECK(!fit("DEStrafe", 1, "las_pd").ok);
|
||||
CHECK(!fit("DEStrafe", 1, "trp_photon").ok);
|
||||
// large torpedo: large torpedoes only
|
||||
CHECK(fit("DETorpedo", 0, "trp_photon").ok);
|
||||
CHECK(!fit("DETorpedo", 0, "can_plasma").ok);
|
||||
// medium missile bank: missiles only
|
||||
CHECK(fit("DETorpedo", 1, "mis").ok);
|
||||
CHECK(!fit("DETorpedo", 1, "bal_gauss").ok);
|
||||
// planetmissile: the planet missiles (medium PlanetMissile in a large bank)
|
||||
CHECK(fit("DEPlanetMissile", 0, "mis_defplat").ok);
|
||||
CHECK(!fit("DEPlanetMissile", 0, "mis").ok);
|
||||
}
|
||||
|
||||
TEST(B4_in_a_design) {
|
||||
Design d = armor();
|
||||
d.known_techs.reset();
|
||||
d.command() = use("DECommand", {"trp_photon"});
|
||||
CHECK_EQ(join(error_rules(d)), "B4");
|
||||
d = armor();
|
||||
d.known_techs.reset();
|
||||
d.mission() = use("DEArmor", {"las_pd", "las_pd", "las_pd"});
|
||||
std::vector<Violation> v = mini_rules().validate(d);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "B4");
|
||||
CHECK_EQ(banks_of(v, "B4").size(), std::size_t(1));
|
||||
CHECK_EQ(banks_of(v, "B4")[0], 2);
|
||||
d.mission() = use("DEArmor", {"mis", "bal_gauss", "mis"});
|
||||
CHECK_EQ(join(error_rules(d)), "B4");
|
||||
}
|
||||
|
||||
TEST(B5_weapon_must_exist_and_be_in_scope) {
|
||||
Design d = armor();
|
||||
d.command() = use("DECommand", {"bal_nothing"});
|
||||
CHECK_EQ(join(error_rules(d)), "B5");
|
||||
d.command().weapons = {WeaponRef::by_id(4242)};
|
||||
CHECK_EQ(join(error_rules(d)), "B5");
|
||||
d.command().weapons = {WeaponRef::by_file("Species/_NPC/weapons/Herald_beam.weapon")};
|
||||
CHECK_EQ(join(error_rules(d)), "B5"); // NPC-only weapon on a player hull
|
||||
Design n;
|
||||
n.race = Species::NPC;
|
||||
n.command() = use("_DECommand", {"Species/_NPC/weapons/Herald_beam.weapon"});
|
||||
n.mission() = use("_DEMission", {"bal_gauss", "bal_gauss", "mis"});
|
||||
n.engine() = use("_DEEngine", {"bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(n)), ""); // fine on an NPC hull
|
||||
}
|
||||
|
||||
TEST(B6_exclusive_species) {
|
||||
Design d = armor();
|
||||
d.known_techs.reset();
|
||||
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "bal_grapple"});
|
||||
CHECK_EQ(join(error_rules(d)), "B6");
|
||||
Design z = armor(Species::Zuul);
|
||||
z.known_techs.reset();
|
||||
z.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "bal_grapple"});
|
||||
CHECK_EQ(join(error_rules(z)), "");
|
||||
}
|
||||
|
||||
TEST(B7_rider_weapons_need_their_rider_section) {
|
||||
Design d = armor();
|
||||
d.known_techs.reset();
|
||||
d.mission() = use("DEAssaultShuttle", {"brd_shuttle", "brd_tarkahunter"});
|
||||
std::vector<Violation> v = mini_rules().validate(d);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "B7"); // Human has _AssaultShuttle but no _HunterShip
|
||||
CHECK_EQ(banks_of(v, "B7")[0], 1);
|
||||
Design t = armor(Species::Tarkas);
|
||||
t.known_techs.reset();
|
||||
t.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"});
|
||||
// Tarkas mini catalog has both riders but no DEAssaultShuttle section; check the weapon rule directly
|
||||
CHECK(mini_catalog().section("Tarkas", "_HunterShip") != nullptr);
|
||||
}
|
||||
|
||||
TEST(B8_hidden_weapons_are_engine_only) {
|
||||
Design d = armor();
|
||||
d.known_techs.reset();
|
||||
d.mission() = use("DEPlanetMissile", {"mis_planet"});
|
||||
CHECK_EQ(join(error_rules(d)), "B8");
|
||||
d.hidden = true;
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
d.hidden = false;
|
||||
d.mission() = use("DEPlanetMissile", {"mis_defplat"});
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
}
|
||||
26
tests/game_design/test_json.cpp
Normal file
26
tests/game_design/test_json.cpp
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// The tools' JSON reader.
|
||||
#include "json_min.h"
|
||||
#include "test_main.h"
|
||||
|
||||
TEST(json_min_reads_the_shapes_the_tools_need) {
|
||||
json_min::Value v;
|
||||
std::string err;
|
||||
CHECK(json_min::parse(" {\"a\": [1, -2.5, \"x\\n\\u00e9\", null, true, false, {}], \"b\": {\"c\": \"d\"}} ", v, err));
|
||||
CHECK(v.is_object());
|
||||
CHECK(v["a"].is_array());
|
||||
CHECK_EQ(v["a"].items.size(), std::size_t(7));
|
||||
CHECK_EQ(v["a"].items[0].as_int(), 1);
|
||||
CHECK(v["a"].items[1].num == -2.5);
|
||||
CHECK_EQ(v["a"].items[2].str(), std::string("x\n\xc3\xa9"));
|
||||
CHECK(v["a"].items[3].is_null());
|
||||
CHECK(v["a"].items[4].as_bool());
|
||||
CHECK(!v["a"].items[5].as_bool());
|
||||
CHECK(v["a"].items[6].is_object());
|
||||
CHECK_EQ(v["b"]["c"].str(), "d");
|
||||
CHECK(v["missing"].is_null());
|
||||
CHECK(!json_min::parse("[1, 2", v, err));
|
||||
CHECK(!err.empty());
|
||||
CHECK(!json_min::parse("{\"a\" 1}", v, err));
|
||||
CHECK(!json_min::parse("[1] x", v, err));
|
||||
CHECK_EQ(json_min::quote("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
|
||||
}
|
||||
32
tests/game_design/test_main.cpp
Normal file
32
tests/game_design/test_main.cpp
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#include "test_main.h"
|
||||
|
||||
namespace testing {
|
||||
|
||||
std::vector<Case>& registry() {
|
||||
static std::vector<Case> r;
|
||||
return r;
|
||||
}
|
||||
|
||||
int& failures() {
|
||||
static int n = 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
void report_failure(const char* file, int line, const std::string& expr) {
|
||||
std::printf(" FAIL %s:%d: %s\n", file, line, expr.c_str());
|
||||
++failures();
|
||||
}
|
||||
|
||||
} // namespace testing
|
||||
|
||||
int main() {
|
||||
int ran = 0;
|
||||
for (const auto& c : testing::registry()) {
|
||||
int before = testing::failures();
|
||||
c.fn();
|
||||
++ran;
|
||||
if (testing::failures() != before) std::printf("[FAILED] %s\n", c.name);
|
||||
}
|
||||
std::printf("%d unit tests, %d failed assertions\n", ran, testing::failures());
|
||||
return testing::failures() ? 1 : 0;
|
||||
}
|
||||
49
tests/game_design/test_main.h
Normal file
49
tests/game_design/test_main.h
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Tiny self-contained test harness (no third-party deps).
|
||||
#pragma once
|
||||
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace testing {
|
||||
|
||||
struct Case {
|
||||
const char* name;
|
||||
std::function<void()> fn;
|
||||
};
|
||||
|
||||
std::vector<Case>& registry();
|
||||
int& failures();
|
||||
void report_failure(const char* file, int line, const std::string& expr);
|
||||
|
||||
struct Register {
|
||||
Register(const char* name, std::function<void()> fn) { registry().push_back({name, std::move(fn)}); }
|
||||
};
|
||||
|
||||
} // namespace testing
|
||||
|
||||
#define TEST(name) \
|
||||
static void test_##name(); \
|
||||
static testing::Register reg_##name(#name, test_##name); \
|
||||
static void test_##name()
|
||||
|
||||
#define CHECK(expr) \
|
||||
do { \
|
||||
if (!(expr)) testing::report_failure(__FILE__, __LINE__, #expr); \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_EQ(a, b) \
|
||||
do { \
|
||||
if (!((a) == (b))) \
|
||||
testing::report_failure(__FILE__, __LINE__, \
|
||||
std::string(#a " == " #b " [got: ") + \
|
||||
testing_to_string(a) + " vs " + \
|
||||
testing_to_string(b) + "]"); \
|
||||
} while (0)
|
||||
|
||||
inline std::string testing_to_string(const std::string& s) { return "\"" + s + "\""; }
|
||||
inline std::string testing_to_string(const char* s) { return std::string("\"") + s + "\""; }
|
||||
inline std::string testing_to_string(bool b) { return b ? "true" : "false"; }
|
||||
template <class T>
|
||||
std::string testing_to_string(const T& v) { return std::to_string(v); }
|
||||
114
tests/game_design/test_stats.cpp
Normal file
114
tests/game_design/test_stats.cpp
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// derive_stats and the default-weapon table on the mini catalog.
|
||||
#include "game/design/defaults.h"
|
||||
#include "game/design/stats.h"
|
||||
#include "mini_catalog.h"
|
||||
#include "test_main.h"
|
||||
|
||||
using namespace game::data;
|
||||
using namespace game::design;
|
||||
|
||||
TEST(stats_sums_and_counts) {
|
||||
DesignStats st = derive_stats(mini_rules(), armor());
|
||||
CHECK_EQ(st.race, "Human");
|
||||
CHECK_EQ(st.hull_class, "destroyer");
|
||||
CHECK_EQ(st.sections.size(), std::size_t(3));
|
||||
CHECK_EQ(st.mass, 800.0 + 2000 + 2500);
|
||||
CHECK_EQ(st.section_cost, 2000.0 + 4000 + 5000);
|
||||
CHECK_EQ(st.section_cost_with_options, st.section_cost);
|
||||
CHECK_EQ(st.health_total, 300.0 + 600 + 450);
|
||||
CHECK_EQ(st.cpoints, 500 + 1000 + 1380);
|
||||
CHECK_EQ(st.crew, 0);
|
||||
CHECK_EQ(st.banks, 5);
|
||||
CHECK_EQ(st.turrets, 1 + 3 + 3 + 1 + 1);
|
||||
CHECK_EQ(st.weapon_cost_per_bank, 50.0 * 4 + 3000);
|
||||
CHECK_EQ(st.weapon_cost_per_mount, 50.0 * (1 + 3 + 3 + 1) + 3000);
|
||||
CHECK_EQ(st.total_cost_estimate, 11000.0 + 3200);
|
||||
CHECK_EQ(st.command_cost, 1);
|
||||
CHECK(!st.command_quota);
|
||||
CHECK(st.ftlspeed && *st.ftlspeed == 0.2);
|
||||
CHECK(st.nodespeed && *st.nodespeed == 4);
|
||||
CHECK_EQ(st.engine_techera, "fission");
|
||||
CHECK(st.netforcelimits && st.netforcelimits->speed == 40);
|
||||
CHECK_EQ(join(st.sections[2].applied_techs), "DRV_Fissn,DRV_Node");
|
||||
CHECK(st.sections[1].banks[2].turret != nullptr);
|
||||
if (st.sections[1].banks[2].turret) CHECK_EQ(st.sections[1].banks[2].turret->model, "turret_m1missile.x");
|
||||
CHECK(st.capacity("range") != nullptr);
|
||||
if (st.capacity("range")) CHECK_EQ(*st.capacity("range"), "9");
|
||||
}
|
||||
|
||||
TEST(stats_option_cost_multiplier) {
|
||||
Design d = armor();
|
||||
d.known_techs.reset();
|
||||
d.mission().options = {"IND_MagLat", "IND_RefCoat"}; // 1.4 x 1.2
|
||||
DesignStats st = derive_stats(mini_rules(), d);
|
||||
CHECK(st.sections[1].option_cost_multiplier > 1.679 && st.sections[1].option_cost_multiplier < 1.681);
|
||||
CHECK(st.section_cost_with_options > 2000 + 4000 * 1.68 + 5000 - 0.01 && st.section_cost_with_options < 2000 + 4000 * 1.68 + 5000 + 0.01);
|
||||
CHECK_EQ(join(st.sections[1].applied_techs), "IND_MagLat,IND_RefCoat");
|
||||
}
|
||||
|
||||
TEST(stats_standalone_and_missing) {
|
||||
Design d;
|
||||
d.race = Species::Human;
|
||||
d.mission() = use("DEDefencePlatform", {"bal_gauss", "bal_gauss", "mis"});
|
||||
DesignStats st = derive_stats(mini_rules(), d);
|
||||
CHECK_EQ(st.sections.size(), std::size_t(1));
|
||||
CHECK(st.ftlspeed && *st.ftlspeed == 0); // a standalone mission section supplies the drive figures
|
||||
CHECK(st.command_quota && *st.command_quota == 20);
|
||||
CHECK(st.capacity("defence_platform") && *st.capacity("defence_platform") == "true");
|
||||
CHECK(st.capacity("scanrange") && *st.capacity("scanrange") == "12");
|
||||
// an unresolvable section contributes nothing; empty banks count as banks
|
||||
d.mission() = use("DENowhere");
|
||||
d.engine() = use("DEFission", {""});
|
||||
st = derive_stats(mini_rules(), d);
|
||||
CHECK_EQ(st.sections.size(), std::size_t(1));
|
||||
CHECK_EQ(st.banks, 1);
|
||||
CHECK_EQ(st.weapon_cost_per_bank, 0.0);
|
||||
// NPC fixed weapons are costed
|
||||
Design n;
|
||||
n.race = Species::NPC;
|
||||
n.mission() = use("_Herald", {"", "", ""});
|
||||
st = derive_stats(mini_rules(), n);
|
||||
CHECK_EQ(st.weapon_cost_per_bank, 7.0 + 9 + 7);
|
||||
CHECK_EQ(st.weapon_cost_per_mount, 7.0 * 2 + 9 + 7);
|
||||
CHECK_EQ(st.crew, 0); // `crew false` is a BadValue -> 0
|
||||
}
|
||||
|
||||
TEST(default_weapons_table) {
|
||||
Loaded<DefaultWeaponTable> t = parse_default_weapons(
|
||||
"// comment\nstandard small \"las_red.weapon\"\nMissile medium \"mis.weapon\"\nbad row\n");
|
||||
CHECK(t.ok());
|
||||
CHECK_EQ(t.value->size(), std::size_t(2));
|
||||
CHECK_EQ(t.problems.size(), std::size_t(1));
|
||||
CHECK(t.value->find("standard", "Small") != nullptr);
|
||||
CHECK(t.value->find("missile", "medium") != nullptr);
|
||||
CHECK(t.value->find("standard", "large") == nullptr);
|
||||
CHECK_EQ(t.value->find("standard", "small")->weapon_file, "las_red.weapon");
|
||||
Loaded<DefaultWeaponTable> missing = load_default_weapons("/nonexistent/root");
|
||||
CHECK(missing.ok());
|
||||
CHECK_EQ(missing.value->size(), std::size_t(0));
|
||||
CHECK_EQ(missing.problems.size(), std::size_t(1));
|
||||
}
|
||||
|
||||
TEST(fill_empty_banks_from_defaults) {
|
||||
Loaded<DefaultWeaponTable> t = parse_default_weapons(
|
||||
"standard small \"las_red.weapon\"\nstandard medium \"las_red.weapon\"\ntorpedo large \"trp_photon.weapon\"\n");
|
||||
Design d = armor();
|
||||
d.known_techs.reset();
|
||||
d.mission() = use("DETorpedo", {"", ""}); // large torpedo + medium missile (no default row for missile)
|
||||
d.engine().weapons.clear(); // short list gets extended
|
||||
int n = fill_empty_banks(d, mini_catalog(), *t.value);
|
||||
CHECK_EQ(n, 2);
|
||||
CHECK_EQ(d.mission().weapons[0].text, "Weapons/trp_photon.weapon");
|
||||
CHECK(d.mission().weapons[1].empty());
|
||||
CHECK_EQ(d.engine().weapons.size(), std::size_t(1));
|
||||
CHECK_EQ(d.engine().weapons[0].text, "Weapons/las_red.weapon");
|
||||
CHECK_EQ(d.command().weapons[0].text, "bal_gauss"); // untouched
|
||||
std::vector<Violation> v = mini_rules().validate(d);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "B2"); // only the missile bank is still empty
|
||||
// NPC fixed banks are filled with their own weapon
|
||||
Design h;
|
||||
h.race = Species::NPC;
|
||||
h.mission() = use("_Herald");
|
||||
CHECK_EQ(fill_empty_banks(h, mini_catalog(), *t.value), 3);
|
||||
CHECK_EQ(h.mission().weapons[1].text, "Species/_NPC/weapons/Herald_beam.weapon");
|
||||
}
|
||||
207
tests/game_design/test_structure.cpp
Normal file
207
tests/game_design/test_structure.cpp
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// A* structure rules on the mini catalog.
|
||||
#include "game/design/rules.h"
|
||||
#include "mini_catalog.h"
|
||||
#include "test_main.h"
|
||||
|
||||
using namespace game::data;
|
||||
using namespace game::design;
|
||||
|
||||
TEST(mini_catalog_loaded) {
|
||||
const Catalog& c = mini_catalog();
|
||||
CHECK_EQ(c.races.size(), std::size_t(4));
|
||||
CHECK(c.section("Human", "DEArmor") != nullptr);
|
||||
CHECK(c.section_by_id("Human", 37) == c.section("Human", "DEArmor"));
|
||||
CHECK(c.weapon_by_id(8) == c.weapon("bal_gauss"));
|
||||
CHECK_EQ(c.turrets.size(), std::size_t(19));
|
||||
CHECK(c.tech.find("IND_CruisCon") != nullptr);
|
||||
}
|
||||
|
||||
TEST(stock_armor_is_valid) {
|
||||
std::vector<Violation> v = mini_rules().validate(armor());
|
||||
CHECK_EQ(v.size(), std::size_t(0));
|
||||
for (const Violation& x : v) std::printf(" %s\n", x.str().c_str());
|
||||
CHECK_EQ(join(error_rules(armor(Species::Tarkas))), "");
|
||||
}
|
||||
|
||||
TEST(resolution_by_id_species_and_file_name) {
|
||||
Design d = armor();
|
||||
d.race = Species::Unknown; // race comes from the first filled slot
|
||||
d.command().species = Species::Human;
|
||||
d.command().section_id = 42;
|
||||
d.command().section.clear();
|
||||
d.mission().section = "Species/Human/sections/DEArmor.shipsection";
|
||||
d.engine().section = "defission";
|
||||
d.command().weapons = {WeaponRef::by_id(8)};
|
||||
d.mission().weapons = {WeaponRef::by_file("Weapons/bal_gauss.weapon"), WeaponRef::by_file("bal_gauss.weapon"),
|
||||
WeaponRef::by_stem("MIS")};
|
||||
CHECK(d.effective_race() == Species::Human);
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
const Ruleset& r = mini_rules();
|
||||
CHECK(r.resolve_section(Species::Human, d.command()) == mini_catalog().section("Human", "DECommand"));
|
||||
CHECK(r.resolve_weapon(WeaponRef::by_id(999)) == nullptr);
|
||||
CHECK(r.resolve_section(Species::Human, use("Nope")) == nullptr);
|
||||
SectionUse bad_id;
|
||||
bad_id.section_id = 999;
|
||||
CHECK(r.resolve_section(Species::Human, bad_id) == nullptr);
|
||||
}
|
||||
|
||||
TEST(A1_reserved_slots_must_be_empty) {
|
||||
Design d = armor();
|
||||
d.slot(Slot::Reserved3) = use("DECommand", {"bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(d)), "A1");
|
||||
}
|
||||
|
||||
TEST(A2_mission_required) {
|
||||
Design d = armor();
|
||||
d.mission() = SectionUse{};
|
||||
CHECK_EQ(join(error_rules(d)), "A2");
|
||||
// an unresolvable mission section stops at A5 (nothing else can be judged)
|
||||
d.mission() = use("DENowhere");
|
||||
CHECK_EQ(join(error_rules(d)), "A5");
|
||||
}
|
||||
|
||||
TEST(A3_hull_needs_command_and_engine) {
|
||||
Design d = armor();
|
||||
d.engine() = SectionUse{};
|
||||
CHECK_EQ(join(error_rules(d)), "A3");
|
||||
d.command() = SectionUse{};
|
||||
std::vector<Violation> v = mini_rules().validate(d);
|
||||
int a3 = 0;
|
||||
for (const Violation& x : v)
|
||||
if (x.rule == "A3") ++a3;
|
||||
CHECK_EQ(a3, 2);
|
||||
}
|
||||
|
||||
TEST(A3_standalone_cannot_take_partners) {
|
||||
Design d = armor();
|
||||
d.mission() = use("DEDefencePlatform", {"bal_gauss", "bal_gauss", "mis"});
|
||||
CHECK_EQ(join(error_rules(d)), "A3");
|
||||
d.command() = SectionUse{};
|
||||
d.engine() = SectionUse{};
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
// even an unresolvable partner counts as "filled"
|
||||
d.command() = use("DENowhere");
|
||||
CHECK_EQ(join(error_rules(d)), "A3,A5");
|
||||
}
|
||||
|
||||
TEST(A4_slot_must_match_section_type) {
|
||||
Design d = armor();
|
||||
d.command() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"});
|
||||
CHECK_EQ(join(error_rules(d)), "A4");
|
||||
d = armor();
|
||||
d.engine() = use("DECommand", {"bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(d)), "A4");
|
||||
}
|
||||
|
||||
TEST(A4_typeless_rider_hull) {
|
||||
// hidden default rider design: [0,0] _AssaultShuttle [0,0] -> info only
|
||||
Design d;
|
||||
d.race = Species::Human;
|
||||
d.hidden = true;
|
||||
d.mission() = use("_AssaultShuttle");
|
||||
d.known_techs = std::vector<std::string>{};
|
||||
std::vector<Violation> v = mini_rules().validate(d);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "");
|
||||
CHECK_EQ(join(rules_at(v, Level::Info)), "A4");
|
||||
// not hidden but design_class rider: still info
|
||||
d.hidden = false;
|
||||
CHECK_EQ(join(rules_of(d, Level::Info)), "A4");
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
// a typeless NPC hull placed in the command slot is an error (and _Herald is a cruiser: A6 too)
|
||||
Design n;
|
||||
n.race = Species::NPC;
|
||||
n.command() = use("_Herald", {"", "", ""});
|
||||
n.mission() = use("_DEMission", {"bal_gauss", "bal_gauss", "mis"});
|
||||
n.engine() = use("_DEEngine", {"bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(n)), "A4,A6");
|
||||
}
|
||||
|
||||
TEST(A5_one_species_catalog) {
|
||||
Design d = armor();
|
||||
d.race = Species::Unknown;
|
||||
CHECK_EQ(join(error_rules(d)), "A5"); // no filled slot names a species
|
||||
d = armor(Species::Liir); // no Liir catalog in the mini root
|
||||
CHECK_EQ(join(error_rules(d)), "A5");
|
||||
d = armor(Species::Zuul);
|
||||
d.known_techs.reset();
|
||||
d.mission() = use("DEDefencePlatform"); // Zuul has no DEDefencePlatform
|
||||
CHECK_EQ(join(error_rules(d)), "A5");
|
||||
d = armor();
|
||||
d.engine().species = Species::Tarkas; // mixed species record
|
||||
CHECK_EQ(join(error_rules(d)), "A3,A5");
|
||||
}
|
||||
|
||||
TEST(A6_class_homogeneous) {
|
||||
Design d = armor();
|
||||
d.known_techs.reset();
|
||||
d.engine() = use("CRFission", {"bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(d)), "A6");
|
||||
Design cr;
|
||||
cr.race = Species::Human;
|
||||
cr.command() = use("CRCommand", {"bal_gauss"});
|
||||
cr.mission() = use("CRArmor", {"bal_gauss"});
|
||||
cr.engine() = use("CRFission", {"bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(cr)), "");
|
||||
}
|
||||
|
||||
TEST(A7_exclude_lists) {
|
||||
Design d = armor();
|
||||
d.command() = use("DERamScoop", {"bal_gauss"});
|
||||
std::vector<Violation> v = mini_rules().validate(d);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "A7");
|
||||
bool at_command = false;
|
||||
for (const Violation& x : v)
|
||||
if (x.rule == "A7" && x.slot && *x.slot == Slot::Command) at_command = true;
|
||||
CHECK(at_command);
|
||||
}
|
||||
|
||||
TEST(A8_station_triples) {
|
||||
Design d;
|
||||
d.race = Species::Human;
|
||||
d.command() = use("DNStation_Fore", {"bal_gauss"});
|
||||
d.mission() = use("DNStation", {"bal_gauss"});
|
||||
d.engine() = use("DNStation_Aft", {"bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
d.command() = use("DNCommand", {"bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(d)), "A8");
|
||||
d.command() = SectionUse{};
|
||||
CHECK_EQ(join(error_rules(d)), "A8");
|
||||
// the explicit_section halves cannot serve another mission section
|
||||
Design e;
|
||||
e.race = Species::Human;
|
||||
e.command() = use("DNStation_Fore", {"bal_gauss"});
|
||||
e.mission() = use("CRArmor", {"bal_gauss"});
|
||||
e.engine() = use("CRFission", {"bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(e)), "A6,A8");
|
||||
}
|
||||
|
||||
TEST(A9_nodesign_is_a_warning) {
|
||||
Design n;
|
||||
n.race = Species::NPC;
|
||||
n.command() = use("_DECommand", {"bal_gauss"});
|
||||
n.mission() = use("_DEMission", {"bal_gauss", "bal_gauss", "mis"});
|
||||
n.engine() = use("_DEEngine", {"bal_gauss"});
|
||||
CHECK_EQ(join(error_rules(n)), "");
|
||||
CHECK_EQ(join(rules_of(n, Level::Warn)), "A9");
|
||||
n.hidden = true;
|
||||
CHECK_EQ(join(rules_of(n, Level::Warn)), "");
|
||||
}
|
||||
|
||||
TEST(sockets_are_geometry_not_a_key) {
|
||||
// DECommand socket_aft CommandNode + DEArmor socket_fore MissionNode01: names differ, design is fine
|
||||
const Catalog& c = mini_catalog();
|
||||
CHECK_EQ(c.section("Human", "DECommand")->socket_aft, "CommandNode");
|
||||
CHECK_EQ(c.section("Human", "DEArmor")->socket_fore, "MissionNode01");
|
||||
CHECK_EQ(join(error_rules(armor())), "");
|
||||
}
|
||||
|
||||
TEST(violation_formatting) {
|
||||
Violation v{"B4", Level::Error, "does not fit", Slot::Mission, 2};
|
||||
CHECK_EQ(v.str(), "ERROR B4 [mission bank 2]: does not fit");
|
||||
Violation w{"A6", Level::Warn, "mixed", std::nullopt, std::nullopt};
|
||||
CHECK_EQ(w.str(), "WARN A6: mixed");
|
||||
CHECK(parse_slot("Engine") == Slot::Engine);
|
||||
CHECK(!parse_slot("slot3"));
|
||||
CHECK_EQ(WeaponRef::by_id(8).describe(), "#8");
|
||||
CHECK_EQ(WeaponRef::none().describe(), "(none)");
|
||||
}
|
||||
190
tests/game_design/test_tech.cpp
Normal file
190
tests/game_design/test_tech.cpp
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// C* tech gating and D* option rules; applied_techs (the save's DOpts).
|
||||
#include "game/design/rules.h"
|
||||
#include "mini_catalog.h"
|
||||
#include "test_main.h"
|
||||
|
||||
using namespace game::data;
|
||||
using namespace game::design;
|
||||
|
||||
namespace {
|
||||
|
||||
Design with_techs(Design d, std::vector<std::string> techs) {
|
||||
d.known_techs = std::move(techs);
|
||||
return d;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(C1_section_requires) {
|
||||
CHECK_EQ(join(error_rules(with_techs(armor(), {"WEP_GsDrvr", "DRV_Node"}))), "C1");
|
||||
CHECK_EQ(join(error_rules(with_techs(armor(), {"WEP_GsDrvr", "DRV_Fissn"}))), "C1");
|
||||
CHECK_EQ(join(error_rules(with_techs(armor(), {"wep_gsdrvr", "drv_fissn", "DRV_NODE"}))), ""); // case-insensitive
|
||||
Design d = armor();
|
||||
d.known_techs.reset(); // no player information: gating skipped entirely
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
}
|
||||
|
||||
TEST(C1_group_requirements) {
|
||||
Design d = armor();
|
||||
d.mission() = use("DETorpedo", {"trp_photon", "mis"});
|
||||
d.known_techs = std::vector<std::string>{"DRV_Fissn", "DRV_Node", "WEP_GsDrvr", "WEP_PlasTrp"};
|
||||
CHECK_EQ(join(error_rules(d)), "C2"); // GRP_Torps satisfied by WEP_PlasTrp; trp_photon itself needs WEP_PhotTrp
|
||||
d.known_techs->push_back("WEP_PhotTrp");
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
d.known_techs = std::vector<std::string>{"DRV_Fissn", "DRV_Node", "WEP_GsDrvr", "WEP_PhotTrp"};
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
TechSet none({"WEP_RedLas"});
|
||||
CHECK(!none.satisfies(mini_catalog().tech, "GRP_Torps"));
|
||||
CHECK(!none.satisfies(mini_catalog().tech, "GRP_NoSuchGroup"));
|
||||
CHECK(TechSet({"WEP_HvyLancer"}).satisfies(mini_catalog().tech, "grp_hvybeam"));
|
||||
}
|
||||
|
||||
TEST(C1_unknown_tech_is_a_warning) {
|
||||
Design d = armor();
|
||||
d.engine() = use("DEOddTech", {"bal_gauss"});
|
||||
// DEOddTech requires DRV_Fissn (known) and XXX_Nowhere (not in the tree):
|
||||
// a token the tree cannot satisfy is reported, never an error.
|
||||
std::vector<Violation> v = mini_rules().validate(d);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "");
|
||||
CHECK_EQ(join(rules_at(v, Level::Warn)), "C1");
|
||||
}
|
||||
|
||||
TEST(C2_weapon_requires) {
|
||||
CHECK_EQ(join(error_rules(with_techs(armor(), {"DRV_Fissn", "DRV_Node"}))), "C2");
|
||||
Design d = armor();
|
||||
d.mission() = use("DEArmor", {"bal_gauss", "las_red", "mis"});
|
||||
CHECK_EQ(join(error_rules(d)), "C2");
|
||||
d.known_techs->push_back("WEP_RedLas");
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
}
|
||||
|
||||
TEST(C3_options_must_be_researched) {
|
||||
Design d = armor();
|
||||
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"}, {"IND_PlyAlloy"});
|
||||
CHECK_EQ(join(error_rules(d)), "C3");
|
||||
d.known_techs->push_back("IND_PlyAlloy");
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
}
|
||||
|
||||
TEST(C4_hull_class_tech) {
|
||||
const Catalog& c = mini_catalog();
|
||||
CHECK(hull_class_tech(*c.section("Human", "DEArmor")) == std::nullopt);
|
||||
CHECK(*hull_class_tech(*c.section("Human", "CRArmor")) == "IND_CruisCon");
|
||||
CHECK(*hull_class_tech(*c.section("Human", "DNCommand")) == "IND_DreadCon");
|
||||
CHECK(hull_class_tech(*c.section("Human", "DNStation")) == std::nullopt); // station exemption
|
||||
CHECK(hull_class_tech(*c.section("Human", "_AssaultShuttle")) == std::nullopt);
|
||||
Design cr;
|
||||
cr.race = Species::Human;
|
||||
cr.command() = use("CRCommand", {"bal_gauss"});
|
||||
cr.mission() = use("CRArmor", {"bal_gauss"});
|
||||
cr.engine() = use("CRFission", {"bal_gauss"});
|
||||
cr.known_techs = std::vector<std::string>{"DRV_Fissn", "DRV_Node", "WEP_GsDrvr"};
|
||||
std::vector<Violation> v = mini_rules().validate(cr);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "C4");
|
||||
int n = 0;
|
||||
for (const Violation& x : v)
|
||||
if (x.rule == "C4") ++n;
|
||||
CHECK_EQ(n, 3);
|
||||
cr.known_techs->push_back("IND_CruisCon");
|
||||
CHECK_EQ(join(error_rules(cr)), "");
|
||||
// station: IND_OrbCom is the gate, no IND_DreadCon needed
|
||||
Design st;
|
||||
st.race = Species::Human;
|
||||
st.command() = use("DNStation_Fore", {"bal_gauss"});
|
||||
st.mission() = use("DNStation", {"bal_gauss"});
|
||||
st.engine() = use("DNStation_Aft", {"bal_gauss"});
|
||||
st.known_techs = std::vector<std::string>{"WEP_GsDrvr", "IND_OrbCom"};
|
||||
CHECK_EQ(join(error_rules(st)), "");
|
||||
}
|
||||
|
||||
TEST(C5_race_can_never_research) {
|
||||
const Ruleset& r = mini_rules();
|
||||
CHECK(r.unobtainable("DRV_Hyper", Species::Human));
|
||||
CHECK(!r.unobtainable("DRV_Hyper", Species::Tarkas));
|
||||
CHECK(r.unobtainable("DRV_Node", Species::Tarkas));
|
||||
CHECK(!r.unobtainable("DRV_Node", Species::Human));
|
||||
CHECK(!r.unobtainable("DRV_Node", Species::NPC)); // never written -> engine default 100
|
||||
CHECK(!r.unobtainable("DRV_Fissn", Species::Human));
|
||||
CHECK(!r.unobtainable("NoSuchTech", Species::Human));
|
||||
Design d = armor();
|
||||
d.engine() = use("DEHyperTest", {"bal_gauss"});
|
||||
d.known_techs = std::vector<std::string>{"DRV_Fissn", "DRV_Hyper", "WEP_GsDrvr"};
|
||||
CHECK_EQ(join(error_rules(d)), "C5"); // researched or not, Human can never have it
|
||||
CHECK_EQ(join(error_rules(armor(Species::Tarkas))), "");
|
||||
}
|
||||
|
||||
TEST(hidden_designs_gate_at_warn_level) {
|
||||
// the Tarkas default assault shuttle exists on turn 1 with DRN_AdvFrm unresearched
|
||||
Design d;
|
||||
d.race = Species::Tarkas;
|
||||
d.hidden = true;
|
||||
d.mission() = use("_AssaultShuttle");
|
||||
d.known_techs = std::vector<std::string>{"DRV_Fissn"};
|
||||
std::vector<Violation> v = mini_rules().validate(d);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "");
|
||||
CHECK_EQ(join(rules_at(v, Level::Warn)), "C1");
|
||||
CHECK_EQ(join(rules_at(v, Level::Info)), "A4");
|
||||
d.hidden = false;
|
||||
CHECK_EQ(join(error_rules(d)), "C1");
|
||||
// C2/C3/C4 downgrade too
|
||||
Design cr;
|
||||
cr.race = Species::Human;
|
||||
cr.hidden = true;
|
||||
cr.command() = use("CRCommand", {"bal_gauss"});
|
||||
cr.mission() = use("CRArmor", {"las_red"}, {"IND_PlyAlloy"});
|
||||
cr.engine() = use("CRFission", {"bal_gauss"});
|
||||
cr.known_techs = std::vector<std::string>{};
|
||||
v = mini_rules().validate(cr);
|
||||
CHECK_EQ(join(rules_at(v, Level::Error)), "D1"); // IND_PlyAlloy is not an option of CRArmor: still an error
|
||||
CHECK_EQ(join(rules_at(v, Level::Warn)), "C1,C2,C3,C4");
|
||||
}
|
||||
|
||||
TEST(D1_options_one_per_group_members_only) {
|
||||
Design d = armor();
|
||||
d.known_techs = std::vector<std::string>{"DRV_Fissn", "DRV_Node", "WEP_GsDrvr", "IND_PlyAlloy", "IND_MagLat",
|
||||
"SLD_MkOne", "SLD_MkTwo", "IND_RefCoat", "DRV_RecFiss"};
|
||||
d.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"}, {"IND_PlyAlloy", "IND_MagLat"});
|
||||
CHECK_EQ(join(error_rules(d)), "D1");
|
||||
d.mission().options = {"DRV_RecFiss"}; // an option of the engine, not of DEArmor
|
||||
CHECK_EQ(join(error_rules(d)), "D1");
|
||||
d.mission().options = {"IND_MagLat", "IND_RefCoat", "SLD_MkOne"}; // one per group incl. optiondef
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
d.mission().options = {"SLD_MkOne", "SLD_MkTwo"};
|
||||
CHECK_EQ(join(error_rules(d)), "D1");
|
||||
d.mission().options = {"ind_maglat"}; // spelled differently: still a member
|
||||
CHECK_EQ(join(error_rules(d)), "");
|
||||
}
|
||||
|
||||
TEST(D2_applied_techs_order) {
|
||||
const Catalog& c = mini_catalog();
|
||||
const ShipSectionDef& eng = *c.section("Human", "DEFission");
|
||||
CHECK_EQ(join(applied_techs(eng, {"IND_RefCoat", "DRV_RecFiss", "IND_PlyAlloy"})),
|
||||
"DRV_Fissn,DRV_Node,IND_PlyAlloy,IND_RefCoat,DRV_RecFiss");
|
||||
CHECK_EQ(join(applied_techs(eng, {})), "DRV_Fissn,DRV_Node");
|
||||
const ShipSectionDef& cr = *c.section("Human", "CRFission");
|
||||
CHECK_EQ(join(applied_techs(cr, {})), "IND_CruisCon,DRV_Fissn,DRV_Node");
|
||||
const ShipSectionDef& st = *c.section("Human", "DNStation");
|
||||
CHECK_EQ(join(applied_techs(st, {})), "IND_OrbCom"); // station: no hull-class tech
|
||||
const ShipSectionDef& arm = *c.section("Human", "DEArmor");
|
||||
CHECK_EQ(join(applied_techs(arm, {"SLD_MkTwo", "IND_QrkRes"})), "IND_QrkRes,SLD_MkTwo"); // optiondef last
|
||||
CHECK_EQ(join(applied_techs(arm, {"NotAnOption"})), ""); // non-members are not applied
|
||||
CHECK_EQ(join(applied_techs(arm, {"ind_qrkres"})), "ind_qrkres"); // spelled as chosen
|
||||
}
|
||||
|
||||
TEST(D2_options_from_applied_round_trip) {
|
||||
const Catalog& c = mini_catalog();
|
||||
const ShipSectionDef& cr = *c.section("Human", "CRFission");
|
||||
std::vector<std::string> dopts = applied_techs(cr, {"IND_PlyAlloy"});
|
||||
CHECK_EQ(join(dopts), "IND_CruisCon,DRV_Fissn,DRV_Node"); // CRFission has no option groups in the mini root
|
||||
CHECK_EQ(join(options_from_applied(cr, dopts)), "");
|
||||
const ShipSectionDef& eng = *c.section("Human", "DEFission");
|
||||
dopts = applied_techs(eng, {"IND_MagLat", "DRV_RecFiss"});
|
||||
CHECK_EQ(join(options_from_applied(eng, dopts)), "IND_MagLat,DRV_RecFiss");
|
||||
// strip on a whole design record (the save's DOpts include the structural techs)
|
||||
Design d = armor();
|
||||
d.engine().options = {"DRV_Fissn", "DRV_Node", "IND_RefCoat"};
|
||||
d.command().options = {"IND_PlyAlloy"};
|
||||
mini_rules().strip_applied_techs(d);
|
||||
CHECK_EQ(join(d.engine().options), "IND_RefCoat");
|
||||
CHECK_EQ(join(d.command().options), "IND_PlyAlloy");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue