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