363 lines
13 KiB
C++
363 lines
13 KiB
C++
#include <algorithm>
|
|
|
|
#include "catch.hpp"
|
|
|
|
#include "ConfigLoader.h"
|
|
#include "DisplayName.h"
|
|
#include "FactionComponent.h"
|
|
#include "GameConfig.h"
|
|
#include "HealthComponent.h"
|
|
#include "RecipesConfig.h"
|
|
#include "SchematicChoiceOption.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 choices.
|
|
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();
|
|
}
|
|
|
|
// Kills enemy stations and applies the first schematic choice (index 0).
|
|
static void killEnemyStationsAndApply(Simulation& sim)
|
|
{
|
|
killEnemyStations(sim);
|
|
if (sim.hasSchematicChoicesPending())
|
|
{
|
|
sim.applySchematicChoice(0);
|
|
}
|
|
}
|
|
|
|
// Destroys station sets until recipeId is unlocked or maxDestructions is reached.
|
|
// Applies schematic choice 0 after each destruction. Returns true if unlocked.
|
|
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; }
|
|
killEnemyStationsAndApply(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)
|
|
{
|
|
killEnemyStationsAndApply(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());
|
|
killEnemyStationsAndApply(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)
|
|
{
|
|
killEnemyStationsAndApply(sim);
|
|
}
|
|
|
|
REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
|
|
}
|
|
|
|
TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
|
|
"[recipe_schematic]")
|
|
{
|
|
Simulation sim(loadConfig());
|
|
|
|
bool foundRecipeChoice = false;
|
|
for (int i = 0; i < 150 && !foundRecipeChoice; ++i)
|
|
{
|
|
killEnemyStations(sim);
|
|
if (sim.hasSchematicChoicesPending())
|
|
{
|
|
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
|
|
{
|
|
if (opt.type == SchematicType::Recipe)
|
|
{
|
|
foundRecipeChoice = true;
|
|
break;
|
|
}
|
|
}
|
|
sim.applySchematicChoice(0);
|
|
}
|
|
}
|
|
CHECK(foundRecipeChoice);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and empty for level-ups",
|
|
"[recipe_schematic]")
|
|
{
|
|
Simulation sim(loadConfig());
|
|
|
|
for (int i = 0; i < 100; ++i)
|
|
{
|
|
killEnemyStations(sim);
|
|
if (!sim.hasSchematicChoicesPending()) { continue; }
|
|
|
|
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
|
|
{
|
|
// Strictly ascending implies sorted and deduplicated.
|
|
for (std::size_t j = 1; j < opt.newlyUnlockedItemNames.size(); ++j)
|
|
{
|
|
CHECK(opt.newlyUnlockedItemNames[j - 1] < opt.newlyUnlockedItemNames[j]);
|
|
}
|
|
|
|
// A level-up doesn't change the explicit unlock state, so the
|
|
// implicit unlock set - and thus this preview - must be empty.
|
|
if (!opt.isNewUnlock)
|
|
{
|
|
CHECK(opt.newlyUnlockedItemNames.empty());
|
|
}
|
|
}
|
|
|
|
sim.applySchematicChoice(0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually become unlocked",
|
|
"[recipe_schematic]")
|
|
{
|
|
Simulation sim(loadConfig());
|
|
const GameConfig cfg = loadConfig();
|
|
|
|
auto unlockedTrackedRecipeIds = [&]()
|
|
{
|
|
std::set<std::string> ids;
|
|
for (const RecipeDef& def : cfg.recipes.recipes)
|
|
{
|
|
if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler)
|
|
&& sim.isRecipeUnlocked(def.id))
|
|
{
|
|
ids.insert(def.id);
|
|
}
|
|
}
|
|
return ids;
|
|
};
|
|
|
|
for (int i = 0; i < 100; ++i)
|
|
{
|
|
killEnemyStations(sim);
|
|
if (!sim.hasSchematicChoicesPending()) { continue; }
|
|
|
|
const std::set<std::string> unlockedBefore = unlockedTrackedRecipeIds();
|
|
const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0];
|
|
|
|
sim.applySchematicChoice(0);
|
|
|
|
std::set<std::string> expectedNames;
|
|
for (const RecipeDef& def : cfg.recipes.recipes)
|
|
{
|
|
if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler)
|
|
&& sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0)
|
|
{
|
|
for (const RecipeOutput& out : def.outputs)
|
|
{
|
|
expectedNames.insert(toDisplayName(out.item));
|
|
}
|
|
}
|
|
}
|
|
const std::vector<std::string> expected(expectedNames.begin(), expectedNames.end());
|
|
|
|
REQUIRE(choice.newlyUnlockedItemNames == expected);
|
|
}
|
|
}
|
|
|