Compare commits

..

2 Commits

Author SHA1 Message Date
1641189b75 explicit recipe unlocking 2026-06-12 17:15:06 +02:00
54a6056b77 implicit item locking 2026-06-12 16:14:21 +02:00
17 changed files with 569 additions and 32 deletions

View File

@@ -70,6 +70,7 @@ duration_seconds = 6.0
[[recipe]]
id = "building_blocks"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "building_block", amount = 10}]

View File

@@ -40,6 +40,38 @@ inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "building_block", amount = 10}]
duration_seconds = 4.0
[[recipe]]
id = "premium_circuit"
building = "assembler"
unlock_at_station_level = -1
inputs = [{item = "circuit_board", amount = 1}]
outputs = [{item = "premium_circuit", amount = 1}]
duration_seconds = 8.0
[[recipe]]
id = "quick_circuit"
building = "assembler"
unlock_at_station_level = 0
inputs = [{item = "copper_ingot", amount = 3}]
outputs = [{item = "circuit_board", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "advanced_circuit"
building = "assembler"
unlock_at_station_level = 1
inputs = [{item = "iron_ingot", amount = 5}]
outputs = [{item = "circuit_board", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "exotic_alloy"
building = "assembler"
unlock_at_station_level = 0
inputs = [{item = "exotic_ore", amount = 2}]
outputs = [{item = "exotic_alloy", amount = 1}]
duration_seconds = 10.0
[[recipe]]
id = "reprocessing_cycle"
building = "reprocessing_plant"

View File

@@ -6,7 +6,7 @@ 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, enemy ship level formula, belt speed, starting building blocks, departure interval.
- **buildings.toml** — building block cost and construction time per building type.
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities.
- **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).
- **ships.toml** — per schematic: a human-readable display name (used in toasts and UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, threat cost formula, player production level, 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).
- **modules.toml** — per module type: id, surface mask, materials list, initial player production level, production time, threat cost, 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).
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
@@ -107,14 +107,14 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Building Types
- REQ-BLD-MINER: **Miner** (2×2): The player selects which ore type it extracts. Each ore type corresponds to a `recipes.toml [[recipe]]` entry with `building = "miner"`, defining the output item and `duration_seconds`. Every asteroid tile is equivalent for mining — any miner can produce any ore type based solely on its selected recipe. Ore never depletes.
- REQ-BLD-MINER: **Miner** (2×2): The player selects which ore type it extracts. Each ore type corresponds to a `recipes.toml [[recipe]]` entry with `building = "miner"`, defining the output item and `duration_seconds`. Every asteroid tile is equivalent for mining — any miner can produce any ore type based solely on its selected recipe. Ore never depletes. Only implicitly unlocked ore-type recipes are available for selection (REQ-LOCK-UI-RECIPE).
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. No recipe selection required. Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`.
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`.
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned at `ships.toml [ship.schematic].player_production_level` (initial value 5, incremented by duplicate schematic drops per REQ-DEF-SCHEMATIC-DROP) with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration.
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. Scrap delivered here is placed onto connected output belts.
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED).
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Each output can optionally have a filter (a list of item types), configurable via the selected building panel. Routing rules:
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
- An item matching only one output's filter is routed to that output.
- An item matching both outputs' filters is distributed by strict alternation between those outputs.
- An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved.
@@ -270,7 +270,33 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-DEF-ENEMY-FIRE: Enemy defence stations automatically fire at player ships within range.
- REQ-DEF-NO-CROSSFIRE: Enemy and player defence stations are never in each other's firing range.
- REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the boss countdown is advanced (REQ-WAV-BOSS-ADVANCE), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP).
- REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop is automatic — no physical item to collect. A schematic is chosen uniformly at random from all **ship and module schematics** whose `unlock_at_station_level` is -1 or is ≤ the level of the destroyed station set. If the player does not yet have that schematic, it is unlocked: ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG). If the player already has it, the schematic's `player_production_level` is incremented by 1 — for ship schematics, subsequent ships of that type are produced at a higher level; for module schematics, all instances of that module type use the higher level in their stat formulas. The player is notified via a toast (REQ-UI-SCHEMATIC-TOAST).
- REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop is automatic — no physical item to collect. A schematic is chosen uniformly at random from the eligible drop pool, which contains:
- All **ship schematics** and **module schematics** whose `unlock_at_station_level` is -1 or is ≤ the level of the destroyed station set.
- 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.
For a **ship or module schematic** drop: if the player does not yet have the schematic, it is unlocked (ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG)). If the player already has it, the schematic's `player_production_level` is incremented by 1 — for ship schematics, subsequent ships of that type are produced at a higher level; for module schematics, all instances of that module type use the higher level in their stat formulas. The player is notified via a toast (REQ-UI-SCHEMATIC-TOAST).
For an **assembler recipe schematic** drop: the recipe is explicitly unlocked and becomes available in the assembler recipe-selection dropdown (subject to REQ-LOCK-UI-RECIPE). The schematic is removed from the drop pool permanently (REQ-LOCK-EXPLICIT). The implicit unlock set is recomputed (REQ-LOCK-IMPLICIT). No toast is shown.
## Progression & Locking
- 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-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).
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 dropdown by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.
3. Repeat step 2 until no new item types are added.
Item types and miner/assembler recipes not reached by this process (and not explicitly unlocked) are locked. Smelter recipes participate in the traversal to propagate unlocking to their inputs but are never themselves shown in any UI dropdown.
- REQ-LOCK-REPROCESSING-POOL: The pool of possible outputs for a Reprocessing Plant cycle (REQ-BLD-REPROCESSING) is restricted to item types that are currently implicitly unlocked (REQ-LOCK-IMPLICIT). Weights are renormalized over the eligible outputs. If no eligible outputs remain, the Reprocessing Plant cannot start a production cycle.
- REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dropdowns.
- REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dropdown.
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
- REQ-LOCK-UI-BLUEPRINT: When a blueprint is placed (REQ-UI-BLUEPRINT-PLACE): if a stored recipe ID for a miner or assembler is currently locked, that building's recipe is left unset rather than applied; if a stored splitter filter entry refers to a locked item type, that entry is silently removed. (The analogous rule for locked ship schematics is defined in REQ-UI-BLUEPRINT-PLACE.)
## Threat Level & Enemy Waves
@@ -392,7 +418,7 @@ The screen is divided into three vertical sections:
- REQ-UI-BLUEPRINT-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). Pressing Q/E rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Clicking a different blueprint button exits the current mode and enters blueprint placement mode for the newly clicked blueprint.
- REQ-UI-BLUEPRINT-PLACE: Left-clicking in blueprint placement mode places the blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. If both conditions are met, a construction site is added to the build queue for each building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
- REQ-UI-BLUEPRINT-PLACE: Left-clicking in blueprint placement mode places the blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. If both conditions are met, a construction site is added to the build queue for each building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. Locked recipe IDs and splitter filter entries for locked item types are handled on placement per REQ-LOCK-UI-BLUEPRINT. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint entry immediately removes that blueprint from the list. If the deleted blueprint was active in blueprint placement mode, that mode is exited.

View File

@@ -47,6 +47,7 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
[this]() { return allocateBuildingId(); },
[](int) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin);

View File

@@ -367,6 +367,15 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
}
def.building = *parsedType;
if (def.building == BuildingType::Assembler)
{
const auto level = mt["unlock_at_station_level"].value<int64_t>();
if (level)
{
def.unlockAtStationLevel = static_cast<int>(*level);
}
}
// inputs may be omitted (e.g. miner recipes). An empty array is fine.
if (mt.contains("inputs"))
{

View File

@@ -32,6 +32,10 @@ struct RecipeDef
std::vector<RecipeIngredient> inputs;
std::vector<RecipeOutput> outputs;
double durationSeconds;
// Assembler only. nullopt = implicit-only locking. -1 = explicitly unlocked
// at game start. >= 0 = locked; schematic enters drop pool at that station
// level once the output item is implicitly unlocked (REQ-LOCK-EXPLICIT).
std::optional<int> unlockAtStationLevel;
};
struct RecipesConfig

View File

@@ -14,12 +14,14 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
std::function<void(int)> addBuildingBlocks,
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> isItemUnlocked,
std::mt19937& rng)
: m_config(config)
, m_belts(belts)
, m_allocateBuildingId(std::move(allocateBuildingId))
, m_addBuildingBlocks(std::move(addBuildingBlocks))
, m_spawnShip(std::move(spawnShip))
, m_isItemUnlocked(std::move(isItemUnlocked))
, m_rng(rng)
{
}
@@ -203,17 +205,19 @@ std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
{
std::vector<const RecipeOutput*> eligible;
std::vector<double> weights;
weights.reserve(recipe.outputs.size());
for (const RecipeOutput& out : recipe.outputs)
{
if (!m_isItemUnlocked(out.item)) { continue; }
eligible.push_back(&out);
weights.push_back(out.probability.value_or(1.0));
}
std::discrete_distribution<int> dist(weights.begin(), weights.end());
const int idx = dist(m_rng);
if (eligible.empty()) { return {}; }
const RecipeOutput& chosen = recipe.outputs[static_cast<std::size_t>(idx)];
std::discrete_distribution<int> dist(weights.begin(), weights.end());
const RecipeOutput& chosen = *eligible[static_cast<std::size_t>(dist(m_rng))];
std::vector<Item> result;
Item item;
item.type.id = chosen.item;
@@ -660,6 +664,7 @@ void BuildingSystem::tickProduction(Tick currentTick)
if (building.type == BuildingType::ReprocessingPlant)
{
chosen = rollReprocessingOutput(*recipe);
if (chosen.empty()) { continue; }
}
else
{

View File

@@ -36,6 +36,7 @@ public:
std::function<void(int)> addBuildingBlocks,
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> isItemUnlocked,
std::mt19937& rng);
// -- Placement / demolish ------------------------------------------------
@@ -134,6 +135,7 @@ private:
std::function<void(int)> m_addBuildingBlocks;
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
std::function<bool(const std::string&)> m_isItemUnlocked;
std::mt19937& m_rng;
std::vector<Building> m_buildings;

View File

@@ -59,6 +59,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout,
moduleLevels);
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>();
@@ -86,6 +87,18 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_moduleSchematicLevels[def.id] = state;
}
// Initialize assembler recipe schematic unlock state.
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& def.unlockAtStationLevel.value() == -1)
{
m_unlockedRecipeSchematicIds.insert(def.id);
}
}
recomputeUnlocked();
placeInitialStructures();
registerForEvents();
}
@@ -147,6 +160,7 @@ void Simulation::reset(unsigned int seed)
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout,
moduleLevels);
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>();
@@ -174,6 +188,18 @@ void Simulation::reset(unsigned int seed)
m_moduleSchematicLevels[def.id] = state;
}
m_unlockedRecipeSchematicIds.clear();
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& def.unlockAtStationLevel.value() == -1)
{
m_unlockedRecipeSchematicIds.insert(def.id);
}
}
recomputeUnlocked();
placeInitialStructures();
}
@@ -512,38 +538,159 @@ void Simulation::tickDeathsAndLoot()
void Simulation::awardSchematicDrop(int destroyedStationLevel)
{
std::vector<std::pair<std::string, bool>> pool; // (id, isModule)
enum class DropType { Ship, Module, Recipe };
struct PoolEntry { std::string id; DropType type; };
std::vector<PoolEntry> pool;
for (const ShipDef& def : m_config.ships.ships)
{
if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel)
{
pool.push_back({def.id, false});
pool.push_back({def.id, DropType::Ship});
}
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel)
{
pool.push_back({def.id, true});
pool.push_back({def.id, DropType::Module});
}
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building != BuildingType::Assembler) { continue; }
if (!def.unlockAtStationLevel.has_value()) { continue; }
const int level = def.unlockAtStationLevel.value();
if (level < 0 || level > destroyedStationLevel) { continue; }
if (m_unlockedRecipeSchematicIds.count(def.id) > 0) { continue; }
bool outputUnlocked = false;
for (const RecipeOutput& out : def.outputs)
{
if (m_unlockedItemIds.count(out.item) > 0) { outputUnlocked = true; break; }
}
if (!outputUnlocked) { continue; }
pool.push_back({def.id, DropType::Recipe});
}
std::uniform_int_distribution<int> dist(0, static_cast<int>(pool.size()) - 1);
const auto& [chosen, isModule] = pool[static_cast<std::size_t>(dist(m_rng))];
const PoolEntry& chosen = pool[static_cast<std::size_t>(dist(m_rng))];
SchematicState& state = isModule
? m_moduleSchematicLevels.at(chosen)
: m_schematicLevels.at(chosen);
const bool wasNew = !state.unlocked;
state.unlocked = true;
state.level += 1;
if (chosen.type == DropType::Recipe)
{
m_unlockedRecipeSchematicIds.insert(chosen.id);
recomputeUnlocked();
}
else
{
SchematicState& state = (chosen.type == DropType::Module)
? m_moduleSchematicLevels.at(chosen.id)
: m_schematicLevels.at(chosen.id);
const bool wasNew = !state.unlocked;
state.unlocked = true;
state.level += 1;
SchematicDropEvent evt;
evt.schematicId = chosen;
evt.newLevel = state.level;
evt.wasNewUnlock = wasNew;
evt.isModuleSchematic = isModule;
m_schematicDropEvents.push_back(evt);
SchematicDropEvent evt;
evt.schematicId = chosen.id;
evt.newLevel = state.level;
evt.wasNewUnlock = wasNew;
evt.isModuleSchematic = (chosen.type == DropType::Module);
m_schematicDropEvents.push_back(evt);
recomputeUnlocked();
}
}
// ---------------------------------------------------------------------------
// Implicit unlock computation (REQ-LOCK-IMPLICIT)
// ---------------------------------------------------------------------------
void Simulation::recomputeUnlocked()
{
m_unlockedItemIds.clear();
m_unlockedRecipeIds.clear();
for (const ShipDef& def : m_config.ships.ships)
{
if (!isSchematicUnlocked(def.id)) { continue; }
for (const RecipeIngredient& mat : def.schematic.materials)
{
m_unlockedItemIds.insert(mat.item);
}
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (!isModuleSchematicUnlocked(def.id)) { continue; }
for (const RecipeIngredient& mat : def.materials)
{
m_unlockedItemIds.insert(mat.item);
}
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& m_unlockedRecipeSchematicIds.count(def.id) > 0)
{
for (const RecipeOutput& out : def.outputs)
{
m_unlockedItemIds.insert(out.item);
}
}
}
bool changed = true;
while (changed)
{
changed = false;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != BuildingType::Miner
&& recipe.building != BuildingType::Smelter
&& recipe.building != BuildingType::Assembler)
{
continue;
}
if (recipe.building == BuildingType::Assembler
&& recipe.unlockAtStationLevel.has_value()
&& m_unlockedRecipeSchematicIds.count(recipe.id) == 0)
{
continue;
}
bool producesUnlocked = false;
for (const RecipeOutput& out : recipe.outputs)
{
if (m_unlockedItemIds.count(out.item) > 0)
{
producesUnlocked = true;
break;
}
}
if (!producesUnlocked) { continue; }
if (recipe.building == BuildingType::Miner
|| recipe.building == BuildingType::Assembler)
{
m_unlockedRecipeIds.insert(recipe.id);
}
for (const RecipeIngredient& ing : recipe.inputs)
{
if (m_unlockedItemIds.insert(ing.item).second)
{
changed = true;
}
}
}
}
}
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const
{
return m_unlockedRecipeIds.count(recipeId) > 0;
}
bool Simulation::isItemUnlocked(const std::string& itemId) const
{
return m_unlockedItemIds.count(itemId) > 0;
}
// ---------------------------------------------------------------------------

View File

@@ -3,6 +3,7 @@
#include <map>
#include <memory>
#include <random>
#include <set>
#include <string>
#include <vector>
@@ -70,6 +71,10 @@ public:
int moduleSchematicLevel(const std::string& moduleId) const;
bool isModuleSchematicUnlocked(const std::string& moduleId) const;
// Implicit recipe/item unlock queries (REQ-LOCK-IMPLICIT).
bool isRecipeUnlocked(const std::string& recipeId) const;
bool isItemUnlocked(const std::string& itemId) const;
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
@@ -131,6 +136,16 @@ private:
std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels;
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
std::set<std::string> m_unlockedRecipeSchematicIds;
// Implicit unlock sets derived from schematic state (REQ-LOCK-IMPLICIT).
std::set<std::string> m_unlockedRecipeIds;
std::set<std::string> m_unlockedItemIds;
// Recomputes m_unlockedRecipeIds and m_unlockedItemIds from current schematic state.
void recomputeUnlocked();
EntityAdmin m_admin;
BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem;

View File

@@ -70,6 +70,7 @@ struct Fixture
[this]() { return nextBuildingId++; },
[this](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng)
, ships(cfg, admin)
, scraps(admin)

View File

@@ -79,6 +79,7 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -104,6 +105,7 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
@@ -132,6 +134,7 @@ TEST_CASE("BuildingSystem: placed building enters construction queue", "[buildin
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -152,6 +155,7 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -185,6 +189,7 @@ TEST_CASE("BuildingSystem: first queued building starts construction immediately
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -202,6 +207,7 @@ TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[b
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -223,6 +229,7 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -247,6 +254,7 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -276,6 +284,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -305,6 +314,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -344,6 +354,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
@@ -385,6 +396,7 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -424,6 +436,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -464,6 +477,7 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
@@ -494,6 +508,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
@@ -552,6 +567,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
REQUIRE_FALSE(
@@ -570,6 +586,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -592,6 +609,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -618,6 +636,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -639,6 +658,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprin
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
// Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1).
@@ -662,6 +682,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
@@ -690,6 +711,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a constru
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -712,6 +734,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -735,6 +758,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -765,6 +789,7 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);

View File

@@ -19,4 +19,5 @@ add_files(
BlueprintSerializerTest.cpp
ModuleConfigTest.cpp
ShipModuleTest.cpp
RecipeSchematicTest.cpp
)

View File

@@ -72,6 +72,7 @@ struct CombatFixture
[this]() { return nextBuildingId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng)
, combat(cfg)
{

View File

@@ -0,0 +1,258 @@
#include <algorithm>
#include "catch.hpp"
#include "ConfigLoader.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "RecipesConfig.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// Zeros the HP of both enemy defence stations and advances one tick so that
// tickDeathsAndLoot fires, triggering the push and schematic drop.
static 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();
}
// Destroys station sets until recipeId is unlocked or maxDestructions is reached.
// Returns true if the recipe is unlocked on exit.
static bool awaitRecipeUnlock(Simulation& sim, const std::string& recipeId,
int maxDestructions = 150)
{
for (int i = 0; i < maxDestructions; ++i)
{
if (sim.isRecipeUnlocked(recipeId)) { return true; }
killEnemyStations(sim);
}
return sim.isRecipeUnlocked(recipeId);
}
// ---------------------------------------------------------------------------
// ConfigLoader: parsing unlock_at_station_level on assembler recipes
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: unlock_at_station_level = -1 parsed correctly", "[recipe_schematic]")
{
const GameConfig cfg = loadConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "premium_circuit"; });
REQUIRE(it != cfg.recipes.recipes.end());
REQUIRE(it->unlockAtStationLevel.has_value());
CHECK(it->unlockAtStationLevel.value() == -1);
}
TEST_CASE("RecipeSchematic: unlock_at_station_level = 0 parsed correctly", "[recipe_schematic]")
{
const GameConfig cfg = loadConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "quick_circuit"; });
REQUIRE(it != cfg.recipes.recipes.end());
REQUIRE(it->unlockAtStationLevel.has_value());
CHECK(it->unlockAtStationLevel.value() == 0);
}
TEST_CASE("RecipeSchematic: assembler recipe without the key has nullopt", "[recipe_schematic]")
{
const GameConfig cfg = loadConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "circuit_board"; });
REQUIRE(it != cfg.recipes.recipes.end());
CHECK_FALSE(it->unlockAtStationLevel.has_value());
}
// ---------------------------------------------------------------------------
// Initial explicit lock state (REQ-LOCK-EXPLICIT)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = -1 is unlocked at game start",
"[recipe_schematic]")
{
const Simulation sim(loadConfig());
REQUIRE(sim.isRecipeUnlocked("premium_circuit"));
}
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 0 is locked at game start",
"[recipe_schematic]")
{
const Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit"));
}
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 1 is locked at game start",
"[recipe_schematic]")
{
const Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
}
// ---------------------------------------------------------------------------
// Implicit unlock graph (REQ-LOCK-IMPLICIT)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: -1 recipe seeds its output item into the implicit unlock set",
"[recipe_schematic]")
{
// premium_circuit is not needed by any ship or module schematic, so it can
// only reach the implicit set via the -1 recipe seed in Phase 1.
const Simulation sim(loadConfig());
REQUIRE(sim.isItemUnlocked("premium_circuit"));
}
TEST_CASE("RecipeSchematic: -1 recipe's inputs are in the implicit unlock set",
"[recipe_schematic]")
{
// premium_circuit takes circuit_board as input; that item was already
// implicitly unlocked by ship schematics, so it must remain unlocked.
const Simulation sim(loadConfig());
REQUIRE(sim.isItemUnlocked("circuit_board"));
}
TEST_CASE("RecipeSchematic: locked recipe's unique input is not in the implicit unlock set",
"[recipe_schematic]")
{
// exotic_alloy has unlock_at_station_level = 0 (locked at start) and takes
// exotic_ore as input. exotic_ore is only reachable through this locked
// recipe, so it must not appear in the implicit set.
const Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isItemUnlocked("exotic_ore"));
}
TEST_CASE("RecipeSchematic: locked recipe's output item is not in the implicit unlock set",
"[recipe_schematic]")
{
// exotic_alloy is produced only by the locked recipe of the same name and
// is not needed by any schematic, so neither the item nor the recipe should
// be unlocked at game start.
const Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isItemUnlocked("exotic_alloy"));
REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy"));
}
TEST_CASE("RecipeSchematic: normal implicit unlock is unaffected for untagged assembler recipes",
"[recipe_schematic]")
{
// circuit_board carries no unlock_at_station_level and is needed by ships
// that start unlocked, so it must still be implicitly unlocked.
const Simulation sim(loadConfig());
REQUIRE(sim.isRecipeUnlocked("circuit_board"));
}
// ---------------------------------------------------------------------------
// Drop pool and station destruction (REQ-DEF-SCHEMATIC-DROP)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: eligible recipe schematic is eventually awarded on station destruction",
"[recipe_schematic]")
{
// quick_circuit has unlock_at_station_level = 0 and produces circuit_board
// (already implicitly unlocked), so it is eligible from the first station
// destruction. With up to 150 trials it must be awarded at least once.
Simulation sim(loadConfig());
REQUIRE(awaitRecipeUnlock(sim, "quick_circuit"));
}
TEST_CASE("RecipeSchematic: recipe whose output is not implicitly unlocked is never awarded",
"[recipe_schematic]")
{
// exotic_alloy produces exotic_alloy (not implicitly unlocked), so the
// output-unlocked guard must keep it out of the drop pool at every level.
Simulation sim(loadConfig());
for (int i = 0; i < 50; ++i)
{
killEnemyStations(sim);
}
REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy"));
}
TEST_CASE("RecipeSchematic: recipe with level > destroyed station level is not awarded",
"[recipe_schematic]")
{
// advanced_circuit has unlock_at_station_level = 1. Destroying a single
// level-0 station set must not award it regardless of the RNG outcome.
Simulation sim(loadConfig());
killEnemyStations(sim);
REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
}
TEST_CASE("RecipeSchematic: recipe with higher level is awarded once eligible station level is reached",
"[recipe_schematic]")
{
// After enough destructions to pass station level 1, advanced_circuit must
// eventually be awarded.
Simulation sim(loadConfig());
REQUIRE(awaitRecipeUnlock(sim, "advanced_circuit", 300));
}
TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not awarded again",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
awaitRecipeUnlock(sim, "quick_circuit");
REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
// Destroy 30 more station sets; the recipe is no longer in the pool.
for (int i = 0; i < 30; ++i)
{
killEnemyStations(sim);
}
REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
}
TEST_CASE("RecipeSchematic: no SchematicDropEvent is emitted for a recipe schematic drop",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
sim.drainSchematicDropEvents(); // clear any startup events
awaitRecipeUnlock(sim, "quick_circuit");
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents();
for (const SchematicDropEvent& ev : events)
{
CHECK(ev.schematicId != "quick_circuit");
}
}
// ---------------------------------------------------------------------------
// reset() restores initial lock state (REQ-LOCK-EXPLICIT, REQ-CFG-RELOAD)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: reset re-locks a previously awarded recipe schematic",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
awaitRecipeUnlock(sim, "quick_circuit");
REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
sim.reset();
REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit"));
}
TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items accessible",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
sim.reset();
REQUIRE(sim.isRecipeUnlocked("premium_circuit"));
REQUIRE(sim.isItemUnlocked("premium_circuit"));
}

View File

@@ -560,7 +560,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
}
else
{
m_sim->buildings().setRecipe(id, bb.recipeId);
const bool needsUnlockCheck = bb.type == BuildingType::Miner
|| bb.type == BuildingType::Assembler;
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
{
m_sim->buildings().setRecipe(id, bb.recipeId);
}
}
}

View File

@@ -296,12 +296,15 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
{
for (const RecipeDef& recipe : m_config->recipes.recipes)
{
if (recipe.building == b->type)
if (recipe.building != b->type) { continue; }
if ((b->type == BuildingType::Miner || b->type == BuildingType::Assembler)
&& !m_sim->isRecipeUnlocked(recipe.id))
{
m_recipeCombo->addItem(
QString::fromStdString(recipe.id),
QString::fromStdString(recipe.id));
continue;
}
m_recipeCombo->addItem(
QString::fromStdString(recipe.id),
QString::fromStdString(recipe.id));
}
}
@@ -648,6 +651,7 @@ void SelectedBuildingPanel::buildSplitterFilters(QPoint splitterTile)
list->clear();
for (const std::string& itemId : items)
{
if (!m_sim->isItemUnlocked(itemId)) { continue; }
QListWidgetItem* row = new QListWidgetItem(
QString::fromStdString(itemId), list);
const bool checked = filter.empty()