4 Commits

Author SHA1 Message Date
730cd3bdf3 refinement 2026-07-03 08:36:33 +02:00
437ac97355 Rewrite progression_design.md as rules/principles document
Turn the rough draft into structured rules for the production tree,
progression pacing, and balancing: resource phases, ratio curve,
shortcut recipes, refactorability, cost archetypes, threat model,
drop pacing, scrap/block economies, and numeric guardrails.

Includes agreed decisions: ship scrap derived from threat via a
scrap_per_threat key, no duplicate schematic drops (Mk2 upgrade
recipes deferred to future work), removal of ship levels, and an
action-item list for the follow-up requirements/code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 08:36:33 +02:00
41081620f0 add first draft 2026-07-03 08:36:33 +02:00
6ea0655eaf implement unlock dependencies 2026-07-03 08:35:45 +02:00
14 changed files with 794 additions and 11 deletions

View File

@@ -4,6 +4,11 @@
# content; stats, materials, and threat costs are placeholders until the # content; stats, materials, and threat costs are placeholders until the
# recipe and balancing passes. # recipe and balancing passes.
# #
# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
# the balancing pass. The laser cannons are the exception: laser_cannon_m is
# gated to station level 1, and laser_cannon_l requires laser_cannon_m to be
# unlocked first (unlock_requires) — a demonstration of the prerequisite chain.
#
# Surface mask footprint ladder — footprints gate which hulls can mount a # Surface mask footprint ladder — footprints gate which hulls can mount a
# module, purely through geometry (see ships.toml for the matching hull # module, purely through geometry (see ships.toml for the matching hull
# grids): # grids):
@@ -39,7 +44,7 @@ attack_rate_hz = 2.0
[[module]] [[module]]
id = "laser_cannon_m" id = "laser_cannon_m"
unlock_at_station_level = -1 unlock_at_station_level = 1
surface_mask = [ surface_mask = [
"OO", "OO",
"OO"] "OO"]
@@ -56,7 +61,8 @@ attack_rate_hz = 1.5
[[module]] [[module]]
id = "laser_cannon_l" id = "laser_cannon_l"
unlock_at_station_level = -1 unlock_at_station_level = 1
unlock_requires = ["laser_cannon_m"]
surface_mask = [ surface_mask = [
"OOO", "OOO",
"OOO", "OOO",

View File

@@ -2,8 +2,10 @@
# #
# First real-content iteration of the production tree. Quantities and # First real-content iteration of the production tree. Quantities and
# durations are a first guess; the balancing pass will tune them and assign # durations are a first guess; the balancing pass will tune them and assign
# real unlock_at_station_level values (everything is unlocked for now so the # real unlock_at_station_level values. Almost everything is unlocked for now so
# full tree is testable). # the full tree is testable; the only gated recipe schematics are the medium and
# large laser-cannon item recipes, which demonstrate the unlock_requires
# prerequisite chain (large requires medium — see modules.toml / ships.toml).
# #
# Input chain per game phase — each phase adds exactly one new base input: # Input chain per game phase — each phase adds exactly one new base input:
# #
@@ -293,7 +295,7 @@ duration_seconds = 4.0
[[recipe]] [[recipe]]
id = "laser_cannon_m_module" id = "laser_cannon_m_module"
unlock_at_station_level = -1 unlock_at_station_level = 1
building = "assembler" building = "assembler"
inputs = [{item = "targeting_unit", amount = 1}, {item = "titanium_frame", amount = 1}] inputs = [{item = "targeting_unit", amount = 1}, {item = "titanium_frame", amount = 1}]
outputs = [{item = "laser_cannon_m_module", amount = 1}] outputs = [{item = "laser_cannon_m_module", amount = 1}]
@@ -317,7 +319,8 @@ duration_seconds = 6.0
[[recipe]] [[recipe]]
id = "laser_cannon_l_module" id = "laser_cannon_l_module"
unlock_at_station_level = -1 unlock_at_station_level = 1
unlock_requires = ["laser_cannon_m_module"]
building = "assembler" building = "assembler"
inputs = [ inputs = [
{item = "targeting_unit", amount = 2}, {item = "targeting_unit", amount = 2},

View File

@@ -4,6 +4,11 @@
# content; stats, materials, and production times are placeholders until the # content; stats, materials, and production times are placeholders until the
# recipe and balancing passes. # recipe and balancing passes.
# #
# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
# the balancing pass. The capital hulls are the exception: battleship is gated
# to station level 1, and dreadnought (level 2) requires battleship to be
# unlocked first (unlock_requires) — a demonstration of the prerequisite chain.
#
# Size classes: # Size classes:
# xs drone 1 cell — exactly one 1x1 module # xs drone 1 cell — exactly one 1x1 module
# s frigate, destroyer no 2x2 area anywhere: only 1x1/1x2/1x3/L modules fit # s frigate, destroyer no 2x2 area anywhere: only 1x1/1x2/1x3/L modules fit
@@ -169,7 +174,7 @@ sensor_range_m = 260
# so no 2x6 drone hangar fits. # so no 2x6 drone hangar fits.
[[ship]] [[ship]]
id = "battleship" id = "battleship"
unlock_at_station_level = -1 unlock_at_station_level = 1
layout = [ layout = [
"XOOOOX", "XOOOOX",
"OOOOOO", "OOOOOO",
@@ -203,7 +208,8 @@ sensor_range_m = 280
# stay the only hangar hull. Bow and stern strips hold supports. # stay the only hangar hull. Bow and stern strips hold supports.
[[ship]] [[ship]]
id = "dreadnought" id = "dreadnought"
unlock_at_station_level = -1 unlock_at_station_level = 2
unlock_requires = ["battleship"]
layout = [ layout = [
"XXXOOOOOXXX", "XXXOOOOOXXX",
"OOOXOOOXOOO", "OOOXOOOXOOO",

318
docs/progression_design.md Normal file
View File

@@ -0,0 +1,318 @@
# Progression & Balancing Design
Rules and principles that govern the production tree, progression pacing,
and balancing. This document contains **rules only** — concrete content
(item lists, recipes, unlock levels, stat numbers) lives in the config files
and `content_design.md`; those numbers must follow the rules stated here.
## Player-experience goals
What each phase of a run should feel like:
- **Early:** learning belts and ratios with forgiving chains. The building
block economy is the main constraint; the player bootstraps a
self-sustaining factory from the starting stock.
- **Mid:** deeper chains, the first real ratio puzzles, and the first
meaningful drop decisions (which schematic, when to push).
- **Late:** combat feeds the factory — capital production requires salvage.
Progress means extending and refactoring the existing factory, not
rebuilding it. Strange ratios are deliberate optimization puzzles.
Overarching: an experienced player gains efficiency through **knowledge**
layout foresight, understanding chains, exploiting shortcut recipes — never
through hidden mechanics. An inexperienced setup should not cost much more
than an experienced one; experience pays off in how easily the factory
adapts later (see Refactorability).
## Resource phases
- A run has exactly **four base inputs**:
1. Two mined ores available from the start.
2. A third mined ore unlocked mid-game (slower to mine than the starting
ores).
3. A fourth input unlocked late-game, obtainable **only** from
reprocessing salvaged scrap.
- The fourth input is the core loop hook: capital ship production requires
fighting (salvaging and reprocessing), not just mining.
- There is no direct "resource unlock" mechanism. Miner recipes unlock
**implicitly** (REQ-LOCK-IMPLICIT) when some unlocked schematic's material
chain reaches that ore. Resource pacing is therefore controlled entirely
through the `unlock_at_station_level` values of ships, modules, and
assembler recipe schematics — and the content must guarantee that the
chains actually connect (a mid-game schematic must require an item whose
chain reaches the third ore, or the ore never unlocks).
## Production tree rules
### Structure
- **Each phase transition adds exactly one new base input chain.** A base
input is a bottom-level resource entering the factory from outside — a
mined ore or the scrap-only input. The early game starts with two ores
as the baseline; the transition to mid adds one (the third ore), the
transition to late adds one (the scrap-only input). No transition ever
introduces more than one unfamiliar bottom-level chain, so the factory
grows in one direction at a time.
- **Intermediates are generic shared parts.** Keep the item count low —
modules and hulls of a tier draw from a shared pool of that tier's and
lower tiers' intermediates rather than each having bespoke inputs.
- **Thematic naming over thematic items.** Inputs should be plausible for
what the recipe produces (crystals for lasers, heat sinks for bigger
lasers). Achieve this through naming and chain membership, not by adding
item types: rename a generic part, don't add a parallel one.
### Ratios
- **Ratio "niceness" degrades with tier.** The producer:consumer ratios
needed for 100% throughput follow a curve:
- Tier 1 (ore → basic material): trivially nice (e.g. 1:1 or 1:2
miner:smelter).
- Tier 2: slightly complex but still clean (e.g. 2:3).
- Higher tiers: increasingly strange ratios, as deliberate optimization
puzzles.
- Exceptions in both directions are allowed when there is a reason — a
clean late chain as a breather, an odd early chain as a teaser — but the
curve is the default.
### Shortcut recipes
- Some strange chains get a **shortcut recipe**: an explicitly unlockable
assembler recipe schematic (`unlock_at_station_level ≥ 0`, drop-only per
REQ-LOCK-EXPLICIT) that skips a step (e.g. t1 → t3 directly) and yields
nice ratios for a chain whose base path is strange.
- **Not every strange chain gets a shortcut.** Some strangeness is
permanent; the absence of a fix is a valid design choice.
- **Shortcuts drop only for known chains.** A shortcut recipe enters the
drop pool only when both its input items and its output item are
already unlocked (in addition to the station level check). The player
is never offered a shortcut for a chain they have not built yet. The
output-item half of this check already exists in
REQ-DEF-SCHEMATIC-DROP; the input half is new (see Action items).
- **Shortcuts are pure rewards, never balance factors.** An item's threat
value is the *maximum* across its producing recipes (REQ-THREAT-ITEM), so
unlocking a cheaper recipe does not lower the item's threat accounting —
the player gains real factory efficiency without their ships being
valued cheaper and without enemy wave budgets shifting. Consequently:
**balance every chain around its base (expensive) path**; the shortcut's
savings define the size of the reward.
### Refactorability
- **Rule (the property):** unlocking the next tier or size of a thing must
be a *local edit* of the existing production line — adding assemblers
and belts, or replacing a machine or two in place — never a rebuild of
the line.
- **What this buys the player:** foresight pays off in space, not blocks.
An experienced player leaves a little slack in the middle of a line,
knowing the next size or tier upgrade means tearing out one assembler
and a few belts there and inserting the new step — plus maybe swapping
a recipe or two elsewhere — while the rest of the line keeps running
untouched.
- **Default technique:** the bigger version introduces one new intermediate
that is produced from a subset of the smaller version's inputs (possibly
plus one additional low-tier material), and otherwise reuses the smaller
version's inputs. Existing lines keep running and feed the new
intermediate's assemblers.
- The property is the rule; the technique is only the default. It may be
broken where it fights thematic plausibility, as long as the property
still holds.
## Cost archetypes
Every item has two cost knobs: **material quantity** and **cycle time**.
Both feed the threat value identically (threat = recursive
production-seconds, REQ-MOD-THREAT), so the split between them does not
change what an item is *worth* — it changes what kind of **factory
pressure** it creates:
- **Material-heavy, fast** (e.g. armor plates): simple items; stress belt
throughput, splitter logistics, and miner/smelter counts.
- **Time-heavy, lean** (e.g. shield modules): technically complex items;
few inputs — possibly higher-tier ones — but long cycles; stress
assembler counts and parallelization.
**Rule:** each module family commits to a clear archetype, so factories
supporting different fleet doctrines feel structurally different to build.
## Threat model (balancing backbone)
- Threat cost = total recursive production-seconds (REQ-MOD-THREAT). One
factory-second equals one threat; player output and enemy wave budgets
are denominated in the same currency.
- **Rule: combat power per threat is roughly constant** across all ships,
modules, and tiers. Higher tiers are better per *ship* and per *module
slot*, not per invested factory-second — their advantage is
concentration (fewer, bigger things; slot geometry per
`content_design.md`) and qualitative capabilities, not a better exchange
rate. Deviations from this rule are deliberate and documented.
- **Difficulty race:** the enemy threat rate (`threat_rate_formula`) is
tuned against the factory output (threat/s) achievable by a competent
player — slightly below it early, crossing above it eventually. The game
is endless; enemy scaling must ultimately outpace any factory, and
player skill shifts *when*, not *whether*.
- **All time scaling lives in the threat rate** — waves get bigger, ships
of a given schematic never get individually stronger. There is no ship
level dimension: stat formulas are plain values, and per-ship level
scaling (`ship_level_formula`) does not exist (see Action items). Push
scaling on enemy defence stations is the separate, player-triggered
difficulty axis and keeps its level formulas.
## Unlock & drop pacing
- **Starting set rule:** the schematics unlocked at game start
(`unlock_at_station_level = -1`) must be exactly enough to reach the
first push unaided — a functioning block loop, small hulls, a basic
weapon, and the salvage loop. Nothing more.
- The `unlock_at_station_level` ladder mirrors the resource phases:
mid-tier hulls/modules/recipes at low station levels, capital content at
higher levels. A schematic must not become available before the chains
its materials need can be unlocked alongside it.
- **Schematics can require other schematics.** Beyond the station-level
gate, a schematic (ship, module, or assembler recipe) may list
prerequisite schematics that must already be unlocked before it enters
the drop pool — e.g. the medium laser requires the small laser; a
future Mk2 requires its base version. Station level gates the earliest
*when*; prerequisites gate the *order*, keeping drop offers coherent
with what the player already owns.
- **No duplicate drops.** Ship and module schematics leave the drop pool
once owned, exactly as assembler recipe schematics already do. There are
no schematic level-ups; player power grows through unlock breadth and
factory scale only, which keeps power-per-threat exact on both sides.
The pool therefore shrinks over a run and late pushes increasingly offer
artifacts — intended: the late game is a race for the win condition.
Per-item progression may return later as Mk2 upgrade recipes (see Open
tasks), never as free level-ups.
- **Artifacts trade power for progress.** Artifact options compete with
schematic picks in the same choice dialog; the artifact chance must be
tuned so that taking one is a real decision (giving up a level-up or
unlock), not automatic in either direction.
## Scrap & reprocessing economy
- Scrap is the bridge from combat back into the factory, with two sinks:
**smelting** (same basic materials as ore — the safe, boring option) and
**reprocessing** (probabilistic higher intermediates, including the
late-game input — the gamble that eventually becomes mandatory).
- The reprocessing output pool renormalizes over implicitly unlocked items
(REQ-LOCK-REPROCESSING-POOL), so its output quality improves
automatically as the run progresses. **Rule:** weights are authored for
the *fully unlocked* pool state; early-game behavior falls out of
renormalization for free and needs no separate staging.
- **Rule: ship scrap drops are derived, never authored.** A destroyed ship
drops `threat cost × scrap_per_threat` (a `world.toml` key), with the
threat cost computed from its actual hull plus installed modules
(REQ-MOD-THREAT) — a kitted-out ship drops more scrap than a bare hull
automatically. `ships.toml` carries no scrap value. Defence stations are
the exception: they keep authored `scrap_drop_formula`s, because pushing
rewards are tuned independently of ship production costs.
- Consequence: the threat value of scrap is the constant
`1 / scrap_per_threat`. The min-`scrap_drop` schematic derivation
(REQ-THREAT-SCRAP) and its potential circularity disappear.
- **Rule:** the late-game input's income rate meaningfully gates capital
production — unlocking a capital hull must not mean spamming it; the
input trickles in slowly enough that every capital ship is a noticeable
investment. The tuning target is relative, not absolute: assume a
reference player who destroys and salvages roughly the threat the game
spawns ("fighting at parity"), and tune `scrap_per_threat`, the
reprocessing weights, and capital material costs so that this player
affords roughly N capital ships per boss cycle. An absolute income rate
would be meaningless (income depends entirely on how much the player
fights) and would not self-scale; per boss cycle, the target tracks the
threat rate as it steps up.
## Building block economy
- Building blocks are the only global currency and the early game's
central constraint. The early game is a bootstrap problem: convert the
starting stock into a self-sustaining block loop before the first waves
bite.
- **Rule:** the starting stock suffices for a minimal block loop plus the
first shipyard — with a little slack for beginner mistakes, but not
enough to skip the loop entirely.
- **Rule:** asteroid expansions compete with buildings for the same
currency. Expansion pricing makes "more space" a real alternative to
"more stuff", not an afterthought.
## Numeric guardrails
Constraints that every recipe must respect, independent of tuning:
- **Belt throughput:** belt speed and per-tile capacity cap how fast a
single belt can feed an input. A recipe whose per-cycle inputs cannot be
sustained by one belt per input at 100% duty cycle is a *deliberate*
design (forcing parallel belts/splitters as part of a high-tier puzzle)
— never an accident of quantity choice.
- **Buffer burstiness:** input buffers hold 2× the per-cycle amount
(REQ-MAT-INPUT-BUFFER), so large per-cycle quantities create bursty belt
demand. Low tiers prefer small quantities with short cycles; big-batch
recipes are reserved for high tiers where burstiness is part of the
puzzle.
- **Cycle times scale with tier** monotonically — a higher-tier item never
has a shorter total chain time than a lower-tier item of the same role.
## Open tasks / future work
- **Rework `recipes.toml`** once the rules in this document are fixed: the
current tree feels too close to Factorio; apply the thematic-naming rule
and the ratio curve to it (renames and quantity changes, not new items).
- **Balancing pass** (see placeholders in `content_design.md`): set the
`unlock_at_station_level` ladder, real threat costs and
`default_modules`, reprocessing weights, and the threat-rate formulas
according to the rules above.
- **Mk2 upgrade recipes** — the deferred design for per-item progression,
to revisit once the config has stabilized. A duplicate-style drop
unlocks a distinct `*_mk2` item whose recipe consumes the Mk1 item plus
higher-tier parts. This preserves power-per-threat (the extra power is
paid in real production-seconds, since threat is recursive), satisfies
the refactorability rule (the Mk1 line keeps running and feeds one new
assembler), and keeps balancing one-dimensional (no level variable
anywhere). Enemy-side progression happens via `default_modules`
variants per era instead of a level formula.
## Action items — changes beyond this document
Agreed changes that require edits to `requirements.md`, the code, and the
configs:
1. **Derive ship scrap from threat.** Add `scrap_per_threat` to
`world.toml`; remove `[ship.loot].scrap_drop` from `ships.toml`; update
REQ-RES-SCRAP-DROP; replace REQ-THREAT-SCRAP with the constant
`1 / scrap_per_threat`. Station `scrap_drop_formula`s stay.
2. **Remove schematic upgrades.** Owned ship/module schematics leave the
drop pool (REQ-DEF-SCHEMATIC-DROP); drop the new-unlock/level-up
annotation from the choice dialog; remove `player_production_level`
from `ships.toml`, `modules.toml`, and the code paths that increment
and evaluate it (REQ-BLD-SHIPYARD, REQ-MOD-CONFIG).
3. **Remove ship levels entirely.** Delete `ship_level_formula`
(REQ-WAV-SHIP-LEVEL); replace all stat formulas in `ships.toml` and
`modules.toml` with plain values (REQ-SHP-STATS, REQ-MOD-CONFIG,
REQ-MOD-STAT-CALC evaluation rules). Enemy defence stations keep their
station-level formulas — push scaling is unaffected.
4. **Balancing tool follow-up.** Remove the per-ship-entry `level` from
`balancing.toml` and the `L<level>` display format (REQ-BAL-TEAM,
REQ-BAL-CONFIG-GAME, REQ-BAL-UI-WIDGET, REQ-BAL-UI-INSPECT-WINDOW);
station entries keep their level. Ship stats panels no longer evaluate
at a level (REQ-MOD-UI-STATS-PANEL, REQ-UI-SHIP-STATS-PANEL).
5. **Fill unfillable schematic slots with artifacts.** With duplicates
removed, the schematic drop pool can run dry — previously unreachable.
Decision: every slot in the choice dialog that cannot be filled with a
schematic because the eligible pool is exhausted is filled with an
artifact option instead (in addition to any artifact option granted by
the regular artifact roll). A push therefore always awards a full
dialog. Update REQ-DEF-SCHEMATIC-DROP.
6. **Confirm wave scaling carries alone.** With per-ship level scaling
gone, `threat_rate_formula` is the only time-scaling axis; verify the
current `world.toml` values still produce the intended difficulty
curve (the config reportedly already runs with a flat ship level, so
this is likely a review, not a retune).
7. **Gate shortcut-recipe drops on their inputs.** Extend the assembler
recipe schematic pool eligibility in REQ-DEF-SCHEMATIC-DROP: in
addition to the existing station-level and output-item checks, all of
the recipe's input item types must be implicitly unlocked as well.
8. **Schematic prerequisites.** Add an optional prerequisite list to
ship, module, and assembler recipe schematic entries (e.g.
`unlock_requires = ["laser_cannon_s"]`): a schematic enters the drop
pool only when every listed schematic is already unlocked, in addition
to the station-level check. Update REQ-DEF-SCHEMATIC-DROP and
REQ-LOCK-EXPLICIT. This is also the mechanism the future Mk2 design
builds on (`*_mk2` requires the base schematic).

View File

@@ -6,9 +6,9 @@ Config files use the TOML format. The following config files drive game paramete
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, and artifact win count. - **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, and artifact win count.
- **buildings.toml** — building block cost and construction time per building type. - **buildings.toml** — building block cost and construction time per building type.
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). - **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). An assembler recipe schematic entry may also define an optional `unlock_requires` list of prerequisite schematic ids (REQ-LOCK-PREREQ).
- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). - **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), an optional `unlock_requires` prerequisite list (REQ-LOCK-PREREQ), a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
- **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC). - **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), an optional `unlock_requires` prerequisite list (REQ-LOCK-PREREQ), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level. - **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it. - **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it.
- **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it. - **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it.
@@ -197,6 +197,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- `surface_mask` — footprint within the ship layout grid (see Module Surface Mask Format). - `surface_mask` — footprint within the ship layout grid (see Module Surface Mask Format).
- `materials` — list of materials required per instance (added to the ship's build cost). - `materials` — list of materials required per instance (added to the ship's build cost).
- `unlock_at_station_level` — the enemy defence station level at which this module's schematic becomes available for unlock; -1 means the player starts with the module schematic already unlocked. - `unlock_at_station_level` — the enemy defence station level at which this module's schematic becomes available for unlock; -1 means the player starts with the module schematic already unlocked.
- `unlock_requires` — optional list of prerequisite schematic ids that must already be unlocked before this module's schematic can drop (REQ-LOCK-PREREQ). Defaults to empty.
- `production_time_seconds` — time added to the ship's production cycle per instance. - `production_time_seconds` — time added to the ship's production cycle per instance.
- `fill_color` — fill color used to render this module's cells in the layout grid. - `fill_color` — fill color used to render this module's cells in the layout grid.
- `glyph` — single character rendered on this module's cells in the layout grid and preview widget. - `glyph` — single character rendered on this module's cells in the layout grid and preview widget.
@@ -307,6 +308,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- All **ship schematics** and **module schematics** whose `unlock_at_station_level` is ≥ 0 and ≤ the level of the destroyed station set, and which have not yet been unlocked. - All **ship schematics** and **module schematics** whose `unlock_at_station_level` is ≥ 0 and ≤ the level of the destroyed station set, and which have not yet been unlocked.
- All **assembler recipe schematics** whose `unlock_at_station_level` is ≥ 0 and ≤ the level of the destroyed station set, whose output item is currently implicitly unlocked (REQ-LOCK-IMPLICIT), and which have not yet been awarded. - All **assembler recipe schematics** whose `unlock_at_station_level` is ≥ 0 and ≤ the level of the destroyed station set, whose output item is currently implicitly unlocked (REQ-LOCK-IMPLICIT), and which have not yet been awarded.
In addition to the conditions above, a schematic is included in the eligible drop pool only when every prerequisite in its optional `unlock_requires` list is currently satisfied (REQ-LOCK-PREREQ). Because the pool is rebuilt for each drop, a schematic gated behind prerequisites first appears only after all of its prerequisites have themselves been unlocked.
Each option in the dialog displays: the schematic name (ship `display_name` from `ships.toml`, module `id` from `modules.toml`, or the output item type for assembler recipes) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact". Each option in the dialog displays: the schematic name (ship `display_name` from `ships.toml`, module `id` from `modules.toml`, or the output item type for assembler recipes) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact".
Each option additionally displays a vertical list of item names labeled "Unlocks recipes for:", showing which recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the output items of miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect: Each option additionally displays a vertical list of item names labeled "Unlocks recipes for:", showing which recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the output items of miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect:
@@ -325,6 +328,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-LOCK-EXPLICIT: Ship schematics, module schematics, and **assembler recipe schematics** (assembler recipes in `recipes.toml` that define `unlock_at_station_level`) are **explicitly** locked or unlocked. A schematic starts unlocked if its `unlock_at_station_level` is -1; all others start locked. Locked schematics are unlocked only by REQ-DEF-SCHEMATIC-DROP. Once unlocked, a schematic is never re-locked within a run; lock states reset to their initial values on Restart (REQ-CFG-RELOAD). Unlike ship and module schematics, an assembler recipe schematic is removed from the drop pool permanently once awarded and cannot be dropped again. - REQ-LOCK-EXPLICIT: Ship schematics, module schematics, and **assembler recipe schematics** (assembler recipes in `recipes.toml` that define `unlock_at_station_level`) are **explicitly** locked or unlocked. A schematic starts unlocked if its `unlock_at_station_level` is -1; all others start locked. Locked schematics are unlocked only by REQ-DEF-SCHEMATIC-DROP. Once unlocked, a schematic is never re-locked within a run; lock states reset to their initial values on Restart (REQ-CFG-RELOAD). Unlike ship and module schematics, an assembler recipe schematic is removed from the drop pool permanently once awarded and cannot be dropped again.
- REQ-LOCK-PREREQ: A ship schematic, module schematic, or assembler recipe schematic may optionally define `unlock_requires` — a list of prerequisite schematic ids (a ship `id`, module `id`, or assembler recipe `id`) that must already be unlocked before this schematic may enter the drop pool. A prerequisite is **satisfied** only when the schematic it names is currently **explicitly unlocked** (REQ-LOCK-EXPLICIT) — that is, the referenced schematic either started unlocked with `unlock_at_station_level = -1` or has been awarded via a drop. This prerequisite check is applied in addition to the per-schematic conditions in REQ-DEF-SCHEMATIC-DROP: a schematic enters the eligible drop pool only when its `unlock_at_station_level` condition is met, it has not yet been unlocked/awarded, and every id in its `unlock_requires` is satisfied. `unlock_requires` defaults to empty (no prerequisites), which reproduces the prior behaviour. The check is re-evaluated against the current explicit-unlock set every time a drop pool is built (after each REQ-DEF-SCHEMATIC-DROP and on Restart per REQ-CFG-RELOAD), so a gated schematic becomes eligible in the first drop after its last prerequisite is unlocked. Every id listed in any `unlock_requires` must resolve to a schematic that is itself explicitly unlockable (a ship, module, or assembler recipe schematic defined in config); an id that names no such schematic is a configuration error that fails config load with a descriptive message (config is loaded at startup and reloaded on Restart, REQ-CFG-RELOAD). A schematic that lists itself, or a cycle of mutually dependent prerequisites, is not a load error but can never become eligible, since no schematic in the cycle can be the first to unlock.
- REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation: - REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation:
1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item type of every currently explicitly unlocked assembler recipe schematic (REQ-LOCK-EXPLICIT). 1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item type of every currently explicitly unlocked assembler recipe schematic (REQ-LOCK-EXPLICIT).
2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe schematic that defines `unlock_at_station_level` and is not yet explicitly unlocked — add each of that recipe's input item types to the set. If the recipe is a miner recipe or an assembler recipe that does not define `unlock_at_station_level`, mark it as implicitly unlocked. Explicitly unlocked assembler recipe schematics are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step. 2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe schematic that defines `unlock_at_station_level` and is not yet explicitly unlocked — add each of that recipe's input item types to the set. If the recipe is a miner recipe or an assembler recipe that does not define `unlock_at_station_level`, mark it as implicitly unlocked. Explicitly unlocked assembler recipe schematics are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.

View File

@@ -4,6 +4,7 @@
#include <sstream> #include <sstream>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <unordered_set>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -383,6 +384,11 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
{ {
def.unlockAtStationLevel = static_cast<int>(*level); def.unlockAtStationLevel = static_cast<int>(*level);
} }
if (mt.contains("unlock_requires"))
{
def.unlockRequires = requireStringArray(mt["unlock_requires"], file,
elemPath + ".unlock_requires");
}
} }
// inputs may be omitted (e.g. miner recipes). An empty array is fine. // inputs may be omitted (e.g. miner recipes). An empty array is fine.
@@ -422,6 +428,10 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
ShipDef def; ShipDef def;
def.id = requireString(mt["id"], file, elemPath + ".id"); def.id = requireString(mt["id"], file, elemPath + ".id");
def.unlockAtStationLevel = static_cast<int>(requireInt(mt["unlock_at_station_level"], file, elemPath + ".unlock_at_station_level")); def.unlockAtStationLevel = static_cast<int>(requireInt(mt["unlock_at_station_level"], file, elemPath + ".unlock_at_station_level"));
if (mt.contains("unlock_requires"))
{
def.unlockRequires = requireStringArray(mt["unlock_requires"], file, elemPath + ".unlock_requires");
}
def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout"); def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout");
// Schematic // Schematic
@@ -573,6 +583,10 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
def.id = requireString(mt["id"], file, elemPath + ".id"); def.id = requireString(mt["id"], file, elemPath + ".id");
def.unlockAtStationLevel = static_cast<int>( def.unlockAtStationLevel = static_cast<int>(
mt["unlock_at_station_level"].value_or<int64_t>(-1)); mt["unlock_at_station_level"].value_or<int64_t>(-1));
if (mt.contains("unlock_requires"))
{
def.unlockRequires = requireStringArray(mt["unlock_requires"], file, elemPath + ".unlock_requires");
}
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask"); def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
def.productionTimeSeconds = requireDouble( def.productionTimeSeconds = requireDouble(
mt["production_time_seconds"], file, elemPath + ".production_time_seconds"); mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
@@ -685,6 +699,67 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
return cfg; return cfg;
} }
namespace
{
// Throws if any id in requiredIds is not a valid explicitly-unlockable schematic.
void checkUnlockRequires(const std::vector<std::string>& requiredIds,
const std::unordered_set<std::string>& schematicIds,
const std::string& file,
const std::string& path)
{
for (const std::string& requiredId : requiredIds)
{
if (schematicIds.count(requiredId) == 0)
{
throw makeError(file, path,
"references unknown schematic '" + requiredId + "'");
}
}
}
// Validates that every id listed in an `unlock_requires` (REQ-LOCK-PREREQ)
// resolves to an explicitly-unlockable schematic: a ship, a module, or an
// assembler recipe that carries `unlock_at_station_level` (a recipe schematic,
// per REQ-LOCK-EXPLICIT). An unresolved id is a config error surfaced at load.
void validateUnlockRequires(const GameConfig& cfg)
{
std::unordered_set<std::string> schematicIds;
for (const ShipDef& def : cfg.ships.ships)
{
schematicIds.insert(def.id);
}
for (const ModuleDef& def : cfg.modules.modules)
{
schematicIds.insert(def.id);
}
for (const RecipeDef& def : cfg.recipes.recipes)
{
if (def.building == BuildingType::Assembler && def.unlockAtStationLevel.has_value())
{
schematicIds.insert(def.id);
}
}
for (const ShipDef& def : cfg.ships.ships)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "ships.toml",
"ship '" + def.id + "'.unlock_requires");
}
for (const ModuleDef& def : cfg.modules.modules)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "modules.toml",
"module '" + def.id + "'.unlock_requires");
}
for (const RecipeDef& def : cfg.recipes.recipes)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "recipes.toml",
"recipe '" + def.id + "'.unlock_requires");
}
}
} // namespace
GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir) GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
{ {
GameConfig cfg; GameConfig cfg;
@@ -694,6 +769,7 @@ GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
cfg.ships = loadShips(configDir + "/ships.toml"); cfg.ships = loadShips(configDir + "/ships.toml");
cfg.stations = loadStations(configDir + "/stations.toml"); cfg.stations = loadStations(configDir + "/stations.toml");
cfg.modules = loadModules(configDir + "/modules.toml"); cfg.modules = loadModules(configDir + "/modules.toml");
validateUnlockRequires(cfg);
cfg.threatCosts = computeThreatCostTable(cfg); cfg.threatCosts = computeThreatCostTable(cfg);
return cfg; return cfg;
} }

View File

@@ -41,6 +41,9 @@ struct ModuleDef
{ {
std::string id; std::string id;
int unlockAtStationLevel; int unlockAtStationLevel;
// Prerequisite schematic ids that must be explicitly unlocked before this
// schematic can enter the drop pool (REQ-LOCK-PREREQ). Empty = none.
std::vector<std::string> unlockRequires;
std::vector<std::string> surfaceMask; std::vector<std::string> surfaceMask;
std::vector<RecipeIngredient> materials; std::vector<RecipeIngredient> materials;
double productionTimeSeconds; double productionTimeSeconds;

View File

@@ -36,6 +36,10 @@ struct RecipeDef
// at game start. >= 0 = locked; schematic enters drop pool at that station // at game start. >= 0 = locked; schematic enters drop pool at that station
// level once the output item is implicitly unlocked (REQ-LOCK-EXPLICIT). // level once the output item is implicitly unlocked (REQ-LOCK-EXPLICIT).
std::optional<int> unlockAtStationLevel; std::optional<int> unlockAtStationLevel;
// Assembler recipe schematics only. Prerequisite schematic ids that must be
// explicitly unlocked before this schematic can enter the drop pool
// (REQ-LOCK-PREREQ). Empty = none.
std::vector<std::string> unlockRequires;
}; };
struct RecipesConfig struct RecipesConfig

View File

@@ -36,6 +36,9 @@ struct ShipDef
{ {
std::string id; std::string id;
int unlockAtStationLevel; int unlockAtStationLevel;
// Prerequisite schematic ids that must be explicitly unlocked before this
// schematic can enter the drop pool (REQ-LOCK-PREREQ). Empty = none.
std::vector<std::string> unlockRequires;
std::vector<std::string> layout; std::vector<std::string> layout;
ShipSchematic schematic; ShipSchematic schematic;

View File

@@ -635,12 +635,14 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
{ {
if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; } if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
if (m_schematicLevels.at(def.id).unlocked) { continue; } if (m_schematicLevels.at(def.id).unlocked) { continue; }
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
pool.push_back({def.id, DropType::Ship}); pool.push_back({def.id, DropType::Ship});
} }
for (const ModuleDef& def : m_config.modules.modules) for (const ModuleDef& def : m_config.modules.modules)
{ {
if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; } if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
if (m_moduleSchematicLevels.at(def.id).unlocked) { continue; } if (m_moduleSchematicLevels.at(def.id).unlocked) { continue; }
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
pool.push_back({def.id, DropType::Module}); pool.push_back({def.id, DropType::Module});
} }
for (const RecipeDef& def : m_config.recipes.recipes) for (const RecipeDef& def : m_config.recipes.recipes)
@@ -656,6 +658,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
if (m_unlockedItemIds.count(out.item) > 0) { outputUnlocked = true; break; } if (m_unlockedItemIds.count(out.item) > 0) { outputUnlocked = true; break; }
} }
if (!outputUnlocked) { continue; } if (!outputUnlocked) { continue; }
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
pool.push_back({def.id, DropType::Recipe}); pool.push_back({def.id, DropType::Recipe});
} }
@@ -808,6 +811,32 @@ std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
return ids; return ids;
} }
bool Simulation::isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const
{
// A prerequisite is satisfied only by an explicitly-unlocked schematic
// (REQ-LOCK-PREREQ): a ship or module schematic marked unlocked, or a recipe
// schematic in the explicit-unlock set. Cross-type ids are looked up with
// find/count so an id from one namespace never throws against another.
const std::map<std::string, SchematicState>::const_iterator shipIt =
m_schematicLevels.find(schematicId);
if (shipIt != m_schematicLevels.end() && shipIt->second.unlocked) { return true; }
const std::map<std::string, SchematicState>::const_iterator moduleIt =
m_moduleSchematicLevels.find(schematicId);
if (moduleIt != m_moduleSchematicLevels.end() && moduleIt->second.unlocked) { return true; }
return m_unlockedRecipeSchematicIds.count(schematicId) > 0;
}
bool Simulation::prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const
{
for (const std::string& requiredId : unlockRequires)
{
if (!isSchematicIdExplicitlyUnlocked(requiredId)) { return false; }
}
return true;
}
Simulation::UnlockedSets Simulation::computeUnlockedSets( Simulation::UnlockedSets Simulation::computeUnlockedSets(
const std::set<std::string>& unlockedShipSchematicIds, const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds, const std::set<std::string>& unlockedModuleSchematicIds,

View File

@@ -215,6 +215,12 @@ private:
std::set<std::string> getUnlockedShipSchematicIds() const; std::set<std::string> getUnlockedShipSchematicIds() const;
std::set<std::string> getUnlockedModuleSchematicIds() const; std::set<std::string> getUnlockedModuleSchematicIds() const;
// True if schematicId names a currently explicitly-unlocked ship, module, or
// assembler recipe schematic (REQ-LOCK-PREREQ satisfaction basis).
bool isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const;
// True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ).
bool prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const;
// Display names (deduplicated, alphabetical) of output items of recipes in // Display names (deduplicated, alphabetical) of output items of recipes in
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds. // hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
std::vector<std::string> computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const; std::vector<std::string> computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const;

View File

@@ -21,6 +21,7 @@ add_files(
ShipModuleTest.cpp ShipModuleTest.cpp
ThreatCostCalculatorTest.cpp ThreatCostCalculatorTest.cpp
RecipeSchematicTest.cpp RecipeSchematicTest.cpp
UnlockPrereqTest.cpp
ArtifactWinConditionTest.cpp ArtifactWinConditionTest.cpp
DeterminismTest.cpp DeterminismTest.cpp
CommandTest.cpp CommandTest.cpp

View File

@@ -6,6 +6,7 @@
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <system_error> #include <system_error>
#include <vector>
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
@@ -343,3 +344,151 @@ duration_seconds = 1.0
std::runtime_error); std::runtime_error);
} }
// --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------
namespace
{
// Copies every regular file from CONFIG_DIR into dir, giving tests a full,
// valid config set they can then perturb before calling loadFromDirectory.
void copyConfigInto(const std::filesystem::path& dir)
{
for (const std::filesystem::directory_entry& entry :
std::filesystem::directory_iterator(std::string(CONFIG_DIR)))
{
if (entry.is_regular_file())
{
std::filesystem::copy_file(entry.path(), dir / entry.path().filename());
}
}
}
} // namespace
TEST_CASE("unlock_requires parses into a ship def", "[config]")
{
TempConfigDir dir;
writeFile(dir.path() / "ships.toml", R"(
[[ship]]
id = "alpha"
unlock_at_station_level = 1
unlock_requires = ["beta", "gamma"]
layout = ["O"]
[ship.schematic]
materials = []
production_time_seconds = 5
[ship.health]
hp = 10
[ship.movement]
speed_mps = 1
main_acceleration_mpss = 1
maneuvering_acceleration_mpss = 1
angular_acceleration_radpss = 1
max_rotation_speed_radps = 1
[ship.sensor]
sensor_range_m = 10
)");
const ShipsConfig cfg = ConfigLoader::loadShips((dir.path() / "ships.toml").string());
REQUIRE(cfg.ships.size() == 1);
CHECK(cfg.ships[0].unlockRequires == std::vector<std::string>{"beta", "gamma"});
}
TEST_CASE("unlock_requires parses into a module def", "[config]")
{
TempConfigDir dir;
writeFile(dir.path() / "modules.toml", R"(
[[module]]
id = "m1"
unlock_at_station_level = 0
unlock_requires = ["dep"]
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
production_time_seconds = 1
fill_color = "#ffffff"
glyph = "M"
)");
const ModulesConfig cfg = ConfigLoader::loadModules((dir.path() / "modules.toml").string());
REQUIRE(cfg.modules.size() == 1);
CHECK(cfg.modules[0].unlockRequires == std::vector<std::string>{"dep"});
}
TEST_CASE("unlock_requires parses only for assembler recipes", "[config]")
{
TempConfigDir dir;
writeFile(dir.path() / "recipes.toml", R"(
[[recipe]]
id = "gated_asm"
building = "assembler"
unlock_at_station_level = 1
unlock_requires = ["prereq_recipe"]
inputs = []
outputs = [{item = "foo", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "a_miner"
building = "miner"
unlock_requires = ["ignored"]
inputs = []
outputs = [{item = "ore", amount = 1}]
duration_seconds = 1.0
)");
const RecipesConfig cfg = ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string());
REQUIRE(cfg.recipes.size() == 2);
CHECK(cfg.recipes[0].unlockRequires == std::vector<std::string>{"prereq_recipe"});
// The field is only consumed for assembler recipe schematics; a miner recipe
// leaves it unparsed (empty).
CHECK(cfg.recipes[1].unlockRequires.empty());
}
TEST_CASE("unlock_requires referencing an unknown schematic is rejected at load", "[config]")
{
TempConfigDir dir;
copyConfigInto(dir.path());
std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app);
out << "\n[[recipe]]\n"
"id = \"gated_bogus\"\n"
"building = \"assembler\"\n"
"unlock_at_station_level = 1\n"
"unlock_requires = [\"___does_not_exist___\"]\n"
"inputs = []\n"
"outputs = [{item = \"circuit_board\", amount = 1}]\n"
"duration_seconds = 1.0\n";
out.close();
try
{
ConfigLoader::loadFromDirectory(dir.path().string());
FAIL("Expected exception");
}
catch (const std::runtime_error& e)
{
const std::string msg = e.what();
REQUIRE(msg.find("___does_not_exist___") != std::string::npos);
}
}
TEST_CASE("unlock_requires referencing a real schematic loads without error", "[config]")
{
TempConfigDir dir;
copyConfigInto(dir.path());
// "interceptor" is a ship schematic in the test config — a valid prerequisite.
std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app);
out << "\n[[recipe]]\n"
"id = \"gated_ok\"\n"
"building = \"assembler\"\n"
"unlock_at_station_level = 1\n"
"unlock_requires = [\"interceptor\"]\n"
"inputs = []\n"
"outputs = [{item = \"circuit_board\", amount = 1}]\n"
"duration_seconds = 1.0\n";
out.close();
REQUIRE_NOTHROW(ConfigLoader::loadFromDirectory(dir.path().string()));
}

View File

@@ -0,0 +1,174 @@
// Drop-pool behaviour for unlock_requires schematic prerequisites
// (REQ-LOCK-PREREQ). A schematic enters the drop pool only when every id in its
// unlock_requires is already explicitly unlocked, on top of the station-level
// (and, for recipes, output-implicitly-unlocked) checks.
//
// Configs are built in memory (load the test config, then mutate) so each case
// is isolated and does not perturb the shared test config used by other suites.
#include <algorithm>
#include <string>
#include <utility>
#include <vector>
#include "catch.hpp"
#include "ConfigLoader.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "ModulesConfig.h"
#include "RecipesConfig.h"
#include "SchematicChoiceOption.h"
#include "ShipsConfig.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "StationBodyComponent.h"
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
ShipDef& findShip(GameConfig& cfg, const std::string& id)
{
for (ShipDef& def : cfg.ships.ships)
{
if (def.id == id) { return def; }
}
FAIL("ship not found: " + id);
return cfg.ships.ships.front();
}
ModuleDef& findModule(GameConfig& cfg, const std::string& id)
{
for (ModuleDef& def : cfg.modules.modules)
{
if (def.id == id) { return def; }
}
FAIL("module not found: " + id);
return cfg.modules.modules.front();
}
RecipeDef& findRecipe(GameConfig& cfg, const std::string& id)
{
for (RecipeDef& def : cfg.recipes.recipes)
{
if (def.id == id) { return def; }
}
FAIL("recipe not found: " + id);
return cfg.recipes.recipes.front();
}
// Zeros the HP of both enemy defence stations and advances one tick so that
// tickDeathsAndLoot fires, triggering the push and schematic choices.
void killEnemyStations(Simulation& sim)
{
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health)
{
if (faction.isEnemy)
{
health.hp = 0.0f;
}
});
sim.tick();
}
void killEnemyStationsAndApply(Simulation& sim)
{
killEnemyStations(sim);
if (sim.hasSchematicChoicesPending())
{
SimulationTestAccess::applySchematicChoice(sim, 0);
}
}
bool optionOffered(const Simulation& sim, const std::string& id)
{
const std::vector<SchematicChoiceOption>& options = sim.getPendingSchematicChoices();
return std::any_of(options.begin(), options.end(),
[&](const SchematicChoiceOption& o) { return o.schematicId == id; });
}
} // namespace
TEST_CASE("UnlockPrereq: a recipe gated behind a locked ship is withheld from the pool",
"[unlock_prereq]")
{
// quick_circuit (assembler recipe, level 0, output circuit_board is implicitly
// unlocked) would normally be eligible at the first station destruction. Gate
// it behind repair_ship (ship, level 0, locked at game start).
GameConfig cfg = loadConfig();
findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"};
Simulation sim(std::move(cfg), 123);
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
killEnemyStations(sim); // destroyed set level 0
REQUIRE(sim.hasSchematicChoicesPending());
// Prerequisite unmet -> the gated recipe must not be offered, while the
// prerequisite schematic itself (the only other eligible pick) is.
CHECK_FALSE(optionOffered(sim, "quick_circuit"));
CHECK(optionOffered(sim, "repair_ship"));
}
TEST_CASE("UnlockPrereq: the gated recipe becomes eligible once its prerequisite is unlocked",
"[unlock_prereq]")
{
GameConfig cfg = loadConfig();
findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"};
Simulation sim(std::move(cfg), 123);
// Applying choice 0 each drop unlocks repair_ship first (the only eligible
// pick at level 0), which then opens quick_circuit for a later drop.
bool unlocked = false;
for (int i = 0; i < 150 && !unlocked; ++i)
{
killEnemyStationsAndApply(sim);
unlocked = sim.isRecipeUnlocked("quick_circuit");
}
CHECK(sim.isSchematicUnlocked("repair_ship")); // prerequisite got unlocked along the way
CHECK(unlocked);
}
TEST_CASE("UnlockPrereq: a module gated behind another module is withheld until it unlocks",
"[unlock_prereq]")
{
// Mirror the app-config demo (laser_cannon_m -> laser_cannon_l) using two
// test-config modules made lockable at the same station level.
GameConfig cfg = loadConfig();
findModule(cfg, "laser_cannon").unlockAtStationLevel = 0;
ModuleDef& armor = findModule(cfg, "armor_plate");
armor.unlockAtStationLevel = 0;
armor.unlockRequires = {"laser_cannon"};
Simulation sim(std::move(cfg), 7);
REQUIRE_FALSE(sim.isModuleSchematicUnlocked("laser_cannon"));
REQUIRE_FALSE(sim.isModuleSchematicUnlocked("armor_plate"));
killEnemyStations(sim); // level 0
REQUIRE(sim.hasSchematicChoicesPending());
// armor_plate is gated behind the still-locked laser_cannon module.
CHECK_FALSE(optionOffered(sim, "armor_plate"));
// Drive the drops until the gated module unlocks; it can only do so after its
// prerequisite has itself been unlocked.
bool armorUnlocked = false;
for (int i = 0; i < 300 && !armorUnlocked; ++i)
{
killEnemyStationsAndApply(sim);
armorUnlocked = sim.isModuleSchematicUnlocked("armor_plate");
if (armorUnlocked)
{
CHECK(sim.isModuleSchematicUnlocked("laser_cannon"));
}
}
CHECK(armorUnlocked);
}