sots-engine/docs/game-design.md

217 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `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.