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);