From 1caf43c86a0053ee7f04d2f502638ce154f764d8 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Fri, 21 Aug 2026 23:29:07 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x --- src/test/BuildingTest.cpp | 23 ++++++ src/test/RecipeSchematicTest.cpp | 138 +++++++++++++++++++++++++++++-- 2 files changed, 156 insertions(+), 5 deletions(-) diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 34178f9..00c079f 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -1079,6 +1079,29 @@ TEST_CASE("BuildingSystem: a group's items are sized and gated together", "[buil 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(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]") { // A group's items come together, so a group holding any locked item is dropped whole diff --git a/src/test/RecipeSchematicTest.cpp b/src/test/RecipeSchematicTest.cpp index c1453aa..aebbd8f 100644 --- a/src/test/RecipeSchematicTest.cpp +++ b/src/test/RecipeSchematicTest.cpp @@ -380,9 +380,14 @@ GameConfig makePreviewConfig() reprocess.building = BuildingType::ReprocessingPlant; reprocess.inputs.push_back(RecipeIngredient{"junk", 4}); reprocess.outputGroups.push_back( - RecipeOutputGroup{{RecipeOutput{"alpha", 1}}, 0.5}); + RecipeOutputGroup{{RecipeOutput{"alpha", 1}}, 0.4}); 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); 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]); REQUIRE(recipeIdsOf(option.newlyUsableRecipes) == std::vector{"make_beta"}); REQUIRE(recipeIdsOf(option.upgradedRecipes) == std::vector{"reprocess"}); - // Drawn whole, both groups, the one it already had included: the caption is what says - // the recipe is not new, so the line does not have to. - REQUIRE(option.upgradedRecipes[0].outputGroupIndices == std::vector({0, 1})); + // Drawn whole, every group it can yield, the one it already had included: the caption + // is what says the recipe is not new, so the line does not have to. Group 2 holds + // both materials and comes with them, so it arrives only now that both are wanted. + REQUIRE(option.upgradedRecipes[0].outputGroupIndices == std::vector({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{"reprocess"}); + // The alpha group alone. Group 2 names alpha as well and is still left out. + REQUIRE(withoutBeta.newlyUsableRecipes[0].outputGroupIndices == std::vector{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{"reprocess"}); + REQUIRE(withBeta.newlyUsableRecipes[0].outputGroupIndices + == std::vector({0, 1, 2})); } // --------------------------------------------------------------------------- // 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 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 unlockedItemsBefore; + std::set unlockedRecipesBefore; + for (int i = 0; i < 60; ++i) + { + std::set unlockedItems; + for (const std::string& itemId : allItemIds) + { + if (sim.isItemUnlocked(itemId)) { unlockedItems.insert(itemId); } + } + std::set 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", "[recipe_schematic]") { @@ -507,6 +624,17 @@ TEST_CASE("RecipeSchematic: the preview lists are sorted, deduplicated, and draw { checkList(opt.newlyUsableRecipes); 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);