From 9065c2682940e0e4a4215d0bb2d2ae576861c799 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 7 Sep 2026 17:55:08 -0400 Subject: [PATCH] game/data: typed catalogs (weapons, sections, turrets, ids, tech tree, strings), cross_check; oracle 229k values 0 diffs --- docs/game-data.md | 273 ++++++++++++++ src/game/data/CMakeLists.txt | 19 + src/game/data/block.cpp | 113 ++++++ src/game/data/block.h | 57 +++ src/game/data/catalog.cpp | 397 ++++++++++++++++++++ src/game/data/catalog.h | 117 ++++++ src/game/data/common.cpp | 66 ++++ src/game/data/common.h | 60 +++ src/game/data/fields.h | 84 +++++ src/game/data/shipsection.cpp | 210 +++++++++++ src/game/data/shipsection.h | 115 ++++++ src/game/data/strings.cpp | 43 +++ src/game/data/strings.h | 38 ++ src/game/data/techtree.cpp | 202 ++++++++++ src/game/data/techtree.h | 102 ++++++ src/game/data/turrets.cpp | 115 ++++++ src/game/data/turrets.h | 78 ++++ src/game/data/weapon.cpp | 150 ++++++++ src/game/data/weapon.h | 108 ++++++ tests/game_data/CMakeLists.txt | 15 + tests/game_data/build_and_run.sh | 66 ++++ tests/game_data/dump_catalog.cpp | 527 +++++++++++++++++++++++++++ tests/game_data/oracle/compare.py | 428 ++++++++++++++++++++++ tests/game_data/realdata_test.cpp | 187 ++++++++++ tests/game_data/test_catalog.cpp | 218 +++++++++++ tests/game_data/test_main.cpp | 32 ++ tests/game_data/test_main.h | 49 +++ tests/game_data/test_shipsection.cpp | 187 ++++++++++ tests/game_data/test_techtree.cpp | 172 +++++++++ tests/game_data/test_turrets.cpp | 72 ++++ tests/game_data/test_weapon.cpp | 178 +++++++++ 31 files changed, 4478 insertions(+) create mode 100644 docs/game-data.md create mode 100644 src/game/data/CMakeLists.txt create mode 100644 src/game/data/block.cpp create mode 100644 src/game/data/block.h create mode 100644 src/game/data/catalog.cpp create mode 100644 src/game/data/catalog.h create mode 100644 src/game/data/common.cpp create mode 100644 src/game/data/common.h create mode 100644 src/game/data/fields.h create mode 100644 src/game/data/shipsection.cpp create mode 100644 src/game/data/shipsection.h create mode 100644 src/game/data/strings.cpp create mode 100644 src/game/data/strings.h create mode 100644 src/game/data/techtree.cpp create mode 100644 src/game/data/techtree.h create mode 100644 src/game/data/turrets.cpp create mode 100644 src/game/data/turrets.h create mode 100644 src/game/data/weapon.cpp create mode 100644 src/game/data/weapon.h create mode 100644 tests/game_data/CMakeLists.txt create mode 100755 tests/game_data/build_and_run.sh create mode 100644 tests/game_data/dump_catalog.cpp create mode 100644 tests/game_data/oracle/compare.py create mode 100644 tests/game_data/realdata_test.cpp create mode 100644 tests/game_data/test_catalog.cpp create mode 100644 tests/game_data/test_main.cpp create mode 100644 tests/game_data/test_main.h create mode 100644 tests/game_data/test_shipsection.cpp create mode 100644 tests/game_data/test_techtree.cpp create mode 100644 tests/game_data/test_turrets.cpp create mode 100644 tests/game_data/test_weapon.cpp diff --git a/docs/game-data.md b/docs/game-data.md new file mode 100644 index 0000000..c6dc8dd --- /dev/null +++ b/docs/game-data.md @@ -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/
.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, 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 { optional value; vector 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//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` | `.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` | `.dam_pop/dam_infra/dam_terra` | `PlanetDamage` | whichever behaviour block is present | + +### `ShipSectionDef` (`shipsection{}`; `Species//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_` / `SECTIONDESC_`; techs whose `ship{section}` names the stem (tech order) | +| `model` (required), `dam_model` | | string | | +| `requires`* | | vector | may name `GRP_` | +| `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, `: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//sections/` (+ `_shipsections.txt`) and +`Species//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". diff --git a/src/game/data/CMakeLists.txt b/src/game/data/CMakeLists.txt new file mode 100644 index 0000000..56b40d8 --- /dev/null +++ b/src/game/data/CMakeLists.txt @@ -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) diff --git a/src/game/data/block.cpp b/src/game/data/block.cpp new file mode 100644 index 0000000..a46cb35 --- /dev/null +++ b/src/game/data/block.cpp @@ -0,0 +1,113 @@ +#include "game/data/block.h" + +#include "game/data/common.h" +#include "mars/parse/value.h" + +namespace game::data { + +std::optional Attr::as_int() const { + if (quoted) return std::nullopt; + return mars::parse::as_int(value); +} + +std::optional Attr::as_double() const { + if (quoted) return std::nullopt; + return mars::parse::as_double(value); +} + +std::optional 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 Block::all(std::string_view key) const { + std::vector 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 Block::blocks_named(std::string_view key) const { + std::vector 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 Block::opt_str(std::string_view key) const { + const Attr* a = find(key); + if (!a) return std::nullopt; + return a->value; +} + +std::vector Block::strs(std::string_view key) const { + std::vector 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 diff --git a/src/game/data/block.h b/src/game/data/block.h new file mode 100644 index 0000000..3bff208 --- /dev/null +++ b/src/game/data/block.h @@ -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 +#include +#include +#include +#include + +#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 as_int() const; // [+-]digits + std::optional as_double() const; // int or C-float shape + std::optional 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 attrs; // scalar pairs, file order + std::vector blocks; // sub-blocks, file order + std::vector 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 all(std::string_view key) const; // file order + const Block* block(std::string_view key) const; // first sub-block with that name + std::vector 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 opt_str(std::string_view key) const; + std::vector strs(std::string_view key) const; // every occurrence +}; + +Block snapshot(const mars::parse::Node& node); + +} // namespace game::data diff --git a/src/game/data/catalog.cpp b/src/game/data/catalog.cpp new file mode 100644 index 0000000..def5ee1 --- /dev/null +++ b/src/game/data/catalog.cpp @@ -0,0 +1,397 @@ +#include "game/data/catalog.h" + +#include +#include +#include +#include + +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 list_files(const fs::path& dir, std::string_view ext, std::vector& problems, + const std::string& rel_dir) { + std::vector 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 list_dirs(const fs::path& dir, std::vector& problems, const std::string& rel_dir) { + std::vector 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& dst, std::vector& 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 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 : §ions[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 Catalog::sections_named(std::string_view stem) const { + std::vector 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(§ions[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 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 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 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 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 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 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 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 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 diff --git a/src/game/data/catalog.h b/src/game/data/catalog.h new file mode 100644 index 0000000..65dc0da --- /dev/null +++ b/src/game/data/catalog.h @@ -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//sections/*.shipsection per-race section catalogs +// Species//sections/_shipsections.txt +// Species//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 +#include +#include +#include +#include +#include +#include + +#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 weapon_requires_dangling; + std::vector section_requires_dangling; + std::vector section_option_dangling; // option / optiondef members + std::vector tech_ship_section_dangling; // tech ship{section} -> any race's catalog + std::vector tech_weapon_file_dangling; // tech weapon{filename} -> a loaded weapon file + std::vector tech_requires_dangling; // tech or non-empty GRP_ + std::vector tech_allows_dangling; // allows child -> tech + std::vector tech_allows_unparsed; + std::vector bank_weapon_dangling; // bank{weapon FILE} -> a loaded weapon file + std::vector manifest_ids_without_file; + std::vector files_without_manifest_id; // from = scope, ref = file + std::vector weapon_turret_pairs_without_row; // ref = "size/class" + std::vector bank_turret_pairs_without_row; + std::vector unresolved_weapon_names; // name token absent from strings + + // informational + std::vector weapon_requires_case_mismatch; + std::vector section_requires_case_mismatch; + std::vector weapons_without_requires; // files + std::vector missing_techname, missing_techdesc; // tech names + std::vector missing_sectionname, missing_sectiondesc; // section stems (folded, distinct) + std::vector 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 weapons; // player weapons first, then race-private; stems folded-sorted + std::vector sections; // by (race, folded stem) + TechTree tech; + TurretTable turrets; + IdRegistry weapon_ids; + std::map section_ids; // race directory -> registry + std::vector races; // race directories found, in load order + StringTable strings; + bool strings_loaded = false; + std::vector 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 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 weapon_by_stem_; // folded; player wins + std::unordered_map weapon_by_file_; // folded rel path + std::unordered_map weapon_by_id_; + std::unordered_map section_by_key_; // folded "race/stem" + std::unordered_map> 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 diff --git a/src/game/data/common.cpp b/src/game/data/common.cpp new file mode 100644 index 0000000..f43be0f --- /dev/null +++ b/src/game/data/common.cpp @@ -0,0 +1,66 @@ +#include "game/data/common.h" + +namespace game::data { + +namespace { +constexpr std::array kSpeciesNames = { + "Human", "Hiver", "Tarkas", "Liir", "_NPC", "Zuul", "Morrigi"}; + +unsigned char lower(unsigned char c) { return (c >= 'A' && c <= 'Z') ? static_cast(c + 32) : c; } +} // namespace + +std::string_view species_name(Species s) { + int i = static_cast(s); + if (i < 0 || i >= kSpeciesCount) return "?"; + return kSpeciesNames[static_cast(i)]; +} + +Species parse_species(std::string_view name) { + for (int i = 0; i < kSpeciesCount; ++i) + if (iequals(name, kSpeciesNames[static_cast(i)])) return static_cast(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(lower(static_cast(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(a[i])) != lower(static_cast(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 diff --git a/src/game/data/common.h b/src/game/data/common.h new file mode 100644 index 0000000..4a168d2 --- /dev/null +++ b/src/game/data/common.h @@ -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 +#include +#include +#include +#include +#include + +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 +struct Loaded { + std::optional value; + std::vector 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 diff --git a/src/game/data/fields.h b/src/game/data/fields.h new file mode 100644 index 0000000..353bded --- /dev/null +++ b/src/game/data/fields.h @@ -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 +#include +#include +#include +#include + +#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& 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 opt_str(std::string_view key) const { return b_.opt_str(key); } + std::vector 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 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 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 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& problems_; +}; + +} // namespace game::data::detail diff --git a/src/game/data/shipsection.cpp b/src/game/data/shipsection.cpp new file mode 100644 index 0000000..bb6b660 --- /dev/null +++ b/src/game/data/shipsection.cpp @@ -0,0 +1,210 @@ +#include "game/data/shipsection.h" + +#include + +#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 read_options(const Block& section) { + struct Item { int ord; OptionGroup group; }; + std::vector 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 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 load_shipsection(const mars::parse::Document& doc, std::string file) { + Loaded 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 parse_shipsection(std::string_view text, std::string file) { + auto parsed = mars::parse::parse_blocks(text); + if (!parsed.ok()) { + Loaded out; + out.problems.push_back({Problem::Kind::Syntax, file, parsed.error().line, "", parsed.error().message}); + return out; + } + Loaded 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 diff --git a/src/game/data/shipsection.h b/src/game/data/shipsection.h new file mode 100644 index 0000000..7b1ab64 --- /dev/null +++ b/src/game/data/shipsection.h @@ -0,0 +1,115 @@ +// game::data -- ShipSectionDef: one `Species//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 +#include +#include +#include +#include + +#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 members; // tech names, file order + bool scalar = false; // written as `option T` (one member) + int line = 0; +}; + +struct MountDef { + std::string node; + std::optional min_azimuth, max_azimuth, min_inclination, max_inclination; + std::optional 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 show_turrets, invincible; + std::vector mounts; + bool repeated_turret_spec = false; // turretsize/turretclass written more than once + int line = 0; +}; + +struct NetForceLimits { + std::optional force_forward, force_right, force_up; + std::optional torque_yaw, torque_pitch, torque_roll; + std::optional 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 id; // from the race's _shipsections.txt + std::optional display_name, description; + std::vector unlocked_by; // techs whose ship{section} lists this stem + + // body + std::string model, dam_model; + std::vector requires; // AND; may name GRP_ + 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 health, mass; + std::optional cost, cpoints, crew; + std::optional command_cost, maintenance_cost, command_quota; + std::string socket_fore, socket_aft, dam_socket_fore, dam_socket_aft; + std::vector options; + std::optional optiondef; + std::vector banks; + std::optional ftlspeed, nodespeed, range, scanrange, tactical_sensor_range; + std::string engine_techera; + std::optional netforcelimits; + std::vector thrusters; + std::vector exclude; + std::string explicit_command_section, explicit_engine_section; + std::optional explicit_section, autonomous, nodesign; + + Block raw; + + bool has_sockets() const { return !socket_fore.empty() || !socket_aft.empty(); } +}; + +Loaded load_shipsection(const mars::parse::Document& doc, std::string file = {}); +Loaded parse_shipsection(std::string_view text, std::string file = {}); + +} // namespace game::data diff --git a/src/game/data/strings.cpp b/src/game/data/strings.cpp new file mode 100644 index 0000000..cf8e941 --- /dev/null +++ b/src/game/data/strings.cpp @@ -0,0 +1,43 @@ +#include "game/data/strings.h" + +#include "mars/text/csv.h" + +namespace game::data { + +std::optional 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 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 parse_string_table(std::string_view csv_text, std::string file) { + Loaded 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 diff --git a/src/game/data/strings.h b/src/game/data/strings.h new file mode 100644 index 0000000..af13c1c --- /dev/null +++ b/src/game/data/strings.h @@ -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 +#include +#include +#include +#include + +#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 get(std::string_view key) const; + // "@TOKEN" -> get("TOKEN"); a plain key is looked up as-is. + std::optional resolve(std::string_view token) const; + + void set(std::string key, std::string text); + const std::vector>& duplicates() const { return duplicates_; } + +private: + std::unordered_map by_key_; // folded key -> text + std::vector> duplicates_; // (key, earlier text) +}; + +Loaded parse_string_table(std::string_view csv_text, std::string file = {}); + +} // namespace game::data diff --git a/src/game/data/techtree.cpp b/src/game/data/techtree.cpp new file mode 100644 index 0000000..13a4058 --- /dev/null +++ b/src/game/data/techtree.cpp @@ -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(c - 32); + return out; +} + +std::vector split_ws(std::string_view s) { + std::vector 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* 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(s)] = static_cast(*n); + e.pct_written[static_cast(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* 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 TechTree::edges_to(std::string_view name) const { + std::vector out; + for (const AllowsEdge& e : edges) + if (iequals(e.to, name)) out.push_back(&e); + return out; +} + +std::vector TechTree::edges_from(std::string_view name) const { + std::vector out; + for (const AllowsEdge& e : edges) + if (iequals(e.from, name)) out.push_back(&e); + return out; +} + +std::vector TechTree::roots() const { + std::vector 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 load_tech_tree(const mars::parse::Document& doc, std::string file) { + Loaded 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{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 parse_tech_tree(std::string_view text, std::string file) { + auto parsed = mars::parse::parse_blocks(text); + if (!parsed.ok()) { + Loaded out; + out.problems.push_back({Problem::Kind::Syntax, file, parsed.error().line, "", parsed.error().message}); + return out; + } + Loaded 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 diff --git a/src/game/data/techtree.h b/src/game/data/techtree.h new file mode 100644 index 0000000..8e7c82f --- /dev/null +++ b/src/game/data/techtree.h @@ -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_" -- 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 +#include +#include +#include +#include +#include +#include + +#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 rp; + std::array pct{}; // effective values (default 100) + std::array pct_written{}; // which ones the file spelled out + std::vector 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(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* 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 threat; + std::string group; // as written; group membership is matched upper-cased + std::optional option_cost; + std::optional unlock_explicitly; + std::vector requires; + std::vector benefits_inc, benefits_dec; // TECHBEN_* tokens, kept as data + std::vector sections; // ship{section} stems + std::vector weapon_files; // weapon{filename} + std::vector allows; // indices into TechTree::edges + std::optional 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 nodes; + std::vector edges; + // upper-cased group name -> member tech names, first-seen order + std::vector>> groups; + + const TechNode* find(std::string_view name) const; // case-insensitive + const std::vector* group_members(std::string_view name_or_ref) const; // "TORPS" or "GRP_Torps" + std::vector edges_to(std::string_view name) const; + std::vector edges_from(std::string_view name) const; + std::vector 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 by_name_; // folded +}; + +Loaded load_tech_tree(const mars::parse::Document& doc, std::string file = {}); +Loaded parse_tech_tree(std::string_view text, std::string file = {}); + +} // namespace game::data diff --git a/src/game/data/turrets.cpp b/src/game/data/turrets.cpp new file mode 100644 index 0000000..67afb76 --- /dev/null +++ b/src/game/data/turrets.cpp @@ -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 TurretTable::rows_for_bank(std::string_view mount_size, + std::string_view turret_class) const { + std::vector 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 parse_turret_table(std::string_view text, std::string file) { + Loaded 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 IdRegistry::id_of(std::string_view name_or_stem) const { + std::optional 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 parse_id_registry(std::string_view text, std::string file) { + Loaded 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 diff --git a/src/game/data/turrets.h b/src/game/data/turrets.h new file mode 100644 index 0000000..388c82f --- /dev/null +++ b/src/game/data/turrets.h @@ -0,0 +1,78 @@ +// game::data -- the turret table (Weapons/_turrets.txt) and the stable id +// registries (Weapons/_weapons.txt, Species//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 +#include +#include +#include + +#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 health, track_speed, azimuth_scale, inclination_scale; + std::string model; // may be empty + int line = 0; +}; + +class TurretTable { +public: + const std::vector& 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 rows_for_bank(std::string_view mount_size, std::string_view turret_class) const; + +private: + std::vector rows_; +}; + +Loaded 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& entries() const { return entries_; } + const std::vector& deleted() const { return deleted_; } + std::size_t size() const { return entries_.size(); } + + const IdEntry* find(int id) const; + std::optional 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 entries_; + std::vector deleted_; +}; + +Loaded parse_id_registry(std::string_view text, std::string file = {}); + +} // namespace game::data diff --git a/src/game/data/weapon.cpp b/src/game/data/weapon.cpp new file mode 100644 index 0000000..6cfb1a9 --- /dev/null +++ b/src/game/data/weapon.cpp @@ -0,0 +1,150 @@ +#include "game/data/weapon.h" + +#include "game/data/fields.h" + +namespace game::data { + +using detail::Fields; + +const std::vector& weapon_behavior_blocks() { + static const std::vector 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 load_weapon(const mars::parse::Document& doc, std::string file) { + Loaded 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 parse_weapon(std::string_view text, std::string file) { + auto parsed = mars::parse::parse_blocks(text); + if (!parsed.ok()) { + Loaded out; + out.problems.push_back({Problem::Kind::Syntax, file, parsed.error().line, "", parsed.error().message}); + return out; + } + Loaded 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 diff --git a/src/game/data/weapon.h b/src/game/data/weapon.h new file mode 100644 index 0000000..3f15bbb --- /dev/null +++ b/src/game/data/weapon.h @@ -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 +// { 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 +#include +#include +#include +#include + +#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 range, deviation, damage; + }; + Band point_blank, effective, maximum; +}; + +// Damage applied to a planet on impact (present in most behaviour blocks). +struct PlanetDamage { + std::optional pop, infra, terra; +}; + +struct BoltDef { + RangeTable rangetable; + PlanetDamage planet; + std::optional mass, beam_origin, beam_length, ricochet_mod; + std::string effect, impact_effect, expire_effect; +}; + +struct FireControl { + std::optional requires_los, requires_inrange, requires_enemycolony; + std::optional manual_target, manual_toggle, manual_launch; + std::optional controllable, holdsfire; + std::optional explicit_target, exclusive_launch, targets_expire; +}; + +struct Ratings { + std::optional 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 id; // from Weapons/_weapons.txt (player weapons only) + std::optional 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 requires; // AND of techs; may be empty + std::vector compatible_section; // rider weapons: section stems they carry + std::string exclusive_species; + std::optional cost; + std::string turret_size; // tiny / small / medium / large (as written) + std::string turret_class; // standard / missile / beam / ... (as written) + std::optional track_speed_mod; + std::optional burst_volleys; + std::optional recharge_time, volley_period, volley_duration, buildup_delay, solution_tolerance; + std::optional range, range_planet, muzzle_speed, hpbonus, dam_est; + std::optional 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 bolt; // typed when behavior_kind == "bolt" + std::optional 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& weapon_behavior_blocks(); + +Loaded load_weapon(const mars::parse::Document& doc, std::string file = {}); +Loaded parse_weapon(std::string_view text, std::string file = {}); + +} // namespace game::data diff --git a/tests/game_data/CMakeLists.txt b/tests/game_data/CMakeLists.txt new file mode 100644 index 0000000..21ca3f2 --- /dev/null +++ b/tests/game_data/CMakeLists.txt @@ -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) diff --git a/tests/game_data/build_and_run.sh b/tests/game_data/build_and_run.sh new file mode 100755 index 0000000..26bb455 --- /dev/null +++ b/tests/game_data/build_and_run.sh @@ -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" diff --git a/tests/game_data/dump_catalog.cpp b/tests/game_data/dump_catalog.cpp new file mode 100644 index 0000000..d7694fd --- /dev/null +++ b/tests/game_data/dump_catalog.cpp @@ -0,0 +1,527 @@ +// dump_catalog +// +// 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 +#include +#include +#include +#include +#include + +#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(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 +J jopt(const std::optional& o); +template <> +J jopt(const std::optional& o) { return o ? jnum(*o) : jnull(); } +template <> +J jopt(const std::optional& o) { return o ? jint(*o) : jnull(); } +template <> +J jopt(const std::optional& o) { return o ? jint(*o) : jnull(); } +template <> +J jopt(const std::optional& o) { return o ? jbool(*o) : jnull(); } +template <> +J jopt(const std::optional& 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& 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 vals; }; + std::vector 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(i); + all.set(species_name(s), jint(e.percent(s))); + if (e.pct_written[static_cast(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& 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(x.dangling_count()))) + .done(); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc != 3) { + std::fprintf(stderr, "usage: dump_catalog \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(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; +} diff --git a/tests/game_data/oracle/compare.py b/tests/game_data/oracle/compare.py new file mode 100644 index 0000000..0881c62 --- /dev/null +++ b/tests/game_data/oracle/compare.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +"""compare.py + +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)) diff --git a/tests/game_data/realdata_test.cpp b/tests/game_data/realdata_test.cpp new file mode 100644 index 0000000..d999b73 --- /dev/null +++ b/tests/game_data/realdata_test.cpp @@ -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 +#include +#include +#include + +#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 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(i)] == 100 && !e.pct_written[static_cast(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; +} diff --git a/tests/game_data/test_catalog.cpp b/tests/game_data/test_catalog.cpp new file mode 100644 index 0000000..d353202 --- /dev/null +++ b/tests/game_data/test_catalog.cpp @@ -0,0 +1,218 @@ +// Catalog on a hand-written miniature data root written to a temp directory. +#include +#include +#include +#include + +#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(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& 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); +} diff --git a/tests/game_data/test_main.cpp b/tests/game_data/test_main.cpp new file mode 100644 index 0000000..ab9b648 --- /dev/null +++ b/tests/game_data/test_main.cpp @@ -0,0 +1,32 @@ +#include "test_main.h" + +namespace testing { + +std::vector& registry() { + static std::vector r; + return r; +} + +int& failures() { + static int n = 0; + return n; +} + +void report_failure(const char* file, int line, const std::string& expr) { + std::printf(" FAIL %s:%d: %s\n", file, line, expr.c_str()); + ++failures(); +} + +} // namespace testing + +int main() { + int ran = 0; + for (const auto& c : testing::registry()) { + int before = testing::failures(); + c.fn(); + ++ran; + if (testing::failures() != before) std::printf("[FAILED] %s\n", c.name); + } + std::printf("%d unit tests, %d failed assertions\n", ran, testing::failures()); + return testing::failures() ? 1 : 0; +} diff --git a/tests/game_data/test_main.h b/tests/game_data/test_main.h new file mode 100644 index 0000000..6c78252 --- /dev/null +++ b/tests/game_data/test_main.h @@ -0,0 +1,49 @@ +// Tiny self-contained test harness (no third-party deps). +#pragma once + +#include +#include +#include +#include + +namespace testing { + +struct Case { + const char* name; + std::function fn; +}; + +std::vector& registry(); +int& failures(); +void report_failure(const char* file, int line, const std::string& expr); + +struct Register { + Register(const char* name, std::function fn) { registry().push_back({name, std::move(fn)}); } +}; + +} // namespace testing + +#define TEST(name) \ + static void test_##name(); \ + static testing::Register reg_##name(#name, test_##name); \ + static void test_##name() + +#define CHECK(expr) \ + do { \ + if (!(expr)) testing::report_failure(__FILE__, __LINE__, #expr); \ + } while (0) + +#define CHECK_EQ(a, b) \ + do { \ + if (!((a) == (b))) \ + testing::report_failure(__FILE__, __LINE__, \ + std::string(#a " == " #b " [got: ") + \ + testing_to_string(a) + " vs " + \ + testing_to_string(b) + "]"); \ + } while (0) + +inline std::string testing_to_string(const std::string& s) { return "\"" + s + "\""; } +inline std::string testing_to_string(const char* s) { return std::string("\"") + s + "\""; } +inline std::string testing_to_string(bool b) { return b ? "true" : "false"; } +template +std::string testing_to_string(const T& v) { return std::to_string(v); } diff --git a/tests/game_data/test_shipsection.cpp b/tests/game_data/test_shipsection.cpp new file mode 100644 index 0000000..9aaaeb3 --- /dev/null +++ b/tests/game_data/test_shipsection.cpp @@ -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")); +} diff --git a/tests/game_data/test_techtree.cpp b/tests/game_data/test_techtree.cpp new file mode 100644 index 0000000..271579b --- /dev/null +++ b/tests/game_data/test_techtree.cpp @@ -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(Species::Human)]); + CHECK(!e.pct_written[static_cast(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(i)], kDefaultAllowPercent); + CHECK(!p.pct_written[static_cast(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 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 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()); +} diff --git a/tests/game_data/test_turrets.cpp b/tests/game_data/test_turrets.cpp new file mode 100644 index 0000000..19ae2fa --- /dev/null +++ b/tests/game_data/test_turrets.cpp @@ -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"); +} diff --git a/tests/game_data/test_weapon.cpp b/tests/game_data/test_weapon.cpp new file mode 100644 index 0000000..38f7f8a --- /dev/null +++ b/tests/game_data/test_weapon.cpp @@ -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}); +}