#include #include #include "testHarness.h" #include "vf/PotionCatalog.h" using namespace vf; namespace { using Ingredient = BrewRecipe::Ingredient; PotionItem bottleOf(PotionKind kind, bool upgraded = false, bool extended = false, PotionItem::Form form = PotionItem::Form::Drinkable) { PotionItem potion; potion.kind = kind; potion.upgraded = upgraded; potion.extended = extended; potion.form = form; return potion; } // Runs a whole ingredient list through an actual brewing stand, one full // four hundred tick brew per step. PotionItem replayOnAStand(const std::vector& steps) { BrewingStandState stand; setBottle(stand, 0, PotionItem{}); addFuel(stand, blazePowderForBrews(static_cast(steps.size())) + 1); for (Ingredient ingredient : steps) { setIngredient(stand, ingredient, 1); for (int tick = 0; tick < brewDurationTicks; ++tick) tickBrewing(stand); } return stand.bottles[0].potion; } PotionItem replayThroughSteps(const std::vector& steps) { PotionItem bottle; for (Ingredient ingredient : steps) { bool changed = false; bottle = catalogBrewStep(bottle, ingredient, changed); if (!changed) return PotionItem{}; } return bottle; } int splashDurationThroughTheStand(const PotionItem& potion) { PotionItem splash = potion; splash.form = PotionItem::Form::Splash; EffectSet target; if (!splashPotion(target, splash, 0.0f, 4.0f)) return 0; return target.active[0].durationTicks; } } // namespace VF_TEST(theCatalogsPublishedConstantsAreTheOnesTheNumbersWereDerivedFrom) { CHECK_NEAR(splashDurationScale, 0.75f, 0.0001f); CHECK_NEAR(lingeringDoseScale, 0.25f, 0.0001f); CHECK_EQ(lingeringCloudTicks, 600); CHECK_EQ(lingeringCloudTicks, 30 * ticksPerSecond); // Pinned against literals rather than the constants, so shifting a constant // cannot quietly shift the expectations with it. const PotionCatalog& catalog = potionCatalog(); PotionItem swiftness = bottleOf(PotionKind::Swiftness); CHECK_EQ(catalog.variantTicks(swiftness), 3600); swiftness.form = PotionItem::Form::Splash; CHECK_EQ(catalog.variantTicks(swiftness), 2700); swiftness.form = PotionItem::Form::Lingering; CHECK_EQ(catalog.variantTicks(swiftness), 900); CHECK_EQ(catalog.cloudTicks(swiftness), 600); } VF_TEST(theCatalogHasExactlyOneRowPerPotionKindInEnumOrder) { const PotionCatalog& catalog = potionCatalog(); CHECK_EQ(catalog.size(), static_cast(PotionKind::Count)); for (int index = 0; index < static_cast(PotionKind::Count); ++index) { PotionKind kind = static_cast(index); const PotionEntry& row = catalog.entry(kind); CHECK_EQ(row.kind, kind); CHECK(row.name != nullptr); CHECK(std::strlen(row.name) > 0); } int duplicateNames = 0; const std::vector& rows = catalog.entries(); for (std::size_t i = 0; i < rows.size(); ++i) { for (std::size_t j = i + 1; j < rows.size(); ++j) { if (std::strcmp(rows[i].name, rows[j].name) == 0) ++duplicateNames; } } CHECK_EQ(duplicateNames, 0); } VF_TEST(everyCatalogDurationAndLevelMatchesWhatTheStandActuallyApplies) { const PotionCatalog& catalog = potionCatalog(); for (int index = 0; index < static_cast(PotionKind::Count); ++index) { PotionKind kind = static_cast(index); const PotionEntry& row = catalog.entry(kind); const struct { bool upgraded; bool extended; } modifiers[] = { {false, false}, {true, false}, {false, true}, {true, true}}; for (const auto& modifier : modifiers) { PotionItem potion = bottleOf(kind, modifier.upgraded, modifier.extended); EffectInstance applied = potionEffect(potion); CHECK_EQ(catalog.variantTicks(potion), applied.durationTicks); if (row.carriesEffect()) { CHECK_EQ(catalog.variantLevel(potion), applied.level()); CHECK_EQ(row.effect, applied.kind); } else { CHECK_EQ(catalog.variantLevel(potion), 0); CHECK_EQ(applied.kind, EffectKind::Count); } } } } VF_TEST(theSplashColumnOfTheCatalogMatchesWhatASplashBottleLandsAtTheImpactPoint) { const PotionCatalog& catalog = potionCatalog(); int checkedTimedPotions = 0; for (const PotionEntry& row : catalog.entries()) { if (!row.carriesEffect()) continue; const struct { bool upgraded; bool extended; } modifiers[] = { {false, false}, {true, false}, {false, true}}; for (const auto& modifier : modifiers) { PotionItem drinkable = bottleOf(row.kind, modifier.upgraded, modifier.extended); PotionItem splash = drinkable; splash.form = PotionItem::Form::Splash; CHECK_EQ(catalog.variantTicks(splash), splashDurationThroughTheStand(drinkable)); CHECK_EQ(catalog.variantLevel(splash), catalog.variantLevel(drinkable)); if (!row.instant) ++checkedTimedPotions; } } CHECK(checkedTimedPotions >= 30); } VF_TEST(lingeringBottlesDoseAQuarterAndLeaveACloudBehindThem) { const PotionCatalog& catalog = potionCatalog(); PotionItem swiftness = bottleOf(PotionKind::Swiftness); CHECK_EQ(catalog.variantTicks(swiftness), 3600); CHECK_EQ(catalog.cloudTicks(swiftness), 0); PotionItem lingering = bottleOf(PotionKind::Swiftness, false, false, PotionItem::Form::Lingering); CHECK_EQ(catalog.variantTicks(lingering), 900); CHECK_EQ(catalog.cloudTicks(lingering), lingeringCloudTicks); PotionItem lingeringStrong = bottleOf(PotionKind::Swiftness, true, false, PotionItem::Form::Lingering); CHECK_EQ(catalog.variantTicks(lingeringStrong), 450); CHECK_EQ(catalog.variantLevel(lingeringStrong), 2); PotionItem lingeringWater = bottleOf(PotionKind::Water, false, false, PotionItem::Form::Lingering); CHECK_EQ(catalog.variantTicks(lingeringWater), 0); CHECK_EQ(catalog.cloudTicks(lingeringWater), 0); } VF_TEST(instantPotionsStayAtOneTickInEveryFormAndModifier) { const PotionCatalog& catalog = potionCatalog(); const PotionKind instants[] = {PotionKind::Healing, PotionKind::Harming}; const PotionItem::Form forms[] = {PotionItem::Form::Drinkable, PotionItem::Form::Splash, PotionItem::Form::Lingering}; for (PotionKind kind : instants) { CHECK(catalog.entry(kind).instant); CHECK(!catalog.entry(kind).acceptsRedstone()); CHECK(catalog.entry(kind).acceptsGlowstone()); for (PotionItem::Form form : forms) { CHECK_EQ(catalog.variantTicks(bottleOf(kind, false, false, form)), 1); CHECK_EQ(catalog.variantTicks(bottleOf(kind, true, false, form)), 1); CHECK_EQ(catalog.variantLevel(bottleOf(kind, true, false, form)), 2); } } } VF_TEST(everyCatalogRouteIsARealRecipeAndAlsoTheShortestOne) { const PotionCatalog& catalog = potionCatalog(); const BrewGraph& graph = brewGraph(); int routesChecked = 0; for (const PotionEntry& row : catalog.entries()) { if (row.ingredient == Ingredient::None) { CHECK_EQ(row.kind, PotionKind::Water); continue; } bool changed = false; PotionItem brewed = brewResult(bottleOf(row.base), row.ingredient, changed); CHECK(changed); CHECK_EQ(brewed.kind, row.kind); CHECK_EQ(graph.depthOf(bottleOf(row.kind)), graph.depthOf(bottleOf(row.base)) + 1); ++routesChecked; } CHECK_EQ(routesChecked, static_cast(PotionKind::Count) - 1); } VF_TEST(harmfulFlagsAndEffectLookupsAgreeWithTheEffectModule) { const PotionCatalog& catalog = potionCatalog(); for (const PotionEntry& row : catalog.entries()) { if (!row.carriesEffect()) { CHECK(!row.harmful); CHECK(!row.instant); continue; } CHECK_EQ(row.harmful, isHarmfulEffect(row.effect)); CHECK_EQ(row.instant, isInstantEffect(row.effect)); CHECK_EQ(catalog.kindForEffect(row.effect), row.kind); } const EffectKind uncovered[] = {EffectKind::Haste, EffectKind::MiningFatigue, EffectKind::Nausea, EffectKind::Resistance, EffectKind::Wither, EffectKind::HealthBoost, EffectKind::Absorption, EffectKind::Saturation, EffectKind::Levitation, EffectKind::Count}; for (EffectKind effect : uncovered) CHECK_EQ(catalog.kindForEffect(effect), PotionKind::Count); } VF_TEST(invertedKindReadsTheFermentedSpiderEyeBranchesStraightOffTheRecipeTable) { const PotionCatalog& catalog = potionCatalog(); CHECK_EQ(catalog.invertedKind(PotionKind::Water), PotionKind::Weakness); CHECK_EQ(catalog.invertedKind(PotionKind::NightVision), PotionKind::Invisibility); CHECK_EQ(catalog.invertedKind(PotionKind::Healing), PotionKind::Harming); CHECK_EQ(catalog.invertedKind(PotionKind::Poison), PotionKind::Harming); CHECK_EQ(catalog.invertedKind(PotionKind::Swiftness), PotionKind::Slowness); CHECK_EQ(catalog.invertedKind(PotionKind::Leaping), PotionKind::Slowness); CHECK_EQ(catalog.invertedKind(PotionKind::Strength), PotionKind::Weakness); const PotionKind untouched[] = {PotionKind::Awkward, PotionKind::Regeneration, PotionKind::FireResistance, PotionKind::WaterBreathing, PotionKind::Harming, PotionKind::Slowness}; for (PotionKind kind : untouched) CHECK_EQ(catalog.invertedKind(kind), PotionKind::Count); // Every inversion the catalog reports has to survive a real brew. for (int index = 0; index < static_cast(PotionKind::Count); ++index) { PotionKind kind = static_cast(index); PotionKind inverted = catalog.invertedKind(kind); bool changed = false; PotionItem brewed = brewResult(bottleOf(kind), Ingredient::FermentedSpiderEye, changed); CHECK_EQ(changed, inverted != PotionKind::Count); if (changed) CHECK_EQ(brewed.kind, inverted); } } VF_TEST(everyPotionInTheCatalogHasABrewingPathThatARealStandCanReplay) { const PotionCatalog& catalog = potionCatalog(); const BrewGraph& graph = brewGraph(); std::vector paths = graph.drinkablePaths(); CHECK_EQ(paths.size(), catalog.size()); for (std::size_t i = 0; i < paths.size(); ++i) { const BrewPath& path = paths[i]; const PotionEntry& row = catalog.entries()[i]; CHECK(path.reachable); CHECK_EQ(path.target.kind, row.kind); CHECK_EQ(path.totalTicks, path.stepCount() * brewDurationTicks); CHECK_EQ(path.stepCount(), graph.depthOf(path.target)); PotionItem brewed = replayOnAStand(path.steps); CHECK_EQ(brewed.kind, row.kind); CHECK_EQ(brewed.form, PotionItem::Form::Drinkable); CHECK(!brewed.upgraded); CHECK(!brewed.extended); } } VF_TEST(everyReachableVariantReplaysExactlyThroughItsOwnShortestPath) { const BrewGraph& graph = brewGraph(); int replayed = 0; for (int index = 0; index < BrewGraph::stateCount(); ++index) { PotionItem state = BrewGraph::stateAt(index); BrewPath path = graph.shortestPath(state); if (!path.reachable) continue; CHECK(replayThroughSteps(path.steps) == state); CHECK_EQ(path.stepCount(), graph.depthOf(state)); ++replayed; } CHECK_EQ(replayed, graph.reachableStateCount()); } VF_TEST(theGraphReachesExactlyTheVariantsTheCatalogDeclaresAndNothingElse) { const PotionCatalog& catalog = potionCatalog(); const BrewGraph& graph = brewGraph(); int expected = 0; for (const PotionEntry& row : catalog.entries()) expected += row.variantCount() * 3; CHECK_EQ(graph.reachableStateCount(), expected); CHECK(graph.reachableStateCount() < BrewGraph::stateCount()); int fromDepths = 0; for (int depth = 0; depth <= BrewGraph::stateCount(); ++depth) { fromDepths += static_cast(graph.statesAtDepth(depth).size()); } CHECK_EQ(fromDepths, graph.reachableStateCount()); CHECK_EQ(graph.statesAtDepth(0).size(), std::size_t{1}); CHECK(graph.statesAtDepth(0)[0] == PotionItem{}); } VF_TEST(modifiersAPotionRefusesLeaveThatVariantWithNoPathAtAll) { const BrewGraph& graph = brewGraph(); const PotionCatalog& catalog = potionCatalog(); const PotionKind noGlowstone[] = {PotionKind::Weakness, PotionKind::FireResistance, PotionKind::WaterBreathing, PotionKind::NightVision, PotionKind::Invisibility}; for (PotionKind kind : noGlowstone) { CHECK(!catalog.entry(kind).acceptsGlowstone()); CHECK(!graph.reachable(bottleOf(kind, true, false))); CHECK_EQ(graph.depthOf(bottleOf(kind, true, false)), -1); CHECK(graph.reachable(bottleOf(kind, false, true))); } const PotionKind noRedstone[] = {PotionKind::Healing, PotionKind::Harming}; for (PotionKind kind : noRedstone) { CHECK(!graph.reachable(bottleOf(kind, false, true))); CHECK(graph.reachable(bottleOf(kind, true, false))); } // The four effectless potions take no modifier at all: glowstone and // redstone on water are recipes, not upgrades. const PotionKind bases[] = {PotionKind::Water, PotionKind::Mundane, PotionKind::Thick, PotionKind::Awkward}; for (PotionKind kind : bases) { CHECK(!graph.reachable(bottleOf(kind, true, false))); CHECK(!graph.reachable(bottleOf(kind, false, true))); CHECK(graph.reachable(bottleOf(kind))); } BrewPath missing = graph.shortestPath(bottleOf(PotionKind::Harming, false, true)); CHECK(!missing.reachable); CHECK_EQ(missing.stepCount(), 0); CHECK_EQ(missing.blazePowderTotal(), 0); CHECK_EQ(missing.totalTicks, 0); } VF_TEST(gunpowderAndDragonBreathAddExactlyOneStepEachOnTopOfTheDrinkable) { const BrewGraph& graph = brewGraph(); PotionItem drinkable = bottleOf(PotionKind::Strength, true); PotionItem splash = drinkable; splash.form = PotionItem::Form::Splash; PotionItem lingering = drinkable; lingering.form = PotionItem::Form::Lingering; int drinkableDepth = graph.depthOf(drinkable); CHECK_EQ(drinkableDepth, 3); CHECK_EQ(graph.depthOf(splash), drinkableDepth + 1); CHECK_EQ(graph.depthOf(lingering), drinkableDepth + 2); BrewPath path = graph.shortestPath(lingering); CHECK(path.reachable); // Dragon breath has to close the chain, but gunpowder is free to land // anywhere before it: splashing the awkward base is just as short. CHECK_EQ(path.steps.back(), Ingredient::DragonBreath); int gunpowderSteps = 0; int dragonBreathSteps = 0; for (Ingredient ingredient : path.steps) { if (ingredient == Ingredient::Gunpowder) ++gunpowderSteps; if (ingredient == Ingredient::DragonBreath) ++dragonBreathSteps; } CHECK_EQ(gunpowderSteps, 1); CHECK_EQ(dragonBreathSteps, 1); CHECK(replayThroughSteps(path.steps) == lingering); } VF_TEST(aLingeringBottleTakesNoFurtherIngredientEvenThoughTheBareStandWouldTakeOne) { PotionItem lingering = bottleOf(PotionKind::Swiftness, false, false, PotionItem::Form::Lingering); for (int step = 1; step < static_cast(Ingredient::Count); ++step) { bool changed = false; PotionItem after = catalogBrewStep(lingering, static_cast(step), changed); CHECK(!changed); CHECK(after == lingering); } // Without the catalog guard the salvaged stand would happily push a // lingering cloud back down to a splash bottle. bool standChanged = false; PotionItem downgraded = brewResult(lingering, Ingredient::Gunpowder, standChanged); CHECK(standChanged); CHECK_EQ(downgraded.form, PotionItem::Form::Splash); } VF_TEST(catalogBrewStepMatchesTheStandForEveryIngredientItShares) { int compared = 0; for (int index = 0; index < BrewGraph::stateCount(); ++index) { PotionItem state = BrewGraph::stateAt(index); if (state.form == PotionItem::Form::Lingering) continue; for (int step = 1; step < static_cast(Ingredient::Count); ++step) { Ingredient ingredient = static_cast(step); if (ingredient == Ingredient::DragonBreath) continue; bool catalogChanged = false; bool standChanged = false; PotionItem viaCatalog = catalogBrewStep(state, ingredient, catalogChanged); PotionItem viaStand = brewResult(state, ingredient, standChanged); CHECK_EQ(catalogChanged, standChanged); CHECK(viaCatalog == viaStand); ++compared; } } CHECK(compared > 1000); } VF_TEST(dragonBreathOnlyBitesOnASplashBottle) { const PotionItem::Form forms[] = {PotionItem::Form::Drinkable, PotionItem::Form::Splash}; for (PotionItem::Form form : forms) { PotionItem bottle = bottleOf(PotionKind::Poison, false, false, form); bool changed = false; PotionItem after = catalogBrewStep(bottle, Ingredient::DragonBreath, changed); CHECK_EQ(changed, form == PotionItem::Form::Splash); if (changed) { CHECK_EQ(after.form, PotionItem::Form::Lingering); CHECK_EQ(after.kind, PotionKind::Poison); } else { CHECK(after == bottle); } } } VF_TEST(theFuelBalanceRoundsUpToWholeBlazePowderAndCountsIngredientUseSeparately) { CHECK_EQ(blazePowderForBrews(0), 0); CHECK_EQ(blazePowderForBrews(-5), 0); CHECK_EQ(blazePowderForBrews(1), 1); CHECK_EQ(blazePowderForBrews(brewsPerBlazePowder), 1); CHECK_EQ(blazePowderForBrews(brewsPerBlazePowder + 1), 2); CHECK_EQ(blazePowderForBrews(3 * brewsPerBlazePowder), 3); const BrewGraph& graph = brewGraph(); BrewPath strength = graph.shortestPath(PotionKind::Strength); CHECK_EQ(strength.stepCount(), 2); CHECK_EQ(strength.blazePowderIngredient, 1); CHECK_EQ(strength.blazePowderFuel, 1); CHECK_EQ(strength.blazePowderTotal(), 2); CHECK_EQ(strength.totalTicks, 2 * brewDurationTicks); BrewPath swiftness = graph.shortestPath(PotionKind::Swiftness); CHECK_EQ(swiftness.blazePowderIngredient, 0); CHECK_EQ(swiftness.blazePowderTotal(), 1); BrewPath water = graph.shortestPath(PotionKind::Water); CHECK(water.reachable); CHECK_EQ(water.stepCount(), 0); CHECK_EQ(water.blazePowderFuel, 0); CHECK_EQ(water.blazePowderTotal(), 0); } VF_TEST(everyPathTheGraphReportsIsGenuinelyShortestForItsTarget) { const BrewGraph& graph = brewGraph(); int relaxationFailures = 0; std::vector produced(static_cast(BrewGraph::stateCount()), false); for (int index = 0; index < BrewGraph::stateCount(); ++index) { PotionItem state = BrewGraph::stateAt(index); int depth = graph.depthOf(state); if (depth < 0) continue; for (int step = 1; step < static_cast(Ingredient::Count); ++step) { bool changed = false; PotionItem next = catalogBrewStep(state, static_cast(step), changed); if (!changed) continue; produced[static_cast(BrewGraph::stateIndex(next))] = true; int nextDepth = graph.depthOf(next); if (nextDepth < 0 || nextDepth > depth + 1) ++relaxationFailures; } } CHECK_EQ(relaxationFailures, 0); // Water is the only bottle no brew produces; everything else the graph // calls reachable has to come out of some edge, and nothing outside the // reachable set may be produced at all. int mismatches = 0; for (int index = 0; index < BrewGraph::stateCount(); ++index) { bool isReachable = graph.depthOf(BrewGraph::stateAt(index)) >= 0; bool expectProduced = isReachable && index != BrewGraph::stateIndex(PotionItem{}); if (produced[static_cast(index)] != expectProduced) ++mismatches; } CHECK_EQ(mismatches, 0); } VF_TEST(stateIndexAndStateAtAreExactInversesOverTheWholeSpace) { for (int index = 0; index < BrewGraph::stateCount(); ++index) { PotionItem state = BrewGraph::stateAt(index); CHECK_EQ(BrewGraph::stateIndex(state), index); CHECK(!(state.upgraded && state.extended)); } CHECK_EQ(BrewGraph::stateCount(), static_cast(PotionKind::Count) * 9); CHECK_EQ(BrewGraph::stateIndex(PotionItem{}), 0); } VF_TEST(theSharedCatalogAndGraphInstancesAreTheSameObjectEveryTime) { CHECK(&potionCatalog() == &potionCatalog()); CHECK(&brewGraph() == &brewGraph()); BrewGraph rebuilt(potionCatalog()); CHECK_EQ(rebuilt.reachableStateCount(), brewGraph().reachableStateCount()); for (int index = 0; index < BrewGraph::stateCount(); ++index) { CHECK_EQ(rebuilt.depthOf(BrewGraph::stateAt(index)), brewGraph().depthOf(BrewGraph::stateAt(index))); } }