explicit recipe unlocking
This commit is contained in:
@@ -70,6 +70,7 @@ duration_seconds = 6.0
|
|||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "building_blocks"
|
id = "building_blocks"
|
||||||
|
unlock_at_station_level = -1
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "iron_ingot", amount = 4}]
|
inputs = [{item = "iron_ingot", amount = 4}]
|
||||||
outputs = [{item = "building_block", amount = 10}]
|
outputs = [{item = "building_block", amount = 10}]
|
||||||
|
|||||||
@@ -40,6 +40,38 @@ inputs = [{item = "iron_ingot", amount = 4}]
|
|||||||
outputs = [{item = "building_block", amount = 10}]
|
outputs = [{item = "building_block", amount = 10}]
|
||||||
duration_seconds = 4.0
|
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]]
|
[[recipe]]
|
||||||
id = "reprocessing_cycle"
|
id = "reprocessing_cycle"
|
||||||
building = "reprocessing_plant"
|
building = "reprocessing_plant"
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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).
|
- **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).
|
- **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.
|
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
|
||||||
@@ -270,17 +270,23 @@ 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-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-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-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
|
## Progression & Locking
|
||||||
|
|
||||||
- REQ-LOCK-EXPLICIT: Ship schematics and module schematics 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).
|
- 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 and module 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 all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics.
|
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, add each of that recipe's input item types to the set. If the recipe is a miner or assembler recipe, mark it as implicitly unlocked.
|
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.
|
3. Repeat step 2 until no new item types are added.
|
||||||
Item types and miner/assembler recipes not reached by this process are locked. Smelter recipes participate in the traversal to propagate unlocking to their inputs but are never themselves shown in any UI dropdown.
|
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-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.
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,15 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
|
|||||||
}
|
}
|
||||||
def.building = *parsedType;
|
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.
|
// inputs may be omitted (e.g. miner recipes). An empty array is fine.
|
||||||
if (mt.contains("inputs"))
|
if (mt.contains("inputs"))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ struct RecipeDef
|
|||||||
std::vector<RecipeIngredient> inputs;
|
std::vector<RecipeIngredient> inputs;
|
||||||
std::vector<RecipeOutput> outputs;
|
std::vector<RecipeOutput> outputs;
|
||||||
double durationSeconds;
|
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
|
struct RecipesConfig
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
|
|||||||
m_moduleSchematicLevels[def.id] = state;
|
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();
|
recomputeUnlocked();
|
||||||
placeInitialStructures();
|
placeInitialStructures();
|
||||||
registerForEvents();
|
registerForEvents();
|
||||||
@@ -177,6 +188,17 @@ void Simulation::reset(unsigned int seed)
|
|||||||
m_moduleSchematicLevels[def.id] = state;
|
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();
|
recomputeUnlocked();
|
||||||
placeInitialStructures();
|
placeInitialStructures();
|
||||||
}
|
}
|
||||||
@@ -516,40 +538,66 @@ void Simulation::tickDeathsAndLoot()
|
|||||||
|
|
||||||
void Simulation::awardSchematicDrop(int destroyedStationLevel)
|
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)
|
for (const ShipDef& def : m_config.ships.ships)
|
||||||
{
|
{
|
||||||
if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel)
|
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)
|
for (const ModuleDef& def : m_config.modules.modules)
|
||||||
{
|
{
|
||||||
if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel)
|
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);
|
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
|
if (chosen.type == DropType::Recipe)
|
||||||
? m_moduleSchematicLevels.at(chosen)
|
{
|
||||||
: m_schematicLevels.at(chosen);
|
m_unlockedRecipeSchematicIds.insert(chosen.id);
|
||||||
const bool wasNew = !state.unlocked;
|
recomputeUnlocked();
|
||||||
state.unlocked = true;
|
}
|
||||||
state.level += 1;
|
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;
|
SchematicDropEvent evt;
|
||||||
evt.schematicId = chosen;
|
evt.schematicId = chosen.id;
|
||||||
evt.newLevel = state.level;
|
evt.newLevel = state.level;
|
||||||
evt.wasNewUnlock = wasNew;
|
evt.wasNewUnlock = wasNew;
|
||||||
evt.isModuleSchematic = isModule;
|
evt.isModuleSchematic = (chosen.type == DropType::Module);
|
||||||
m_schematicDropEvents.push_back(evt);
|
m_schematicDropEvents.push_back(evt);
|
||||||
|
|
||||||
recomputeUnlocked();
|
recomputeUnlocked();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -577,6 +625,18 @@ void Simulation::recomputeUnlocked()
|
|||||||
m_unlockedItemIds.insert(mat.item);
|
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;
|
bool changed = true;
|
||||||
while (changed)
|
while (changed)
|
||||||
@@ -590,6 +650,12 @@ void Simulation::recomputeUnlocked()
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (recipe.building == BuildingType::Assembler
|
||||||
|
&& recipe.unlockAtStationLevel.has_value()
|
||||||
|
&& m_unlockedRecipeSchematicIds.count(recipe.id) == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
bool producesUnlocked = false;
|
bool producesUnlocked = false;
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const RecipeOutput& out : recipe.outputs)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ private:
|
|||||||
std::map<std::string, SchematicState> m_schematicLevels;
|
std::map<std::string, SchematicState> m_schematicLevels;
|
||||||
std::map<std::string, SchematicState> m_moduleSchematicLevels;
|
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).
|
// Implicit unlock sets derived from schematic state (REQ-LOCK-IMPLICIT).
|
||||||
std::set<std::string> m_unlockedRecipeIds;
|
std::set<std::string> m_unlockedRecipeIds;
|
||||||
std::set<std::string> m_unlockedItemIds;
|
std::set<std::string> m_unlockedItemIds;
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ add_files(
|
|||||||
BlueprintSerializerTest.cpp
|
BlueprintSerializerTest.cpp
|
||||||
ModuleConfigTest.cpp
|
ModuleConfigTest.cpp
|
||||||
ShipModuleTest.cpp
|
ShipModuleTest.cpp
|
||||||
|
RecipeSchematicTest.cpp
|
||||||
)
|
)
|
||||||
|
|||||||
258
src/test/RecipeSchematicTest.cpp
Normal file
258
src/test/RecipeSchematicTest.cpp
Normal 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"));
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user