#include "testHarness.h" #include "vf/Advancement.h" #include "vf/Scoreboard.h" #include using namespace vf; namespace { using Event = AdvancementCriterion::Event; using Trigger = AdvancementCriterion::Trigger; ResourceId id(const std::string& text) { ResourceId parsed; ResourceId::parse(text, "voxelforge", parsed); return parsed; } const char* const rootAdvancement = R"json({ "display": {"title": "Getting started", "description": "Chop something down"}, "criteria": { "anyWood": {"trigger": "itemObtained", "item": "voxelforge:wood"} }, "rewards": {"experience": 5} })json"; const char* const toolsAdvancement = R"json({ "parent": "voxelforge:root", "display": {"title": "A workbench of your own"}, "criteria": { "sticks": {"trigger": "itemObtained", "item": "voxelforge:stick", "count": 4}, "table": {"trigger": "itemObtained", "item": "voxelforge:crafting_table"} }, "rewards": {"experience": 10, "recipes": ["voxelforge:redstone_torch"]} })json"; const char* const hunterAdvancement = R"json({ "parent": "voxelforge:tools", "display": {"title": "Monster hunter", "hidden": true}, "criteria": { "zombies": {"trigger": "mobKilled", "species": "zombie", "count": 3}, "creeper": {"trigger": "mobKilled", "species": "creeper"} }, "requirements": [["zombies", "creeper"]], "rewards": {"experience": 20, "loot": ["voxelforge:chests/starter"]} })json"; const char* const travellerAdvancement = R"json({ "parent": "voxelforge:root", "display": {"title": "Into the fire"}, "criteria": { "portal": {"trigger": "dimensionEntered", "dimension": "nether"}, "brew": {"trigger": "potionBrewed", "potion": "healing"}, "trade": {"trigger": "villagerTraded", "profession": "librarian", "item": "voxelforge:emerald_gem"}, "mine": {"trigger": "blockBroken", "block": "voxelforge:obsidian", "tool": "voxelforge:diamond_pickaxe"} }, "requirements": [["portal"], ["brew", "trade", "mine"]] })json"; DataPack contentPack() { DataPackMeta meta; meta.name = "story"; DataPack pack(meta); pack.addFile("data/voxelforge/advancements/root.json", rootAdvancement); pack.addFile("data/voxelforge/advancements/tools.json", toolsAdvancement); pack.addFile("data/voxelforge/advancements/hunter.json", hunterAdvancement); pack.addFile("data/voxelforge/advancements/traveller.json", travellerAdvancement); return pack; } AdvancementTree loadTree(const DataPack& pack, std::vector& errors) { DataPackLoader loader; loader.addPack(pack); loader.load(); return loadAdvancements(loader, errors); } Advancement makeAdvancement(const std::string& self, const std::string& parent) { Advancement node; node.id = id(self); if (!parent.empty()) node.parent = id(parent); AdvancementCriterion criterion; criterion.name = "tick"; criterion.trigger = Trigger::ItemObtained; node.criteria.push_back(criterion); return node; } } // namespace VF_TEST(advancementsLoadOutOfAPackAndKeepTheirTreeShape) { std::vector errors; AdvancementTree tree = loadTree(contentPack(), errors); CHECK(errors.empty()); CHECK_EQ(tree.size(), static_cast(4)); const Advancement* root = tree.find(id("voxelforge:root")); CHECK(root != nullptr); CHECK(root->isRoot()); CHECK_EQ(root->title, std::string("Getting started")); CHECK_EQ(root->description, std::string("Chop something down")); CHECK_EQ(root->criteria.size(), static_cast(1)); CHECK(root->criteria[0].item == ItemId::Wood); CHECK_EQ(root->criteria[0].matchMask, AdvancementCriterion::MatchItem); CHECK_EQ(root->reward.experience, 5); const Advancement* tools = tree.find(id("voxelforge:tools")); CHECK(tools != nullptr); CHECK(!tools->isRoot()); CHECK(tools->parent == id("voxelforge:root")); CHECK_EQ(tools->criteria.size(), static_cast(2)); CHECK_EQ(tools->findCriterion("sticks")->requiredCount, 4); CHECK(tools->findCriterion("absent") == nullptr); CHECK_EQ(tools->reward.recipes.size(), static_cast(1)); CHECK(tools->reward.recipes[0] == id("voxelforge:redstone_torch")); const Advancement* traveller = tree.find(id("voxelforge:traveller")); CHECK(traveller->findCriterion("portal")->dimension == DimensionId::Nether); CHECK(traveller->findCriterion("brew")->potion == PotionKind::Healing); CHECK(traveller->findCriterion("trade")->profession == VillagerProfession::Librarian); CHECK(traveller->findCriterion("trade")->item == ItemId::EmeraldGem); CHECK(traveller->findCriterion("mine")->block == Block::Obsidian); CHECK(traveller->findCriterion("mine")->tool == ItemId::DiamondPickaxe); CHECK_EQ(traveller->requirements.size(), static_cast(2)); CHECK_EQ(tree.roots().size(), static_cast(1)); CHECK(tree.roots()[0] == id("voxelforge:root")); CHECK_EQ(tree.childrenOf(id("voxelforge:root")).size(), static_cast(2)); CHECK_EQ(tree.childrenOf(id("voxelforge:tools")).size(), static_cast(1)); CHECK_EQ(tree.depthOf(id("voxelforge:root")), 0); CHECK_EQ(tree.depthOf(id("voxelforge:tools")), 1); CHECK_EQ(tree.depthOf(id("voxelforge:hunter")), 2); CHECK_EQ(tree.depthOf(id("voxelforge:absent")), -1); CHECK(!tree.hasCycle()); CHECK(tree.missingParents().empty()); CHECK_EQ(tree.ids().size(), static_cast(4)); } VF_TEST(anAdvancementIsGrantedOnlyWhenItsLastCriterionCompletes) { std::vector errors; AdvancementTree tree = loadTree(contentPack(), errors); AdvancementProgress progress(tree); const AdvancementId tools = id("voxelforge:tools"); CHECK(!progress.granted(tools)); CHECK_EQ(progress.grantedCount(), 0); std::vector granted = progress.notify(Event::itemObtained(ItemId::Stick, 2), 100); CHECK(granted.empty()); CHECK(!progress.criterionDone(tools, "sticks")); CHECK_EQ(progress.criterionProgress(tools, "sticks"), 2); CHECK_EQ(progress.criterionTick(tools, "sticks"), -1); granted = progress.notify(Event::itemObtained(ItemId::Stick, 2), 140); CHECK(granted.empty()); CHECK(progress.criterionDone(tools, "sticks")); CHECK_EQ(progress.criterionProgress(tools, "sticks"), 4); CHECK_EQ(progress.criterionTick(tools, "sticks"), 140); CHECK(!progress.granted(tools)); // An unrelated item moves nothing. granted = progress.notify(Event::itemObtained(ItemId::Dirt, 64), 150); CHECK(granted.empty()); CHECK(!progress.granted(tools)); granted = progress.notify(Event::itemObtained(ItemId::CraftingTable), 200); CHECK_EQ(granted.size(), static_cast(1)); if (!granted.empty()) CHECK(granted[0] == tools); CHECK(progress.granted(tools)); CHECK_EQ(progress.grantedTick(tools), 200); CHECK_EQ(progress.completedCriteria(tools).size(), static_cast(2)); CHECK_EQ(progress.totalExperience(), 10); // Granting is a one shot: repeating the last event awards nothing again. granted = progress.notify(Event::itemObtained(ItemId::CraftingTable), 260); CHECK(granted.empty()); CHECK_EQ(progress.grantedTick(tools), 200); CHECK_EQ(progress.totalExperience(), 10); CHECK_EQ(progress.grantedCount(), 1); } VF_TEST(requirementGroupsAreSatisfiedByAnyOneOfTheirCriteria) { std::vector errors; AdvancementTree tree = loadTree(contentPack(), errors); const Advancement* hunter = tree.find(id("voxelforge:hunter")); CHECK(hunter != nullptr); CHECK(!hunter->satisfiedBy({})); CHECK(hunter->satisfiedBy({"creeper"})); CHECK(hunter->satisfiedBy({"zombies"})); CHECK(hunter->danglingRequirements().empty()); // Without requirement groups every criterion is needed. const Advancement* tools = tree.find(id("voxelforge:tools")); CHECK(tools->requirements.empty()); CHECK(!tools->satisfiedBy({"sticks"})); CHECK(tools->satisfiedBy({"sticks", "table"})); const Advancement* traveller = tree.find(id("voxelforge:traveller")); CHECK(!traveller->satisfiedBy({"portal"})); CHECK(!traveller->satisfiedBy({"brew", "trade", "mine"})); CHECK(traveller->satisfiedBy({"portal", "mine"})); AdvancementProgress progress(tree); std::vector granted = progress.notify(Event::dimensionEntered(DimensionId::Nether), 10); CHECK(granted.empty()); granted = progress.notify(Event::dimensionEntered(DimensionId::End), 20); CHECK(granted.empty()); granted = progress.notify(Event::villagerTraded(VillagerProfession::Farmer, ItemId::EmeraldGem), 30); CHECK(granted.empty()); granted = progress.notify(Event::villagerTraded(VillagerProfession::Librarian, ItemId::EmeraldGem), 40); CHECK_EQ(granted.size(), static_cast(1)); if (!granted.empty()) CHECK(granted[0] == id("voxelforge:traveller")); } VF_TEST(countedCriteriaNeedEveryOneOfTheirEvents) { std::vector errors; AdvancementTree tree = loadTree(contentPack(), errors); AdvancementProgress progress(tree); const AdvancementId hunter = id("voxelforge:hunter"); progress.notify(Event::mobKilled(Species::Zombie), 1); progress.notify(Event::mobKilled(Species::Skeleton), 2); CHECK_EQ(progress.criterionProgress(hunter, "zombies"), 1); progress.notify(Event::mobKilled(Species::Zombie), 3); CHECK_EQ(progress.criterionProgress(hunter, "zombies"), 2); CHECK(!progress.granted(hunter)); std::vector granted = progress.notify(Event::mobKilled(Species::Zombie), 4); CHECK_EQ(granted.size(), static_cast(1)); if (!granted.empty()) CHECK(granted[0] == hunter); CHECK(progress.criterionDone(hunter, "zombies")); CHECK(!progress.criterionDone(hunter, "creeper")); CHECK_EQ(progress.grantedTick(hunter), 4); CHECK_EQ(progress.totalExperience(), 20); CHECK_EQ(progress.awardedLootTables().size(), static_cast(1)); CHECK(progress.awardedLootTables()[0] == id("voxelforge:chests/starter")); CHECK(progress.unlockedRecipes().empty()); } VF_TEST(hiddenAdvancementsStayOutOfSightUntilTheParentIsGranted) { std::vector errors; AdvancementTree tree = loadTree(contentPack(), errors); AdvancementProgress progress(tree); const AdvancementId hunter = id("voxelforge:hunter"); const AdvancementId tools = id("voxelforge:tools"); CHECK(progress.visible(id("voxelforge:root"))); CHECK(progress.visible(tools)); CHECK(!progress.visible(hunter)); progress.notify(Event::itemObtained(ItemId::Stick, 4), 10); CHECK(!progress.visible(hunter)); progress.notify(Event::itemObtained(ItemId::CraftingTable), 20); CHECK(progress.granted(tools)); CHECK(progress.visible(hunter)); // A hidden advancement is also its own reveal once it is earned. AdvancementTree lone; Advancement secret = makeAdvancement("voxelforge:secret", ""); secret.hidden = true; secret.criteria[0].matchMask = AdvancementCriterion::MatchItem; secret.criteria[0].item = ItemId::DiamondGem; CHECK(lone.add(secret)); AdvancementProgress hidden(lone); CHECK(!hidden.visible(secret.id)); hidden.notify(Event::itemObtained(ItemId::DiamondGem), 5); CHECK(hidden.granted(secret.id)); CHECK(hidden.visible(secret.id)); CHECK(!hidden.visible(id("voxelforge:nobody"))); } VF_TEST(aParentLoopIsReportedInsteadOfWalkedForever) { AdvancementTree tree; CHECK(tree.add(makeAdvancement("voxelforge:first", "voxelforge:third"))); CHECK(tree.add(makeAdvancement("voxelforge:second", "voxelforge:first"))); CHECK(tree.add(makeAdvancement("voxelforge:third", "voxelforge:second"))); CHECK(tree.add(makeAdvancement("voxelforge:selfish", "voxelforge:selfish"))); CHECK(tree.add(makeAdvancement("voxelforge:orphan", "voxelforge:nowhere"))); CHECK(tree.hasCycle()); CHECK(tree.cycleFrom(id("voxelforge:first"))); CHECK(tree.cycleFrom(id("voxelforge:second"))); CHECK(tree.cycleFrom(id("voxelforge:selfish"))); CHECK(!tree.cycleFrom(id("voxelforge:orphan"))); CHECK_EQ(tree.depthOf(id("voxelforge:first")), -1); CHECK_EQ(tree.depthOf(id("voxelforge:selfish")), -1); CHECK_EQ(tree.depthOf(id("voxelforge:orphan")), -1); CHECK_EQ(tree.missingParents().size(), static_cast(1)); CHECK(tree.missingParents()[0] == id("voxelforge:orphan")); CHECK(tree.roots().empty()); // A looping node still answers questions instead of hanging the caller. AdvancementProgress progress(tree); CHECK(progress.visible(id("voxelforge:first"))); std::vector granted = progress.notify(Event::itemObtained(ItemId::Dirt), 1); CHECK_EQ(granted.size(), static_cast(5)); CHECK(progress.granted(id("voxelforge:first"))); // Adding the same id twice is refused, so a duplicate cannot rewrite a node. CHECK(!tree.add(makeAdvancement("voxelforge:first", "voxelforge:third"))); Advancement nameless; CHECK(!tree.add(nameless)); CHECK_EQ(tree.size(), static_cast(5)); tree.clear(); CHECK_EQ(tree.size(), static_cast(0)); } VF_TEST(criteriaOnlyAcceptTheEventsTheyDescribe) { AdvancementCriterion anyKill; anyKill.trigger = Trigger::MobKilled; CHECK(anyKill.accepts(Event::mobKilled(Species::Pig))); CHECK(anyKill.accepts(Event::mobKilled(Species::Creeper))); CHECK(!anyKill.accepts(Event::itemObtained(ItemId::Stick))); AdvancementCriterion creepersOnly = anyKill; creepersOnly.matchMask = AdvancementCriterion::MatchSpecies; creepersOnly.species = Species::Creeper; CHECK(creepersOnly.accepts(Event::mobKilled(Species::Creeper))); CHECK(!creepersOnly.accepts(Event::mobKilled(Species::Pig))); AdvancementCriterion minedWithIron; minedWithIron.trigger = Trigger::BlockBroken; minedWithIron.matchMask = AdvancementCriterion::MatchBlock | AdvancementCriterion::MatchTool; minedWithIron.block = Block::DiamondOre; minedWithIron.tool = ItemId::IronPickaxe; CHECK(minedWithIron.accepts(Event::blockBroken(Block::DiamondOre, ItemId::IronPickaxe))); CHECK(!minedWithIron.accepts(Event::blockBroken(Block::DiamondOre, ItemId::StonePickaxe))); CHECK(!minedWithIron.accepts(Event::blockBroken(Block::CoalOre, ItemId::IronPickaxe))); AdvancementCriterion brewed; brewed.trigger = Trigger::PotionBrewed; brewed.matchMask = AdvancementCriterion::MatchPotion; brewed.potion = PotionKind::Swiftness; CHECK(brewed.accepts(Event::potionBrewed(PotionKind::Swiftness))); CHECK(!brewed.accepts(Event::potionBrewed(PotionKind::Healing))); CHECK(!brewed.accepts(Event::dimensionEntered(DimensionId::End))); for (int i = 0; i < static_cast(Trigger::Count); ++i) { Trigger trigger = static_cast(i); Trigger reparsed = Trigger::Count; CHECK(parseAdvancementTriggerName(advancementTriggerName(trigger), reparsed)); CHECK(reparsed == trigger); } Trigger unknown = Trigger::Count; CHECK(!parseAdvancementTriggerName("telekinesis", unknown)); } VF_TEST(oneUnreadableAdvancementDoesNotCostTheRest) { DataPack pack = contentPack(); pack.addFile("data/voxelforge/advancements/broken.json", R"json({"criteria": {"x": {"trigger": "telekinesis"}}})json"); pack.addFile("data/voxelforge/advancements/empty.json", R"json({"criteria": {}})json"); pack.addFile("data/voxelforge/advancements/ghost.json", R"json({"criteria": {"x": {"trigger": "itemObtained", "item": "voxelforge:cheese"}}})json"); pack.addFile("data/voxelforge/advancements/mismatch.json", R"json({"criteria": {"x": {"trigger": "itemObtained"}}, "requirements": [["y"]]})json"); std::vector errors; AdvancementTree tree = loadTree(pack, errors); CHECK_EQ(errors.size(), static_cast(4)); CHECK_EQ(tree.size(), static_cast(4)); CHECK(tree.find(id("voxelforge:root")) != nullptr); CHECK(tree.find(id("voxelforge:broken")) == nullptr); for (const DataPackError& failure : errors) { CHECK(failure.file.find("data/voxelforge/advancements/") == 0u); CHECK(!failure.reason.empty()); } } VF_TEST(advancementDocumentsAreCheckedFieldByField) { Advancement parsed; std::string problem; const char* const rejected[] = { R"json([])json", R"json({})json", R"json({"criteria": {}})json", R"json({"criteria": {"x": {}}})json", R"json({"criteria": {"x": {"trigger": "itemObtained", "count": 0}}})json", R"json({"criteria": {"x": {"trigger": "itemObtained", "item": "NOT AN ID"}}})json", R"json({"criteria": {"x": {"trigger": "mobKilled", "species": "dragon"}}})json", R"json({"criteria": {"x": {"trigger": "dimensionEntered", "dimension": "attic"}}})json", R"json({"criteria": {"x": {"trigger": "potionBrewed", "potion": "coffee"}}})json", R"json({"criteria": {"x": {"trigger": "villagerTraded", "profession": "banker"}}})json", R"json({"criteria": {"x": {"trigger": "blockBroken", "block": "voxelforge:cheese"}}})json", R"json({"parent": "BAD ID", "criteria": {"x": {"trigger": "itemObtained"}}})json", R"json({"criteria": {"x": {"trigger": "itemObtained"}}, "requirements": [[]]})json", R"json({"criteria": {"x": {"trigger": "itemObtained"}}, "rewards": {"experience": -1}})json", R"json({"criteria": {"x": {"trigger": "itemObtained"}}, "rewards": {"recipes": ["NOT AN ID"]}})json", R"json({"criteria": {"x": {"trigger": "itemObtained"}}, "display": "a string"})json", }; for (const char* text : rejected) { JsonParseResult document = parseJson(text); CHECK(document.ok); CHECK(!parseAdvancementJson(document.root, "voxelforge", parsed, problem)); CHECK(!problem.empty()); } JsonParseResult minimal = parseJson(R"json({"criteria": {"x": {"trigger": "itemObtained"}}})json"); CHECK(parseAdvancementJson(minimal.root, "voxelforge", parsed, problem)); CHECK(parsed.isRoot()); CHECK(!parsed.hidden); CHECK_EQ(parsed.criteria.size(), static_cast(1)); CHECK_EQ(parsed.criteria[0].matchMask, 0u); CHECK_EQ(parsed.criteria[0].requiredCount, 1); CHECK(parsed.reward.empty()); // A bare namespace default applies to every id inside the document. JsonParseResult defaulted = parseJson( R"json({"parent": "root", "criteria": {"x": {"trigger": "itemObtained", "item": "stick"}}, "rewards": {"recipes": ["torch"]}})json"); CHECK(parseAdvancementJson(defaulted.root, "extras", parsed, problem)); CHECK(parsed.parent == ResourceId("extras", "root")); CHECK(parsed.criteria[0].item == ItemId::Stick); CHECK(parsed.reward.recipes[0] == ResourceId("extras", "torch")); } VF_TEST(oneMiningRunFeedsBothTheAdvancementTreeAndTheStatSheet) { DataPackMeta meta; meta.name = "mining"; DataPack pack(meta); pack.addFile("data/voxelforge/advancements/quarry.json", R"json({"display": {"title": "Quarry"}, "criteria": {"stone": {"trigger": "blockBroken", "block": "voxelforge:stone", "count": 5}}, "rewards": {"experience": 15}})json"); std::vector errors; AdvancementTree tree = loadTree(pack, errors); CHECK(errors.empty()); AdvancementProgress progress(tree); Scoreboard board; ScoreObjective mined; mined.name = "mined"; mined.criterion = ScoreObjective::Criterion::Statistic; mined.statKind = StatKind::BlocksBroken; mined.statSubject = static_cast(Block::Stone); CHECK(board.addObjective(mined)); const AdvancementId quarry = id("voxelforge:quarry"); for (int strike = 1; strike <= 4; ++strike) { progress.notify(Event::blockBroken(Block::Stone, ItemId::IronPickaxe), strike * 10); board.stats("foksiu").addBlockBroken(Block::Stone); CHECK_EQ(board.score("mined", "foksiu"), strike); CHECK(!progress.granted(quarry)); } std::vector granted = progress.notify(Event::blockBroken(Block::Stone, ItemId::IronPickaxe), 50); board.stats("foksiu").addBlockBroken(Block::Stone); CHECK_EQ(granted.size(), static_cast(1)); CHECK(progress.granted(quarry)); CHECK_EQ(progress.totalExperience(), 15); CHECK_EQ(board.score("mined", "foksiu"), 5); CHECK_EQ(board.stats("foksiu").blocksBroken(Block::Stone), 5); // Breaking something else moves the sheet but not this advancement. board.stats("foksiu").addBlockBroken(Block::Dirt, 3); CHECK_EQ(board.score("mined", "foksiu"), 5); CHECK_EQ(board.stats("foksiu").total(StatKind::BlocksBroken), 8); }