isRecipeUnlocked only ever meant anything for miner and assembler recipes: the traversal inserted those two and nothing else. So every caller carried the same branch -- ask the unlock state for a miner or an assembler, ask something else otherwise -- in the blueprint gate, the selection dialog, and the item tooltip's producer list, three copies of one exception. Recipe unlocking moves out of the item traversal into a pass of its own, run once the item set has settled: a recipe is unlocked when it is not a gated assembler recipe still awaiting its group, and it has an output group it could actually yield. Smelter and reprocessing recipes are judged by that too, so the question is now meaningful for all of them and the three branches collapse to one call. The two conditions stay independent on purpose. Explicit gating is about permission and outlives a wanted output -- a drop-only recipe whose output is useful anyway stays locked until it is awarded. Reprocessing still takes no part in the item traversal (REQ-BLD-REPROCESSING): it is asked in the new pass whether it can yield anything, without its yields feeding what counts as unlocked. Judging a group whole rather than per item (REQ-LOCK-OUTPUT-POOL) is what keeps a group's locked companion item from letting the whole group through. Recorded replays from before this change will not reproduce: unlocked recipe ids feed the state checksum, and smelter and reprocessing ids now join them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
449 lines
17 KiB
C++
449 lines
17 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 "SimulationTestAccess.h"
|
|
#include "StationBodyComponent.h"
|
|
#include "TestConfig.h"
|
|
|
|
// 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.getAdmin().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())
|
|
{
|
|
SimulationTestAccess::applySchematicChoice(sim, 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: unlocked_at_start and unlock-group gating on assembler recipes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("RecipeSchematic: unlocked_at_start = true parsed correctly", "[recipe_schematic]")
|
|
{
|
|
const GameConfig cfg = loadTestConfig();
|
|
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());
|
|
CHECK(it->unlockedAtStart);
|
|
}
|
|
|
|
TEST_CASE("RecipeSchematic: a gated recipe is granted by an unlock group", "[recipe_schematic]")
|
|
{
|
|
const GameConfig cfg = loadTestConfig();
|
|
const bool granted = std::any_of(cfg.unlocks.groups.begin(), cfg.unlocks.groups.end(),
|
|
[](const UnlockGroupDef& g)
|
|
{
|
|
return std::find(g.recipes.begin(), g.recipes.end(), "quick_circuit") != g.recipes.end();
|
|
});
|
|
CHECK(granted);
|
|
}
|
|
|
|
TEST_CASE("RecipeSchematic: an untagged assembler recipe has unlocked_at_start = false", "[recipe_schematic]")
|
|
{
|
|
const GameConfig cfg = loadTestConfig();
|
|
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->unlockedAtStart);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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(loadTestConfig());
|
|
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(loadTestConfig());
|
|
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(loadTestConfig());
|
|
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(loadTestConfig());
|
|
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(loadTestConfig());
|
|
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(loadTestConfig());
|
|
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(loadTestConfig());
|
|
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(loadTestConfig());
|
|
REQUIRE(sim.isRecipeUnlocked("circuit_board"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Recipe unlocking, one rule for every building (REQ-LOCK-IMPLICIT step 4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("RecipeSchematic: a smelter recipe is unlocked once its output is wanted",
|
|
"[recipe_schematic]")
|
|
{
|
|
// Smelter recipes are judged like any other now: iron_ingot's output feeds the
|
|
// circuit board every unlocked ship needs, so the recipe is one the player can run.
|
|
const Simulation sim(loadTestConfig());
|
|
REQUIRE(sim.isRecipeUnlocked("iron_ingot"));
|
|
REQUIRE(sim.isRecipeUnlocked("copper_ingot"));
|
|
}
|
|
|
|
TEST_CASE("RecipeSchematic: a reprocessing recipe is unlocked by one yieldable group",
|
|
"[recipe_schematic]")
|
|
{
|
|
// reprocessing_cycle can yield iron_ingot, circuit_board or advanced_alloy. The
|
|
// first two are wanted from the start, so the recipe is runnable even though
|
|
// advanced_alloy is not -- one group it can yield is enough (REQ-LOCK-OUTPUT-POOL).
|
|
const Simulation sim(loadTestConfig());
|
|
REQUIRE(sim.isRecipeUnlocked("reprocessing_cycle"));
|
|
|
|
// Being obtainable from the plant is not what unlocks an item, so the group the
|
|
// plant cannot usefully yield stays locked (REQ-BLD-REPROCESSING).
|
|
REQUIRE_FALSE(sim.isItemUnlocked("advanced_alloy"));
|
|
}
|
|
|
|
TEST_CASE("RecipeSchematic: explicit gating outlives a wanted output", "[recipe_schematic]")
|
|
{
|
|
// The two halves of the rule are independent. quick_circuit produces circuit_board,
|
|
// which is unlocked from the start, so it passes the yield test outright -- and must
|
|
// still be locked, because its unlock group has not been awarded (REQ-LOCK-EXPLICIT).
|
|
// Were recipe unlocking decided by outputs alone, blueprints and dialogs would hand
|
|
// the player every drop-only recipe whose output happens to be wanted anyway.
|
|
const Simulation sim(loadTestConfig());
|
|
REQUIRE(sim.isItemUnlocked("circuit_board"));
|
|
REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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(loadTestConfig());
|
|
REQUIRE(awaitRecipeUnlock(sim, "quick_circuit"));
|
|
}
|
|
|
|
TEST_CASE("RecipeSchematic: an implicitly-gated recipe with no unlock group is never awarded",
|
|
"[recipe_schematic]")
|
|
{
|
|
// exotic_alloy is in no unlock group and its output/inputs are unreachable
|
|
// via the item graph, so it can never be dropped or implicitly unlocked.
|
|
Simulation sim(loadTestConfig());
|
|
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(loadTestConfig());
|
|
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(loadTestConfig());
|
|
REQUIRE(awaitRecipeUnlock(sim, "advanced_circuit", 300));
|
|
}
|
|
|
|
TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not awarded again",
|
|
"[recipe_schematic]")
|
|
{
|
|
Simulation sim(loadTestConfig());
|
|
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(loadTestConfig());
|
|
|
|
bool foundRecipeChoice = false;
|
|
for (int i = 0; i < 150 && !foundRecipeChoice; ++i)
|
|
{
|
|
killEnemyStations(sim);
|
|
if (sim.hasSchematicChoicesPending())
|
|
{
|
|
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
|
|
{
|
|
for (const GrantedSchematic& grant : opt.grantedItems)
|
|
{
|
|
if (grant.type == SchematicType::Recipe) { foundRecipeChoice = true; break; }
|
|
}
|
|
if (foundRecipeChoice) { break; }
|
|
}
|
|
SimulationTestAccess::applySchematicChoice(sim, 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(loadTestConfig());
|
|
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(loadTestConfig());
|
|
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: newlyUnlockedRecipeIds is sorted, deduplicated, and empty for level-ups",
|
|
"[recipe_schematic]")
|
|
{
|
|
Simulation sim(loadTestConfig());
|
|
|
|
for (int i = 0; i < 100; ++i)
|
|
{
|
|
killEnemyStations(sim);
|
|
if (!sim.hasSchematicChoicesPending()) { continue; }
|
|
|
|
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
|
|
{
|
|
// Strictly ascending display names imply sorted and deduplicated.
|
|
for (std::size_t j = 1; j < opt.newlyUnlockedRecipeIds.size(); ++j)
|
|
{
|
|
CHECK(toDisplayName(opt.newlyUnlockedRecipeIds[j - 1])
|
|
< toDisplayName(opt.newlyUnlockedRecipeIds[j]));
|
|
}
|
|
}
|
|
|
|
SimulationTestAccess::applySchematicChoice(sim, 0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually become unlocked",
|
|
"[recipe_schematic]")
|
|
{
|
|
Simulation sim(loadTestConfig());
|
|
const GameConfig cfg = loadTestConfig();
|
|
|
|
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];
|
|
|
|
SimulationTestAccess::applySchematicChoice(sim, 0);
|
|
|
|
std::vector<std::string> expected;
|
|
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)
|
|
{
|
|
expected.push_back(def.id);
|
|
}
|
|
}
|
|
std::sort(expected.begin(), expected.end(),
|
|
[](const std::string& lhs, const std::string& rhs)
|
|
{
|
|
return toDisplayName(lhs) < toDisplayName(rhs);
|
|
});
|
|
|
|
REQUIRE(choice.newlyUnlockedRecipeIds == expected);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Owned ship/module schematics leave the drop pool (REQ-DEF-SCHEMATIC-DROP)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("SchematicDrop: an owned ship schematic is never offered again",
|
|
"[recipe_schematic]")
|
|
{
|
|
// repair_ship has unlock_at_station_level = 0 in the test config, so it is
|
|
// locked at start and becomes eligible once a station set is destroyed.
|
|
Simulation sim(loadTestConfig());
|
|
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
|
|
|
|
bool wasUnlocked = false;
|
|
for (int i = 0; i < 200; ++i)
|
|
{
|
|
killEnemyStations(sim);
|
|
if (!sim.hasSchematicChoicesPending()) { continue; }
|
|
|
|
const std::vector<SchematicChoiceOption>& choices =
|
|
sim.getPendingSchematicChoices();
|
|
|
|
// Locate the option granting repair_ship, if present in this drop.
|
|
int repairShipIndex = -1;
|
|
for (int j = 0; j < static_cast<int>(choices.size()); ++j)
|
|
{
|
|
for (const GrantedSchematic& grant : choices[j].grantedItems)
|
|
{
|
|
if (grant.id == "repair_ship") { repairShipIndex = j; break; }
|
|
}
|
|
}
|
|
|
|
// Once owned, it must never appear in the pool again.
|
|
if (sim.isSchematicUnlocked("repair_ship"))
|
|
{
|
|
CHECK(repairShipIndex == -1);
|
|
}
|
|
|
|
// Prefer picking repair_ship the first time it shows up so we exercise
|
|
// the transition from unowned to owned; otherwise just take choice 0.
|
|
int pick = 0;
|
|
if (repairShipIndex >= 0 && !sim.isSchematicUnlocked("repair_ship"))
|
|
{
|
|
pick = repairShipIndex;
|
|
wasUnlocked = true;
|
|
}
|
|
SimulationTestAccess::applySchematicChoice(sim, pick);
|
|
}
|
|
|
|
// Sanity: we actually unlocked it at some point, so the exclusion CHECK above
|
|
// was meaningfully exercised.
|
|
CHECK(wasUnlocked);
|
|
CHECK(sim.isSchematicUnlocked("repair_ship"));
|
|
}
|
|
|