sots-engine/docs/game-design.md

15 KiB
Raw Blame History

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

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.