test the unlock rules that nothing was holding in place
Four gaps, each covering a rule this design leans on rather than merely states. The single-group exemption was half tested: one case pinned that no randomness is drawn, none that the building still produces. That half is the load-bearing one. It is why the pool cannot test eligibility, and so why the display filters more strictly than the pool rather than the pool being brought into line with it -- an asymmetry a later reader would reasonably try to tidy away, with the suite staying green. Judging a group whole only differs from judging its items one by one when a group holds more than one item, and no shipped or fixture recipe does. The synthetic preview config now has such a group, and the same award is asked twice either side of its second item being wanted: alpha alone opens the alpha group, both open all three. Monotonicity was assumed in a comment and relied on in code -- an upgraded recipe is spotted by its group count having grown, which only means "gained" so long as nothing can be lost. And step 4's guarantee that an unlocked recipe has a group it can yield, which computeUsableRecipeGroups treats as impossible to violate rather than as a case to render, is now checked across a run of awards instead of trusted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
@@ -1079,6 +1079,29 @@ TEST_CASE("BuildingSystem: a group's items are sized and gated together", "[buil
|
|||||||
REQUIRE_FALSE(recipeOutputsFit(assembler, recipe));
|
REQUIRE_FALSE(recipeOutputsFit(assembler, recipe));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: a single-group recipe produces what nothing yet demands",
|
||||||
|
"[building][unlock]")
|
||||||
|
{
|
||||||
|
// The other half of the single-group exemption (REQ-LOCK-OUTPUT-POOL), the half that
|
||||||
|
// is about eligibility rather than entropy. Implicit unlocking is derived from demand
|
||||||
|
// (REQ-LOCK-IMPLICIT), so an ordinary recipe's output can be perfectly producible
|
||||||
|
// while nothing calls for it yet. Testing eligibility here would not gate a drop, it
|
||||||
|
// would stop the building producing at all -- which is why the display filters more
|
||||||
|
// strictly than the pool does, rather than the pool being brought into line with it.
|
||||||
|
PlacementFixture f(std::nullopt, [](const std::string&) { return false; });
|
||||||
|
|
||||||
|
Tick tick = 0;
|
||||||
|
const BuildingId id =
|
||||||
|
f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
|
||||||
|
f.bs.setRecipe(f.state, id, "mine_iron_ore");
|
||||||
|
runTicks(f.production, f.cfg, f.state, f.belts,
|
||||||
|
static_cast<int>(secondsToTicks(10.0)) + 40, tick);
|
||||||
|
|
||||||
|
const Building* miner = findBuilding(f.state, id);
|
||||||
|
REQUIRE(miner != nullptr);
|
||||||
|
REQUIRE(miner->getOutputItemCount() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("BuildingSystem: a group with a locked item is never picked", "[building]")
|
TEST_CASE("BuildingSystem: a group with a locked item is never picked", "[building]")
|
||||||
{
|
{
|
||||||
// A group's items come together, so a group holding any locked item is dropped whole
|
// A group's items come together, so a group holding any locked item is dropped whole
|
||||||
|
|||||||
@@ -380,9 +380,14 @@ GameConfig makePreviewConfig()
|
|||||||
reprocess.building = BuildingType::ReprocessingPlant;
|
reprocess.building = BuildingType::ReprocessingPlant;
|
||||||
reprocess.inputs.push_back(RecipeIngredient{"junk", 4});
|
reprocess.inputs.push_back(RecipeIngredient{"junk", 4});
|
||||||
reprocess.outputGroups.push_back(
|
reprocess.outputGroups.push_back(
|
||||||
RecipeOutputGroup{{RecipeOutput{"alpha", 1}}, 0.5});
|
RecipeOutputGroup{{RecipeOutput{"alpha", 1}}, 0.4});
|
||||||
reprocess.outputGroups.push_back(
|
reprocess.outputGroups.push_back(
|
||||||
RecipeOutputGroup{{RecipeOutput{"beta", 1}}, 0.5});
|
RecipeOutputGroup{{RecipeOutput{"beta", 1}}, 0.4});
|
||||||
|
// A group of two, which no shipped or fixture recipe has: the only shape in which
|
||||||
|
// judging a group whole differs from judging its items one by one
|
||||||
|
// (REQ-LOCK-OUTPUT-POOL).
|
||||||
|
reprocess.outputGroups.push_back(
|
||||||
|
RecipeOutputGroup{{RecipeOutput{"alpha", 1}, RecipeOutput{"beta", 1}}, 0.2});
|
||||||
cfg.recipes.recipes.push_back(reprocess);
|
cfg.recipes.recipes.push_back(reprocess);
|
||||||
|
|
||||||
UnlockGroupDef lateShipGroup{};
|
UnlockGroupDef lateShipGroup{};
|
||||||
@@ -459,15 +464,127 @@ TEST_CASE("RecipeSchematic: a recipe gaining an output group is an upgrade, not
|
|||||||
const SchematicChoiceOption option = state.makeUnlockOption(cfg.unlocks.groups[0]);
|
const SchematicChoiceOption option = state.makeUnlockOption(cfg.unlocks.groups[0]);
|
||||||
REQUIRE(recipeIdsOf(option.newlyUsableRecipes) == std::vector<std::string>{"make_beta"});
|
REQUIRE(recipeIdsOf(option.newlyUsableRecipes) == std::vector<std::string>{"make_beta"});
|
||||||
REQUIRE(recipeIdsOf(option.upgradedRecipes) == std::vector<std::string>{"reprocess"});
|
REQUIRE(recipeIdsOf(option.upgradedRecipes) == std::vector<std::string>{"reprocess"});
|
||||||
// Drawn whole, both groups, the one it already had included: the caption is what says
|
// Drawn whole, every group it can yield, the one it already had included: the caption
|
||||||
// the recipe is not new, so the line does not have to.
|
// is what says the recipe is not new, so the line does not have to. Group 2 holds
|
||||||
REQUIRE(option.upgradedRecipes[0].outputGroupIndices == std::vector<int>({0, 1}));
|
// both materials and comes with them, so it arrives only now that both are wanted.
|
||||||
|
REQUIRE(option.upgradedRecipes[0].outputGroupIndices == std::vector<int>({0, 1, 2}));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("RecipeSchematic: a group of two waits for the last of its items",
|
||||||
|
"[recipe_schematic]")
|
||||||
|
{
|
||||||
|
// The same award twice, differing only in whether beta is wanted yet. The plant's
|
||||||
|
// third group yields alpha and beta together, so holding alpha unlocked is worth
|
||||||
|
// nothing on its own: a group is judged whole, because its items are produced
|
||||||
|
// together (REQ-LOCK-OUTPUT-POOL). This is the one shape in which that rule differs
|
||||||
|
// from testing items one at a time, and no shipped or fixture recipe has it.
|
||||||
|
const GameConfig cfg = makePreviewConfig();
|
||||||
|
|
||||||
|
UnlockState betaLocked(cfg);
|
||||||
|
betaLocked.initializeUnlockState();
|
||||||
|
const SchematicChoiceOption withoutBeta =
|
||||||
|
betaLocked.makeUnlockOption(cfg.unlocks.groups[1]);
|
||||||
|
REQUIRE(recipeIdsOf(withoutBeta.newlyUsableRecipes)
|
||||||
|
== std::vector<std::string>{"reprocess"});
|
||||||
|
// The alpha group alone. Group 2 names alpha as well and is still left out.
|
||||||
|
REQUIRE(withoutBeta.newlyUsableRecipes[0].outputGroupIndices == std::vector<int>{0});
|
||||||
|
|
||||||
|
UnlockState betaWanted(cfg);
|
||||||
|
betaWanted.initializeUnlockState();
|
||||||
|
betaWanted.awardUnlockGroup(betaWanted.makeUnlockOption(cfg.unlocks.groups[0]));
|
||||||
|
const SchematicChoiceOption withBeta =
|
||||||
|
betaWanted.makeUnlockOption(cfg.unlocks.groups[1]);
|
||||||
|
REQUIRE(recipeIdsOf(withBeta.newlyUsableRecipes)
|
||||||
|
== std::vector<std::string>{"reprocess"});
|
||||||
|
REQUIRE(withBeta.newlyUsableRecipes[0].outputGroupIndices
|
||||||
|
== std::vector<int>({0, 1, 2}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unlock dialog: preview shape and ordering (REQ-DEF-SCHEMATIC-DROP)
|
// Unlock dialog: preview shape and ordering (REQ-DEF-SCHEMATIC-DROP)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("RecipeSchematic: unlocking never takes anything away", "[recipe_schematic]")
|
||||||
|
{
|
||||||
|
// Monotonicity, which the preview relies on rather than merely enjoys: an upgraded
|
||||||
|
// recipe is spotted by its group count having grown, which only means "gained groups"
|
||||||
|
// while nothing can ever be lost. Should unlocking ever become reversible, that
|
||||||
|
// comparison turns from cheap into wrong, and this is what would say so.
|
||||||
|
Simulation sim(loadTestConfig());
|
||||||
|
const GameConfig cfg = loadTestConfig();
|
||||||
|
|
||||||
|
std::set<std::string> allItemIds;
|
||||||
|
for (const RecipeDef& def : cfg.recipes.recipes)
|
||||||
|
{
|
||||||
|
for (const std::string& item : getProducibleItems(def)) { allItemIds.insert(item); }
|
||||||
|
for (const RecipeIngredient& ing : def.inputs) { allItemIds.insert(ing.item); }
|
||||||
|
}
|
||||||
|
|
||||||
|
std::set<std::string> unlockedItemsBefore;
|
||||||
|
std::set<std::string> unlockedRecipesBefore;
|
||||||
|
for (int i = 0; i < 60; ++i)
|
||||||
|
{
|
||||||
|
std::set<std::string> unlockedItems;
|
||||||
|
for (const std::string& itemId : allItemIds)
|
||||||
|
{
|
||||||
|
if (sim.isItemUnlocked(itemId)) { unlockedItems.insert(itemId); }
|
||||||
|
}
|
||||||
|
std::set<std::string> unlockedRecipes;
|
||||||
|
for (const RecipeDef& def : cfg.recipes.recipes)
|
||||||
|
{
|
||||||
|
if (sim.isRecipeUnlocked(def.id)) { unlockedRecipes.insert(def.id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const std::string& itemId : unlockedItemsBefore)
|
||||||
|
{
|
||||||
|
CHECK(unlockedItems.count(itemId) == 1);
|
||||||
|
}
|
||||||
|
for (const std::string& recipeId : unlockedRecipesBefore)
|
||||||
|
{
|
||||||
|
CHECK(unlockedRecipes.count(recipeId) == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockedItemsBefore = unlockedItems;
|
||||||
|
unlockedRecipesBefore = unlockedRecipes;
|
||||||
|
|
||||||
|
killEnemyStationsAndApply(sim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("RecipeSchematic: an unlocked recipe always has a group it can yield",
|
||||||
|
"[recipe_schematic]")
|
||||||
|
{
|
||||||
|
// REQ-LOCK-IMPLICIT step 4 makes this true by construction, and the preview leans on
|
||||||
|
// it: computeUsableRecipeGroups treats a usable recipe with no yieldable group as
|
||||||
|
// impossible rather than as a case to render. Checked across a run of awards, since
|
||||||
|
// it must hold in every state and not only at the start.
|
||||||
|
Simulation sim(loadTestConfig());
|
||||||
|
const GameConfig cfg = loadTestConfig();
|
||||||
|
|
||||||
|
for (int i = 0; i < 40; ++i)
|
||||||
|
{
|
||||||
|
for (const RecipeDef& def : cfg.recipes.recipes)
|
||||||
|
{
|
||||||
|
if (!sim.isRecipeUnlocked(def.id)) { continue; }
|
||||||
|
|
||||||
|
bool anyGroupYieldable = false;
|
||||||
|
for (const RecipeOutputGroup& group : def.outputGroups)
|
||||||
|
{
|
||||||
|
if (isOutputGroupUnlocked(
|
||||||
|
group,
|
||||||
|
[&sim](const std::string& id) { return sim.isItemUnlocked(id); }))
|
||||||
|
{
|
||||||
|
anyGroupYieldable = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CHECK(anyGroupYieldable);
|
||||||
|
}
|
||||||
|
|
||||||
|
killEnemyStationsAndApply(sim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("RecipeSchematic: the preview lists are sorted, deduplicated, and drawable",
|
TEST_CASE("RecipeSchematic: the preview lists are sorted, deduplicated, and drawable",
|
||||||
"[recipe_schematic]")
|
"[recipe_schematic]")
|
||||||
{
|
{
|
||||||
@@ -507,6 +624,17 @@ TEST_CASE("RecipeSchematic: the preview lists are sorted, deduplicated, and draw
|
|||||||
{
|
{
|
||||||
checkList(opt.newlyUsableRecipes);
|
checkList(opt.newlyUsableRecipes);
|
||||||
checkList(opt.upgradedRecipes);
|
checkList(opt.upgradedRecipes);
|
||||||
|
|
||||||
|
// The lists are two answers to one question, so a recipe belongs to exactly
|
||||||
|
// one of them: a recipe cannot be both new to the player and one they
|
||||||
|
// already run (REQ-DEF-SCHEMATIC-DROP).
|
||||||
|
for (const PreviewedRecipe& newly : opt.newlyUsableRecipes)
|
||||||
|
{
|
||||||
|
for (const PreviewedRecipe& upgraded : opt.upgradedRecipes)
|
||||||
|
{
|
||||||
|
CHECK(newly.recipeId != upgraded.recipeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user