explicit recipe unlocking
This commit is contained in:
@@ -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"))
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -87,6 +87,17 @@ 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();
|
||||
@@ -177,6 +188,17 @@ 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();
|
||||
}
|
||||
@@ -516,40 +538,66 @@ 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();
|
||||
recomputeUnlocked();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -577,6 +625,18 @@ void Simulation::recomputeUnlocked()
|
||||
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)
|
||||
@@ -590,6 +650,12 @@ void Simulation::recomputeUnlocked()
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -136,6 +136,9 @@ 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;
|
||||
|
||||
@@ -19,4 +19,5 @@ add_files(
|
||||
BlueprintSerializerTest.cpp
|
||||
ModuleConfigTest.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