game/data: typed catalogs (weapons, sections, turrets, ids, tech tree, strings), cross_check; oracle 229k values 0 diffs

This commit is contained in:
alex 2026-09-07 17:55:08 -04:00
parent afb1532d7a
commit 9065c26829
31 changed files with 4478 additions and 0 deletions

273
docs/game-data.md Normal file
View file

@ -0,0 +1,273 @@
# `game::data` — the typed catalogs
`src/game/data/` turns the parsed data files into the definition objects the
game builds at startup: weapons, ship sections, the turret table, the stable
id registries, the tech tree, the string table, and a `Catalog` that loads a
whole data root, resolves names and cross-checks every reference. It sits on
`mars::parse` (brace blocks) and `mars::text` (rows, manifests, CSV); library
target `game_data` (static, `src/game/data/CMakeLists.txt`), include as
`game/data/<header>.h`. C++17, no dependencies, no exceptions across the API.
Behaviour is specified by the RE findings (`data-model.md` §4,
`data-parsers.md`, `SHIP_DESIGN_RULES.md` §1–3); the normalised JSON catalogs
the reference Python tooling emits are the oracle, and this module is checked
against them value by value (§5).
```
src/game/data/
common.h/.cpp Species (engine index order), Problem, Loaded<T>, case folding
block.h/.cpp Block/Attr: copyable snapshot of a parsed block, typed reads
fields.h internal: typed reads that record Problems
weapon.h/.cpp WeaponDef, RangeTable, BoltDef, FireControl, Ratings; load_weapon
shipsection.h/.cpp ShipSectionDef, OptionGroup, BankDef, MountDef, NetForceLimits; load_shipsection
turrets.h/.cpp TurretTable (_turrets.txt), IdRegistry (_weapons/_shipsections.txt)
techtree.h/.cpp TechTree, TechNode, AllowsEdge, parse_allows
strings.h/.cpp StringTable (Strings.csv)
catalog.h/.cpp Catalog: load_catalog(root), lookups, cross_check()
tests/game_data/
build_and_run.sh plain g++ build; unit tests; real-data facts; oracle comparison
test_*.cpp hand-written samples for every loader and the option normalisation
realdata_test.cpp counts / spot facts / cross-check against the published numbers
dump_catalog.cpp whole catalog -> JSON (typed fields + raw blocks)
oracle/compare.py field-by-field comparison with the reference catalogs
```
## 1. Common conventions
- **Loaders are total.** `load_x(const mars::parse::Document&)` returns
`Loaded<T> { optional<T> value; vector<Problem> problems; }`. Only a file
with no usable top-level block (or a hard syntax error in the `parse_x`
text convenience) yields no value; everything else loads and reports.
`Problem { kind, file, line, key, message }` kinds: `MissingBlock`,
`MissingKey`, `BadValue`, `Unparsed`, `Duplicate`, `IoError`, `Syntax`
(the parser's lenient recoveries are reported as `Syntax` "recovered: …").
- **Typed fields are `optional`.** A key that is absent is `nullopt`; a key
whose value does not have the expected shape is `nullopt` *plus* a
`BadValue` problem, and the text stays reachable in `raw`. Ints need the
int shape; doubles accept int or C-float; bools accept `true`/`false` (any
case) or an int (`hidden 1`, `autonomous 1`, `nodesign 1`).
- **Last occurrence wins** when a scalar key repeats (`mass 1800 mass 1000`
on two engine sections, `crew` on four Zuul sections, `turretsize` in 19
banks). Repeatable keys (`requires`, `exclude`, `compatible_section`,
`option`, `bank`, `mount`, `thruster`, `allows`, `inc`/`dec`, `section`,
`filename`) are vectors in file order.
- **Text kept as written, matched folded.** `turretclass PlanetMissile`,
`section_type Engine`, `requires WEP_HCLAS` are stored verbatim; every
lookup, enum derivation and cross-link comparison is ASCII
case-insensitive, which is how the data itself is consistent.
- **`raw`.** Every def keeps a `Block` copy of its whole block: ordered
`attrs` (key, text, quoted, line), `blocks`, `items`, with `find` (last
wins) / `all` / `block` / `blocks_named` / `str` / `strs` and typed
`Attr::as_int/as_double/as_bool`. The long tail of section role flags and
capacities (`refinery`, `police`, `colonizer_pop`, `mining_rate`, sounds,
`anim{}`) and the non-`bolt` weapon behaviour blocks live there.
- **Species** are indexed in the engine's own order — `Human 0, Hiver 1,
Tarkas 2, Liir 3, _NPC 4, Zuul 5, Morrigi 6` (the save-file species table).
## 2. Schemas as implemented
### `WeaponDef` (`weapon{}`; `Weapons/*.weapon`, `Species/<Race>/weapons/*.weapon`)
| field | key | type | notes |
|---|---|---|---|
| `stem`, `file`, `scope`, `id` | — | | catalog identity; `scope` NPC for anything under `Species/`; `id` from `_weapons.txt` (player only) |
| `display_name` | — | optional string | `name` resolved through the string table (`@X` → `X`); a non-`@` name is its own display name |
| `name` | `name` | string | `@WEAPON_*` token, required |
| `weapon_class`, `weapon_family`, `weapon_damage_type` | `weaponclass` (required), `weaponfamily`, `weapondamagetype` | string | |
| `requires` | `requires`* | vector | AND of techs; 30 weapons have none |
| `compatible_section`* / `exclusive_species` | | vector / string | rider carriers; `zuul` on the two grapples |
| `cost`, `burst_volleys`, `range`, `range_planet`, `muzzle_speed`, `hpbonus`, `dam_est` | | optional int | |
| `turret_size`, `turret_class` | `turretsize`, `turretclass` (both required) | string | |
| `track_speed_mod`, `recharge_time`, `volley_period`, `volley_duration`, `buildup_delay`, `solution_tolerance` | | optional double | |
| `hidden`, `pinpoint`, `blindfire`, `secondary_pd` | | optional bool | |
| `model1..3`, `muzzle_effect`, `muzzle_sound`, `icon_file`, `icon_rect` | | string | |
| `fc` | `fc_requires_los … fc_targets_expire` | 11 optional bools | fire-control flags |
| `ratings` | `rating_frate/dam/acc/range` | optional double | UI stars; fractional on 18 weapons |
| `behavior_kind` | first of `bolt beam torpedo rider missile chainlightning col mine disintegrator grapple projectedshield mirv nodecannon siege mesonprojector spyship wraith` present | string | `behavior()` returns that block from `raw` |
| `bolt` | `bolt{}` | optional `BoltDef` | `rangetable`, `planet` (`dam_pop/infra/terra`), `mass`, `beam_origin`, `beam_length`, `ricochet_mod`, `effect`, `impact_effect`, `expire_effect` |
| `rangetable` | `<behaviour>.rangetable{}` | optional `RangeTable` | present for `bolt` (66) and `torpedo` (19): three bands `point_blank/effective/maximum` of `{range, deviation, damage}` from `pb_/eff_/max_range[_dev|_dam]` |
| `planet_damage` | `<behaviour>.dam_pop/dam_infra/dam_terra` | `PlanetDamage` | whichever behaviour block is present |
### `ShipSectionDef` (`shipsection{}`; `Species/<Race>/sections/*.shipsection`)
| field | key | type | notes |
|---|---|---|---|
| `race`, `species`, `stem`, `file`, `id` | — | | `race` is the directory name; `id` from the race's `_shipsections.txt` |
| `display_name`, `description`, `unlocked_by` | — | | `SECTIONNAME_<stem>` / `SECTIONDESC_<stem>`; techs whose `ship{section}` names the stem (tech order) |
| `model` (required), `dam_model` | | string | |
| `requires`* | | vector | may name `GRP_<group>` |
| `section_type_text` → `section_type` | `section_type` | string → `SectionType {None, Command, Mission, Engine, Other}` | 41 sections have none (riders, NPC hulls) |
| `section_class_text` → `section_class` | `section_class` | string → `SectionClass {None, Destroyer, Cruiser, Dreadnought, Other}` | |
| `design_class`, `entity_class` | | string | `station`, `rider`, … |
| `health`, `mass` (both required) | | optional double | NPC masses are written as floats |
| `cost`, `cpoints`, `crew`, `command_cost`, `maintenance_cost`, `command_quota` | | optional int | |
| `socket_fore/aft`, `dam_socket_fore/aft` | | string | geometry only; `has_sockets()` is the hull-vs-standalone test (design rule A3) |
| `options` | `option T` and `option{ option A … }`* | vector of `OptionGroup { members, scalar, line }` | **one normalised list in file order**: a scalar entry is a one-member group with `scalar = true`, a block keeps its members. 229 scalar entries in 153 files, 1,226 blocks |
| `optiondef` | `optiondef{ option … }` | optional `OptionGroup` | the shield-level group on the 12 shield sections; kept separate because it is a separate key |
| `banks` | `bank{}`* | vector of `BankDef` | `turret_class`, `turret_size` (last if repeated; `repeated_turret_spec` flags the 19 banks that do), `weapon` (fixed weapon file on 404 NPC banks), `show_turrets`, `invincible`, `mounts` (`MountDef { node, min/max_azimuth, min/max_inclination, home_azimuth/inclination }`) |
| `ftlspeed`, `nodespeed`, `range`, `scanrange`, `tactical_sensor_range` | | optional double | |
| `engine_techera` | | string | `fission / fusion / antimatter` |
| `netforcelimits` | `netforcelimits{}` | optional `NetForceLimits` | `force_forward/right/up`, `torque_yaw/pitch/roll`, `speed`, `rotspeed` |
| `thrusters` | `thruster{}`* | vector | `node`, `effect`, `idle_effect` |
| `exclude`* | `exclude "STEM"` | vector | ramscoop command sections |
| `explicit_command_section`, `explicit_engine_section`, `explicit_section` | | string, string, optional bool | station triples |
| `autonomous`, `nodesign` | | optional bool | written as `1` or `true` |
### `TurretTable` (`Weapons/_turrets.txt`) and `IdRegistry`
`TurretRow { mount_size, weapon_size, turret_class, health, track_speed,
azimuth_scale, inclination_scale, model }` from the 8-token positional rows
(a row with any other width is `Unparsed` and skipped). `find(mount, weapon,
class)`, `has_weapon_pair(weapon_size, class)`, `has_bank_pair(mount_size,
class)`, `rows_for_bank(...)` — all case-insensitive.
`IdRegistry` wraps `mars::text::Manifest`: `IdEntry { id, name, stem, line }`,
`deleted()` tombstones, `find(id)` (last assignment wins), `id_of(name or
stem)` case-insensitive (`DEWar.SHIPSECTION`, `dewar`, `DEWar.shipsection`
all resolve), `is_deleted(id)`. Manifest problems map to `Unparsed` /
`Duplicate`.
### `TechTree` (`TechTree/MasterTechList.tech`)
`TechNode { name, family (as written, 155 nodes), family_inferred (upper
name prefix), type, threat, group (as written), option_cost,
unlock_explicitly, requires, benefits_inc, benefits_dec, sections,
weapon_files, allows (edge indices), display_name, description, raw }`.
`AllowsEdge { from, to, rp, pct[7], pct_written[7], unparsed, text, line }` —
`parse_allows` splits the string on whitespace: token 0 is the child,
`RP:n` (any case) the cost, `<Species>:n` a percentage for that species
(any of the seven names, any case); anything else lands in `unparsed` and is
reported. **Unlisted species default to 100** — this is the engine default
the RE notes describe (`data-parsers.md`, "believed 100"); `pct_written`
records which values the file actually spelled out so a consumer can tell
the two apart. `_NPC` is never written and is therefore always 100.
`groups`: upper-cased `group` value → member names (first-seen order);
`group_members("TORPS")` and `group_members("GRP_Torps")` both work;
`requirement_exists(token)` is true for a tech name or a non-empty group.
`strategy{ inc/dec }` is kept as the `TECHBEN_*` token lists — no semantics
are attached here. `edges_to / edges_from / roots()` are graph helpers.
### `StringTable` (`Locale/EN/Strings.csv`)
Column 0 → column 1 of every data row of the commented CSV; keys folded, the
later row wins on a repeat (the four trailing-space duplicates), `resolve()`
strips a leading `@`.
### `Catalog`
`load_catalog(root)` reads `Weapons/*.weapon` + `_weapons.txt` +
`_turrets.txt`, every `Species/<dir>/sections/` (+ `_shipsections.txt`) and
`Species/<dir>/weapons/`, `TechTree/MasterTechList.tech` and, if present,
`Locale/EN/Strings.csv`; missing mandatory files are `IoError` problems, the
rest still loads. Weapons are ordered player-first then by folded stem,
sections by (race, folded stem); ids, display names, descriptions and
`unlocked_by` are filled in; lookups: `weapon(stem)` (player catalog wins on
a stem clash), `weapon_by_file`, `weapon_by_id`, `section(race, stem)`,
`section_by_id`, `sections_named(stem)` (all races), `tech_node`.
`cross_check()` → `CrossCheck` with one vector per link kind. Dangling
(counted by `dangling_count()`, `clean()` = 0): weapon/section `requires` →
tech (a `GRP_x` needs a non-empty group), section `option`/`optiondef`
members → tech, tech `ship{section}` → any race's section, tech
`weapon{filename}` → a loaded weapon file, tech `requires` → tech/group,
`allows` child → tech (and unparsed edges), `bank{weapon}` → weapon file,
manifest id → file and file → manifest id, weapon and bank
(size, class) → a turret row, `@name` → string. Informational: case-only
mismatches, weapons without `requires`, missing `TECHNAME_/TECHDESC_` and
`SECTIONNAME_/SECTIONDESC_` (per distinct stem), roots.
## 3. Normalisation decisions
| decision | why |
|---|---|
| `option` scalar and block forms merge into one ordered list of groups | the engine treats a bare `option T` as a one-member choice (design rule D1); order is preserved through the entries' position in the block so the save-file `DOpts` order (group order) can be reproduced |
| `optiondef` stays a separate optional group | different key, different meaning (shield level), only 12 files |
| repeated scalar → last value, flagged on banks | the design-rule validator takes last; the engine's choice is unknown (open question), so the fact is kept (`repeated_turret_spec`, `raw.all(key)`) rather than hidden |
| numeric fields that do not parse → `nullopt` + `BadValue`, text kept in `raw` | 34 values in the shipped data are typos (`force_right o` ×22, `crew false` ×9, `trackspeed_mod 1.0f`, `min_inclination 0-5`, `max_inclination 90\`); guessing what the engine's converter makes of them would be fiction, so they are reported instead |
| bools accept 0/1 | `hidden 1`, `autonomous 1`, `nodesign 1` coexist with `true` in the data |
| `section_type`/`section_class` enums beside the text | the data spells them in two cases; behaviour code wants the enum, the oracle comparison wants the text |
| species percentages stored for all seven species, default 100 | matches the RE description of the engine default and gives `_NPC` (which has a catalog but never appears in `allows`) a defined value |
| `family_inferred` from the name prefix | half the nodes lack `family`, and where present it is sometimes the *display* branch (`WEP_Dsrptr` has `family "TRP"`) |
| NPC weapons are `scope NPC` with no id | they have no manifest; NPC banks reference them by file path (`bank{weapon}`) |
## 4. Tests
`tests/game_data/build_and_run.sh` (plain `g++ -std=c++17 -Wall -Wextra
-Wpedantic -Werror`):
1. **Unit tests** — 27 cases on hand-written samples: every typed field of a
weapon, `bolt` + `rangetable`, torpedo/beam behaviour detection, missing
keys and bad values as problems, fatal no-block / syntax error, lenient
recovery, last-wins and list order; section fields, the option
normalisation across both forms, `optiondef`, banks with repeated
`turretsize`, mounts, `netforcelimits`, thrusters, `exclude`, enum
parsing, long-tail access through `raw`; turret rows (comment lines,
empty model, short row), case-insensitive fit lookups; id registry with a
tombstone, stem/name lookup, duplicate id; `parse_allows` full / partial
/ bad tokens, tree nodes, groups, `GRP_` resolution, strategy links, ship
/ weapon links, roots, duplicate names; a miniature data root written to
a temp directory exercising `load_catalog`, every lookup, and every
`cross_check` list (each one seeded with exactly one dangling case); a
missing root.
2. **Real-data facts** (`SOTS_DATA_DIR` set, else SKIP) — counts and
published facts (below).
3. **Oracle comparison** — `dump_catalog` writes every def with its typed
fields and its `raw` block rendered in the reference reader's dict shape;
`oracle/compare.py` compares with `weapons.json`, `shipsections.json`,
`tech_tree.json`, `strings.json`, `crosslink.json`.
Canonicalisation in `compare.py`: `raw` bodies are compared exactly and
type-aware (this re-proves the block snapshot); typed fields are compared
against the oracle value under the same key with *last of a repeated key*,
*repeatable keys always lists*, *numbers numerically*, *bools accept the
oracle's 0/1*, *absent = null/""/[]*; option lists are normalised on the
oracle side the same way the loader does it; `allows` edges compare
`{from, to, rp, pct-as-written}` and the effective 7-species percentages
against `pct.get(race, 100)`; the cross-check report is compared list by
list with `crosslink.json` (manifest gaps folded, scalar options recomputed
from the dump). Strings are cp1252 in the files and are decoded to Unicode
by the dumper, since the oracle JSON holds decoded text.
### Results (2026-09-07, owner's `gob-extract`)
| what | ours | published |
|---|---|---|
| weapons | 207 (123 player, 84 NPC) | 207 |
| ship sections | 875 (Human 144, Hiver 137, Morrigi 141, Liir 135, Tarkas 132, Zuul 122, _NPC 64) | 875 |
| tech nodes / `allows` edges / groups | 293 / 354 / 9 | 293 / 354 / 9 |
| turret rows / weapon ids / deleted | 42 / 123 / {36, 58, 59} | same |
| strings | 5,196 | 5,196 |
| banks / mounts / scalar options / repeated-spec banks / banks without size | 3,721 / 7,375 / 229 / 19 / 2 | same |
| lenient parse recoveries | 12 | 12 |
| **oracle comparison** | **229,042 values, 0 differences** | — |
| cross-check dangling | 13 = 10 manifest ids without a file (Tarkas 3, 4, 16, 32, 43, 108, 109; `DEWar` in Human 98, Liir 86, Morrigi 39) + 3 NPC weapon name tokens absent from strings | same |
| every other link | 0 dangling; 18 / 7 case-only mismatches; 30 weapons without `requires`; 67 stems without `SECTIONDESC_`; 12 roots | same |
**Divergences: none in values.** The 34 untyped tokens listed in §3 are
`BadValue` problems on our side and plain strings/bools in the oracle;
`compare.py` requires that set to match exactly (it is spelled out in
`KNOWN_UNTYPED`), so a change in either direction is a failure.
Skips cleanly with `SOTS_DATA_DIR` unset. `tests/game_data/build/` (which
holds the dump) is git-ignored; nothing from the game is committed.
## 5. Open questions
- **Repeated scalar keys** — first or last value (two engine sections'
`mass`, four `crew`, 19 bank `turretsize/turretclass`)? Last is
implemented; `raw.all()` keeps both.
- **Converter leniency** — what the engine makes of `force_right o`,
`crew false`, `trackspeed_mod 1.0f`, `min_inclination 0-5`,
`max_inclination 90\`. Probably `0`, `0`, `1.0`, `0`, `90` if it uses
C-style prefix parsing; not assumed here.
- **`allows` default of 100** for unlisted species is taken from the RE
notes ("believed"), not proven from the binary.
- **`section_type` for typeless sections** — `Other`/`None` here; whether
the engine treats a missing type as "mission" for rider hulls is code.
- **`tech ship{section}`** is kept as data (`sections`, `unlocked_by`) and
is *not* used as a build gate, per `SHIP_DESIGN_RULES.md` §4.
- **Turret fit rules** (standard banks accepting missiles, strafe accepting
standard, grapples) belong to the design validator, not this module;
`TurretTable` only answers "is there a row".

View file

@ -0,0 +1,19 @@
# game_data -- typed catalog definitions built from the data files
# (weapons, ship sections, turrets, id registries, tech tree, strings) and the
# Catalog that loads a whole data root and cross-checks its references.
# Include from the root with add_subdirectory(src/game/data) after the two
# mars modules; link game_data.
add_library(game_data STATIC
common.cpp
block.cpp
weapon.cpp
shipsection.cpp
turrets.cpp
techtree.cpp
strings.cpp
catalog.cpp
)
target_include_directories(game_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../../..)
target_link_libraries(game_data PUBLIC mars_parse mars_text)
target_compile_features(game_data PUBLIC cxx_std_17)
target_compile_options(game_data PRIVATE -Wall -Wextra -Wpedantic)

113
src/game/data/block.cpp Normal file
View file

@ -0,0 +1,113 @@
#include "game/data/block.h"
#include "game/data/common.h"
#include "mars/parse/value.h"
namespace game::data {
std::optional<std::int64_t> Attr::as_int() const {
if (quoted) return std::nullopt;
return mars::parse::as_int(value);
}
std::optional<double> Attr::as_double() const {
if (quoted) return std::nullopt;
return mars::parse::as_double(value);
}
std::optional<bool> Attr::as_bool() const {
if (quoted) return std::nullopt;
if (auto b = mars::parse::as_bool(value)) return b;
if (auto i = mars::parse::as_int(value)) return *i != 0;
return std::nullopt;
}
const Attr* Block::find(std::string_view key) const {
const Attr* hit = nullptr;
for (const Attr& a : attrs)
if (iequals(a.key, key)) hit = &a;
return hit;
}
const Attr* Block::find_first(std::string_view key) const {
for (const Attr& a : attrs)
if (iequals(a.key, key)) return &a;
return nullptr;
}
std::vector<const Attr*> Block::all(std::string_view key) const {
std::vector<const Attr*> out;
for (const Attr& a : attrs)
if (iequals(a.key, key)) out.push_back(&a);
return out;
}
const Block* Block::block(std::string_view key) const {
for (const Block& b : blocks)
if (iequals(b.name, key)) return &b;
return nullptr;
}
std::vector<const Block*> Block::blocks_named(std::string_view key) const {
std::vector<const Block*> out;
for (const Block& b : blocks)
if (iequals(b.name, key)) out.push_back(&b);
return out;
}
bool Block::has(std::string_view key) const { return find(key) != nullptr || block(key) != nullptr; }
std::string Block::str(std::string_view key) const {
const Attr* a = find(key);
return a ? a->value : std::string();
}
std::optional<std::string> Block::opt_str(std::string_view key) const {
const Attr* a = find(key);
if (!a) return std::nullopt;
return a->value;
}
std::vector<std::string> Block::strs(std::string_view key) const {
std::vector<std::string> out;
for (const Attr& a : attrs)
if (iequals(a.key, key)) out.push_back(a.value);
return out;
}
Block snapshot(const mars::parse::Node& node) {
using mars::parse::Entry;
Block b;
b.name = node.name;
b.line = node.line;
int ord = 0;
for (const Entry& e : node.entries) {
switch (e.kind) {
case Entry::Kind::Pair: {
Attr a;
a.key = e.key;
a.value = e.value;
a.quoted = e.quoted;
a.line = e.line;
a.ord = ord;
b.attrs.push_back(std::move(a));
break;
}
case Entry::Kind::Item:
b.items.push_back(e.value);
break;
case Entry::Kind::Block: {
Block sub = snapshot(*e.block);
sub.name = e.key;
sub.ord = ord;
if (sub.line == 0) sub.line = e.line;
b.blocks.push_back(std::move(sub));
break;
}
}
++ord;
}
return b;
}
} // namespace game::data

57
src/game/data/block.h Normal file
View file

@ -0,0 +1,57 @@
// game::data -- a self-contained, copyable snapshot of a parsed brace block.
//
// Every catalog definition keeps one of these as `raw`: the typed fields on
// the def are the keys the engine's behaviour code needs; everything else in
// the file (role flags, sounds, class-specific behaviour blocks, ...) stays
// reachable here without a second parse. Lookups are case-insensitive, like
// the engine's own key matching; when a scalar key repeats, `find` returns the
// LAST occurrence (the same rule the loaders use for typed fields).
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include "mars/parse/blocks.h"
namespace game::data {
struct Attr {
std::string key; // as written
std::string value; // raw text (cp1252 bytes untouched)
bool quoted = false;
int line = 0;
int ord = 0; // position among all entries of the enclosing block
// Shape-checked conversions; nullopt for quoted values or the wrong shape.
std::optional<std::int64_t> as_int() const; // [+-]digits
std::optional<double> as_double() const; // int or C-float shape
std::optional<bool> as_bool() const; // true/false (any case) or an int (0 = false)
};
struct Block {
std::string name; // as written; "" for a document root
std::vector<Attr> attrs; // scalar pairs, file order
std::vector<Block> blocks; // sub-blocks, file order
std::vector<std::string> items; // bare quoted / trailing barewords
int line = 0;
int ord = 0;
const Attr* find(std::string_view key) const; // last scalar with that key
const Attr* find_first(std::string_view key) const;
std::vector<const Attr*> all(std::string_view key) const; // file order
const Block* block(std::string_view key) const; // first sub-block with that name
std::vector<const Block*> blocks_named(std::string_view key) const;
bool has(std::string_view key) const; // attr or block
// Typed reads of the last scalar under `key`. `str` returns "" when absent.
std::string str(std::string_view key) const;
std::optional<std::string> opt_str(std::string_view key) const;
std::vector<std::string> strs(std::string_view key) const; // every occurrence
};
Block snapshot(const mars::parse::Node& node);
} // namespace game::data

397
src/game/data/catalog.cpp Normal file
View file

@ -0,0 +1,397 @@
#include "game/data/catalog.h"
#include <algorithm>
#include <fstream>
#include <set>
#include <sstream>
namespace game::data {
namespace fs = std::filesystem;
bool read_file(const fs::path& path, std::string& out) {
std::ifstream in(path, std::ios::binary);
if (!in) return false;
std::ostringstream ss;
ss << in.rdbuf();
out = ss.str();
return true;
}
namespace {
std::string rel_string(const fs::path& root, const fs::path& p) {
std::error_code ec;
fs::path rel = fs::relative(p, root, ec);
std::string s = (ec ? p : rel).generic_string();
return s;
}
// Sorted list of regular files under `dir` with the given extension (case-insensitive).
std::vector<fs::path> list_files(const fs::path& dir, std::string_view ext, std::vector<Problem>& problems,
const std::string& rel_dir) {
std::vector<fs::path> out;
std::error_code ec;
if (!fs::is_directory(dir, ec)) return out;
for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) {
if (!it->is_regular_file(ec)) continue;
std::string name = it->path().filename().string();
auto dot = name.rfind('.');
if (dot == std::string::npos) continue;
if (!iequals(std::string_view(name).substr(dot + 1), ext)) continue;
out.push_back(it->path());
}
if (ec) problems.push_back({Problem::Kind::IoError, rel_dir, 0, "", "cannot list directory: " + ec.message()});
std::sort(out.begin(), out.end(), [](const fs::path& a, const fs::path& b) {
return fold(a.filename().string()) < fold(b.filename().string());
});
return out;
}
std::vector<fs::path> list_dirs(const fs::path& dir, std::vector<Problem>& problems, const std::string& rel_dir) {
std::vector<fs::path> out;
std::error_code ec;
if (!fs::is_directory(dir, ec)) return out;
for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
if (it->is_directory(ec)) out.push_back(it->path());
if (ec) problems.push_back({Problem::Kind::IoError, rel_dir, 0, "", "cannot list directory: " + ec.message()});
std::sort(out.begin(), out.end(), [](const fs::path& a, const fs::path& b) {
return fold(a.filename().string()) < fold(b.filename().string());
});
return out;
}
void append(std::vector<Problem>& dst, std::vector<Problem>& src) {
for (auto& p : src) dst.push_back(std::move(p));
src.clear();
}
void load_weapon_dir(Catalog& cat, const fs::path& root, const fs::path& dir, WeaponScope scope) {
std::string rel_dir = rel_string(root, dir);
for (const fs::path& p : list_files(dir, "weapon", cat.problems, rel_dir)) {
std::string rel = rel_string(root, p);
std::string text;
if (!read_file(p, text)) {
cat.problems.push_back({Problem::Kind::IoError, rel, 0, "", "cannot read file"});
continue;
}
Loaded<WeaponDef> w = parse_weapon(text, rel);
append(cat.problems, w.problems);
if (!w.value) continue;
w.value->stem = std::string(strip_extension(p.filename().string()));
w.value->scope = scope;
cat.weapons.push_back(std::move(*w.value));
}
}
} // namespace
std::size_t CrossCheck::dangling_count() const {
return weapon_requires_dangling.size() + section_requires_dangling.size() + section_option_dangling.size() +
tech_ship_section_dangling.size() + tech_weapon_file_dangling.size() + tech_requires_dangling.size() +
tech_allows_dangling.size() + tech_allows_unparsed.size() + bank_weapon_dangling.size() +
manifest_ids_without_file.size() + files_without_manifest_id.size() +
weapon_turret_pairs_without_row.size() + bank_turret_pairs_without_row.size() +
unresolved_weapon_names.size();
}
const WeaponDef* Catalog::weapon(std::string_view stem) const {
auto it = weapon_by_stem_.find(fold(stem));
return it == weapon_by_stem_.end() ? nullptr : &weapons[it->second];
}
const WeaponDef* Catalog::weapon_by_file(std::string_view rel_path) const {
std::string k = fold(rel_path);
std::replace(k.begin(), k.end(), '\\', '/');
auto it = weapon_by_file_.find(k);
return it == weapon_by_file_.end() ? nullptr : &weapons[it->second];
}
const WeaponDef* Catalog::weapon_by_id(int id) const {
auto it = weapon_by_id_.find(id);
return it == weapon_by_id_.end() ? nullptr : &weapons[it->second];
}
const ShipSectionDef* Catalog::section(std::string_view race, std::string_view stem) const {
auto it = section_by_key_.find(fold(race) + "/" + fold(stem));
return it == section_by_key_.end() ? nullptr : &sections[it->second];
}
const ShipSectionDef* Catalog::section_by_id(std::string_view race, int id) const {
for (const auto& kv : section_ids) {
if (!iequals(kv.first, race)) continue;
const IdEntry* e = kv.second.find(id);
return e ? section(race, e->stem) : nullptr;
}
return nullptr;
}
std::vector<const ShipSectionDef*> Catalog::sections_named(std::string_view stem) const {
std::vector<const ShipSectionDef*> out;
auto it = sections_by_stem_.find(fold(stem));
if (it == sections_by_stem_.end()) return out;
for (std::size_t i : it->second) out.push_back(&sections[i]);
return out;
}
void Catalog::rebuild_index() {
weapon_by_stem_.clear();
weapon_by_file_.clear();
weapon_by_id_.clear();
section_by_key_.clear();
sections_by_stem_.clear();
for (std::size_t i = 0; i < weapons.size(); ++i) {
const WeaponDef& w = weapons[i];
std::string k = fold(w.stem);
auto it = weapon_by_stem_.find(k);
if (it == weapon_by_stem_.end() || (w.scope == WeaponScope::Player && weapons[it->second].scope != WeaponScope::Player))
weapon_by_stem_[k] = i;
weapon_by_file_[fold(w.file)] = i;
if (w.id) weapon_by_id_[*w.id] = i;
}
for (std::size_t i = 0; i < sections.size(); ++i) {
const ShipSectionDef& s = sections[i];
section_by_key_[fold(s.race) + "/" + fold(s.stem)] = i;
sections_by_stem_[fold(s.stem)].push_back(i);
}
}
Catalog load_catalog(const fs::path& root) {
Catalog cat;
// --- weapons ---------------------------------------------------------
load_weapon_dir(cat, root, root / "Weapons", WeaponScope::Player);
{
std::string text;
fs::path p = root / "Weapons" / "_weapons.txt";
if (read_file(p, text)) {
Loaded<IdRegistry> r = parse_id_registry(text, rel_string(root, p));
append(cat.problems, r.problems);
if (r.value) cat.weapon_ids = std::move(*r.value);
} else {
cat.problems.push_back({Problem::Kind::IoError, "Weapons/_weapons.txt", 0, "", "missing id manifest"});
}
p = root / "Weapons" / "_turrets.txt";
if (read_file(p, text)) {
Loaded<TurretTable> t = parse_turret_table(text, rel_string(root, p));
append(cat.problems, t.problems);
if (t.value) cat.turrets = std::move(*t.value);
} else {
cat.problems.push_back({Problem::Kind::IoError, "Weapons/_turrets.txt", 0, "", "missing turret table"});
}
}
// --- species: sections (+ ids) and race-private weapons --------------
for (const fs::path& race_dir : list_dirs(root / "Species", cat.problems, "Species")) {
std::string race = race_dir.filename().string();
fs::path sec_dir = race_dir / "sections";
std::error_code ec;
bool has_sections = fs::is_directory(sec_dir, ec);
if (has_sections) {
cat.races.push_back(race);
Species sp = parse_species(race);
std::string rel_dir = rel_string(root, sec_dir);
for (const fs::path& p : list_files(sec_dir, "shipsection", cat.problems, rel_dir)) {
std::string rel = rel_string(root, p);
std::string text;
if (!read_file(p, text)) {
cat.problems.push_back({Problem::Kind::IoError, rel, 0, "", "cannot read file"});
continue;
}
Loaded<ShipSectionDef> s = parse_shipsection(text, rel);
append(cat.problems, s.problems);
if (!s.value) continue;
s.value->race = race;
s.value->species = sp;
s.value->stem = std::string(strip_extension(p.filename().string()));
cat.sections.push_back(std::move(*s.value));
}
std::string text;
fs::path mp = sec_dir / "_shipsections.txt";
if (read_file(mp, text)) {
Loaded<IdRegistry> r = parse_id_registry(text, rel_string(root, mp));
append(cat.problems, r.problems);
if (r.value) cat.section_ids[race] = std::move(*r.value);
} else {
cat.problems.push_back({Problem::Kind::IoError, rel_string(root, mp), 0, "", "missing id manifest"});
}
}
load_weapon_dir(cat, root, race_dir / "weapons", WeaponScope::NPC);
}
// --- tech tree -------------------------------------------------------
{
fs::path p = root / "TechTree" / "MasterTechList.tech";
std::string text;
if (read_file(p, text)) {
Loaded<TechTree> t = parse_tech_tree(text, rel_string(root, p));
append(cat.problems, t.problems);
if (t.value) cat.tech = std::move(*t.value);
} else {
cat.problems.push_back({Problem::Kind::IoError, "TechTree/MasterTechList.tech", 0, "", "missing tech tree"});
}
}
// --- strings (optional) ----------------------------------------------
{
fs::path p = root / "Locale" / "EN" / "Strings.csv";
std::string text;
if (read_file(p, text)) {
Loaded<StringTable> s = parse_string_table(text, rel_string(root, p));
append(cat.problems, s.problems);
if (s.value) {
cat.strings = std::move(*s.value);
cat.strings_loaded = true;
}
}
}
// --- stable order, ids, names, links ---------------------------------
std::stable_sort(cat.weapons.begin(), cat.weapons.end(), [](const WeaponDef& a, const WeaponDef& b) {
if (a.scope != b.scope) return a.scope == WeaponScope::Player;
return fold(a.stem) < fold(b.stem);
});
std::stable_sort(cat.sections.begin(), cat.sections.end(), [](const ShipSectionDef& a, const ShipSectionDef& b) {
if (a.race != b.race) return a.race < b.race;
return fold(a.stem) < fold(b.stem);
});
for (WeaponDef& w : cat.weapons) {
if (w.scope == WeaponScope::Player) w.id = cat.weapon_ids.id_of(w.stem);
if (!w.name.empty() && w.name[0] == '@') {
if (cat.strings_loaded)
if (auto s = cat.strings.resolve(w.name)) w.display_name = std::string(*s);
} else if (!w.name.empty()) {
w.display_name = w.name;
}
}
for (ShipSectionDef& s : cat.sections) {
auto ids = cat.section_ids.find(s.race);
if (ids != cat.section_ids.end()) s.id = ids->second.id_of(s.stem);
if (cat.strings_loaded) {
if (auto v = cat.strings.get("SECTIONNAME_" + s.stem)) s.display_name = std::string(*v);
if (auto v = cat.strings.get("SECTIONDESC_" + s.stem)) s.description = std::string(*v);
}
for (const TechNode& t : cat.tech.nodes)
for (const std::string& sec : t.sections)
if (iequals(sec, s.stem)) {
s.unlocked_by.push_back(t.name);
break;
}
}
if (cat.strings_loaded)
for (TechNode& t : cat.tech.nodes) {
if (auto v = cat.strings.get("TECHNAME_" + t.name)) t.display_name = std::string(*v);
if (auto v = cat.strings.get("TECHDESC_" + t.name)) t.description = std::string(*v);
}
cat.rebuild_index();
return cat;
}
CrossCheck Catalog::cross_check() const {
CrossCheck x;
x.strings_available = strings_loaded;
auto tech_ref = [&](std::string_view token, const TechNode** exact) -> const TechNode* {
const TechNode* t = tech.find(token);
if (exact) *exact = (t && t->name == token) ? t : nullptr;
return t;
};
// weapon requires -> tech
for (const WeaponDef& w : weapons) {
if (w.requires.empty()) x.weapons_without_requires.push_back(w.file);
for (const std::string& r : w.requires) {
const TechNode* exact = nullptr;
const TechNode* t = tech_ref(r, &exact);
if (!t)
x.weapon_requires_dangling.push_back({w.file, r, 0});
else if (!exact)
x.weapon_requires_case_mismatch.push_back({w.file, r, 0});
}
if (!turrets.has_weapon_pair(w.turret_size, w.turret_class))
x.weapon_turret_pairs_without_row.push_back({w.file, w.turret_size + "/" + w.turret_class, 0});
if (strings_loaded && !w.name.empty() && w.name[0] == '@' && !strings.resolve(w.name))
x.unresolved_weapon_names.push_back({w.file, w.name, 0});
}
// sections: requires, options, bank weapons, turret pairs
for (const ShipSectionDef& s : sections) {
for (const std::string& r : s.requires) {
if (is_group_ref(r)) {
if (!tech.requirement_exists(r)) x.section_requires_dangling.push_back({s.file, r, 0});
continue;
}
const TechNode* exact = nullptr;
const TechNode* t = tech_ref(r, &exact);
if (!t)
x.section_requires_dangling.push_back({s.file, r, 0});
else if (!exact)
x.section_requires_case_mismatch.push_back({s.file, r, 0});
}
auto check_group = [&](const OptionGroup& g) {
for (const std::string& m : g.members)
if (!tech.find(m)) x.section_option_dangling.push_back({s.file, m, g.line});
};
for (const OptionGroup& g : s.options) check_group(g);
if (s.optiondef) check_group(*s.optiondef);
for (const BankDef& b : s.banks) {
if (!b.weapon.empty() && !weapon_by_file(b.weapon)) x.bank_weapon_dangling.push_back({s.file, b.weapon, b.line});
if (b.turret_size.empty() && b.turret_class.empty()) continue; // NPC fixed banks may omit both
if (!turrets.has_bank_pair(b.turret_size, b.turret_class))
x.bank_turret_pairs_without_row.push_back({s.file, b.turret_size + "/" + b.turret_class, b.line});
}
}
// tech: ship sections, weapon files, requires, allows
std::set<std::string> weapon_files;
for (const WeaponDef& w : weapons) weapon_files.insert(fold(w.file));
for (const TechNode& t : tech.nodes) {
for (const std::string& sec : t.sections)
if (sections_named(sec).empty()) x.tech_ship_section_dangling.push_back({t.name, sec, t.line});
for (const std::string& wf : t.weapon_files) {
std::string k = fold(wf);
std::replace(k.begin(), k.end(), '\\', '/');
if (!weapon_files.count(k)) x.tech_weapon_file_dangling.push_back({t.name, wf, t.line});
}
for (const std::string& r : t.requires)
if (!tech.requirement_exists(r)) x.tech_requires_dangling.push_back({t.name, r, t.line});
for (std::size_t ei : t.allows) {
const AllowsEdge& e = tech.edges[ei];
if (!tech.find(e.to)) x.tech_allows_dangling.push_back({t.name, e.to, e.line});
if (!e.rp || !e.unparsed.empty()) x.tech_allows_unparsed.push_back({t.name, e.text, e.line});
}
if (strings_loaded) {
if (!t.display_name) x.missing_techname.push_back(t.name);
if (!t.description) x.missing_techdesc.push_back(t.name);
}
}
for (const TechNode* r : tech.roots()) x.tech_roots.push_back(r->name);
// manifests <-> files
for (const IdEntry& e : weapon_ids.entries()) {
const WeaponDef* w = weapon(e.stem);
if (!w || w->scope != WeaponScope::Player) x.manifest_ids_without_file.push_back({"Weapons", e.id, e.name});
}
for (const WeaponDef& w : weapons)
if (w.scope == WeaponScope::Player && !w.id) x.files_without_manifest_id.push_back({"Weapons", w.file, 0});
for (const auto& kv : section_ids) {
for (const IdEntry& e : kv.second.entries())
if (!section(kv.first, e.stem)) x.manifest_ids_without_file.push_back({kv.first, e.id, e.name});
}
for (const ShipSectionDef& s : sections)
if (!s.id) x.files_without_manifest_id.push_back({s.race, s.file, 0});
// section strings: per distinct stem
if (strings_loaded) {
std::set<std::string> seen;
for (const ShipSectionDef& s : sections) {
std::string k = fold(s.stem);
if (!seen.insert(k).second) continue;
if (!strings.get("SECTIONNAME_" + s.stem)) x.missing_sectionname.push_back(k);
if (!strings.get("SECTIONDESC_" + s.stem)) x.missing_sectiondesc.push_back(k);
}
}
return x;
}
} // namespace game::data

117
src/game/data/catalog.h Normal file
View file

@ -0,0 +1,117 @@
// game::data -- Catalog: every definition in a data root, cross-linked.
//
// Root layout (an extracted sots.gob + sots_local_en.gob, or $SOTS_DATA_DIR):
// Weapons/*.weapon player weapons
// Weapons/_weapons.txt their stable ids
// Weapons/_turrets.txt turret fit table
// Species/<Race>/sections/*.shipsection per-race section catalogs
// Species/<Race>/sections/_shipsections.txt
// Species/<Race>/weapons/*.weapon race-private (NPC) weapons, no ids
// TechTree/MasterTechList.tech
// Locale/EN/Strings.csv optional; name/desc resolution
//
// Loading is total: unreadable or malformed files become Problems and the
// rest of the catalog still loads. Lookups fold ASCII case, which is how the
// data itself cross-references (`WEP_HCLAS` -> tech `WEP_HCLas`).
#pragma once
#include <filesystem>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#include "game/data/common.h"
#include "game/data/shipsection.h"
#include "game/data/strings.h"
#include "game/data/techtree.h"
#include "game/data/turrets.h"
#include "game/data/weapon.h"
namespace game::data {
struct CrossCheck {
struct Ref {
std::string from; // file (relative) or tech name
std::string ref; // the token that was referenced
int line = 0;
};
struct ManifestGap {
std::string scope; // "Weapons" or the race directory
int id = 0;
std::string name; // as written in the manifest
};
// dangling references (each must be empty for a clean catalog)
std::vector<Ref> weapon_requires_dangling;
std::vector<Ref> section_requires_dangling;
std::vector<Ref> section_option_dangling; // option / optiondef members
std::vector<Ref> tech_ship_section_dangling; // tech ship{section} -> any race's catalog
std::vector<Ref> tech_weapon_file_dangling; // tech weapon{filename} -> a loaded weapon file
std::vector<Ref> tech_requires_dangling; // tech or non-empty GRP_
std::vector<Ref> tech_allows_dangling; // allows child -> tech
std::vector<Ref> tech_allows_unparsed;
std::vector<Ref> bank_weapon_dangling; // bank{weapon FILE} -> a loaded weapon file
std::vector<ManifestGap> manifest_ids_without_file;
std::vector<Ref> files_without_manifest_id; // from = scope, ref = file
std::vector<Ref> weapon_turret_pairs_without_row; // ref = "size/class"
std::vector<Ref> bank_turret_pairs_without_row;
std::vector<Ref> unresolved_weapon_names; // name token absent from strings
// informational
std::vector<Ref> weapon_requires_case_mismatch;
std::vector<Ref> section_requires_case_mismatch;
std::vector<std::string> weapons_without_requires; // files
std::vector<std::string> missing_techname, missing_techdesc; // tech names
std::vector<std::string> missing_sectionname, missing_sectiondesc; // section stems (folded, distinct)
std::vector<std::string> tech_roots; // never allowed by anything
bool strings_available = false;
std::size_t dangling_count() const;
bool clean() const { return dangling_count() == 0; }
};
class Catalog {
public:
std::vector<WeaponDef> weapons; // player weapons first, then race-private; stems folded-sorted
std::vector<ShipSectionDef> sections; // by (race, folded stem)
TechTree tech;
TurretTable turrets;
IdRegistry weapon_ids;
std::map<std::string, IdRegistry> section_ids; // race directory -> registry
std::vector<std::string> races; // race directories found, in load order
StringTable strings;
bool strings_loaded = false;
std::vector<Problem> problems; // everything non-fatal met while loading
// Lookups (case-insensitive). weapon(stem) prefers the player catalog.
const WeaponDef* weapon(std::string_view stem) const;
const WeaponDef* weapon_by_file(std::string_view rel_path) const;
const WeaponDef* weapon_by_id(int id) const;
const ShipSectionDef* section(std::string_view race, std::string_view stem) const;
const ShipSectionDef* section_by_id(std::string_view race, int id) const;
std::vector<const ShipSectionDef*> sections_named(std::string_view stem) const; // across races
const TechNode* tech_node(std::string_view name) const { return tech.find(name); }
CrossCheck cross_check() const;
void rebuild_index();
private:
std::unordered_map<std::string, std::size_t> weapon_by_stem_; // folded; player wins
std::unordered_map<std::string, std::size_t> weapon_by_file_; // folded rel path
std::unordered_map<int, std::size_t> weapon_by_id_;
std::unordered_map<std::string, std::size_t> section_by_key_; // folded "race/stem"
std::unordered_map<std::string, std::vector<std::size_t>> sections_by_stem_;
};
// Load everything under `root`. Problems (missing optional files included)
// are collected on the returned catalog; the function itself never fails.
Catalog load_catalog(const std::filesystem::path& root);
// Read a file as raw bytes; false (and a Problem) when it cannot be read.
bool read_file(const std::filesystem::path& path, std::string& out);
} // namespace game::data

66
src/game/data/common.cpp Normal file
View file

@ -0,0 +1,66 @@
#include "game/data/common.h"
namespace game::data {
namespace {
constexpr std::array<std::string_view, kSpeciesCount> kSpeciesNames = {
"Human", "Hiver", "Tarkas", "Liir", "_NPC", "Zuul", "Morrigi"};
unsigned char lower(unsigned char c) { return (c >= 'A' && c <= 'Z') ? static_cast<unsigned char>(c + 32) : c; }
} // namespace
std::string_view species_name(Species s) {
int i = static_cast<int>(s);
if (i < 0 || i >= kSpeciesCount) return "?";
return kSpeciesNames[static_cast<std::size_t>(i)];
}
Species parse_species(std::string_view name) {
for (int i = 0; i < kSpeciesCount; ++i)
if (iequals(name, kSpeciesNames[static_cast<std::size_t>(i)])) return static_cast<Species>(i);
if (iequals(name, "NPC")) return Species::NPC;
return Species::Unknown;
}
std::string_view problem_kind_name(Problem::Kind k) {
switch (k) {
case Problem::Kind::MissingBlock: return "MissingBlock";
case Problem::Kind::MissingKey: return "MissingKey";
case Problem::Kind::BadValue: return "BadValue";
case Problem::Kind::Unparsed: return "Unparsed";
case Problem::Kind::Duplicate: return "Duplicate";
case Problem::Kind::IoError: return "IoError";
case Problem::Kind::Syntax: return "Syntax";
}
return "?";
}
std::string fold(std::string_view s) {
std::string out(s);
for (char& c : out) c = static_cast<char>(lower(static_cast<unsigned char>(c)));
return out;
}
bool iequals(std::string_view a, std::string_view b) {
if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i)
if (lower(static_cast<unsigned char>(a[i])) != lower(static_cast<unsigned char>(b[i]))) return false;
return true;
}
bool starts_with_fold(std::string_view s, std::string_view prefix) {
return s.size() >= prefix.size() && iequals(s.substr(0, prefix.size()), prefix);
}
std::string_view strip_extension(std::string_view filename) {
auto dot = filename.rfind('.');
if (dot == std::string_view::npos || dot == 0) return filename;
return filename.substr(0, dot);
}
std::string_view basename(std::string_view path) {
auto slash = path.find_last_of("/\\");
return slash == std::string_view::npos ? path : path.substr(slash + 1);
}
} // namespace game::data

60
src/game/data/common.h Normal file
View file

@ -0,0 +1,60 @@
// game::data -- types shared by the catalog loaders.
//
// The loaders are total: every input yields a value (or, when the file has no
// usable top-level block, no value) plus a list of Problems. Nothing throws
// across this API.
#pragma once
#include <array>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace game::data {
// The species index order the engine uses in its save records and species
// tables (Human 0 .. Morrigi 6). Tech `allows` percentages are stored in this
// order; `_NPC` is a real slot (it has its own section catalog).
enum class Species : int { Human = 0, Hiver, Tarkas, Liir, NPC, Zuul, Morrigi, Unknown };
constexpr int kSpeciesCount = 7;
std::string_view species_name(Species s); // "Human" .. "_NPC"; "?" for Unknown
Species parse_species(std::string_view name); // case-insensitive; "NPC" == "_NPC"
struct Problem {
enum class Kind {
MissingBlock, // file has no top-level block of the expected name
MissingKey, // a key the schema requires is absent
BadValue, // a value does not have the expected shape (kept in raw)
Unparsed, // a structured value (allows string, turret row) could not be parsed
Duplicate, // an id / name occurs twice
IoError, // a file could not be read or a directory listed
Syntax // the underlying reader rejected the file
};
Kind kind = Kind::BadValue;
std::string file; // relative path when known
int line = 0; // 1-based; 0 = whole file
std::string key; // offending key / token when relevant
std::string message;
};
std::string_view problem_kind_name(Problem::Kind k);
template <class T>
struct Loaded {
std::optional<T> value;
std::vector<Problem> problems;
bool ok() const { return value.has_value(); }
explicit operator bool() const { return ok(); }
};
// ASCII helpers (the data is cp1252; bytes >= 0x80 are never folded).
std::string fold(std::string_view s);
bool iequals(std::string_view a, std::string_view b);
bool starts_with_fold(std::string_view s, std::string_view prefix);
std::string_view strip_extension(std::string_view filename); // "x.weapon" -> "x"
std::string_view basename(std::string_view path); // last '/'-separated part
} // namespace game::data

84
src/game/data/fields.h Normal file
View file

@ -0,0 +1,84 @@
// game::data -- internal helper: typed reads of a Block's scalars that record
// a Problem when a value is present but has the wrong shape. Shared by the
// loaders; not part of the public API.
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include "game/data/block.h"
#include "game/data/common.h"
namespace game::data::detail {
class Fields {
public:
Fields(const Block& b, std::string file, std::vector<Problem>& problems)
: b_(b), file_(std::move(file)), problems_(problems) {}
const Block& block() const { return b_; }
// Last occurrence wins for every accessor (the rule for repeated scalar keys).
std::string str(std::string_view key) const { return b_.str(key); }
std::optional<std::string> opt_str(std::string_view key) const { return b_.opt_str(key); }
std::vector<std::string> strs(std::string_view key) const { return b_.strs(key); }
std::string required_str(std::string_view key) {
const Attr* a = b_.find(key);
if (!a) {
missing(key);
return {};
}
return a->value;
}
std::optional<std::int64_t> opt_int(std::string_view key) {
const Attr* a = b_.find(key);
if (!a) return std::nullopt;
auto v = a->as_int();
if (!v) bad(*a, "expected an integer");
return v;
}
std::optional<double> opt_double(std::string_view key) {
const Attr* a = b_.find(key);
if (!a) return std::nullopt;
auto v = a->as_double();
if (!v) bad(*a, "expected a number");
return v;
}
std::optional<bool> opt_bool(std::string_view key) {
const Attr* a = b_.find(key);
if (!a) return std::nullopt;
auto v = a->as_bool();
if (!v) bad(*a, "expected true/false or 0/1");
return v;
}
void missing(std::string_view key) {
problems_.push_back({Problem::Kind::MissingKey, file_, b_.line, std::string(key),
"required key `" + std::string(key) + "` missing in block `" + b_.name + "`"});
}
void bad(const Attr& a, std::string_view what) {
problems_.push_back({Problem::Kind::BadValue, file_, a.line, a.key,
std::string(what) + " for `" + a.key + "`, got `" + a.value + "`"});
}
void problem(Problem::Kind kind, int line, std::string key, std::string message) {
problems_.push_back({kind, file_, line, std::move(key), std::move(message)});
}
Fields sub(const Block& child) const { return Fields(child, file_, problems_); }
private:
const Block& b_;
std::string file_;
std::vector<Problem>& problems_;
};
} // namespace game::data::detail

View file

@ -0,0 +1,210 @@
#include "game/data/shipsection.h"
#include <algorithm>
#include "game/data/fields.h"
namespace game::data {
using detail::Fields;
std::string_view section_type_name(SectionType t) {
switch (t) {
case SectionType::None: return "";
case SectionType::Command: return "command";
case SectionType::Mission: return "mission";
case SectionType::Engine: return "engine";
case SectionType::Other: return "other";
}
return "";
}
std::string_view section_class_name(SectionClass c) {
switch (c) {
case SectionClass::None: return "";
case SectionClass::Destroyer: return "destroyer";
case SectionClass::Cruiser: return "cruiser";
case SectionClass::Dreadnought: return "dreadnought";
case SectionClass::Other: return "other";
}
return "";
}
SectionType parse_section_type(std::string_view s) {
if (s.empty()) return SectionType::None;
if (iequals(s, "command")) return SectionType::Command;
if (iequals(s, "mission")) return SectionType::Mission;
if (iequals(s, "engine")) return SectionType::Engine;
return SectionType::Other;
}
SectionClass parse_section_class(std::string_view s) {
if (s.empty()) return SectionClass::None;
if (iequals(s, "destroyer")) return SectionClass::Destroyer;
if (iequals(s, "cruiser")) return SectionClass::Cruiser;
if (iequals(s, "dreadnought")) return SectionClass::Dreadnought;
return SectionClass::Other;
}
namespace {
// `option` entries: scalar pairs and blocks share the key; the normalised
// list keeps file order across both forms (ord = position in the block).
std::vector<OptionGroup> read_options(const Block& section) {
struct Item { int ord; OptionGroup group; };
std::vector<Item> items;
for (const Attr& a : section.attrs) {
if (!iequals(a.key, "option")) continue;
OptionGroup g;
g.members.push_back(a.value);
g.scalar = true;
g.line = a.line;
items.push_back({a.ord, std::move(g)});
}
for (const Block& b : section.blocks) {
if (!iequals(b.name, "option")) continue;
OptionGroup g;
g.members = b.strs("option");
g.scalar = false;
g.line = b.line;
items.push_back({b.ord, std::move(g)});
}
std::stable_sort(items.begin(), items.end(), [](const Item& x, const Item& y) { return x.ord < y.ord; });
std::vector<OptionGroup> out;
out.reserve(items.size());
for (auto& it : items) out.push_back(std::move(it.group));
return out;
}
MountDef read_mount(Fields& f) {
MountDef m;
m.node = f.required_str("node");
m.min_azimuth = f.opt_double("min_azimuth");
m.max_azimuth = f.opt_double("max_azimuth");
m.min_inclination = f.opt_double("min_inclination");
m.max_inclination = f.opt_double("max_inclination");
m.home_azimuth = f.opt_double("home_azimuth");
m.home_inclination = f.opt_double("home_inclination");
m.line = f.block().line;
return m;
}
BankDef read_bank(Fields& f) {
BankDef b;
b.turret_class = f.str("turretclass");
b.turret_size = f.str("turretsize");
b.weapon = f.str("weapon");
b.show_turrets = f.opt_bool("showturrets");
b.invincible = f.opt_bool("invincible");
b.repeated_turret_spec = f.block().all("turretsize").size() > 1 || f.block().all("turretclass").size() > 1;
b.line = f.block().line;
for (const Block* m : f.block().blocks_named("mount")) {
Fields mf = f.sub(*m);
b.mounts.push_back(read_mount(mf));
}
return b;
}
NetForceLimits read_netforce(Fields& f) {
NetForceLimits n;
n.force_forward = f.opt_double("force_forward");
n.force_right = f.opt_double("force_right");
n.force_up = f.opt_double("force_up");
n.torque_yaw = f.opt_double("torque_yaw");
n.torque_pitch = f.opt_double("torque_pitch");
n.torque_roll = f.opt_double("torque_roll");
n.speed = f.opt_double("speed");
n.rotspeed = f.opt_double("rotspeed");
return n;
}
} // namespace
Loaded<ShipSectionDef> load_shipsection(const mars::parse::Document& doc, std::string file) {
Loaded<ShipSectionDef> out;
const mars::parse::Node* node = doc.root.first_block("shipsection");
if (!node) {
out.problems.push_back({Problem::Kind::MissingBlock, file, 0, "shipsection", "no top-level shipsection{} block"});
return out;
}
ShipSectionDef s;
s.file = file;
s.raw = snapshot(*node);
Fields f(s.raw, file, out.problems);
s.model = f.required_str("model");
s.dam_model = f.str("dam_model");
s.requires = f.strs("requires");
s.section_type_text = f.str("section_type");
s.section_type = parse_section_type(s.section_type_text);
s.section_class_text = f.str("section_class");
s.section_class = parse_section_class(s.section_class_text);
s.design_class = f.str("design_class");
s.entity_class = f.str("entity_class");
s.health = f.opt_double("health");
if (!f.block().find("health")) f.missing("health");
s.mass = f.opt_double("mass");
if (!f.block().find("mass")) f.missing("mass");
s.cost = f.opt_int("cost");
s.cpoints = f.opt_int("cpoints");
s.crew = f.opt_int("crew");
s.command_cost = f.opt_int("command_cost");
s.maintenance_cost = f.opt_int("maintenance_cost");
s.command_quota = f.opt_int("command_quota");
s.socket_fore = f.str("socket_fore");
s.socket_aft = f.str("socket_aft");
s.dam_socket_fore = f.str("dam_socket_fore");
s.dam_socket_aft = f.str("dam_socket_aft");
s.options = read_options(s.raw);
if (const Block* od = s.raw.block("optiondef")) {
OptionGroup g;
g.members = od->strs("option");
g.line = od->line;
s.optiondef = std::move(g);
}
for (const Block* b : s.raw.blocks_named("bank")) {
Fields bf = f.sub(*b);
s.banks.push_back(read_bank(bf));
}
s.ftlspeed = f.opt_double("ftlspeed");
s.nodespeed = f.opt_double("nodespeed");
s.range = f.opt_double("range");
s.scanrange = f.opt_double("scanrange");
s.tactical_sensor_range = f.opt_double("tacticalsensorrange");
s.engine_techera = f.str("engine_techera");
if (const Block* n = s.raw.block("netforcelimits")) {
Fields nf = f.sub(*n);
s.netforcelimits = read_netforce(nf);
}
for (const Block* t : s.raw.blocks_named("thruster")) {
ThrusterDef th;
th.node = t->str("node");
th.effect = t->str("effect");
th.idle_effect = t->str("idle_effect");
s.thrusters.push_back(std::move(th));
}
s.exclude = f.strs("exclude"); // one `exclude "STEM"` line per forbidden partner
s.explicit_command_section = f.str("explicit_command_section");
s.explicit_engine_section = f.str("explicit_engine_section");
s.explicit_section = f.opt_bool("explicit_section");
s.autonomous = f.opt_bool("autonomous");
s.nodesign = f.opt_bool("nodesign");
out.value = std::move(s);
return out;
}
Loaded<ShipSectionDef> parse_shipsection(std::string_view text, std::string file) {
auto parsed = mars::parse::parse_blocks(text);
if (!parsed.ok()) {
Loaded<ShipSectionDef> out;
out.problems.push_back({Problem::Kind::Syntax, file, parsed.error().line, "", parsed.error().message});
return out;
}
Loaded<ShipSectionDef> out = load_shipsection(parsed.value(), file);
for (const auto& d : parsed.value().warnings)
out.problems.push_back({Problem::Kind::Syntax, file, d.line, "", "recovered: " + d.message});
return out;
}
} // namespace game::data

115
src/game/data/shipsection.h Normal file
View file

@ -0,0 +1,115 @@
// game::data -- ShipSectionDef: one `Species/<Race>/sections/*.shipsection`.
//
// shipsection {
// model PATH requires TECH... section_type command|mission|engine
// section_class destroyer|cruiser|dreadnought
// health mass cost cpoints crew socket_fore/socket_aft NODE
// option TECH -- one-member option group
// option { option A option B } -- mutually exclusive option group
// optiondef { option ... } -- shield-level group (shield sections)
// bank { turretclass C turretsize S [weapon FILE] mount { node N min/max_azimuth min/max_inclination } ... }
// ftlspeed nodespeed engine_techera netforcelimits { force_* torque_* speed rotspeed }
// thruster { node effect idle_effect }
// }
//
// `options` is the normalised view of every `option` entry in file order:
// a scalar `option T` becomes a one-member group, a block keeps its members.
// `section_type` / `section_class` are case-mixed in the data; the enums are
// derived case-insensitively and the text is kept as written.
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include "game/data/block.h"
#include "game/data/common.h"
#include "mars/parse/blocks.h"
namespace game::data {
enum class SectionType { None, Command, Mission, Engine, Other };
enum class SectionClass { None, Destroyer, Cruiser, Dreadnought, Other };
std::string_view section_type_name(SectionType t);
std::string_view section_class_name(SectionClass c);
SectionType parse_section_type(std::string_view s);
SectionClass parse_section_class(std::string_view s);
struct OptionGroup {
std::vector<std::string> members; // tech names, file order
bool scalar = false; // written as `option T` (one member)
int line = 0;
};
struct MountDef {
std::string node;
std::optional<double> min_azimuth, max_azimuth, min_inclination, max_inclination;
std::optional<double> home_azimuth, home_inclination;
int line = 0;
};
struct BankDef {
std::string turret_class; // last value if the key repeats
std::string turret_size;
std::string weapon; // fixed weapon file (NPC banks); "" when the design chooses
std::optional<bool> show_turrets, invincible;
std::vector<MountDef> mounts;
bool repeated_turret_spec = false; // turretsize/turretclass written more than once
int line = 0;
};
struct NetForceLimits {
std::optional<double> force_forward, force_right, force_up;
std::optional<double> torque_yaw, torque_pitch, torque_roll;
std::optional<double> speed, rotspeed;
};
struct ThrusterDef {
std::string node, effect, idle_effect;
};
struct ShipSectionDef {
// identity (filled by the catalog)
std::string race; // directory name under Species/
Species species = Species::Unknown;
std::string stem;
std::string file;
std::optional<int> id; // from the race's _shipsections.txt
std::optional<std::string> display_name, description;
std::vector<std::string> unlocked_by; // techs whose ship{section} lists this stem
// body
std::string model, dam_model;
std::vector<std::string> requires; // AND; may name GRP_<group>
std::string section_type_text;
SectionType section_type = SectionType::None;
std::string section_class_text;
SectionClass section_class = SectionClass::None;
std::string design_class, entity_class;
std::optional<double> health, mass;
std::optional<std::int64_t> cost, cpoints, crew;
std::optional<std::int64_t> command_cost, maintenance_cost, command_quota;
std::string socket_fore, socket_aft, dam_socket_fore, dam_socket_aft;
std::vector<OptionGroup> options;
std::optional<OptionGroup> optiondef;
std::vector<BankDef> banks;
std::optional<double> ftlspeed, nodespeed, range, scanrange, tactical_sensor_range;
std::string engine_techera;
std::optional<NetForceLimits> netforcelimits;
std::vector<ThrusterDef> thrusters;
std::vector<std::string> exclude;
std::string explicit_command_section, explicit_engine_section;
std::optional<bool> explicit_section, autonomous, nodesign;
Block raw;
bool has_sockets() const { return !socket_fore.empty() || !socket_aft.empty(); }
};
Loaded<ShipSectionDef> load_shipsection(const mars::parse::Document& doc, std::string file = {});
Loaded<ShipSectionDef> parse_shipsection(std::string_view text, std::string file = {});
} // namespace game::data

43
src/game/data/strings.cpp Normal file
View file

@ -0,0 +1,43 @@
#include "game/data/strings.h"
#include "mars/text/csv.h"
namespace game::data {
std::optional<std::string_view> StringTable::get(std::string_view key) const {
auto it = by_key_.find(fold(key));
if (it == by_key_.end()) return std::nullopt;
return std::string_view(it->second);
}
std::optional<std::string_view> StringTable::resolve(std::string_view token) const {
if (!token.empty() && token[0] == '@') token.remove_prefix(1);
return get(token);
}
void StringTable::set(std::string key, std::string text) {
std::string k = fold(key);
auto it = by_key_.find(k);
if (it != by_key_.end()) {
duplicates_.emplace_back(key, it->second);
it->second = std::move(text);
return;
}
by_key_.emplace(std::move(k), std::move(text));
}
Loaded<StringTable> parse_string_table(std::string_view csv_text, std::string file) {
Loaded<StringTable> out;
StringTable t;
auto csv = mars::text::parse_csv(csv_text);
for (const auto& p : csv.problems)
out.problems.push_back({Problem::Kind::Unparsed, file, p.line, "", p.message});
for (const auto& row : csv.value.rows) {
if (row.cells.empty()) continue;
t.set(row.cells[0], row.cells.size() > 1 ? row.cells[1] : std::string());
}
out.value = std::move(t);
return out;
}
} // namespace game::data

38
src/game/data/strings.h Normal file
View file

@ -0,0 +1,38 @@
// game::data -- the localisation table (Locale/EN/Strings.csv: Key,String,Size,Notes).
//
// Every `@TOKEN` in the catalogs and every TECHNAME_/SECTIONNAME_/... key
// resolves here. Keys are matched case-insensitively; when a key repeats the
// later row wins (the shipped file repeats four keys with a trailing space,
// which the CSV reader has already stripped).
#pragma once
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#include "game/data/common.h"
namespace game::data {
class StringTable {
public:
std::size_t size() const { return by_key_.size(); }
bool empty() const { return by_key_.empty(); }
std::optional<std::string_view> get(std::string_view key) const;
// "@TOKEN" -> get("TOKEN"); a plain key is looked up as-is.
std::optional<std::string_view> resolve(std::string_view token) const;
void set(std::string key, std::string text);
const std::vector<std::pair<std::string, std::string>>& duplicates() const { return duplicates_; }
private:
std::unordered_map<std::string, std::string> by_key_; // folded key -> text
std::vector<std::pair<std::string, std::string>> duplicates_; // (key, earlier text)
};
Loaded<StringTable> parse_string_table(std::string_view csv_text, std::string file = {});
} // namespace game::data

202
src/game/data/techtree.cpp Normal file
View file

@ -0,0 +1,202 @@
#include "game/data/techtree.h"
#include "game/data/fields.h"
#include "mars/parse/value.h"
#include "mars/text/value.h"
namespace game::data {
using detail::Fields;
namespace {
std::string upper(std::string_view s) {
std::string out(s);
for (char& c : out)
if (c >= 'a' && c <= 'z') c = static_cast<char>(c - 32);
return out;
}
std::vector<std::string_view> split_ws(std::string_view s) {
std::vector<std::string_view> out;
std::size_t i = 0;
while (i < s.size()) {
while (i < s.size() && mars::text::is_space(s[i])) ++i;
std::size_t start = i;
while (i < s.size() && !mars::text::is_space(s[i])) ++i;
if (i > start) out.push_back(s.substr(start, i - start));
}
return out;
}
} // namespace
AllowsEdge parse_allows(std::string_view text, std::string from, int line, std::vector<Problem>* problems,
std::string_view file) {
AllowsEdge e;
e.from = std::move(from);
e.text = std::string(text);
e.line = line;
auto toks = split_ws(text);
if (toks.empty()) {
if (problems)
problems->push_back({Problem::Kind::Unparsed, std::string(file), line, "allows", "empty allows string"});
return e;
}
e.to = std::string(toks[0]);
for (std::size_t i = 1; i < toks.size(); ++i) {
std::string_view t = toks[i];
auto colon = t.find(':');
if (colon == std::string_view::npos) {
e.unparsed.emplace_back(t);
continue;
}
std::string_view key = t.substr(0, colon);
std::string_view val = t.substr(colon + 1);
auto n = mars::parse::as_int(val);
if (!n) {
e.unparsed.emplace_back(t);
continue;
}
if (iequals(key, "RP")) {
e.rp = *n;
continue;
}
Species s = parse_species(key);
if (s == Species::Unknown) {
e.unparsed.emplace_back(t);
continue;
}
e.pct[static_cast<std::size_t>(s)] = static_cast<int>(*n);
e.pct_written[static_cast<std::size_t>(s)] = true;
}
if (problems) {
if (!e.rp)
problems->push_back({Problem::Kind::Unparsed, std::string(file), line, "allows",
"allows `" + e.text + "` has no RP: cost"});
for (const std::string& u : e.unparsed)
problems->push_back({Problem::Kind::Unparsed, std::string(file), line, "allows",
"allows `" + e.text + "`: unrecognised token `" + u + "`"});
}
return e;
}
const TechNode* TechTree::find(std::string_view name) const {
auto it = by_name_.find(fold(name));
return it == by_name_.end() ? nullptr : &nodes[it->second];
}
const std::vector<std::string>* TechTree::group_members(std::string_view name_or_ref) const {
std::string_view g = is_group_ref(name_or_ref) ? group_of_ref(name_or_ref) : name_or_ref;
for (const auto& kv : groups)
if (iequals(kv.first, g)) return &kv.second;
return nullptr;
}
std::vector<const AllowsEdge*> TechTree::edges_to(std::string_view name) const {
std::vector<const AllowsEdge*> out;
for (const AllowsEdge& e : edges)
if (iequals(e.to, name)) out.push_back(&e);
return out;
}
std::vector<const AllowsEdge*> TechTree::edges_from(std::string_view name) const {
std::vector<const AllowsEdge*> out;
for (const AllowsEdge& e : edges)
if (iequals(e.from, name)) out.push_back(&e);
return out;
}
std::vector<const TechNode*> TechTree::roots() const {
std::vector<const TechNode*> out;
for (const TechNode& n : nodes)
if (edges_to(n.name).empty()) out.push_back(&n);
return out;
}
bool TechTree::requirement_exists(std::string_view token) const {
if (is_group_ref(token)) {
const auto* m = group_members(token);
return m && !m->empty();
}
return find(token) != nullptr;
}
void TechTree::rebuild_index() {
by_name_.clear();
for (std::size_t i = 0; i < nodes.size(); ++i) by_name_[fold(nodes[i].name)] = i; // last wins
}
Loaded<TechTree> load_tech_tree(const mars::parse::Document& doc, std::string file) {
Loaded<TechTree> out;
TechTree tree;
auto techs = doc.root.blocks("tech");
if (techs.empty()) {
out.problems.push_back({Problem::Kind::MissingBlock, file, 0, "tech", "no tech{} blocks"});
return out;
}
for (const mars::parse::Node* node : techs) {
TechNode t;
t.raw = snapshot(*node);
t.line = node->line;
Fields f(t.raw, file, out.problems);
t.name = f.required_str("name");
if (t.name.empty()) continue;
t.family = f.str("family");
auto us = t.name.find('_');
t.family_inferred = upper(us == std::string::npos ? std::string_view(t.name) : std::string_view(t.name).substr(0, us));
t.type = f.str("type");
t.threat = f.opt_int("threat");
t.group = f.str("group");
t.option_cost = f.opt_double("option_cost");
t.unlock_explicitly = f.opt_bool("unlock_explicitly");
t.requires = f.strs("requires");
for (const Block* s : t.raw.blocks_named("strategy")) {
for (const std::string& v : s->strs("inc")) t.benefits_inc.push_back(v);
for (const std::string& v : s->strs("dec")) t.benefits_dec.push_back(v);
}
for (const Block* s : t.raw.blocks_named("ship"))
for (const std::string& v : s->strs("section")) t.sections.push_back(v);
for (const Block* w : t.raw.blocks_named("weapon"))
for (const std::string& v : w->strs("filename")) t.weapon_files.push_back(v);
for (const Attr* a : t.raw.all("allows")) {
AllowsEdge e = parse_allows(a->value, t.name, a->line, &out.problems, file);
t.allows.push_back(tree.edges.size());
tree.edges.push_back(std::move(e));
}
if (!t.group.empty()) {
std::string g = upper(t.group);
bool found = false;
for (auto& kv : tree.groups)
if (kv.first == g) {
kv.second.push_back(t.name);
found = true;
}
if (!found) tree.groups.emplace_back(g, std::vector<std::string>{t.name});
}
for (const TechNode& n : tree.nodes)
if (iequals(n.name, t.name)) {
out.problems.push_back({Problem::Kind::Duplicate, file, t.line, t.name, "tech name repeats: " + t.name});
break;
}
tree.nodes.push_back(std::move(t));
}
tree.rebuild_index();
out.value = std::move(tree);
return out;
}
Loaded<TechTree> parse_tech_tree(std::string_view text, std::string file) {
auto parsed = mars::parse::parse_blocks(text);
if (!parsed.ok()) {
Loaded<TechTree> out;
out.problems.push_back({Problem::Kind::Syntax, file, parsed.error().line, "", parsed.error().message});
return out;
}
Loaded<TechTree> out = load_tech_tree(parsed.value(), file);
for (const auto& d : parsed.value().warnings)
out.problems.push_back({Problem::Kind::Syntax, file, d.line, "", "recovered: " + d.message});
return out;
}
} // namespace game::data

102
src/game/data/techtree.h Normal file
View file

@ -0,0 +1,102 @@
// game::data -- the research graph (TechTree/MasterTechList.tech).
//
// tech {
// name "IND_Waldo" family "IND" type P threat N group TORPS option_cost 1.2
// requires "TECH" | "GRP_<group>" -- prerequisite beyond the allows edge
// allows "CHILD RP:cost Human:% Zuul:% Hiver:% Tarkas:% Liir:% Morrigi:%"
// strategy { inc TECHBEN_X dec TECHBEN_Y }
// ship { section STEM ... } -- "new section" notice (not a build gate)
// weapon { filename "Weapons/x.weapon" }
// }
//
// `allows` is a directed edge parent -> child with the research-point cost of
// the child when reached through this parent and a per-species availability
// percentage. A species not named on the edge gets the engine default of 100
// (data-parsers.md; the file never writes it). Species are indexed in the
// engine's species order (common.h), so `_NPC` has a slot and is always 100.
#pragma once
#include <array>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#include "game/data/block.h"
#include "game/data/common.h"
#include "mars/parse/blocks.h"
namespace game::data {
constexpr int kDefaultAllowPercent = 100;
struct AllowsEdge {
std::string from, to;
std::optional<std::int64_t> rp;
std::array<int, kSpeciesCount> pct{}; // effective values (default 100)
std::array<bool, kSpeciesCount> pct_written{}; // which ones the file spelled out
std::vector<std::string> unparsed; // tokens that were neither RP: nor a species
std::string text; // the allows string as written
int line = 0;
AllowsEdge() { pct.fill(kDefaultAllowPercent); pct_written.fill(false); }
int percent(Species s) const { return pct[static_cast<std::size_t>(s)]; }
};
// Parse one allows string. `problems` (optional) receives an Unparsed entry
// for leftover tokens / missing RP.
AllowsEdge parse_allows(std::string_view text, std::string from, int line = 0,
std::vector<Problem>* problems = nullptr, std::string_view file = {});
struct TechNode {
std::string name;
std::string family; // as written (often absent)
std::string family_inferred; // upper-cased name prefix before '_'
std::string type; // "P" on project techs
std::optional<std::int64_t> threat;
std::string group; // as written; group membership is matched upper-cased
std::optional<double> option_cost;
std::optional<bool> unlock_explicitly;
std::vector<std::string> requires;
std::vector<std::string> benefits_inc, benefits_dec; // TECHBEN_* tokens, kept as data
std::vector<std::string> sections; // ship{section} stems
std::vector<std::string> weapon_files; // weapon{filename}
std::vector<std::size_t> allows; // indices into TechTree::edges
std::optional<std::string> display_name, description; // TECHNAME_/TECHDESC_ (catalog fills)
Block raw;
int line = 0;
bool is_root() const { return starts_with_fold(name.size() >= 5 ? name.substr(name.size() - 5) : name, "_ROOT"); }
};
inline bool is_group_ref(std::string_view token) { return starts_with_fold(token, "GRP_"); }
inline std::string_view group_of_ref(std::string_view token) { return token.substr(4); } // after GRP_
class TechTree {
public:
std::vector<TechNode> nodes;
std::vector<AllowsEdge> edges;
// upper-cased group name -> member tech names, first-seen order
std::vector<std::pair<std::string, std::vector<std::string>>> groups;
const TechNode* find(std::string_view name) const; // case-insensitive
const std::vector<std::string>* group_members(std::string_view name_or_ref) const; // "TORPS" or "GRP_Torps"
std::vector<const AllowsEdge*> edges_to(std::string_view name) const;
std::vector<const AllowsEdge*> edges_from(std::string_view name) const;
std::vector<const TechNode*> roots() const; // nodes no edge points at
// A `requires` token is satisfiable if it names a tech or a non-empty group.
bool requirement_exists(std::string_view token) const;
void rebuild_index();
private:
std::unordered_map<std::string, std::size_t> by_name_; // folded
};
Loaded<TechTree> load_tech_tree(const mars::parse::Document& doc, std::string file = {});
Loaded<TechTree> parse_tech_tree(std::string_view text, std::string file = {});
} // namespace game::data

115
src/game/data/turrets.cpp Normal file
View file

@ -0,0 +1,115 @@
#include "game/data/turrets.h"
#include "mars/text/flat_kv.h"
#include "mars/text/manifest.h"
namespace game::data {
const TurretRow* TurretTable::find(std::string_view mount_size, std::string_view weapon_size,
std::string_view turret_class) const {
for (const TurretRow& r : rows_)
if (iequals(r.mount_size, mount_size) && iequals(r.weapon_size, weapon_size) &&
iequals(r.turret_class, turret_class))
return &r;
return nullptr;
}
bool TurretTable::has_weapon_pair(std::string_view weapon_size, std::string_view turret_class) const {
for (const TurretRow& r : rows_)
if (iequals(r.weapon_size, weapon_size) && iequals(r.turret_class, turret_class)) return true;
return false;
}
bool TurretTable::has_bank_pair(std::string_view mount_size, std::string_view turret_class) const {
for (const TurretRow& r : rows_)
if (iequals(r.mount_size, mount_size) && iequals(r.turret_class, turret_class)) return true;
return false;
}
std::vector<const TurretRow*> TurretTable::rows_for_bank(std::string_view mount_size,
std::string_view turret_class) const {
std::vector<const TurretRow*> out;
for (const TurretRow& r : rows_)
if (iequals(r.mount_size, mount_size) && iequals(r.turret_class, turret_class)) out.push_back(&r);
return out;
}
Loaded<TurretTable> parse_turret_table(std::string_view text, std::string file) {
Loaded<TurretTable> out;
TurretTable table;
auto rows = mars::text::parse_rows(text);
for (const auto& p : rows.problems)
out.problems.push_back({Problem::Kind::Unparsed, file, p.line, "", p.message});
for (const mars::text::Row& r : rows.value) {
if (r.tokens.size() != 8) {
out.problems.push_back({Problem::Kind::Unparsed, file, r.line, "",
"turret row has " + std::to_string(r.tokens.size()) + " fields, expected 8"});
continue;
}
TurretRow row;
row.mount_size = r.tokens[0].text;
row.weapon_size = r.tokens[1].text;
row.turret_class = r.tokens[2].text;
row.health = r.tokens[3].as_float();
row.track_speed = r.tokens[4].as_float();
row.azimuth_scale = r.tokens[5].as_float();
row.inclination_scale = r.tokens[6].as_float();
row.model = r.tokens[7].text;
row.line = r.line;
if (!row.health || !row.track_speed || !row.azimuth_scale || !row.inclination_scale)
out.problems.push_back({Problem::Kind::BadValue, file, r.line, "", "turret row has a non-numeric stat"});
table.add(std::move(row));
}
out.value = std::move(table);
return out;
}
const IdEntry* IdRegistry::find(int id) const {
const IdEntry* hit = nullptr;
for (const IdEntry& e : entries_)
if (e.id == id) hit = &e; // last assignment wins, like the reference
return hit;
}
std::optional<int> IdRegistry::id_of(std::string_view name_or_stem) const {
std::optional<int> hit;
for (const IdEntry& e : entries_)
if (iequals(e.name, name_or_stem) || iequals(e.stem, name_or_stem)) hit = e.id;
return hit;
}
bool IdRegistry::is_deleted(int id) const {
for (int d : deleted_)
if (d == id) return true;
return false;
}
void IdRegistry::add(IdEntry e) {
if (e.stem.empty()) e.stem = std::string(strip_extension(e.name));
entries_.push_back(std::move(e));
}
Loaded<IdRegistry> parse_id_registry(std::string_view text, std::string file) {
Loaded<IdRegistry> out;
IdRegistry reg;
auto m = mars::text::parse_manifest(text);
for (const auto& p : m.problems) {
Problem::Kind k = Problem::Kind::Unparsed;
if (p.kind == mars::text::Problem::Kind::DuplicateId ||
p.kind == mars::text::Problem::Kind::DeletedAndAssigned)
k = Problem::Kind::Duplicate;
out.problems.push_back({k, file, p.line, p.id >= 0 ? std::to_string(p.id) : std::string(), p.message});
}
for (const auto& e : m.value.entries()) {
IdEntry ie;
ie.id = e.id;
ie.name = e.name;
ie.line = e.line;
reg.add(std::move(ie));
}
for (int d : m.value.deleted()) reg.add_deleted(d);
out.value = std::move(reg);
return out;
}
} // namespace game::data

78
src/game/data/turrets.h Normal file
View file

@ -0,0 +1,78 @@
// game::data -- the turret table (Weapons/_turrets.txt) and the stable id
// registries (Weapons/_weapons.txt, Species/<Race>/sections/_shipsections.txt).
//
// _turrets.txt is a whitespace-positional table, one row per (mount size,
// weapon size, class):
// size weapon-size class health track-speed azimuth% inclination% "model"
// A row is what makes a weapon of (weapon-size, class) fit a bank of (size,
// class); the model may be "" (the weapon supplies its own turret model).
//
// The id manifests assign the persistent network / savegame ids; retired ids
// stay as `// DELETED - n` tombstones and must never be reused.
#pragma once
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include "game/data/common.h"
namespace game::data {
struct TurretRow {
std::string mount_size; // bank turretsize (small / medium / large)
std::string weapon_size; // weapon turretsize (tiny / small / medium / large)
std::string turret_class;
std::optional<double> health, track_speed, azimuth_scale, inclination_scale;
std::string model; // may be empty
int line = 0;
};
class TurretTable {
public:
const std::vector<TurretRow>& rows() const { return rows_; }
std::size_t size() const { return rows_.size(); }
void add(TurretRow row) { rows_.push_back(std::move(row)); }
// All comparisons are case-insensitive (the data mixes `Large`/`large`,
// `Missile`/`missile`, `PlanetMissile`/`planetmissile`).
const TurretRow* find(std::string_view mount_size, std::string_view weapon_size, std::string_view turret_class) const;
bool has_weapon_pair(std::string_view weapon_size, std::string_view turret_class) const;
bool has_bank_pair(std::string_view mount_size, std::string_view turret_class) const;
std::vector<const TurretRow*> rows_for_bank(std::string_view mount_size, std::string_view turret_class) const;
private:
std::vector<TurretRow> rows_;
};
Loaded<TurretTable> parse_turret_table(std::string_view text, std::string file = {});
struct IdEntry {
int id = 0;
std::string name; // as written, e.g. "DEWar.SHIPSECTION"
std::string stem; // name without extension, as written
int line = 0;
};
class IdRegistry {
public:
const std::vector<IdEntry>& entries() const { return entries_; }
const std::vector<int>& deleted() const { return deleted_; }
std::size_t size() const { return entries_.size(); }
const IdEntry* find(int id) const;
std::optional<int> id_of(std::string_view name_or_stem) const; // case-insensitive
bool is_deleted(int id) const;
void add(IdEntry e);
void add_deleted(int id) { deleted_.push_back(id); }
private:
std::vector<IdEntry> entries_;
std::vector<int> deleted_;
};
Loaded<IdRegistry> parse_id_registry(std::string_view text, std::string file = {});
} // namespace game::data

150
src/game/data/weapon.cpp Normal file
View file

@ -0,0 +1,150 @@
#include "game/data/weapon.h"
#include "game/data/fields.h"
namespace game::data {
using detail::Fields;
const std::vector<std::string_view>& weapon_behavior_blocks() {
static const std::vector<std::string_view> names = {
"bolt", "beam", "torpedo", "rider", "missile", "chainlightning", "col", "mine",
"disintegrator", "grapple", "projectedshield", "mirv", "nodecannon", "siege",
"mesonprojector", "spyship", "wraith"};
return names;
}
namespace {
RangeTable read_rangetable(Fields& f) {
RangeTable t;
auto band = [&](RangeTable::Band& b, const char* prefix) {
std::string p(prefix);
b.range = f.opt_double(p + "_range");
b.deviation = f.opt_double(p + "_range_dev");
b.damage = f.opt_double(p + "_range_dam");
};
band(t.point_blank, "pb");
band(t.effective, "eff");
band(t.maximum, "max");
return t;
}
PlanetDamage read_planet_damage(Fields& f) {
PlanetDamage d;
d.pop = f.opt_double("dam_pop");
d.infra = f.opt_double("dam_infra");
d.terra = f.opt_double("dam_terra");
return d;
}
} // namespace
Loaded<WeaponDef> load_weapon(const mars::parse::Document& doc, std::string file) {
Loaded<WeaponDef> out;
const mars::parse::Node* node = doc.root.first_block("weapon");
if (!node) {
out.problems.push_back({Problem::Kind::MissingBlock, file, 0, "weapon", "no top-level weapon{} block"});
return out;
}
WeaponDef w;
w.file = file;
w.raw = snapshot(*node);
Fields f(w.raw, file, out.problems);
w.name = f.required_str("name");
w.weapon_class = f.required_str("weaponclass");
w.weapon_family = f.str("weaponfamily");
w.weapon_damage_type = f.str("weapondamagetype");
w.requires = f.strs("requires");
w.compatible_section = f.strs("compatible_section");
w.exclusive_species = f.str("exclusive_species");
w.cost = f.opt_int("cost");
w.turret_size = f.required_str("turretsize");
w.turret_class = f.required_str("turretclass");
w.track_speed_mod = f.opt_double("trackspeed_mod");
w.burst_volleys = f.opt_int("burst_volleys");
w.recharge_time = f.opt_double("recharge_time");
w.volley_period = f.opt_double("volley_period");
w.volley_duration = f.opt_double("volley_duration");
w.buildup_delay = f.opt_double("buildup_delay");
w.solution_tolerance = f.opt_double("solution_tolerance");
w.range = f.opt_int("range");
w.range_planet = f.opt_int("range_planet");
w.muzzle_speed = f.opt_int("muzzle_speed");
w.hpbonus = f.opt_int("hpbonus");
w.dam_est = f.opt_int("dam_est");
w.hidden = f.opt_bool("hidden");
w.pinpoint = f.opt_bool("pinpoint");
w.blindfire = f.opt_bool("blindfire");
w.secondary_pd = f.opt_bool("secondary_pd");
w.model1 = f.str("model1");
w.model2 = f.str("model2");
w.model3 = f.str("model3");
w.muzzle_effect = f.str("muzzle_effect");
w.muzzle_sound = f.str("muzzle_sound");
w.icon_file = f.str("icon_file");
w.icon_rect = f.str("icon_rect");
w.fc.requires_los = f.opt_bool("fc_requires_los");
w.fc.requires_inrange = f.opt_bool("fc_requires_inrange");
w.fc.requires_enemycolony = f.opt_bool("fc_requires_enemycolony");
w.fc.manual_target = f.opt_bool("fc_manual_target");
w.fc.manual_toggle = f.opt_bool("fc_manual_toggle");
w.fc.manual_launch = f.opt_bool("fc_manual_launch");
w.fc.controllable = f.opt_bool("fc_controllable");
w.fc.holdsfire = f.opt_bool("fc_holdsfire");
w.fc.explicit_target = f.opt_bool("fc_explicit_target");
w.fc.exclusive_launch = f.opt_bool("fc_exclusive_launch");
w.fc.targets_expire = f.opt_bool("fc_targets_expire");
w.ratings.fire_rate = f.opt_double("rating_frate");
w.ratings.damage = f.opt_double("rating_dam");
w.ratings.accuracy = f.opt_double("rating_acc");
w.ratings.range = f.opt_double("rating_range");
for (std::string_view name : weapon_behavior_blocks()) {
const Block* b = w.raw.block(name);
if (!b) continue;
w.behavior_kind = std::string(name);
Fields bf = f.sub(*b);
w.planet_damage = read_planet_damage(bf);
if (const Block* rt = b->block("rangetable")) {
Fields rf = f.sub(*rt);
w.rangetable = read_rangetable(rf);
}
if (iequals(name, "bolt")) {
BoltDef bolt;
if (w.rangetable) bolt.rangetable = *w.rangetable;
bolt.planet = w.planet_damage;
bolt.mass = bf.opt_double("mass");
bolt.beam_origin = bf.opt_double("beam_origin");
bolt.beam_length = bf.opt_double("beam_length");
bolt.ricochet_mod = bf.opt_double("ricochet_mod");
bolt.effect = bf.str("effect");
bolt.impact_effect = bf.str("impact_effect");
bolt.expire_effect = bf.str("expire_effect");
w.bolt = std::move(bolt);
}
break;
}
out.value = std::move(w);
return out;
}
Loaded<WeaponDef> parse_weapon(std::string_view text, std::string file) {
auto parsed = mars::parse::parse_blocks(text);
if (!parsed.ok()) {
Loaded<WeaponDef> out;
out.problems.push_back({Problem::Kind::Syntax, file, parsed.error().line, "", parsed.error().message});
return out;
}
Loaded<WeaponDef> out = load_weapon(parsed.value(), std::move(file));
for (const auto& d : parsed.value().warnings)
out.problems.push_back({Problem::Kind::Syntax, out.value ? out.value->file : std::string(), d.line, "",
"recovered: " + d.message});
return out;
}
} // namespace game::data

108
src/game/data/weapon.h Normal file
View file

@ -0,0 +1,108 @@
// game::data -- WeaponDef: one `*.weapon` file.
//
// weapon {
// name @WEAPON_X weaponclass bullet weaponfamily gauss
// requires TECH [requires TECH2] cost N
// turretsize small turretclass standard
// burst_volleys N recharge_time T range R range_planet RP
// fc_* true/false rating_* N
// <class block> { rangetable {...} dam_pop dam_infra dam_terra ... }
// }
//
// Typed fields cover the keys design/combat rules read; the class-specific
// behaviour block (bolt/beam/torpedo/missile/...) is typed for `bolt` and
// reachable for every class through `behavior()` / `raw`.
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include "game/data/block.h"
#include "game/data/common.h"
#include "mars/parse/blocks.h"
namespace game::data {
enum class WeaponScope { Player, NPC };
// Damage falloff by range band: point-blank / effective / maximum.
struct RangeTable {
struct Band {
std::optional<double> range, deviation, damage;
};
Band point_blank, effective, maximum;
};
// Damage applied to a planet on impact (present in most behaviour blocks).
struct PlanetDamage {
std::optional<double> pop, infra, terra;
};
struct BoltDef {
RangeTable rangetable;
PlanetDamage planet;
std::optional<double> mass, beam_origin, beam_length, ricochet_mod;
std::string effect, impact_effect, expire_effect;
};
struct FireControl {
std::optional<bool> requires_los, requires_inrange, requires_enemycolony;
std::optional<bool> manual_target, manual_toggle, manual_launch;
std::optional<bool> controllable, holdsfire;
std::optional<bool> explicit_target, exclusive_launch, targets_expire;
};
struct Ratings {
std::optional<double> fire_rate, damage, accuracy, range;
};
struct WeaponDef {
// identity (filled by the catalog; empty/nullopt for a standalone load)
std::string stem; // file name without extension, as on disk
std::string file; // path relative to the data root, '/'-separated
WeaponScope scope = WeaponScope::Player;
std::optional<int> id; // from Weapons/_weapons.txt (player weapons only)
std::optional<std::string> display_name; // resolved `name` token
// body
std::string name; // "@WEAPON_X" token as written
std::string weapon_class; // drives engine behaviour (bullet, beam, missile, ...)
std::string weapon_family; // AI grouping (gauss, laser, torpedo, ...)
std::string weapon_damage_type;
std::vector<std::string> requires; // AND of techs; may be empty
std::vector<std::string> compatible_section; // rider weapons: section stems they carry
std::string exclusive_species;
std::optional<std::int64_t> cost;
std::string turret_size; // tiny / small / medium / large (as written)
std::string turret_class; // standard / missile / beam / ... (as written)
std::optional<double> track_speed_mod;
std::optional<std::int64_t> burst_volleys;
std::optional<double> recharge_time, volley_period, volley_duration, buildup_delay, solution_tolerance;
std::optional<std::int64_t> range, range_planet, muzzle_speed, hpbonus, dam_est;
std::optional<bool> hidden, pinpoint, blindfire, secondary_pd;
std::string model1, model2, model3;
std::string muzzle_effect, muzzle_sound, icon_file, icon_rect;
FireControl fc;
Ratings ratings;
std::string behavior_kind; // name of the class block found (bolt, beam, torpedo, ...)
std::optional<BoltDef> bolt; // typed when behavior_kind == "bolt"
std::optional<RangeTable> rangetable; // from bolt{} or torpedo{}
PlanetDamage planet_damage; // dam_pop/infra/terra of the behaviour block
Block raw; // the whole weapon{} block
const Block* behavior() const { return behavior_kind.empty() ? nullptr : raw.block(behavior_kind); }
};
// Names of the behaviour blocks the engine keys on `weaponclass`; the loader
// takes the first of these present in the weapon block.
const std::vector<std::string_view>& weapon_behavior_blocks();
Loaded<WeaponDef> load_weapon(const mars::parse::Document& doc, std::string file = {});
Loaded<WeaponDef> parse_weapon(std::string_view text, std::string file = {});
} // namespace game::data

View file

@ -0,0 +1,15 @@
# Optional CMake wiring for the game_data tests; the canonical runner is
# build_and_run.sh (plain g++). Include from the root with
# add_subdirectory(tests/game_data) after add_subdirectory(src/game/data).
add_executable(game_data_unit_tests
test_main.cpp test_weapon.cpp test_shipsection.cpp test_turrets.cpp test_techtree.cpp test_catalog.cpp)
target_link_libraries(game_data_unit_tests PRIVATE game_data)
target_include_directories(game_data_unit_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME game_data_unit COMMAND game_data_unit_tests)
add_executable(game_data_realdata_test realdata_test.cpp)
target_link_libraries(game_data_realdata_test PRIVATE game_data)
add_test(NAME game_data_realdata COMMAND game_data_realdata_test) # SKIPs without SOTS_DATA_DIR
add_executable(game_data_dump_catalog dump_catalog.cpp)
target_link_libraries(game_data_dump_catalog PRIVATE game_data)

View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Self-contained build + test for src/game/data (plain g++, no CMake needed).
#
# tests/game_data/build_and_run.sh
#
# Always builds and runs the unit tests. When SOTS_DATA_DIR points at an
# extracted sots.gob (+ sots_local_en.gob) tree it also runs the real-data
# facts test, dumps the whole catalog and, if the reference catalogs are
# reachable (SOTS_ORACLE_DIR, default ~/sots-re/verify/results/data-catalogs),
# compares field by field with oracle/compare.py.
#
# Env: CXX (g++), PYTHON (python3), BUILD_DIR (tests/game_data/build),
# SOTS_DATA_DIR, SOTS_ORACLE_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"
)
mkdir -p "$BUILD"
echo "== building unit tests"
"$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/test_main.cpp" "$HERE/test_weapon.cpp" "$HERE/test_shipsection.cpp" \
"$HERE/test_turrets.cpp" "$HERE/test_techtree.cpp" "$HERE/test_catalog.cpp" -o "$BUILD/unit_tests"
echo "== running unit tests"
"$BUILD/unit_tests"
if [ -z "${SOTS_DATA_DIR:-}" ]; then
echo "== SOTS_DATA_DIR 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
echo "== building real-data tools"
"$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/realdata_test.cpp" -o "$BUILD/realdata_test"
"$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/dump_catalog.cpp" -o "$BUILD/dump_catalog"
echo "== real-data facts"
"$BUILD/realdata_test"
echo "== dumping catalog"
"$BUILD/dump_catalog" "$SOTS_DATA_DIR" "$BUILD/catalog.json"
ORACLE=${SOTS_ORACLE_DIR:-$HOME/sots-re/verify/results/data-catalogs}
if [ ! -f "$ORACLE/weapons.json" ]; then
echo "== oracle catalogs not found at $ORACLE (set SOTS_ORACLE_DIR): skipping comparison"
exit 0
fi
echo "== comparing with the oracle catalogs"
read -ra PY <<< "$PYTHON" # PYTHON may be a command with arguments ("uv run python3")
exec "${PY[@]}" "$HERE/oracle/compare.py" "$BUILD/catalog.json" "$ORACLE"

View file

@ -0,0 +1,527 @@
// dump_catalog <data-root> <out.json>
//
// Loads the whole catalog and writes one JSON document for
// tests/game_data/oracle/compare.py:
// * every weapon / section with its typed fields (`typed`) and the raw block
// rendered in the reference reader's dict shape (`raw`): keys folded,
// repeats -> lists, barewords typed;
// * the tech tree (nodes, edges, groups), the turret table, id registries;
// * the cross_check() report.
// Strings are cp1252 in the files; they are decoded to Unicode here because
// the oracle JSON was written from decoded text.
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#include "game/data/catalog.h"
#include "mars/parse/value.h"
using namespace game::data;
namespace {
// ---- minimal JSON writer --------------------------------------------------
std::string json_string(std::string_view bytes) {
static const unsigned cp1252[32] = {
0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160,
0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022,
0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178};
std::string out = "\"";
for (unsigned char c : bytes) {
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 || c > 0x7e) {
unsigned cp = c;
if (c >= 0x80 && c <= 0x9f) cp = cp1252[c - 0x80];
char buf[8];
std::snprintf(buf, sizeof buf, "\\u%04x", cp);
out += buf;
} else {
out += static_cast<char>(c);
}
}
}
out += '"';
return out;
}
std::string json_double(double v) {
char buf[64];
std::snprintf(buf, sizeof buf, "%.17g", v);
std::string s(buf);
if (s.find_first_of(".eEn") == std::string::npos) s += ".0"; // keep it a float for Python
return s;
}
struct J {
std::string s;
};
J jnull() { return {"null"}; }
J jbool(bool b) { return {b ? "true" : "false"}; }
J jint(long long v) { return {std::to_string(v)}; }
J jnum(double v) { return {json_double(v)}; }
J jstr(std::string_view v) { return {json_string(v)}; }
template <class T>
J jopt(const std::optional<T>& o);
template <>
J jopt(const std::optional<double>& o) { return o ? jnum(*o) : jnull(); }
template <>
J jopt(const std::optional<std::int64_t>& o) { return o ? jint(*o) : jnull(); }
template <>
J jopt(const std::optional<int>& o) { return o ? jint(*o) : jnull(); }
template <>
J jopt(const std::optional<bool>& o) { return o ? jbool(*o) : jnull(); }
template <>
J jopt(const std::optional<std::string>& o) { return o ? jstr(*o) : jnull(); }
struct JArr {
std::string s = "[";
bool first = true;
JArr& push(const J& v) {
if (!first) s += ',';
first = false;
s += v.s;
return *this;
}
J done() const { return {s + "]"}; }
};
struct JObj {
std::string s = "{";
bool first = true;
JObj& set(std::string_view key, const J& v) {
if (!first) s += ',';
first = false;
s += json_string(key);
s += ':';
s += v.s;
return *this;
}
J done() const { return {s + "}"}; }
};
J jstrs(const std::vector<std::string>& v) {
JArr a;
for (const auto& s : v) a.push(jstr(s));
return a.done();
}
// ---- raw block in the reference dict shape --------------------------------
J typed_bareword(const std::string& tok) {
using mars::parse::ScalarKind;
switch (mars::parse::classify(tok)) {
case ScalarKind::Int:
if (auto v = mars::parse::as_int(tok)) return jint(*v);
return jstr(tok);
case ScalarKind::Float: return jnum(*mars::parse::as_double(tok));
case ScalarKind::Bool: return jbool(*mars::parse::as_bool(tok));
case ScalarKind::Text: break;
}
return jstr(tok);
}
J raw_json(const Block& b) {
struct Slot { std::string key; std::vector<std::string> vals; };
std::vector<Slot> slots;
auto add = [&](const std::string& key, const std::string& val) {
for (Slot& s : slots)
if (s.key == key) {
s.vals.push_back(val);
return;
}
slots.push_back({key, {val}});
};
// merge attrs and sub-blocks in file order
std::size_t ai = 0, bi = 0;
while (ai < b.attrs.size() || bi < b.blocks.size()) {
bool take_attr = bi >= b.blocks.size() || (ai < b.attrs.size() && b.attrs[ai].ord < b.blocks[bi].ord);
if (take_attr) {
const Attr& a = b.attrs[ai++];
add(fold(a.key), a.quoted ? jstr(a.value).s : typed_bareword(a.value).s);
} else {
const Block& sub = b.blocks[bi++];
add(fold(sub.name), raw_json(sub).s);
}
}
for (const std::string& it : b.items) add("_items", jstr(it).s);
JObj o;
for (const Slot& s : slots) {
if (s.vals.size() == 1) {
o.set(s.key, {s.vals[0]});
} else {
JArr a;
for (const auto& v : s.vals) a.push({v});
o.set(s.key, a.done());
}
}
return o.done();
}
// ---- typed views ----------------------------------------------------------
J rangetable_json(const RangeTable& t) {
JObj o;
auto band = [&](const RangeTable::Band& b, const char* p) {
std::string pre(p);
o.set(pre + "_range", jopt(b.range));
o.set(pre + "_range_dev", jopt(b.deviation));
o.set(pre + "_range_dam", jopt(b.damage));
};
band(t.point_blank, "pb");
band(t.effective, "eff");
band(t.maximum, "max");
return o.done();
}
J planet_json(const PlanetDamage& p) {
return JObj().set("dam_pop", jopt(p.pop)).set("dam_infra", jopt(p.infra)).set("dam_terra", jopt(p.terra)).done();
}
J weapon_json(const WeaponDef& w) {
JObj t;
t.set("name", jstr(w.name))
.set("weaponclass", jstr(w.weapon_class))
.set("weaponfamily", jstr(w.weapon_family))
.set("weapondamagetype", jstr(w.weapon_damage_type))
.set("requires", jstrs(w.requires))
.set("compatible_section", jstrs(w.compatible_section))
.set("exclusive_species", jstr(w.exclusive_species))
.set("cost", jopt(w.cost))
.set("turretsize", jstr(w.turret_size))
.set("turretclass", jstr(w.turret_class))
.set("trackspeed_mod", jopt(w.track_speed_mod))
.set("burst_volleys", jopt(w.burst_volleys))
.set("recharge_time", jopt(w.recharge_time))
.set("volley_period", jopt(w.volley_period))
.set("volley_duration", jopt(w.volley_duration))
.set("buildup_delay", jopt(w.buildup_delay))
.set("solution_tolerance", jopt(w.solution_tolerance))
.set("range", jopt(w.range))
.set("range_planet", jopt(w.range_planet))
.set("muzzle_speed", jopt(w.muzzle_speed))
.set("hpbonus", jopt(w.hpbonus))
.set("dam_est", jopt(w.dam_est))
.set("hidden", jopt(w.hidden))
.set("pinpoint", jopt(w.pinpoint))
.set("blindfire", jopt(w.blindfire))
.set("secondary_pd", jopt(w.secondary_pd))
.set("model1", jstr(w.model1))
.set("model2", jstr(w.model2))
.set("model3", jstr(w.model3))
.set("muzzle_effect", jstr(w.muzzle_effect))
.set("muzzle_sound", jstr(w.muzzle_sound))
.set("icon_file", jstr(w.icon_file))
.set("icon_rect", jstr(w.icon_rect))
.set("fc_requires_los", jopt(w.fc.requires_los))
.set("fc_requires_inrange", jopt(w.fc.requires_inrange))
.set("fc_requires_enemycolony", jopt(w.fc.requires_enemycolony))
.set("fc_manual_target", jopt(w.fc.manual_target))
.set("fc_manual_toggle", jopt(w.fc.manual_toggle))
.set("fc_manual_launch", jopt(w.fc.manual_launch))
.set("fc_controllable", jopt(w.fc.controllable))
.set("fc_holdsfire", jopt(w.fc.holdsfire))
.set("fc_explicit_target", jopt(w.fc.explicit_target))
.set("fc_exclusive_launch", jopt(w.fc.exclusive_launch))
.set("fc_targets_expire", jopt(w.fc.targets_expire))
.set("rating_frate", jopt(w.ratings.fire_rate))
.set("rating_dam", jopt(w.ratings.damage))
.set("rating_acc", jopt(w.ratings.accuracy))
.set("rating_range", jopt(w.ratings.range))
.set("behavior_kind", jstr(w.behavior_kind))
.set("planet_damage", planet_json(w.planet_damage))
.set("rangetable", w.rangetable ? rangetable_json(*w.rangetable) : jnull());
if (w.bolt) {
JObj b;
b.set("rangetable", rangetable_json(w.bolt->rangetable))
.set("dam_pop", jopt(w.bolt->planet.pop))
.set("dam_infra", jopt(w.bolt->planet.infra))
.set("dam_terra", jopt(w.bolt->planet.terra))
.set("mass", jopt(w.bolt->mass))
.set("beam_origin", jopt(w.bolt->beam_origin))
.set("beam_length", jopt(w.bolt->beam_length))
.set("ricochet_mod", jopt(w.bolt->ricochet_mod))
.set("effect", jstr(w.bolt->effect))
.set("impact_effect", jstr(w.bolt->impact_effect))
.set("expire_effect", jstr(w.bolt->expire_effect));
t.set("bolt", b.done());
} else {
t.set("bolt", jnull());
}
JObj o;
o.set("stem", jstr(w.stem))
.set("file", jstr(w.file))
.set("scope", jstr(w.scope == WeaponScope::Player ? "player" : "NPC"))
.set("id", jopt(w.id))
.set("display_name", jopt(w.display_name))
.set("typed", t.done())
.set("raw", raw_json(w.raw));
return o.done();
}
J section_json(const ShipSectionDef& s) {
JObj t;
t.set("model", jstr(s.model))
.set("dam_model", jstr(s.dam_model))
.set("requires", jstrs(s.requires))
.set("section_type", jstr(s.section_type_text))
.set("section_type_enum", jstr(section_type_name(s.section_type)))
.set("section_class", jstr(s.section_class_text))
.set("section_class_enum", jstr(section_class_name(s.section_class)))
.set("design_class", jstr(s.design_class))
.set("entity_class", jstr(s.entity_class))
.set("health", jopt(s.health))
.set("mass", jopt(s.mass))
.set("cost", jopt(s.cost))
.set("cpoints", jopt(s.cpoints))
.set("crew", jopt(s.crew))
.set("command_cost", jopt(s.command_cost))
.set("maintenance_cost", jopt(s.maintenance_cost))
.set("command_quota", jopt(s.command_quota))
.set("socket_fore", jstr(s.socket_fore))
.set("socket_aft", jstr(s.socket_aft))
.set("dam_socket_fore", jstr(s.dam_socket_fore))
.set("dam_socket_aft", jstr(s.dam_socket_aft))
.set("ftlspeed", jopt(s.ftlspeed))
.set("nodespeed", jopt(s.nodespeed))
.set("range", jopt(s.range))
.set("scanrange", jopt(s.scanrange))
.set("tacticalsensorrange", jopt(s.tactical_sensor_range))
.set("engine_techera", jstr(s.engine_techera))
.set("exclude", jstrs(s.exclude))
.set("explicit_command_section", jstr(s.explicit_command_section))
.set("explicit_engine_section", jstr(s.explicit_engine_section))
.set("explicit_section", jopt(s.explicit_section))
.set("autonomous", jopt(s.autonomous))
.set("nodesign", jopt(s.nodesign));
{
JArr groups;
for (const OptionGroup& g : s.options)
groups.push(JObj().set("members", jstrs(g.members)).set("scalar", jbool(g.scalar)).done());
t.set("option", groups.done());
t.set("optiondef", s.optiondef ? jstrs(s.optiondef->members) : jnull());
}
{
JArr banks;
for (const BankDef& b : s.banks) {
JArr mounts;
for (const MountDef& m : b.mounts)
mounts.push(JObj()
.set("node", jstr(m.node))
.set("min_azimuth", jopt(m.min_azimuth))
.set("max_azimuth", jopt(m.max_azimuth))
.set("min_inclination", jopt(m.min_inclination))
.set("max_inclination", jopt(m.max_inclination))
.set("home_azimuth", jopt(m.home_azimuth))
.set("home_inclination", jopt(m.home_inclination))
.done());
banks.push(JObj()
.set("turretclass", jstr(b.turret_class))
.set("turretsize", jstr(b.turret_size))
.set("weapon", jstr(b.weapon))
.set("showturrets", jopt(b.show_turrets))
.set("invincible", jopt(b.invincible))
.set("repeated_turret_spec", jbool(b.repeated_turret_spec))
.set("mount", mounts.done())
.done());
}
t.set("bank", banks.done());
}
if (s.netforcelimits) {
const NetForceLimits& n = *s.netforcelimits;
t.set("netforcelimits", JObj()
.set("force_forward", jopt(n.force_forward))
.set("force_right", jopt(n.force_right))
.set("force_up", jopt(n.force_up))
.set("torque_yaw", jopt(n.torque_yaw))
.set("torque_pitch", jopt(n.torque_pitch))
.set("torque_roll", jopt(n.torque_roll))
.set("speed", jopt(n.speed))
.set("rotspeed", jopt(n.rotspeed))
.done());
} else {
t.set("netforcelimits", jnull());
}
{
JArr th;
for (const ThrusterDef& d : s.thrusters)
th.push(JObj().set("node", jstr(d.node)).set("effect", jstr(d.effect)).set("idle_effect", jstr(d.idle_effect)).done());
t.set("thruster", th.done());
}
JObj o;
o.set("race", jstr(s.race))
.set("species", jstr(species_name(s.species)))
.set("stem", jstr(s.stem))
.set("file", jstr(s.file))
.set("id", jopt(s.id))
.set("display_name", jopt(s.display_name))
.set("description", jopt(s.description))
.set("unlocked_by", jstrs(s.unlocked_by))
.set("typed", t.done())
.set("raw", raw_json(s.raw));
return o.done();
}
J tech_json(const TechTree& t) {
JArr nodes;
for (const TechNode& n : t.nodes) {
JArr allows;
for (std::size_t ei : n.allows) allows.push(jstr(t.edges[ei].to));
nodes.push(JObj()
.set("name", jstr(n.name))
.set("display_name", jopt(n.display_name))
.set("description", jopt(n.description))
.set("family", n.family.empty() ? jnull() : jstr(n.family))
.set("family_inferred", jstr(n.family_inferred))
.set("type", n.type.empty() ? jnull() : jstr(n.type))
.set("threat", jopt(n.threat))
.set("group", n.group.empty() ? jnull() : jstr(n.group))
.set("option_cost", jopt(n.option_cost))
.set("unlock_explicitly", jopt(n.unlock_explicitly))
.set("requires", jstrs(n.requires))
.set("benefits_inc", jstrs(n.benefits_inc))
.set("benefits_dec", jstrs(n.benefits_dec))
.set("sections", jstrs(n.sections))
.set("weapons", jstrs(n.weapon_files))
.set("allows", allows.done())
.done());
}
JArr edges;
for (const AllowsEdge& e : t.edges) {
JObj pct, all;
for (int i = 0; i < kSpeciesCount; ++i) {
auto s = static_cast<Species>(i);
all.set(species_name(s), jint(e.percent(s)));
if (e.pct_written[static_cast<std::size_t>(i)]) pct.set(species_name(s), jint(e.percent(s)));
}
edges.push(JObj()
.set("from", jstr(e.from))
.set("to", jstr(e.to))
.set("rp", jopt(e.rp))
.set("pct", pct.done())
.set("pct_effective", all.done())
.set("unparsed", jstrs(e.unparsed))
.done());
}
JObj groups;
for (const auto& kv : t.groups) groups.set(kv.first, jstrs(kv.second));
return JObj().set("nodes", nodes.done()).set("edges", edges.done()).set("groups", groups.done()).done();
}
J turrets_json(const TurretTable& t) {
JArr rows;
for (const TurretRow& r : t.rows())
rows.push(JObj()
.set("mount_size", jstr(r.mount_size))
.set("weapon_size", jstr(r.weapon_size))
.set("class", jstr(r.turret_class))
.set("health", jopt(r.health))
.set("track_speed", jopt(r.track_speed))
.set("azimuth_scale", jopt(r.azimuth_scale))
.set("inclination_scale", jopt(r.inclination_scale))
.set("model", jstr(r.model))
.done());
return rows.done();
}
J refs_json(const std::vector<CrossCheck::Ref>& v) {
JArr a;
for (const auto& r : v) a.push(JArr().push(jstr(r.from)).push(jstr(r.ref)).done());
return a.done();
}
J registry_json(const IdRegistry& r) {
JArr ids;
for (const IdEntry& e : r.entries()) ids.push(JArr().push(jint(e.id)).push(jstr(e.name)).done());
JArr del;
for (int d : r.deleted()) del.push(jint(d));
return JObj().set("ids", ids.done()).set("deleted", del.done()).done();
}
J cross_json(const CrossCheck& x) {
JArr gaps;
for (const auto& g : x.manifest_ids_without_file)
gaps.push(JObj().set("scope", jstr(g.scope)).set("id", jint(g.id)).set("name", jstr(g.name)).done());
return JObj()
.set("weapon_requires_dangling", refs_json(x.weapon_requires_dangling))
.set("weapon_requires_case_mismatch", refs_json(x.weapon_requires_case_mismatch))
.set("weapons_without_requires", jstrs(x.weapons_without_requires))
.set("section_requires_dangling", refs_json(x.section_requires_dangling))
.set("section_requires_case_mismatch", refs_json(x.section_requires_case_mismatch))
.set("section_option_dangling", refs_json(x.section_option_dangling))
.set("tech_ship_section_dangling", refs_json(x.tech_ship_section_dangling))
.set("tech_weapon_file_dangling", refs_json(x.tech_weapon_file_dangling))
.set("tech_requires_dangling", refs_json(x.tech_requires_dangling))
.set("tech_allows_dangling", refs_json(x.tech_allows_dangling))
.set("tech_allows_unparsed", refs_json(x.tech_allows_unparsed))
.set("bank_weapon_dangling", refs_json(x.bank_weapon_dangling))
.set("manifest_ids_without_file", gaps.done())
.set("files_without_manifest_id", refs_json(x.files_without_manifest_id))
.set("weapon_turret_pairs_without_row", refs_json(x.weapon_turret_pairs_without_row))
.set("bank_turret_pairs_without_row", refs_json(x.bank_turret_pairs_without_row))
.set("unresolved_weapon_names", refs_json(x.unresolved_weapon_names))
.set("missing_techname", jstrs(x.missing_techname))
.set("missing_techdesc", jstrs(x.missing_techdesc))
.set("missing_sectionname", jstrs(x.missing_sectionname))
.set("missing_sectiondesc", jstrs(x.missing_sectiondesc))
.set("tech_roots", jstrs(x.tech_roots))
.set("strings_available", jbool(x.strings_available))
.set("dangling_count", jint(static_cast<long long>(x.dangling_count())))
.done();
}
} // namespace
int main(int argc, char** argv) {
if (argc != 3) {
std::fprintf(stderr, "usage: dump_catalog <data-root> <out.json>\n");
return 2;
}
Catalog cat = load_catalog(argv[1]);
JArr weapons;
for (const WeaponDef& w : cat.weapons) weapons.push(weapon_json(w));
JArr sections;
for (const ShipSectionDef& s : cat.sections) sections.push(section_json(s));
JObj manifests;
manifests.set("Weapons", registry_json(cat.weapon_ids));
for (const auto& kv : cat.section_ids) manifests.set(kv.first, registry_json(kv.second));
JArr problems;
for (const Problem& p : cat.problems)
problems.push(JObj()
.set("kind", jstr(problem_kind_name(p.kind)))
.set("file", jstr(p.file))
.set("line", jint(p.line))
.set("key", jstr(p.key))
.set("message", jstr(p.message))
.done());
JObj doc;
doc.set("weapons", weapons.done())
.set("sections", sections.done())
.set("tech", tech_json(cat.tech))
.set("turrets", turrets_json(cat.turrets))
.set("manifests", manifests.done())
.set("strings_loaded", jbool(cat.strings_loaded))
.set("string_count", jint(static_cast<long long>(cat.strings.size())))
.set("races", jstrs(cat.races))
.set("problems", problems.done())
.set("cross_check", cross_json(cat.cross_check()));
std::ofstream out(argv[2], std::ios::binary);
if (!out) {
std::fprintf(stderr, "cannot write %s\n", argv[2]);
return 1;
}
out << doc.done().s << '\n';
std::printf("dumped %zu weapons, %zu sections, %zu techs, %zu edges, %zu problems -> %s\n", cat.weapons.size(),
cat.sections.size(), cat.tech.nodes.size(), cat.tech.edges.size(), cat.problems.size(), argv[2]);
return 0;
}

View file

@ -0,0 +1,428 @@
#!/usr/bin/env python3
"""compare.py <ours.json> <oracle-dir>
Compares the dump_catalog output with the reference catalogs produced by the
RE repo's verify.py (tech_tree.json, weapons.json, shipsections.json,
strings.json, crosslink.json), field by field.
Canonicalisation (both sides):
* raw bodies: exact, type-aware (bool != int != float != str), after the
oracle's identity keys are removed. This proves the block snapshot.
* typed fields: the oracle value under the same key, with these rules --
- repeated scalar keys -> the LAST value (the loader's rule);
- repeatable list keys (requires, exclude, ...) -> always a list;
- numbers compare numerically (int 50 == float 50.0);
- bools accept the oracle's 0/1 ints;
- a typed field that is absent (null / "" / []) matches a missing key;
- a typed field that is null while the oracle holds a non-numeric
value is an "untyped" divergence: the data carries a token the
engine schema does not accept. These are listed and must match the
documented set exactly.
Exit 1 on any unexpected difference.
"""
import json
import os
import sys
# Values in the shipped data that do not have the shape the typed schema
# expects. The loader records a Problem and keeps the text in `raw`.
KNOWN_UNTYPED = {
("weapon", "Weapons/las_red.weapon", "trackspeed_mod", "1.0f"),
("section", "Species/Hiver/sections/CRFusion.shipsection", "mount.min_inclination", "0-5"),
("section", "Species/Morrigi/sections/DNAIC.shipsection", "mount.max_inclination", "90\\"),
}
# `crew false` on the nine NPC hull sections
for f in ("_CRRipperCommand", "_CRRipperEngine", "_CRRipperMission", "_DECommand", "_DEEngine", "_DEMission",
"_VNMFrontSection", "_VNMMidSection", "_VNMRearSection"):
KNOWN_UNTYPED.add(("section", f"Species/_NPC/sections/{f}.shipsection", "crew", False))
# `force_right o` (a letter o for zero) on 22 sections
for race, stems in {
"Hiver": ("CRDeflector", "CRDisruptor", "DEFireControl"),
"Human": ("CRAbsorber", "CRAssault", "CRBattleBridge", "CRDeepScan", "CRFireControl"),
"Liir": ("CRDeepScan", "CRFireControl", "DEDeepScan"),
"Morrigi": ("CRAbsorber", "CRAssault", "CRBattleBridge", "CRDeepScan", "CRFireControl"),
"Tarkas": ("CRCommand", "CRDeepScan"),
"Zuul": ("CRAssault", "CRBattleBridge", "CRDeepScan", "CRFireControl"),
}.items():
for st in stems:
KNOWN_UNTYPED.add(("section", f"Species/{race}/sections/{st}.shipsection", "netforcelimits.force_right", "o"))
MAX_DIFFS = 40
def L(v):
"""get_list: missing -> [], scalar -> [x], list -> list."""
if v is None:
return []
return v if isinstance(v, list) else [v]
def last(v):
return v[-1] if isinstance(v, list) else v
class Cmp:
def __init__(self):
self.diffs = []
self.untyped = set()
self.checked = 0
def diff(self, path, msg):
self.diffs.append(f"{path}: {msg}")
# --- exact structural compare (raw bodies) --------------------------
def exact(self, a, b, path):
self.checked += 1
if type(a) is not type(b):
self.diff(path, f"type {type(a).__name__} != {type(b).__name__} ({a!r} vs {b!r})")
return
if isinstance(a, dict):
for k in sorted(set(a) | set(b)):
if k not in a:
self.diff(f"{path}.{k}", "missing in ours")
elif k not in b:
self.diff(f"{path}.{k}", "extra in ours")
else:
self.exact(a[k], b[k], f"{path}.{k}")
elif isinstance(a, list):
if len(a) != len(b):
self.diff(path, f"length {len(a)} != {len(b)}")
for i, (x, y) in enumerate(zip(a, b)):
self.exact(x, y, f"{path}[{i}]")
elif a != b:
self.diff(path, f"{a!r} != {b!r}")
# --- typed field compare ---------------------------------------------
def scalar(self, kind, file, key, ours, oracle, path):
"""ours: typed value (None/str/number/bool). oracle: raw oracle value (scalar or list)."""
self.checked += 1
oracle = last(oracle)
if ours is None or ours == "" or ours == []:
if oracle is None:
return
if isinstance(oracle, (str, bool)) and ours is None:
self.untyped.add((kind, file, key, oracle))
return
if ours == "" and oracle == "":
return
self.diff(path, f"ours absent, oracle {oracle!r}")
return
if oracle is None:
self.diff(path, f"ours {ours!r}, oracle missing")
return
if isinstance(ours, bool):
if isinstance(oracle, bool):
ok = ours == oracle
elif isinstance(oracle, int):
ok = ours == (oracle != 0)
else:
ok = False
elif isinstance(ours, (int, float)):
ok = isinstance(oracle, (int, float)) and not isinstance(oracle, bool) and float(ours) == float(oracle)
elif isinstance(ours, str):
if isinstance(oracle, str):
ok = ours == oracle
elif isinstance(oracle, bool):
ok = ours.lower() == str(oracle).lower()
else:
try:
ok = float(ours) == float(oracle)
except ValueError:
ok = False
else:
ok = ours == oracle
if not ok:
self.diff(path, f"{ours!r} != {oracle!r}")
def strlist(self, ours, oracle, path):
self.checked += 1
exp = [str(x) for x in L(oracle)]
if list(ours) != exp:
self.diff(path, f"{ours!r} != {exp!r}")
WEAPON_TYPED_SCALARS = [
"name", "weaponclass", "weaponfamily", "weapondamagetype", "exclusive_species", "cost", "turretsize",
"turretclass", "trackspeed_mod", "burst_volleys", "recharge_time", "volley_period", "volley_duration",
"buildup_delay", "solution_tolerance", "range", "range_planet", "muzzle_speed", "hpbonus", "dam_est",
"hidden", "pinpoint", "blindfire", "secondary_pd", "model1", "model2", "model3", "muzzle_effect",
"muzzle_sound", "icon_file", "icon_rect", "fc_requires_los", "fc_requires_inrange",
"fc_requires_enemycolony", "fc_manual_target", "fc_manual_toggle", "fc_manual_launch", "fc_controllable",
"fc_holdsfire", "fc_explicit_target", "fc_exclusive_launch", "fc_targets_expire", "rating_frate",
"rating_dam", "rating_acc", "rating_range",
]
BEHAVIOR_BLOCKS = ["bolt", "beam", "torpedo", "rider", "missile", "chainlightning", "col", "mine",
"disintegrator", "grapple", "projectedshield", "mirv", "nodecannon", "siege",
"mesonprojector", "spyship", "wraith"]
RANGE_KEYS = ["pb_range", "pb_range_dev", "pb_range_dam", "eff_range", "eff_range_dev", "eff_range_dam",
"max_range", "max_range_dev", "max_range_dam"]
BOLT_SCALARS = ["dam_pop", "dam_infra", "dam_terra", "mass", "beam_origin", "beam_length", "ricochet_mod",
"effect", "impact_effect", "expire_effect"]
SECTION_TYPED_SCALARS = [
"model", "dam_model", "section_type", "section_class", "design_class", "entity_class", "health", "mass",
"cost", "cpoints", "crew", "command_cost", "maintenance_cost", "command_quota", "socket_fore",
"socket_aft", "dam_socket_fore", "dam_socket_aft", "ftlspeed", "nodespeed", "range", "scanrange",
"tacticalsensorrange", "engine_techera", "explicit_command_section", "explicit_engine_section",
"explicit_section", "autonomous", "nodesign",
]
MOUNT_KEYS = ["min_azimuth", "max_azimuth", "min_inclination", "max_inclination", "home_azimuth", "home_inclination"]
NFL_KEYS = ["force_forward", "force_right", "force_up", "torque_yaw", "torque_pitch", "torque_roll", "speed", "rotspeed"]
WEAPON_ID_KEYS = {"stem", "file", "scope", "id", "display_name"}
SECTION_ID_KEYS = {"race", "stem", "file", "id", "display_name", "description", "unlocked_by"}
def compare_weapons(c, ours, oracle):
ob = {w["file"].lower(): w for w in oracle["weapons"]}
if len(ours) != len(oracle["weapons"]):
c.diff("weapons", f"count {len(ours)} != {len(oracle['weapons'])}")
for w in ours:
p = f"weapon[{w['file']}]"
o = ob.get(w["file"].lower())
if o is None:
c.diff(p, "not in oracle")
continue
for k in ("stem", "scope", "id", "display_name"):
if w[k] != o[k]:
c.diff(f"{p}.{k}", f"{w[k]!r} != {o[k]!r}")
body = {k: v for k, v in o.items() if k not in WEAPON_ID_KEYS}
c.exact(w["raw"], body, f"{p}.raw")
t = w["typed"]
for k in WEAPON_TYPED_SCALARS:
c.scalar("weapon", w["file"], k, t[k], body.get(k), f"{p}.{k}")
c.strlist(t["requires"], body.get("requires"), f"{p}.requires")
c.strlist(t["compatible_section"], body.get("compatible_section"), f"{p}.compatible_section")
kind = next((b for b in BEHAVIOR_BLOCKS if b in body), "")
if t["behavior_kind"] != kind:
c.diff(f"{p}.behavior_kind", f"{t['behavior_kind']!r} != {kind!r}")
beh = body.get(kind, {}) if kind else {}
for k in ("dam_pop", "dam_infra", "dam_terra"):
c.scalar("weapon", w["file"], f"{kind}.{k}", t["planet_damage"][k], beh.get(k), f"{p}.planet_damage.{k}")
rt = beh.get("rangetable")
if (t["rangetable"] is None) != (rt is None):
c.diff(f"{p}.rangetable", f"presence {t['rangetable'] is not None} != {rt is not None}")
elif rt is not None:
for k in RANGE_KEYS:
c.scalar("weapon", w["file"], f"rangetable.{k}", t["rangetable"][k], rt.get(k), f"{p}.rangetable.{k}")
if (t["bolt"] is None) != (kind != "bolt"):
c.diff(f"{p}.bolt", f"presence mismatch (kind {kind})")
elif t["bolt"] is not None:
for k in BOLT_SCALARS:
c.scalar("weapon", w["file"], f"bolt.{k}", t["bolt"][k], beh.get(k), f"{p}.bolt.{k}")
for k in RANGE_KEYS:
c.scalar("weapon", w["file"], f"bolt.rangetable.{k}", t["bolt"]["rangetable"][k], (rt or {}).get(k),
f"{p}.bolt.rangetable.{k}")
def norm_options(v):
out = []
for e in L(v):
if isinstance(e, dict):
out.append({"members": [str(x) for x in L(e.get("option"))], "scalar": False})
else:
out.append({"members": [str(e)], "scalar": True})
return out
def compare_sections(c, ours, oracle):
ob = {s["file"].lower(): s for s in oracle["sections"]}
if len(ours) != len(oracle["sections"]):
c.diff("sections", f"count {len(ours)} != {len(oracle['sections'])}")
for s in ours:
p = f"section[{s['file']}]"
o = ob.get(s["file"].lower())
if o is None:
c.diff(p, "not in oracle")
continue
for k in ("race", "stem", "id", "display_name", "description", "unlocked_by"):
if s[k] != o[k]:
c.diff(f"{p}.{k}", f"{s[k]!r} != {o[k]!r}")
body = {k: v for k, v in o.items() if k not in SECTION_ID_KEYS}
c.exact(s["raw"], body, f"{p}.raw")
t = s["typed"]
for k in SECTION_TYPED_SCALARS:
c.scalar("section", s["file"], k, t[k], body.get(k), f"{p}.{k}")
c.strlist(t["requires"], body.get("requires"), f"{p}.requires")
c.strlist(t["exclude"], body.get("exclude"), f"{p}.exclude")
exp_opts = norm_options(body.get("option"))
if t["option"] != exp_opts:
c.diff(f"{p}.option", f"{t['option']!r} != {exp_opts!r}")
od = body.get("optiondef")
exp_od = [str(x) for x in L(od.get("option"))] if isinstance(od, dict) else None
if t["optiondef"] != exp_od:
c.diff(f"{p}.optiondef", f"{t['optiondef']!r} != {exp_od!r}")
banks = L(body.get("bank"))
if len(t["bank"]) != len(banks):
c.diff(f"{p}.bank", f"count {len(t['bank'])} != {len(banks)}")
for i, (tb, ob_) in enumerate(zip(t["bank"], banks)):
bp = f"{p}.bank[{i}]"
c.scalar("section", s["file"], "bank.turretclass", tb["turretclass"], ob_.get("turretclass"), f"{bp}.turretclass")
c.scalar("section", s["file"], "bank.turretsize", tb["turretsize"], ob_.get("turretsize"), f"{bp}.turretsize")
c.scalar("section", s["file"], "bank.weapon", tb["weapon"], ob_.get("weapon"), f"{bp}.weapon")
c.scalar("section", s["file"], "bank.showturrets", tb["showturrets"], ob_.get("showturrets"), f"{bp}.showturrets")
c.scalar("section", s["file"], "bank.invincible", tb["invincible"], ob_.get("invincible"), f"{bp}.invincible")
rep = len(L(ob_.get("turretsize"))) > 1 or len(L(ob_.get("turretclass"))) > 1
if tb["repeated_turret_spec"] != rep:
c.diff(f"{bp}.repeated_turret_spec", f"{tb['repeated_turret_spec']} != {rep}")
mounts = L(ob_.get("mount"))
if len(tb["mount"]) != len(mounts):
c.diff(f"{bp}.mount", f"count {len(tb['mount'])} != {len(mounts)}")
for j, (tm, om) in enumerate(zip(tb["mount"], mounts)):
mp = f"{bp}.mount[{j}]"
c.scalar("section", s["file"], "mount.node", tm["node"], om.get("node"), f"{mp}.node")
for k in MOUNT_KEYS:
c.scalar("section", s["file"], f"mount.{k}", tm[k], om.get(k), f"{mp}.{k}")
nfl = body.get("netforcelimits")
if (t["netforcelimits"] is None) != (nfl is None):
c.diff(f"{p}.netforcelimits", "presence mismatch")
elif nfl is not None:
for k in NFL_KEYS:
c.scalar("section", s["file"], f"netforcelimits.{k}", t["netforcelimits"][k], last(nfl).get(k),
f"{p}.netforcelimits.{k}")
th = L(body.get("thruster"))
if len(t["thruster"]) != len(th):
c.diff(f"{p}.thruster", f"count {len(t['thruster'])} != {len(th)}")
for i, (tt, ot) in enumerate(zip(t["thruster"], th)):
for k in ("node", "effect", "idle_effect"):
c.scalar("section", s["file"], f"thruster.{k}", tt[k], ot.get(k), f"{p}.thruster[{i}].{k}")
def compare_tech(c, ours, oracle):
on = {n["name"]: n for n in oracle["nodes"]}
if len(ours["nodes"]) != len(oracle["nodes"]):
c.diff("tech.nodes", f"count {len(ours['nodes'])} != {len(oracle['nodes'])}")
for n in ours["nodes"]:
p = f"tech[{n['name']}]"
o = on.get(n["name"])
if o is None:
c.diff(p, "not in oracle")
continue
for k in ("display_name", "description", "family", "family_inferred", "type", "threat", "group",
"option_cost", "requires", "benefits_inc", "benefits_dec", "sections", "weapons", "allows"):
c.checked += 1
a, b = n[k], o[k]
if isinstance(a, (int, float)) and isinstance(b, (int, float)) and not isinstance(a, bool):
ok = float(a) == float(b)
else:
ok = a == b
if not ok:
c.diff(f"{p}.{k}", f"{a!r} != {b!r}")
if len(ours["edges"]) != len(oracle["edges"]):
c.diff("tech.edges", f"count {len(ours['edges'])} != {len(oracle['edges'])}")
for i, (a, b) in enumerate(zip(ours["edges"], oracle["edges"])):
c.checked += 1
mine = {"from": a["from"], "to": a["to"], "rp": a["rp"], "pct": a["pct"]}
if mine != b:
c.diff(f"tech.edges[{i}]", f"{mine!r} != {b!r}")
if a["unparsed"]:
c.diff(f"tech.edges[{i}]", f"unparsed tokens {a['unparsed']}")
for race, v in a["pct_effective"].items():
exp = b["pct"].get(race, 100)
if v != exp:
c.diff(f"tech.edges[{i}].pct_effective.{race}", f"{v} != {exp}")
c.checked += 1
if ours["groups"] != oracle["groups"]:
c.diff("tech.groups", f"{ours['groups']!r} != {oracle['groups']!r}")
def compare_crosslink(c, ours, dump, oracle):
x = ours
def pairs(v):
return sorted((a, b) for a, b in v)
def eq(path, a, b):
c.checked += 1
if a != b:
c.diff(path, f"{a!r} != {b!r}")
eq("crosslink.weapon_requires_dangling", pairs(x["weapon_requires_dangling"]), pairs(oracle["weapon_requires_dangling"]))
eq("crosslink.weapon_requires_case_mismatch", pairs(x["weapon_requires_case_mismatch"]),
pairs(oracle["weapon_requires_case_mismatch"]))
eq("crosslink.weapon_without_requires", sorted(x["weapons_without_requires"]), sorted(oracle["weapon_without_requires"]))
eq("crosslink.shipsection_requires_dangling", pairs(x["section_requires_dangling"]),
pairs(oracle["shipsection_requires_dangling"]))
eq("crosslink.shipsection_requires_case_mismatch", pairs(x["section_requires_case_mismatch"]),
pairs(oracle["shipsection_requires_case_mismatch"]))
eq("crosslink.shipsection_option_dangling", pairs(x["section_option_dangling"]), pairs(oracle["shipsection_option_dangling"]))
scalar = sorted((s["file"], m) for s in dump["sections"] for g in s["typed"]["option"] if g["scalar"] for m in g["members"])
eq("crosslink.shipsection_scalar_option", scalar, pairs(oracle["shipsection_scalar_option"]))
eq("crosslink.tech_ship_section_dangling", pairs(x["tech_ship_section_dangling"]), pairs(oracle["tech_ship_section_dangling"]))
eq("crosslink.tech_weapon_filename_dangling", pairs(x["tech_weapon_file_dangling"]), pairs(oracle["tech_weapon_filename_dangling"]))
eq("crosslink.tech_requires_dangling", pairs(x["tech_requires_dangling"]), pairs(oracle["tech_requires_dangling"]))
eq("crosslink.tech_allows_dangling", pairs(x["tech_allows_dangling"]), pairs(oracle["tech_allows_dangling"]))
eq("crosslink.tech_allows_unparsed", pairs(x["tech_allows_unparsed"]), pairs(oracle["tech_allows_unparsed"]))
eq("crosslink.bank_weapon_dangling", pairs(x["bank_weapon_dangling"]), pairs(oracle["bank_weapon_dangling"]))
for scope, om in oracle["manifests"].items():
m = dump["manifests"].get(scope)
if m is None:
c.diff(f"crosslink.manifests.{scope}", "missing in ours")
continue
eq(f"crosslink.manifests.{scope}.ids", len(m["ids"]), om["ids"])
eq(f"crosslink.manifests.{scope}.deleted", sorted(m["deleted"]), sorted(om["deleted"]))
gaps = sorted(g["name"].lower() for g in x["manifest_ids_without_file"] if g["scope"] == scope)
eq(f"crosslink.manifests.{scope}.listed_but_no_file", gaps, sorted(om["listed_but_no_file"]))
unl = sorted(r for s_, r in x["files_without_manifest_id"] if s_ == scope)
eq(f"crosslink.manifests.{scope}.file_but_unlisted", unl, sorted(om["file_but_unlisted"]))
os_ = oracle["strings"]
eq("crosslink.strings.missing_techname", sorted(x["missing_techname"]), sorted(os_["missing_techname"]))
eq("crosslink.strings.missing_techdesc", sorted(x["missing_techdesc"]), sorted(os_["missing_techdesc"]))
eq("crosslink.strings.missing_sectionname", sorted(x["missing_sectionname"]), sorted(os_["missing_sectionname"]))
eq("crosslink.strings.missing_sectiondesc", sorted(x["missing_sectiondesc"]), sorted(os_["missing_sectiondesc"]))
eq("crosslink.strings.unresolved_weapon_name", pairs(x["unresolved_weapon_names"]), pairs(os_["unresolved_weapon_name"]))
ot = oracle["turrets"]
eq("crosslink.turrets.turret_rows", len(dump["turrets"]), ot["turret_rows"])
eq("crosslink.turrets.weapon_pairs_without_turret", pairs(x["weapon_turret_pairs_without_row"]),
pairs(ot["weapon_size_class_pairs_without_turret"]))
eq("crosslink.turrets.bank_pairs_without_turret", pairs(x["bank_turret_pairs_without_row"]),
pairs(ot["bank_size_class_pairs_without_turret"]))
banks = [b for s in dump["sections"] for b in s["typed"]["bank"]]
eq("crosslink.turrets.banks_without_turretsize", sum(1 for b in banks if b["turretsize"] == ""), ot["banks_without_turretsize"])
eq("crosslink.turrets.banks_with_repeated_size_or_class", sum(1 for b in banks if b["repeated_turret_spec"]),
ot["banks_with_repeated_size_or_class"])
def compare_strings(c, dump, oracle):
c.checked += 1
if dump["string_count"] != len(oracle):
c.diff("strings.count", f"{dump['string_count']} != {len(oracle)}")
# display names already compared per weapon / section / tech
def main(argv):
ours = json.load(open(argv[1], encoding="ascii"))
odir = argv[2]
oracle = {n: json.load(open(os.path.join(odir, n + ".json"), encoding="utf-8"))
for n in ("tech_tree", "weapons", "shipsections", "strings", "crosslink")}
c = Cmp()
compare_weapons(c, ours["weapons"], oracle["weapons"])
compare_sections(c, ours["sections"], oracle["shipsections"])
compare_tech(c, ours["tech"], oracle["tech_tree"])
compare_strings(c, ours, oracle["strings"])
compare_crosslink(c, ours["cross_check"], ours, oracle["crosslink"])
print(f"compared {c.checked} values: {len(ours['weapons'])} weapons, {len(ours['sections'])} sections, "
f"{len(ours['tech']['nodes'])} techs, {len(ours['tech']['edges'])} edges, crosslink")
rc = 0
if c.untyped != KNOWN_UNTYPED:
print("untyped values differ from the documented set:")
for u in sorted(c.untyped - KNOWN_UNTYPED, key=str):
print(" unexpected:", u)
for u in sorted(KNOWN_UNTYPED - c.untyped, key=str):
print(" missing: ", u)
rc = 1
else:
print(f"documented untyped values: {len(c.untyped)} (as expected)")
if c.diffs:
print(f"DIFF: {len(c.diffs)} differences")
for d in c.diffs[:MAX_DIFFS]:
print(" ", d)
if len(c.diffs) > MAX_DIFFS:
print(f" ... {len(c.diffs) - MAX_DIFFS} more")
rc = 1
else:
print("OK: 100% agreement with the oracle catalogs")
return rc
if __name__ == "__main__":
sys.exit(main(sys.argv))

View file

@ -0,0 +1,187 @@
// Real-data facts: loads $SOTS_DATA_DIR and checks counts, spot values and
// the cross-check report against what the RE notes document. SKIPs cleanly
// when the variable is unset. Nothing from the data is embedded here beyond
// the handful of published facts being asserted.
#include <cstdio>
#include <cstdlib>
#include <map>
#include <string>
#include "game/data/catalog.h"
using namespace game::data;
namespace {
int failures = 0;
#define EXPECT(cond) \
do { \
if (!(cond)) { \
std::printf(" FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
++failures; \
} \
} while (0)
} // namespace
int main() {
const char* dir = std::getenv("SOTS_DATA_DIR");
if (!dir || !*dir) {
std::printf("real-data: SKIP (SOTS_DATA_DIR not set)\n");
return 0;
}
Catalog cat = load_catalog(dir);
// ---- counts ---------------------------------------------------------
std::size_t player = 0, npc = 0;
for (const WeaponDef& w : cat.weapons) (w.scope == WeaponScope::Player ? player : npc)++;
std::printf("weapons %zu (player %zu, NPC %zu) sections %zu techs %zu edges %zu turrets %zu races %zu strings %zu\n",
cat.weapons.size(), player, npc, cat.sections.size(), cat.tech.nodes.size(), cat.tech.edges.size(),
cat.turrets.size(), cat.races.size(), cat.strings.size());
EXPECT(cat.weapons.size() == 207);
EXPECT(player == 123 && npc == 84);
EXPECT(cat.sections.size() == 875);
EXPECT(cat.tech.nodes.size() == 293);
EXPECT(cat.tech.edges.size() == 354);
EXPECT(cat.turrets.size() == 42);
EXPECT(cat.races.size() == 7);
EXPECT(cat.weapon_ids.size() == 123);
EXPECT(cat.weapon_ids.deleted().size() == 3 && cat.weapon_ids.is_deleted(36) && cat.weapon_ids.is_deleted(58) &&
cat.weapon_ids.is_deleted(59));
EXPECT(cat.strings_loaded && cat.strings.size() == 5196);
EXPECT(cat.tech.groups.size() == 9);
std::map<std::string, std::size_t> per_race;
for (const ShipSectionDef& s : cat.sections) per_race[s.race]++;
EXPECT(per_race["Human"] == 144 && per_race["Hiver"] == 137 && per_race["Morrigi"] == 141 && per_race["Liir"] == 135 &&
per_race["Tarkas"] == 132 && per_race["Zuul"] == 122 && per_race["_NPC"] == 64);
// ---- spot facts -----------------------------------------------------
const WeaponDef* g = cat.weapon("bal_gauss");
EXPECT(g && g->id && *g->id == 8);
EXPECT(g && g->cost && *g->cost == 50 && g->turret_size == "small" && g->turret_class == "standard");
EXPECT(g && g->requires.size() == 1 && g->requires[0] == "WEP_GsDrvr");
EXPECT(g && g->bolt && g->bolt->rangetable.maximum.range && *g->bolt->rangetable.maximum.range == 455);
EXPECT(g && g->bolt && g->bolt->planet.pop && *g->bolt->planet.pop == 3500);
EXPECT(g && g->fc.requires_los && *g->fc.requires_los && g->fc.manual_target && !*g->fc.manual_target);
EXPECT(g && g->display_name && *g->display_name == "Gauss Cannon");
EXPECT(cat.weapon_by_id(8) == g);
EXPECT(cat.weapon("mis") && cat.weapon("mis")->requires.empty());
const ShipSectionDef* fis = cat.section("Human", "DEFission");
EXPECT(fis && fis->id && *fis->id == 47);
EXPECT(fis && fis->requires.size() == 2 && fis->requires[1] == "DRV_Node");
EXPECT(fis && fis->section_type == SectionType::Engine && fis->section_class == SectionClass::Destroyer);
EXPECT(fis && fis->options.size() == 3 && fis->options[0].members.size() == 5 && fis->options[2].scalar &&
fis->options[2].members[0] == "DRV_RecFiss");
EXPECT(fis && fis->banks.size() == 1 && fis->banks[0].mounts.size() == 1 && fis->banks[0].turret_size == "small");
EXPECT(fis && fis->netforcelimits && fis->netforcelimits->speed && *fis->netforcelimits->speed == 40);
EXPECT(fis && fis->ftlspeed && *fis->ftlspeed == 0.2 && fis->nodespeed && *fis->nodespeed == 4);
EXPECT(fis && fis->engine_techera == "fission" && fis->thrusters.size() == 4);
EXPECT(fis && fis->display_name && *fis->display_name == "Fission" && fis->unlocked_by.empty());
EXPECT(cat.section_by_id("Human", 47) == fis);
EXPECT(cat.section("human", "DEWAR") == nullptr);
EXPECT(cat.sections_named("DECommand").size() == 6); // every player race
const TechNode* fissn = cat.tech_node("DRV_Fissn");
EXPECT(fissn != nullptr);
if (fissn) {
bool fusn = false, recfiss = false;
for (std::size_t ei : fissn->allows) {
const AllowsEdge& e = cat.tech.edges[ei];
if (e.to == "DRV_Fusn") {
fusn = true;
EXPECT(e.rp && *e.rp == 85000);
for (int i = 0; i < kSpeciesCount; ++i) EXPECT(e.pct[static_cast<std::size_t>(i)] == 100 && !e.pct_written[static_cast<std::size_t>(i)]);
}
if (e.to == "DRV_RecFiss") {
recfiss = true;
EXPECT(e.percent(Species::Human) == 50 && e.percent(Species::Liir) == 95 && e.percent(Species::NPC) == 100);
}
}
EXPECT(fusn && recfiss);
}
const TechNode* root = cat.tech_node("DRV_ROOT");
EXPECT(root && root->is_root());
if (root)
for (std::size_t ei : root->allows) {
const AllowsEdge& e = cat.tech.edges[ei];
if (e.to == "DRV_Node") EXPECT(e.percent(Species::Human) == 100 && e.percent(Species::Tarkas) == 0);
if (e.to == "DRV_Hyper") EXPECT(e.percent(Species::Tarkas) == 100 && e.percent(Species::Human) == 0);
}
const TechNode* ply = cat.tech_node("IND_PlyAlloy");
EXPECT(ply && ply->option_cost && *ply->option_cost == 1.2 && ply->type == "P" && ply->benefits_inc.size() == 1 &&
ply->benefits_inc[0] == "TECHBEN_HULLSTR");
const auto* torps = cat.tech.group_members("GRP_Torps");
EXPECT(torps && torps->size() == 12);
EXPECT(cat.tech.requirement_exists("GRP_Shields") && cat.tech.roots().size() == 12);
EXPECT(cat.tech_node("WEP_HCLAS") && cat.tech_node("WEP_HCLAS")->name == "WEP_HCLas"); // case-insensitive lookup
// ---- load-time problems -------------------------------------------
int bad = 0, syntax = 0, other = 0;
for (const Problem& p : cat.problems) {
if (p.kind == Problem::Kind::BadValue) ++bad;
else if (p.kind == Problem::Kind::Syntax) ++syntax;
else ++other;
}
std::printf("problems: %d bad values, %d syntax recoveries, %d other\n", bad, syntax, other);
// force_right `o` x22, crew `false` x9 (NPC hulls), trackspeed_mod `1.0f`,
// min_inclination `0-5`, max_inclination `90\` -- each keeps its text in raw.
EXPECT(bad == 34);
EXPECT(syntax == 12); // the 12 lenient recoveries the parser documents
EXPECT(other == 0);
// ---- cross-check --------------------------------------------------
CrossCheck x = cat.cross_check();
std::printf("cross-check: dangling %zu (manifest gaps %zu, unresolved names %zu); case mismatches %zu/%zu; "
"no-requires %zu; missing sectiondesc %zu; roots %zu\n",
x.dangling_count(), x.manifest_ids_without_file.size(), x.unresolved_weapon_names.size(),
x.weapon_requires_case_mismatch.size(), x.section_requires_case_mismatch.size(),
x.weapons_without_requires.size(), x.missing_sectiondesc.size(), x.tech_roots.size());
EXPECT(x.strings_available);
EXPECT(x.weapon_requires_dangling.empty());
EXPECT(x.section_requires_dangling.empty());
EXPECT(x.section_option_dangling.empty());
EXPECT(x.tech_ship_section_dangling.empty());
EXPECT(x.tech_weapon_file_dangling.empty());
EXPECT(x.tech_requires_dangling.empty());
EXPECT(x.tech_allows_dangling.empty());
EXPECT(x.tech_allows_unparsed.empty());
EXPECT(x.bank_weapon_dangling.empty());
EXPECT(x.files_without_manifest_id.empty());
EXPECT(x.weapon_turret_pairs_without_row.empty());
EXPECT(x.bank_turret_pairs_without_row.empty());
EXPECT(x.manifest_ids_without_file.size() == 10);
int tarkas = 0, dewar = 0;
for (const auto& m : x.manifest_ids_without_file) {
if (m.scope == "Tarkas") ++tarkas;
if (iequals(m.name, "DEWar.shipsection")) ++dewar;
}
EXPECT(tarkas == 7 && dewar == 3);
EXPECT(x.unresolved_weapon_names.size() == 3);
for (const auto& r : x.unresolved_weapon_names) EXPECT(r.from.rfind("Species/_NPC/", 0) == 0);
EXPECT(x.dangling_count() == 13);
EXPECT(x.weapon_requires_case_mismatch.size() == 18);
EXPECT(x.section_requires_case_mismatch.size() == 7);
EXPECT(x.weapons_without_requires.size() == 30);
EXPECT(x.missing_techname.empty() && x.missing_techdesc.empty() && x.missing_sectionname.empty());
EXPECT(x.missing_sectiondesc.size() == 67);
EXPECT(x.tech_roots.size() == 12);
std::size_t scalar_options = 0, repeated_banks = 0, banks_no_size = 0, banks = 0, mounts = 0;
for (const ShipSectionDef& s : cat.sections) {
for (const OptionGroup& g : s.options) scalar_options += g.scalar;
for (const BankDef& b : s.banks) {
++banks;
mounts += b.mounts.size();
repeated_banks += b.repeated_turret_spec;
banks_no_size += b.turret_size.empty();
}
}
std::printf("banks %zu mounts %zu; scalar options %zu; banks with repeated size/class %zu; without size %zu\n", banks,
mounts, scalar_options, repeated_banks, banks_no_size);
EXPECT(banks == 3721 && mounts == 7375);
EXPECT(scalar_options == 229);
EXPECT(repeated_banks == 19);
EXPECT(banks_no_size == 2);
std::printf(failures ? "real-data: %d FAILED\n" : "real-data: OK\n", failures);
return failures ? 1 : 0;
}

View file

@ -0,0 +1,218 @@
// Catalog on a hand-written miniature data root written to a temp directory.
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include "game/data/catalog.h"
#include "test_main.h"
using namespace game::data;
namespace fs = std::filesystem;
namespace {
void put(const fs::path& p, const char* text) {
fs::create_directories(p.parent_path());
std::ofstream(p, std::ios::binary) << text;
}
fs::path make_root() {
auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
fs::path root = fs::temp_directory_path() / ("game_data_test_" + std::to_string(static_cast<long long>(stamp)));
fs::remove_all(root);
put(root / "Weapons/bal_gauss.weapon",
"weapon { name @WEAPON_BAL_GAUSS weaponclass bullet requires WEP_GsDrvr cost 50 turretsize small turretclass standard\n"
" bolt { rangetable { max_range 455 } dam_pop 3500 } }\n");
put(root / "Weapons/las_odd.weapon",
"weapon { name @WEAPON_LAS_ODD weaponclass beam requires WEP_NOPE requires wep_gsdrvr turretsize Large turretclass Beam }\n");
put(root / "Weapons/mis.weapon",
"weapon { name @WEAPON_MIS weaponclass missile turretsize medium turretclass missile }\n");
put(root / "Weapons/_weapons.txt", "// ids\n8 bal_gauss.weapon\n21 mis.weapon\n// DELETED - 36\n40 ghost.weapon\n");
put(root / "Weapons/_turrets.txt",
"small small standard 120 320 1 1 \"s.x\"\nmedium medium standard 500 140 1 1 \"m.x\"\n"
"medium medium missile 400 100 1 0.25 \"mm.x\"\nlarge large beam 1500 80 1 1 \"\"\n");
put(root / "Species/Human/sections/DECommand.shipsection",
"shipsection { model a.X section_type command section_class destroyer health 1 mass 2 socket_aft CommandNode\n"
" option { option IND_PlyAlloy option IND_Nope }\n"
" bank { turretclass standard turretsize small mount { node N1 } } }\n");
put(root / "Species/Human/sections/DEFission.shipsection",
"shipsection { model b.X section_type engine section_class destroyer health 1 mass 2 socket_fore EngineNode\n"
" requires DRV_Fissn requires grp_torps requires GRP_Empty\n"
" bank { turretclass strafe turretsize small mount { node N1 } } }\n");
put(root / "Species/Human/sections/_shipsections.txt", "1 DECommand.shipsection\n47 defission.SHIPSECTION\n98 DEWar.shipsection\n");
put(root / "Species/_NPC/sections/_Herald.shipsection",
"shipsection { model c.X section_class cruiser health 1 mass 2\n"
" bank { weapon \"Species/_NPC/weapons/Herald_gun.weapon\" turretclass standard turretsize medium mount { node N1 } }\n"
" bank { weapon \"Species/_NPC/weapons/Missing.weapon\" mount { node N2 } } }\n");
put(root / "Species/_NPC/sections/_shipsections.txt", "1 _Herald.shipsection\n");
put(root / "Species/_NPC/weapons/Herald_gun.weapon",
"weapon { name @WEAPON_NPC_HERALD weaponclass bullet turretsize medium turretclass standard hidden 1 }\n");
put(root / "TechTree/MasterTechList.tech",
"tech { name \"IND_ROOT\" family \"IND\" allows \"IND_PlyAlloy RP:5000\" allows \"WEP_Missing RP:1\" allows \"BAD\" }\n"
"tech { name \"IND_PlyAlloy\" ship { section DECommand section DENowhere } weapon { filename \"Weapons/bal_gauss.weapon\" }\n"
" weapon { filename \"Weapons/nope.weapon\" } }\n"
"tech { name \"WEP_GsDrvr\" group TORPS requires \"GRP_TORPS\" requires \"GRP_Nothing\" requires \"IND_Gone\" }\n"
"tech { name \"DRV_Fissn\" }\n");
put(root / "Locale/EN/Strings.csv",
"#Key+A955,String,Size,Notes\n"
"WEAPON_BAL_GAUSS,Gauss Cannon,,\n"
"WEAPON_MIS,Missile,,\n"
"SECTIONNAME_DECommand,Command,,\n"
"SECTIONDESC_DECommand,\"The, command\",,\n"
"SECTIONNAME_DEFission,Fission,,\n"
"TECHNAME_IND_ROOT,Industrial,,\n"
"TECHNAME_IND_PlyAlloy,Polysilicate Alloys,,\n"
"TECHDESC_IND_PlyAlloy,desc,,\n"
"TECHNAME_WEP_GsDrvr,Gauss Driver,,\n"
"TECHNAME_DRV_Fissn,Fission,,\n");
return root;
}
bool has_ref(const std::vector<CrossCheck::Ref>& v, const std::string& from, const std::string& ref) {
for (const auto& r : v)
if (r.from == from && r.ref == ref) return true;
return false;
}
} // namespace
TEST(catalog_loads_mini_root) {
fs::path root = make_root();
Catalog cat = load_catalog(root);
CHECK_EQ(cat.weapons.size(), std::size_t{4});
CHECK_EQ(cat.sections.size(), std::size_t{3});
CHECK_EQ(cat.tech.nodes.size(), std::size_t{4});
CHECK_EQ(cat.tech.edges.size(), std::size_t{3});
CHECK_EQ(cat.turrets.size(), std::size_t{4});
CHECK_EQ(cat.weapon_ids.size(), std::size_t{3});
CHECK(cat.weapon_ids.is_deleted(36));
CHECK_EQ(cat.section_ids.size(), std::size_t{2});
CHECK_EQ(cat.races.size(), std::size_t{2}); // Human, _NPC (sorted, '_' > letters)
CHECK(cat.strings_loaded);
CHECK_EQ(cat.strings.size(), std::size_t{10});
// ordering: player weapons first, folded stems
CHECK_EQ(cat.weapons[0].stem, "bal_gauss");
CHECK_EQ(cat.weapons[1].stem, "las_odd");
CHECK_EQ(cat.weapons[2].stem, "mis");
CHECK_EQ(cat.weapons[3].stem, "Herald_gun");
CHECK(cat.weapons[3].scope == WeaponScope::NPC);
CHECK_EQ(cat.weapons[3].file, "Species/_NPC/weapons/Herald_gun.weapon");
CHECK(!cat.weapons[3].id);
CHECK(!cat.weapons[3].display_name); // token missing from strings
const WeaponDef* g = cat.weapon("BAL_GAUSS");
CHECK(g != nullptr);
CHECK(g->id && *g->id == 8);
CHECK(g->display_name && *g->display_name == "Gauss Cannon");
CHECK(cat.weapon_by_id(8) == g);
CHECK(cat.weapon_by_file("weapons/BAL_GAUSS.weapon") == g);
CHECK(cat.weapon_by_file("Species\\_NPC\\weapons\\herald_gun.weapon") == &cat.weapons[3]);
CHECK(cat.weapon("nothing") == nullptr);
CHECK(cat.weapon_by_id(40) == nullptr);
const ShipSectionDef* cmd = cat.section("human", "decommand");
CHECK(cmd != nullptr);
CHECK(cmd->species == Species::Human);
CHECK(cmd->id && *cmd->id == 1);
CHECK(cmd->display_name && *cmd->display_name == "Command");
CHECK(cmd->description && *cmd->description == "The, command");
CHECK_EQ(cmd->unlocked_by.size(), std::size_t{1});
CHECK_EQ(cmd->unlocked_by[0], "IND_PlyAlloy");
const ShipSectionDef* fis = cat.section("Human", "DEFission");
CHECK(fis && fis->id && *fis->id == 47); // manifest spelled defission.SHIPSECTION
CHECK(fis->unlocked_by.empty());
CHECK(!fis->description);
CHECK(cat.section_by_id("Human", 47) == fis);
CHECK(cat.section_by_id("Human", 98) == nullptr);
CHECK(cat.section_by_id("Zuul", 1) == nullptr);
const ShipSectionDef* herald = cat.section("_NPC", "_Herald");
CHECK(herald && herald->species == Species::NPC);
CHECK_EQ(cat.sections_named("decommand").size(), std::size_t{1});
CHECK(cat.sections_named("nope").empty());
const TechNode* ply = cat.tech_node("ind_plyalloy");
CHECK(ply && ply->display_name && *ply->display_name == "Polysilicate Alloys");
CHECK(ply->description && *ply->description == "desc");
CHECK(!cat.tech_node("IND_ROOT")->description);
// load-time problems: the bad allows string only (everything else is well formed)
int unparsed = 0;
for (const Problem& p : cat.problems)
if (p.kind == Problem::Kind::Unparsed) ++unparsed;
CHECK_EQ(unparsed, 1);
fs::remove_all(root);
}
TEST(catalog_cross_check_reports_every_dangling_kind) {
fs::path root = make_root();
Catalog cat = load_catalog(root);
CrossCheck x = cat.cross_check();
CHECK(x.strings_available);
CHECK(!x.clean());
CHECK_EQ(x.weapon_requires_dangling.size(), std::size_t{1});
CHECK(has_ref(x.weapon_requires_dangling, "Weapons/las_odd.weapon", "WEP_NOPE"));
CHECK_EQ(x.weapon_requires_case_mismatch.size(), std::size_t{1});
CHECK(has_ref(x.weapon_requires_case_mismatch, "Weapons/las_odd.weapon", "wep_gsdrvr"));
CHECK_EQ(x.weapons_without_requires.size(), std::size_t{2}); // mis, Herald_gun
CHECK_EQ(x.section_requires_dangling.size(), std::size_t{1}); // GRP_Empty
CHECK(has_ref(x.section_requires_dangling, "Species/Human/sections/DEFission.shipsection", "GRP_Empty"));
CHECK(x.section_requires_case_mismatch.empty()); // grp_torps is a group ref, not a tech
CHECK_EQ(x.section_option_dangling.size(), std::size_t{1});
CHECK(has_ref(x.section_option_dangling, "Species/Human/sections/DECommand.shipsection", "IND_Nope"));
CHECK_EQ(x.tech_ship_section_dangling.size(), std::size_t{1});
CHECK(has_ref(x.tech_ship_section_dangling, "IND_PlyAlloy", "DENowhere"));
CHECK_EQ(x.tech_weapon_file_dangling.size(), std::size_t{1});
CHECK(has_ref(x.tech_weapon_file_dangling, "IND_PlyAlloy", "Weapons/nope.weapon"));
CHECK_EQ(x.tech_requires_dangling.size(), std::size_t{2}); // GRP_Nothing, IND_Gone
CHECK_EQ(x.tech_allows_dangling.size(), std::size_t{2}); // WEP_Missing, BAD
CHECK_EQ(x.tech_allows_unparsed.size(), std::size_t{1}); // BAD has no RP
CHECK_EQ(x.tech_roots.size(), std::size_t{3}); // IND_ROOT, WEP_GsDrvr, DRV_Fissn
CHECK_EQ(x.bank_weapon_dangling.size(), std::size_t{1});
CHECK(has_ref(x.bank_weapon_dangling, "Species/_NPC/sections/_Herald.shipsection", "Species/_NPC/weapons/Missing.weapon"));
CHECK_EQ(x.manifest_ids_without_file.size(), std::size_t{2});
bool ghost = false, dewar = false;
for (const auto& m : x.manifest_ids_without_file) {
if (m.scope == "Weapons" && m.id == 40 && m.name == "ghost.weapon") ghost = true;
if (m.scope == "Human" && m.id == 98 && m.name == "DEWar.shipsection") dewar = true;
}
CHECK(ghost && dewar);
CHECK_EQ(x.files_without_manifest_id.size(), std::size_t{1});
CHECK(has_ref(x.files_without_manifest_id, "Weapons", "Weapons/las_odd.weapon"));
// turret fit: las_odd is Large/Beam (row exists, case-folded); Herald_gun medium/standard ok
CHECK(x.weapon_turret_pairs_without_row.empty());
CHECK_EQ(x.bank_turret_pairs_without_row.size(), std::size_t{1}); // strafe bank, no strafe row
CHECK(has_ref(x.bank_turret_pairs_without_row, "Species/Human/sections/DEFission.shipsection", "small/strafe"));
CHECK_EQ(x.unresolved_weapon_names.size(), std::size_t{2}); // LAS_ODD, NPC_HERALD
CHECK(has_ref(x.unresolved_weapon_names, "Species/_NPC/weapons/Herald_gun.weapon", "@WEAPON_NPC_HERALD"));
CHECK(x.missing_techname.empty());
CHECK_EQ(x.missing_techdesc.size(), std::size_t{3});
CHECK_EQ(x.missing_sectionname.size(), std::size_t{1}); // _herald
CHECK_EQ(x.missing_sectiondesc.size(), std::size_t{2}); // defission, _herald
CHECK_EQ(x.dangling_count(), std::size_t{1 + 1 + 1 + 1 + 1 + 2 + 2 + 1 + 1 + 2 + 1 + 0 + 1 + 2});
fs::remove_all(root);
}
TEST(catalog_missing_root_is_all_problems_no_crash) {
Catalog cat = load_catalog(fs::temp_directory_path() / "game_data_does_not_exist");
CHECK(cat.weapons.empty());
CHECK(cat.sections.empty());
CHECK(!cat.strings_loaded);
CHECK(cat.problems.size() >= 3); // weapons manifest, turrets, tech tree
CrossCheck x = cat.cross_check();
CHECK(x.clean());
CHECK(!x.strings_available);
}

View file

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

View file

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

View file

@ -0,0 +1,187 @@
// ShipSectionDef loader on hand-written samples; option normalisation.
#include "game/data/shipsection.h"
#include "test_main.h"
using namespace game::data;
namespace {
const char* kEngine =
"shipsection\n{\n"
"\tmodel Species/Test/art/sections/Engine.X\n"
"\tsocket_fore EngineNode\n"
"\tSection_Type Engine\n"
"\tsection_class Destroyer\n"
"\trequires DRV_Fissn\n"
"\trequires DRV_Node\n"
"\tengine_techera fission\n"
"\thealth 450\n\tmass 2500\n\tcost 5000\n\tcpoints 1380\n\tcrew 3\n"
"\tnodespeed 4\n\tftlspeed .2\n\trange 9\n"
"\toption IND_First\n" // scalar group before the blocks
"\toption { option IND_PlyAlloy option IND_MagLat option IND_QrkRes }\n"
"\toption { option IND_RefCoat option IND_ImpRfCt }\n"
"\toption DRV_RecFiss\n" // scalar group after them
"\toptiondef { option SLD_MkOne option SLD_MkTwo }\n"
"\texclude \"DEFission\"\n\texclude \"DEPulseFission\"\n"
"\tnetforcelimits { force_forward 23000 force_right 23000 force_up 23000\n"
"\t torque_yaw 0 torque_pitch 0 torque_roll 6.4e+7 speed 40 rotspeed -10 }\n"
"\tthruster { node EngineThruster01 effect effects/a.effect idle_effect effects/b.effect }\n"
"\tthruster { node EngineThruster02 effect effects/a.effect }\n"
"\tbank\n\t{\n"
"\t\tturretclass standard\n\t\tturretsize medium\n\t\tturretsize small\n" // repeated: last wins
"\t\tmount { node LightGunNode15 min_azimuth -140 max_azimuth 140 min_inclination -12 max_inclination 90 }\n"
"\t\tmount { node LightGunNode16 min_azimuth -140 max_azimuth 140 home_azimuth 0 }\n"
"\t}\n"
"\tbank { weapon \"Species/_NPC/weapons/fixed.weapon\" turretclass standard turretsize small showturrets false\n"
"\t mount { node N1 min_azimuth 0 max_azimuth 0 } }\n"
"\tautonomous 1\n"
"\tnodesign true\n"
"}\n";
} // namespace
TEST(section_typed_fields) {
auto r = parse_shipsection(kEngine, "Species/Test/sections/DEEngine.shipsection");
CHECK(r.ok());
CHECK(r.problems.empty());
const ShipSectionDef& s = *r.value;
CHECK_EQ(s.model, "Species/Test/art/sections/Engine.X");
CHECK_EQ(s.socket_fore, "EngineNode");
CHECK_EQ(s.socket_aft, "");
CHECK(s.has_sockets());
CHECK_EQ(s.section_type_text, "Engine");
CHECK(s.section_type == SectionType::Engine);
CHECK_EQ(s.section_class_text, "Destroyer");
CHECK(s.section_class == SectionClass::Destroyer);
CHECK_EQ(s.requires.size(), std::size_t{2});
CHECK_EQ(s.requires[1], "DRV_Node");
CHECK_EQ(s.engine_techera, "fission");
CHECK(s.health && *s.health == 450);
CHECK(s.mass && *s.mass == 2500);
CHECK(s.cost && *s.cost == 5000);
CHECK(s.cpoints && *s.cpoints == 1380);
CHECK(s.crew && *s.crew == 3);
CHECK(s.nodespeed && *s.nodespeed == 4);
CHECK(s.ftlspeed && *s.ftlspeed == 0.2);
CHECK(s.range && *s.range == 9);
CHECK(s.autonomous && *s.autonomous);
CHECK(s.nodesign && *s.nodesign);
CHECK(!s.explicit_section);
CHECK_EQ(s.exclude.size(), std::size_t{2});
CHECK_EQ(s.exclude[1], "DEPulseFission");
}
TEST(section_option_normalisation_keeps_file_order_across_forms) {
auto r = parse_shipsection(kEngine);
CHECK(r.ok());
const ShipSectionDef& s = *r.value;
CHECK_EQ(s.options.size(), std::size_t{4});
CHECK(s.options[0].scalar);
CHECK_EQ(s.options[0].members.size(), std::size_t{1});
CHECK_EQ(s.options[0].members[0], "IND_First");
CHECK(!s.options[1].scalar);
CHECK_EQ(s.options[1].members.size(), std::size_t{3});
CHECK_EQ(s.options[1].members[2], "IND_QrkRes");
CHECK(!s.options[2].scalar);
CHECK_EQ(s.options[2].members.size(), std::size_t{2});
CHECK(s.options[3].scalar);
CHECK_EQ(s.options[3].members[0], "DRV_RecFiss");
CHECK(s.options[0].line < s.options[1].line && s.options[2].line < s.options[3].line);
CHECK(s.optiondef.has_value());
CHECK_EQ(s.optiondef->members.size(), std::size_t{2});
CHECK_EQ(s.optiondef->members[1], "SLD_MkTwo");
}
TEST(section_banks_and_mounts) {
auto r = parse_shipsection(kEngine);
CHECK(r.ok());
const ShipSectionDef& s = *r.value;
CHECK_EQ(s.banks.size(), std::size_t{2});
const BankDef& b0 = s.banks[0];
CHECK_EQ(b0.turret_class, "standard");
CHECK_EQ(b0.turret_size, "small"); // last of the two turretsize lines
CHECK(b0.repeated_turret_spec);
CHECK_EQ(b0.weapon, "");
CHECK(!b0.show_turrets);
CHECK_EQ(b0.mounts.size(), std::size_t{2});
CHECK_EQ(b0.mounts[0].node, "LightGunNode15");
CHECK(b0.mounts[0].min_azimuth && *b0.mounts[0].min_azimuth == -140);
CHECK(b0.mounts[0].max_inclination && *b0.mounts[0].max_inclination == 90);
CHECK(!b0.mounts[1].min_inclination);
CHECK(b0.mounts[1].home_azimuth && *b0.mounts[1].home_azimuth == 0);
const BankDef& b1 = s.banks[1];
CHECK_EQ(b1.weapon, "Species/_NPC/weapons/fixed.weapon");
CHECK(!b1.repeated_turret_spec);
CHECK(b1.show_turrets && !*b1.show_turrets);
CHECK_EQ(b1.mounts.size(), std::size_t{1});
}
TEST(section_netforcelimits_and_thrusters) {
auto r = parse_shipsection(kEngine);
CHECK(r.ok());
const ShipSectionDef& s = *r.value;
CHECK(s.netforcelimits.has_value());
CHECK(s.netforcelimits->force_forward && *s.netforcelimits->force_forward == 23000);
CHECK(s.netforcelimits->torque_roll && *s.netforcelimits->torque_roll == 6.4e7);
CHECK(s.netforcelimits->rotspeed && *s.netforcelimits->rotspeed == -10);
CHECK_EQ(s.thrusters.size(), std::size_t{2});
CHECK_EQ(s.thrusters[0].idle_effect, "effects/b.effect");
CHECK_EQ(s.thrusters[1].idle_effect, "");
}
TEST(section_enum_parsing_is_case_insensitive) {
CHECK(parse_section_type("command") == SectionType::Command);
CHECK(parse_section_type("Command") == SectionType::Command);
CHECK(parse_section_type("MISSION") == SectionType::Mission);
CHECK(parse_section_type("") == SectionType::None);
CHECK(parse_section_type("rider") == SectionType::Other);
CHECK(parse_section_class("Dreadnought") == SectionClass::Dreadnought);
CHECK(parse_section_class("cruiser") == SectionClass::Cruiser);
CHECK(parse_section_class("station") == SectionClass::Other);
CHECK_EQ(std::string(section_type_name(SectionType::Engine)), "engine");
}
TEST(section_bad_values_are_problems_raw_keeps_text) {
auto r = parse_shipsection(
"shipsection { model m.X health 10 mass 20 crew false netforcelimits { force_right o speed 1 } }", "s.shipsection");
CHECK(r.ok());
int bad = 0;
for (const Problem& p : r.problems)
if (p.kind == Problem::Kind::BadValue) ++bad;
CHECK_EQ(bad, 2);
CHECK(!r.value->crew);
CHECK(r.value->netforcelimits && !r.value->netforcelimits->force_right);
CHECK(r.value->netforcelimits->speed && *r.value->netforcelimits->speed == 1);
CHECK_EQ(r.value->raw.str("crew"), "false");
}
TEST(section_missing_required_keys_reported) {
auto r = parse_shipsection("shipsection { section_type mission }");
CHECK(r.ok());
int missing = 0;
for (const Problem& p : r.problems)
if (p.kind == Problem::Kind::MissingKey) ++missing;
CHECK_EQ(missing, 3); // model, health, mass
CHECK(!r.value->has_sockets());
CHECK(r.value->options.empty());
CHECK(!r.value->optiondef);
CHECK(!r.value->netforcelimits);
}
TEST(section_unclosed_file_is_recovered) {
auto r = parse_shipsection("shipsection\n{\n model m.X\n health 1\n mass 2\n bank { turretclass standard turretsize small }\n");
CHECK(r.ok());
CHECK_EQ(r.problems.size(), std::size_t{1});
CHECK(r.problems[0].kind == Problem::Kind::Syntax);
CHECK_EQ(r.value->banks.size(), std::size_t{1});
}
TEST(section_long_tail_reachable_through_raw) {
auto r = parse_shipsection("shipsection { model m.X health 1 mass 2 refinery true mining_rate 5 death_effect a death_effect b }");
CHECK(r.ok());
const Block& raw = r.value->raw;
CHECK(raw.find("refinery") && raw.find("refinery")->as_bool().value_or(false));
CHECK(raw.find("MINING_RATE") && *raw.find("MINING_RATE")->as_int() == 5);
CHECK_EQ(raw.strs("death_effect").size(), std::size_t{2});
CHECK(!raw.has("police"));
}

View file

@ -0,0 +1,172 @@
// TechTree: allows edges, groups, links, on a hand-written tree.
#include "game/data/techtree.h"
#include "test_main.h"
using namespace game::data;
namespace {
const char* kTree =
"tech\n{\n"
"\tname \t\"IND_ROOT\"\n"
"\tfamily\t\"IND\"\n"
"\tallows \t\"IND_Waldo \t\tRP:5000\"\n"
"\tallows \t\"IND_StlthArm \tRP:0\t\tHuman:0 Zuul:0 Hiver:0 Tarkas:0 Liir:0 Morrigi:100\"\n"
"}\n"
"tech {\n"
"\tname\t\"IND_Waldo\"\n"
"\tthreat\t2\n"
"\ttype\tP\n"
"\tallows\t\"IND_PlyAlloy RP:20000 Human:50 Liir:95\"\n"
"\tallows\t\"WEP_Dsrptr RP:10000 Human:20 Zuul:90 Hiver:40 Tarkas:80 Liir:90 Morrigi:80\"\n"
"\tstrategy { inc TECHBEN_INDOUTPUT inc TECHBEN_HULLSTR dec TECHBEN_SHIPCONCOST }\n"
"\tship { section DEHammerhead section CRHammerhead }\n"
"}\n"
"tech {\n"
"\tname\t\"IND_PlyAlloy\"\n"
"\tOPTION_COST\t1.2\n"
"\trequires\t\"IND_Waldo\"\n"
"}\n"
"tech {\n"
"\tname\t\"IND_StlthArm\"\n"
"\tunlock_explicitly\ttrue\n"
"}\n"
"tech {\n"
"\tname\t\"WEP_Dsrptr\"\n"
"\tfamily\t\"TRP\"\n"
"\tGROUP\tTorps\n"
"\tweapon { filename \"Weapons/trp_disruptor.weapon\" }\n"
"}\n"
"tech {\n"
"\tname\t\"WEP_PhotTrp\"\n"
"\tgroup\tTORPS\n"
"\trequires\tGRP_Torps\n"
"\trequires\tGRP_Mines\n"
"}\n";
} // namespace
TEST(allows_parsing_full_and_partial) {
AllowsEdge e = parse_allows("DRV_Node \t\tRP:0\t\tHuman:100\tZuul:0\t\tHiver:0\t\tTarkas:0\tLiir:0\t\tMorrigi:0", "DRV_ROOT", 7);
CHECK_EQ(e.from, "DRV_ROOT");
CHECK_EQ(e.to, "DRV_Node");
CHECK(e.rp && *e.rp == 0);
CHECK_EQ(e.percent(Species::Human), 100);
CHECK_EQ(e.percent(Species::Zuul), 0);
CHECK_EQ(e.percent(Species::Morrigi), 0);
CHECK_EQ(e.percent(Species::NPC), 100); // never written; engine default
CHECK(e.pct_written[static_cast<int>(Species::Human)]);
CHECK(!e.pct_written[static_cast<int>(Species::NPC)]);
CHECK(e.unparsed.empty());
CHECK_EQ(e.line, 7);
AllowsEdge p = parse_allows("DRV_Fusn RP:85000", "DRV_Fissn");
CHECK(p.rp && *p.rp == 85000);
for (int i = 0; i < kSpeciesCount; ++i) {
CHECK_EQ(p.pct[static_cast<std::size_t>(i)], kDefaultAllowPercent);
CHECK(!p.pct_written[static_cast<std::size_t>(i)]);
}
AllowsEdge q = parse_allows("X rp:5 human:10", "Y"); // key case does not matter
CHECK(q.rp && *q.rp == 5);
CHECK_EQ(q.percent(Species::Human), 10);
}
TEST(allows_unparsed_tokens_are_reported) {
std::vector<Problem> problems;
AllowsEdge e = parse_allows("CHILD Klingon:50 bogus RP:x", "T", 3, &problems, "f.tech");
CHECK_EQ(e.to, "CHILD");
CHECK(!e.rp);
CHECK_EQ(e.unparsed.size(), std::size_t{3});
CHECK_EQ(problems.size(), std::size_t{4}); // no RP + 3 tokens
CHECK(problems[0].kind == Problem::Kind::Unparsed);
CHECK_EQ(problems[0].file, "f.tech");
std::vector<Problem> none;
AllowsEdge empty = parse_allows(" ", "T", 1, &none);
CHECK_EQ(empty.to, "");
CHECK_EQ(none.size(), std::size_t{1});
}
TEST(tech_tree_nodes_edges_groups) {
auto r = parse_tech_tree(kTree, "MasterTechList.tech");
CHECK(r.ok());
CHECK(r.problems.empty());
const TechTree& t = *r.value;
CHECK_EQ(t.nodes.size(), std::size_t{6});
CHECK_EQ(t.edges.size(), std::size_t{4});
const TechNode* root = t.find("ind_root"); // case-insensitive
CHECK(root != nullptr);
CHECK_EQ(root->family, "IND");
CHECK_EQ(root->family_inferred, "IND");
CHECK(root->is_root());
CHECK_EQ(root->allows.size(), std::size_t{2});
const AllowsEdge& stealth = t.edges[root->allows[1]];
CHECK_EQ(stealth.to, "IND_StlthArm");
CHECK_EQ(stealth.percent(Species::Morrigi), 100);
CHECK_EQ(stealth.percent(Species::Human), 0);
CHECK_EQ(stealth.percent(Species::NPC), 100);
const TechNode* waldo = t.find("IND_Waldo");
CHECK(waldo != nullptr);
CHECK(waldo->threat && *waldo->threat == 2);
CHECK_EQ(waldo->type, "P");
CHECK_EQ(waldo->family, "");
CHECK_EQ(waldo->family_inferred, "IND");
CHECK(!waldo->is_root());
CHECK_EQ(waldo->benefits_inc.size(), std::size_t{2});
CHECK_EQ(waldo->benefits_inc[1], "TECHBEN_HULLSTR");
CHECK_EQ(waldo->benefits_dec.size(), std::size_t{1});
CHECK_EQ(waldo->sections.size(), std::size_t{2});
CHECK_EQ(waldo->sections[0], "DEHammerhead");
const AllowsEdge& ply = t.edges[waldo->allows[0]];
CHECK_EQ(ply.percent(Species::Human), 50);
CHECK_EQ(ply.percent(Species::Liir), 95);
CHECK_EQ(ply.percent(Species::Zuul), 100); // unlisted -> default
const TechNode* alloy = t.find("IND_PlyAlloy");
CHECK(alloy->option_cost && *alloy->option_cost == 1.2); // OPTION_COST key case
CHECK_EQ(alloy->requires.size(), std::size_t{1});
CHECK(t.find("IND_StlthArm")->unlock_explicitly.value_or(false));
const TechNode* dsr = t.find("WEP_Dsrptr");
CHECK_EQ(dsr->family, "TRP");
CHECK_EQ(dsr->family_inferred, "WEP");
CHECK_EQ(dsr->group, "Torps");
CHECK_EQ(dsr->weapon_files.size(), std::size_t{1});
CHECK_EQ(dsr->weapon_files[0], "Weapons/trp_disruptor.weapon");
CHECK_EQ(t.groups.size(), std::size_t{1});
CHECK_EQ(t.groups[0].first, "TORPS");
CHECK_EQ(t.groups[0].second.size(), std::size_t{2});
const auto* m = t.group_members("GRP_torps");
CHECK(m && m->size() == 2 && (*m)[1] == "WEP_PhotTrp");
CHECK(t.group_members("TORPS") == m);
CHECK(t.group_members("GRP_MINES") == nullptr);
CHECK(t.requirement_exists("GRP_Torps"));
CHECK(!t.requirement_exists("GRP_Mines"));
CHECK(t.requirement_exists("ind_waldo"));
CHECK(!t.requirement_exists("IND_Nope"));
CHECK_EQ(t.edges_to("WEP_Dsrptr").size(), std::size_t{1});
CHECK_EQ(t.edges_from("IND_ROOT").size(), std::size_t{2});
auto roots = t.roots();
CHECK_EQ(roots.size(), std::size_t{2}); // IND_ROOT and WEP_PhotTrp; everything else is allowed by something
CHECK_EQ(roots[0]->name, "IND_ROOT");
CHECK_EQ(roots[1]->name, "WEP_PhotTrp");
}
TEST(tech_tree_problems) {
auto r = parse_tech_tree("tech { name \"A\" allows \"B\" } tech { name \"a\" } tech { threat 1 }", "t.tech");
CHECK(r.ok());
int unparsed = 0, dup = 0, missing = 0;
for (const Problem& p : r.problems) {
if (p.kind == Problem::Kind::Unparsed) ++unparsed;
if (p.kind == Problem::Kind::Duplicate) ++dup;
if (p.kind == Problem::Kind::MissingKey) ++missing;
}
CHECK_EQ(unparsed, 1); // allows "B" without RP:
CHECK_EQ(dup, 1); // "a" repeats "A" case-insensitively
CHECK_EQ(missing, 1); // nameless tech dropped
CHECK_EQ(r.value->nodes.size(), std::size_t{2});
CHECK(!parse_tech_tree("weapon { }").ok());
}

View file

@ -0,0 +1,72 @@
// TurretTable (positional rows) and IdRegistry (manifests).
#include "game/data/turrets.h"
#include "test_main.h"
using namespace game::data;
TEST(turret_table_rows) {
auto r = parse_turret_table(
"// size weapon-size class health track-speed az% inc% \"model\"\n"
"\n"
"small\ttiny\tstandard 20 360 1.00 1.00 \"turret_pd.x\"\n"
"large medium Missile 950 100 1.00 0.25 \"turret_l2missile.x\"\n"
"large large beam 1500 80 1.00 1.00 \"\"\n"
"large large spinal 800 // too short\n",
"_turrets.txt");
CHECK(r.ok());
CHECK_EQ(r.value->size(), std::size_t{3});
CHECK_EQ(r.problems.size(), std::size_t{1});
CHECK(r.problems[0].kind == Problem::Kind::Unparsed);
CHECK_EQ(r.problems[0].line, 6);
const TurretRow& pd = r.value->rows()[0];
CHECK_EQ(pd.mount_size, "small");
CHECK_EQ(pd.weapon_size, "tiny");
CHECK(pd.health && *pd.health == 20);
CHECK(pd.track_speed && *pd.track_speed == 360);
CHECK_EQ(pd.model, "turret_pd.x");
CHECK_EQ(r.value->rows()[2].model, "");
CHECK(r.value->rows()[1].inclination_scale && *r.value->rows()[1].inclination_scale == 0.25);
}
TEST(turret_table_lookup_is_case_insensitive) {
auto r = parse_turret_table(
"small small standard 120 320 1 1 \"a.x\"\n"
"large medium Missile 950 100 1 0.25 \"b.x\"\n");
const TurretTable& t = *r.value;
CHECK(t.find("Small", "SMALL", "Standard") != nullptr);
CHECK(t.find("large", "medium", "missile") != nullptr);
CHECK(t.find("large", "large", "missile") == nullptr);
CHECK(t.has_weapon_pair("medium", "MISSILE"));
CHECK(!t.has_weapon_pair("tiny", "standard"));
CHECK(t.has_bank_pair("LARGE", "missile"));
CHECK(!t.has_bank_pair("medium", "missile"));
CHECK_EQ(t.rows_for_bank("large", "missile").size(), std::size_t{1});
}
TEST(id_registry_from_manifest) {
auto r = parse_id_registry(
"// Only add to this list\n"
"1 can_am.weapon\n"
"8 bal_gauss.weapon\n"
"// DELETED - 36\n"
"98 DEWar.SHIPSECTION\n"
"8 dup.weapon\n",
"_weapons.txt");
CHECK(r.ok());
const IdRegistry& reg = *r.value;
CHECK_EQ(reg.size(), std::size_t{4});
CHECK_EQ(reg.deleted().size(), std::size_t{1});
CHECK(reg.is_deleted(36));
CHECK(!reg.is_deleted(8));
CHECK(reg.id_of("bal_gauss.weapon") && *reg.id_of("bal_gauss.weapon") == 8);
CHECK(reg.id_of("BAL_GAUSS") && *reg.id_of("BAL_GAUSS") == 8); // stem, any case
CHECK(reg.id_of("dewar.shipsection") && *reg.id_of("dewar.shipsection") == 98);
CHECK(reg.id_of("dewar") && *reg.id_of("dewar") == 98);
CHECK(!reg.id_of("missing"));
CHECK(reg.find(98) && reg.find(98)->stem == "DEWar");
CHECK(reg.find(8) && reg.find(8)->name == "dup.weapon"); // later assignment wins
CHECK(reg.find(7) == nullptr);
CHECK_EQ(r.problems.size(), std::size_t{1});
CHECK(r.problems[0].kind == Problem::Kind::Duplicate);
CHECK_EQ(r.problems[0].key, "8");
}

View file

@ -0,0 +1,178 @@
// WeaponDef loader on hand-written samples.
#include "game/data/weapon.h"
#include "test_main.h"
using namespace game::data;
namespace {
const char* kGun =
"weapon\n{\n"
"\tname\t@WEAPON_TEST_GUN\n"
"\tWeaponClass\tbullet\n" // key case must not matter
"\tweaponfamily\tgauss\n"
"\tRequires\tWEP_A\n"
"\trequires\tWEP_B\n"
"\tcost\t50\n"
"\tturretsize\tsmall\n"
"\tturretclass\tStandard\n"
"\ttrackspeed_mod\t1.0\n"
"\tburst_volleys\t3\n"
"\trecharge_time\t2.5\n"
"\tmuzzle_speed\t300\n"
"\ticon_rect\t\"96 64 32 32\"\n"
"\trange\t455\n"
"\trange_planet\t1075\n"
"\thidden\t1\n"
"\tfc_requires_los\ttrue\n"
"\tfc_controllable\tFALSE\n"
"\tbolt\n\t{\n"
"\t\tbeam_origin\t-0.5\n"
"\t\tbeam_length\t1.5\n"
"\t\timpact_effect\teffects/x_impact.effect\n"
"\t\trangetable { pb_range 125 pb_range_dev 4 pb_range_dam 45\n"
"\t\t eff_range 320 eff_range_dev 5.5 eff_range_dam 45\n"
"\t\t max_range 455 max_range_dev 6 max_range_dam 40 }\n"
"\t\teffect\teffects/x_bullet.effect\n"
"\t\tricochet_mod\t-.8\n"
"\t\tmass 100\n"
"\t\tdam_pop\t3500\n"
"\t\tdam_infra\t.00005\n"
"\t\tdam_terra\t0\n"
"\t}\n"
"\tdam_est\t12\n"
"\trating_frate\t7\n"
"\trating_dam\t2.5\n"
"}\n";
} // namespace
TEST(weapon_typed_fields) {
auto r = parse_weapon(kGun, "Weapons/test_gun.weapon");
CHECK(r.ok());
CHECK(r.problems.empty());
const WeaponDef& w = *r.value;
CHECK_EQ(w.file, "Weapons/test_gun.weapon");
CHECK_EQ(w.name, "@WEAPON_TEST_GUN");
CHECK_EQ(w.weapon_class, "bullet");
CHECK_EQ(w.weapon_family, "gauss");
CHECK_EQ(w.requires.size(), std::size_t{2});
CHECK_EQ(w.requires[0], "WEP_A");
CHECK_EQ(w.requires[1], "WEP_B");
CHECK(w.cost && *w.cost == 50);
CHECK_EQ(w.turret_size, "small");
CHECK_EQ(w.turret_class, "Standard"); // kept as written
CHECK(w.track_speed_mod && *w.track_speed_mod == 1.0);
CHECK(w.burst_volleys && *w.burst_volleys == 3);
CHECK(w.recharge_time && *w.recharge_time == 2.5);
CHECK(w.muzzle_speed && *w.muzzle_speed == 300);
CHECK_EQ(w.icon_rect, "96 64 32 32");
CHECK(w.range && *w.range == 455);
CHECK(w.range_planet && *w.range_planet == 1075);
CHECK(w.hidden && *w.hidden); // `hidden 1` reads as true
CHECK(w.fc.requires_los && *w.fc.requires_los);
CHECK(w.fc.controllable && !*w.fc.controllable); // FALSE any case
CHECK(!w.fc.holdsfire); // absent -> nullopt
CHECK(w.dam_est && *w.dam_est == 12);
CHECK(w.ratings.fire_rate && *w.ratings.fire_rate == 7);
CHECK(w.ratings.damage && *w.ratings.damage == 2.5);
CHECK(!w.ratings.accuracy);
CHECK_EQ(w.scope == WeaponScope::Player, true);
CHECK(!w.id);
}
TEST(weapon_bolt_block) {
auto r = parse_weapon(kGun);
CHECK(r.ok());
const WeaponDef& w = *r.value;
CHECK_EQ(w.behavior_kind, "bolt");
CHECK(w.behavior() != nullptr);
CHECK(w.bolt.has_value());
const BoltDef& b = *w.bolt;
CHECK(b.beam_origin && *b.beam_origin == -0.5);
CHECK(b.beam_length && *b.beam_length == 1.5);
CHECK(b.ricochet_mod && *b.ricochet_mod == -0.8);
CHECK(b.mass && *b.mass == 100);
CHECK_EQ(b.effect, "effects/x_bullet.effect");
CHECK_EQ(b.impact_effect, "effects/x_impact.effect");
CHECK_EQ(b.expire_effect, "");
CHECK(b.planet.pop && *b.planet.pop == 3500);
CHECK(b.planet.infra && *b.planet.infra == 0.00005);
CHECK(b.planet.terra && *b.planet.terra == 0);
CHECK(b.rangetable.point_blank.range && *b.rangetable.point_blank.range == 125);
CHECK(b.rangetable.effective.deviation && *b.rangetable.effective.deviation == 5.5);
CHECK(b.rangetable.maximum.damage && *b.rangetable.maximum.damage == 40);
// the weapon-level views mirror the bolt
CHECK(w.rangetable && w.rangetable->maximum.range && *w.rangetable->maximum.range == 455);
CHECK(w.planet_damage.pop && *w.planet_damage.pop == 3500);
}
TEST(weapon_torpedo_has_rangetable_but_no_bolt) {
auto r = parse_weapon(
"weapon { name @W weaponclass torpedo turretsize large turretclass torpedo\n"
" torpedo { tracking 1 rangetable { pb_range 200 max_range 1400 } dam_pop 100000 dam_infra 0.0001 } }");
CHECK(r.ok());
const WeaponDef& w = *r.value;
CHECK_EQ(w.behavior_kind, "torpedo");
CHECK(!w.bolt);
CHECK(w.rangetable && w.rangetable->maximum.range && *w.rangetable->maximum.range == 1400);
CHECK(!w.rangetable->effective.range);
CHECK(w.planet_damage.pop && *w.planet_damage.pop == 100000);
CHECK(!w.planet_damage.terra);
CHECK(w.behavior()->find("tracking") != nullptr);
}
TEST(weapon_beam_without_rangetable) {
auto r = parse_weapon("weapon { name @W weaponclass beam turretsize large turretclass beam beam { dam 40 dam_pop 1 } }");
CHECK(r.ok());
CHECK_EQ(r.value->behavior_kind, "beam");
CHECK(!r.value->rangetable);
CHECK(!r.value->bolt);
CHECK(r.value->planet_damage.pop.has_value());
}
TEST(weapon_missing_keys_and_bad_values_are_problems) {
auto r = parse_weapon("weapon { weaponclass bullet turretsize small turretclass standard cost 1.0f trackspeed_mod \"1\" }", "x.weapon");
CHECK(r.ok()); // still loads
int missing = 0, bad = 0;
for (const Problem& p : r.problems) {
if (p.kind == Problem::Kind::MissingKey) ++missing;
if (p.kind == Problem::Kind::BadValue) ++bad;
CHECK_EQ(p.file, "x.weapon");
}
CHECK_EQ(missing, 1); // name
CHECK_EQ(bad, 2); // cost 1.0f, quoted trackspeed_mod
CHECK(!r.value->cost);
CHECK(!r.value->track_speed_mod);
CHECK_EQ(r.value->raw.str("cost"), "1.0f"); // raw keeps the text
}
TEST(weapon_no_block_is_fatal) {
auto r = parse_weapon("shipsection { model x }");
CHECK(!r.ok());
CHECK_EQ(r.problems.size(), std::size_t{1});
CHECK(r.problems[0].kind == Problem::Kind::MissingBlock);
}
TEST(weapon_syntax_error_is_fatal_and_recovery_is_reported) {
auto bad = parse_weapon("weapon { name \"unterminated }");
CHECK(!bad.ok());
CHECK(bad.problems[0].kind == Problem::Kind::Syntax);
auto rec = parse_weapon("weapon { name @W weaponclass bullet turretsize small turretclass standard\n"); // EOF closes
CHECK(rec.ok());
CHECK_EQ(rec.problems.size(), std::size_t{1});
CHECK(rec.problems[0].kind == Problem::Kind::Syntax);
CHECK_EQ(rec.value->name, "@W");
}
TEST(weapon_repeated_scalar_last_wins_and_lists_keep_order) {
auto r = parse_weapon(
"weapon { name @W weaponclass rider turretsize large turretclass dronerider cost 1 cost 2\n"
" compatible_section _Drone compatible_section _DroneHeavy exclusive_species zuul }");
CHECK(r.ok());
CHECK(r.value->cost && *r.value->cost == 2);
CHECK_EQ(r.value->compatible_section.size(), std::size_t{2});
CHECK_EQ(r.value->compatible_section[1], "_DroneHeavy");
CHECK_EQ(r.value->exclusive_species, "zuul");
CHECK_EQ(r.value->raw.all("cost").size(), std::size_t{2});
}