#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "rpg/Quest.h" #include "rpg/Tracker.h" #include "rpg/RPG.h" using namespace eve::rpg; TEST_CASE("rpg.quest.registryCRUD") { QuestRegistry::clear(); QuestDefinition def; def.id = "quest.intro"; def.tags = {"main"}; def.extra["title"] = "Intro"; QuestRegistry::registerQuest(def); CHECK_EQ(QuestRegistry::count(), 1); const QuestDefinition *found = QuestRegistry::find("quest.intro"); REQUIRE(found != nullptr); CHECK(found->hasTag("main")); CHECK_EQ(found->getExtra("title", ""), std::string("Intro")); CHECK_EQ(found->startPolicy, std::string("manual")); CHECK_EQ(found->completePolicy, std::string("auto")); CHECK(QuestRegistry::remove("quest.intro")); CHECK_EQ(QuestRegistry::count(), 0); QuestRegistry::clear(); } TEST_CASE("rpg.quest.loadFromJsonDefaultsAndRewards") { QuestRegistry::clear(); int n = QuestRegistry::loadFromJson(R"([ { "id": "quest.hunt", "startPolicy": "manual", "completePolicy": "claim", "requires": ["quest.intro"], "tags": ["main"], "objectives": [ {"id": "kill_slime", "topic": "kill", "target": "slime", "count": 10}, {"id": "any_kill", "topic": "kill", "count": 0} ], "rewards": [ {"type": "item", "id": "potion", "amount": 3}, {"type": "attribute", "id": "xp", "amount": 50} ], "extra": {"title": "Hunt"} } ])"); CHECK_EQ(n, 1); const QuestDefinition *d = QuestRegistry::find("quest.hunt"); REQUIRE(d != nullptr); CHECK_EQ(d->startPolicy, std::string("manual")); CHECK_EQ(d->completePolicy, std::string("claim")); REQUIRE(d->requiresIds.size() == 1); CHECK_EQ(d->requiresIds[0], std::string("quest.intro")); REQUIRE(d->objectives.size() == 2); CHECK_EQ(d->objectives[1].count, 1); // count<=0 → 1 CHECK(d->objectives[1].target.empty()); REQUIRE(d->rewards.size() == 2); CHECK_EQ(d->rewards[0].type, std::string("item")); CHECK_EQ(d->rewards[1].amount, 50.0); CHECK_EQ(d->getExtra("title", ""), std::string("Hunt")); QuestRegistry::clear(); } TEST_CASE("rpg.quest.loadFromJsonRejectsBadAndCycles") { QuestRegistry::clear(); int n = QuestRegistry::loadFromJson(R"([ {"startPolicy": "auto"}, { "id": "dup.obj", "objectives": [ {"id": "a", "topic": "kill", "count": 1}, {"id": "a", "topic": "talk", "count": 1} ] }, {"id": "cyc.a", "requires": ["cyc.b"]}, {"id": "cyc.b", "requires": ["cyc.a"]}, {"id": "ok.solo", "startPolicy": "weird", "completePolicy": "nope"} ])"); CHECK_EQ(n, 1); CHECK(QuestRegistry::find("dup.obj") == nullptr); CHECK(QuestRegistry::find("cyc.a") == nullptr); CHECK(QuestRegistry::find("cyc.b") == nullptr); const QuestDefinition *ok = QuestRegistry::find("ok.solo"); REQUIRE(ok != nullptr); CHECK_EQ(ok->startPolicy, std::string("manual")); CHECK_EQ(ok->completePolicy, std::string("auto")); QuestRegistry::clear(); } TEST_CASE("rpg.quest.trackerInitialStatesAndSyncAuto") { QuestRegistry::clear(); QuestRegistry::loadFromJson(R"([ {"id":"q.intro","startPolicy":"manual"}, {"id":"q.auto","startPolicy":"auto","objectives":[{"id":"o","topic":"kill","target":"x","count":1}]}, {"id":"q.locked","startPolicy":"auto","requires":["q.missing"]}, {"id":"q.chain","startPolicy":"manual","requires":["q.intro"]} ])"); Tracker log; CHECK_EQ(log.getCount(), 4); CHECK_EQ(log.getState("q.intro"), std::string("inactive")); CHECK_EQ(log.getState("q.auto"), std::string("active")); CHECK_EQ(log.getState("q.locked"), std::string("locked")); CHECK_EQ(log.getState("q.chain"), std::string("locked")); CHECK_EQ(log.getState("nope"), std::string("")); QuestRegistry::loadFromJson(R"([{"id":"q.late","startPolicy":"auto","objectives":[{"id":"o","topic":"kill","target":"x","count":1}]}])"); CHECK_EQ(log.getCount(), 4); log.syncAuto(); CHECK_EQ(log.getCount(), 5); CHECK_EQ(log.getState("q.late"), std::string("active")); CHECK(log.hasTag("q.intro", "x") == false); QuestRegistry::clear(); } TEST_CASE("rpg.quest.activateAndNotify") { QuestRegistry::clear(); QuestRegistry::loadFromJson(R"([ {"id":"q.lock","startPolicy":"manual","requires":["nope"]}, {"id":"q.hunt","startPolicy":"manual","completePolicy":"auto", "objectives":[ {"id":"k","topic":"kill","target":"slime","count":2}, {"id":"any","topic":"kill","count":3} ]}, {"id":"q.flag","startPolicy":"manual","completePolicy":"auto","objectives":[]} ])"); Tracker log; CHECK_EQ(log.canActivateReason("q.lock"), std::string("locked")); CHECK(!log.activate("q.lock")); CHECK_EQ(log.getState("q.lock"), std::string("locked")); CHECK(log.canActivate("q.hunt")); CHECK(log.activate("q.hunt")); CHECK_EQ(log.getState("q.hunt"), std::string("active")); CHECK_EQ(log.canActivateReason("q.hunt"), std::string("alreadyActive")); log.notify("talk", "npc", 1); CHECK_EQ(log.getObjectiveCurrent("q.hunt", 0), 0); log.notify("kill", "slime", 1); CHECK_EQ(log.getObjectiveCurrent("q.hunt", 0), 1); CHECK_EQ(log.getObjectiveCurrent("q.hunt", 1), 1); // target 空,吃所有 kill log.notify("kill", "goblin", 5); CHECK_EQ(log.getObjectiveCurrent("q.hunt", 0), 1); // slime 不涨 CHECK_EQ(log.getObjectiveCurrent("q.hunt", 1), 3); // 封顶 3 CHECK(log.isObjectiveDone("q.hunt", 1)); log.notify("kill", "slime", 0); CHECK_EQ(log.getObjectiveCurrent("q.hunt", 0), 1); log.notify("kill", "slime", 1); CHECK(log.isObjectiveDone("q.hunt", 0)); CHECK_EQ(log.getState("q.hunt"), std::string("completed")); CHECK(log.activate("q.flag")); CHECK_EQ(log.getState("q.flag"), std::string("completed")); QuestRegistry::clear(); } TEST_CASE("rpg.quest.claimEventsAndChain") { QuestRegistry::clear(); QuestRegistry::loadFromJson(R"([ {"id":"a","startPolicy":"manual","completePolicy":"claim", "objectives":[{"id":"k","topic":"kill","target":"slime","count":1}]}, {"id":"b","startPolicy":"auto","completePolicy":"auto","requires":["a"], "objectives":[{"id":"t","topic":"talk","target":"npc","count":1}]} ])"); Tracker log; CHECK_EQ(log.getState("b"), std::string("locked")); CHECK(log.activate("a")); log.notify("kill", "slime", 1); CHECK_EQ(log.getState("a"), std::string("ready")); CHECK(!log.claim("nope")); CHECK(!log.claim("b")); CHECK(log.claim("a")); CHECK_EQ(log.getState("a"), std::string("completed")); CHECK_EQ(log.getState("b"), std::string("active")); log.pollEvents(); bool sawProgress = false, sawReady = false, sawComplete = false, sawActivateB = false; for (int i = 0; i < log.getEventCount(); ++i) { auto act = log.getEventAction(i); auto id = log.getEventEntryId(i); if (act == "progress" && id == "a") sawProgress = true; if (act == "ready" && id == "a") sawReady = true; if (act == "complete" && id == "a") sawComplete = true; if (act == "activate" && id == "b") sawActivateB = true; } CHECK(sawProgress); CHECK(sawReady); CHECK(sawComplete); CHECK(sawActivateB); Tracker ach; QuestRegistry::loadFromJson(R"([ {"id":"ach.kill","startPolicy":"auto","completePolicy":"auto","tags":["achievement"], "objectives":[{"id":"k","topic":"kill","target":"slime","count":1}]} ])"); ach.syncAuto(); log.notify("kill", "slime", 1); ach.notify("kill", "slime", 1); CHECK_EQ(log.getState("ach.kill"), std::string("")); // log 未 sync 这条也可;隔离重点是事件 CHECK_EQ(ach.getState("ach.kill"), std::string("completed")); log.pollEvents(); ach.pollEvents(); bool logCompletedAch = false; for (int i = 0; i < log.getEventCount(); ++i) if (log.getEventEntryId(i) == "ach.kill") logCompletedAch = true; CHECK(!logCompletedAch); CHECK(ach.getEventCount() >= 1); QuestRegistry::clear(); } TEST_CASE("rpg.quest.resetAbandonFailNoCascade") { QuestRegistry::clear(); QuestRegistry::loadFromJson(R"([ {"id":"daily","startPolicy":"auto","completePolicy":"auto", "objectives":[{"id":"k","topic":"kill","target":"slime","count":1}]}, {"id":"main","startPolicy":"manual","completePolicy":"auto","requires":["daily"], "objectives":[{"id":"t","topic":"talk","target":"npc","count":1}]} ])"); Tracker log; log.notify("kill", "slime", 1); CHECK_EQ(log.getState("daily"), std::string("completed")); CHECK(log.activate("main")); CHECK_EQ(log.getState("main"), std::string("active")); CHECK(log.reset("daily")); CHECK_EQ(log.getState("daily"), std::string("active")); CHECK_EQ(log.getObjectiveCurrent("daily", 0), 0); CHECK_EQ(log.getState("main"), std::string("active")); // 不级联 CHECK(log.abandon("main")); CHECK_EQ(log.getState("main"), std::string("failed")); log.notify("talk", "npc", 1); CHECK_EQ(log.getObjectiveCurrent("main", 0), 0); CHECK(!log.activate("main")); CHECK_EQ(log.canActivateReason("main"), std::string("failed")); CHECK(log.reset("main")); // daily 已 reset 且未完成,main 的 requires 不满足 → locked CHECK_EQ(log.getState("main"), std::string("locked")); Tracker t2; QuestRegistry::loadFromJson(R"([{"id":"only","startPolicy":"manual"}])"); t2.syncAuto(); CHECK(t2.fail("only", "timeout")); CHECK_EQ(t2.getState("only"), std::string("failed")); t2.pollEvents(); bool sawTimeout = false; for (int i = 0; i < t2.getEventCount(); ++i) if (t2.getEventAction(i) == "fail" && t2.getEventReason(i) == "timeout") sawTimeout = true; CHECK(sawTimeout); QuestRegistry::clear(); } TEST_CASE("rpg.quest.facadeManualClaim") { auto *rpg = RPG::create(); rpg->clearQuestDefinitions(); int n = rpg->registerQuestsFromJson(R"([ {"id":"quest.hunt","startPolicy":"manual","completePolicy":"claim", "objectives":[{"id":"k","topic":"kill","target":"slime","count":1}], "rewards":[{"type":"item","id":"potion","amount":3}]} ])"); CHECK_EQ(n, 1); CHECK_EQ(rpg->getQuestDefinitionCount(), 1); Tracker *log = rpg->newTracker(); REQUIRE(log != nullptr); CHECK(log->activate("quest.hunt")); log->notify("kill", "slime", 1); CHECK_EQ(log->getState("quest.hunt"), std::string("ready")); CHECK(log->claim("quest.hunt")); CHECK_EQ(log->getState("quest.hunt"), std::string("completed")); CHECK_EQ(log->getRewardCount("quest.hunt"), 1); CHECK_EQ(log->getRewardId("quest.hunt", 0), std::string("potion")); log->pollEvents(); CHECK(log->getEventCount() >= 1); rpg->clearQuestDefinitions(); } TEST_CASE("rpg.quest.versionedSnapshotRoundTripAndRegistryMismatchRollback") { QuestRegistry::clear(); REQUIRE_EQ(QuestRegistry::loadFromJson(R"([ {"id":"quest.hunt","startPolicy":"auto","completePolicy":"claim", "objectives":[{"id":"kill","topic":"kill","target":"slime","count":3}]} ])"), 1); Tracker source; source.notify("kill", "slime", 2); source.pollEvents(); auto encoded = source.snapshotJson(); REQUIRE(encoded.ok()); Tracker restored; restored.notify("kill", "slime", 1); REQUIRE(restored.restoreSnapshotJson(encoded.value()).ok()); CHECK_EQ(restored.getState("quest.hunt"), std::string("active")); CHECK_EQ(restored.getObjectiveCurrent("quest.hunt", 0), 2); restored.pollEvents(); CHECK_EQ(restored.getEventCount(), 0); const auto before = restored.snapshotJson(); REQUIRE(before.ok()); auto malformed = restored.restoreSnapshotJson( R"({"schema":"eve.rpg.quest-tracker","version":1,"entries":[{"id":"quest.hunt","state":"completed","objectives":[{"id":"kill","current":1}]}]})"); CHECK(!malformed.ok()); auto afterMalformed = restored.snapshotJson(); REQUIRE(afterMalformed.ok()); CHECK_EQ(afterMalformed.value(), before.value()); QuestRegistry::clear(); REQUIRE_EQ(QuestRegistry::loadFromJson(R"([ {"id":"quest.hunt","startPolicy":"auto","completePolicy":"claim", "objectives":[{"id":"kill-v2","topic":"kill","target":"slime","count":3}]} ])"), 1); auto mismatch = restored.restoreSnapshotJson(encoded.value()); CHECK(!mismatch.ok()); CHECK_EQ(restored.getObjectiveCurrent("quest.hunt", 0), 2); QuestRegistry::clear(); } TEST_CASE("rpg.quest.strictReplacementCommitsCompleteCatalogueAtomically") { QuestRegistry::clear(); REQUIRE_EQ(QuestRegistry::loadFromJson(R"([{"id":"old.quest"}])"), 1); auto replaced = QuestRegistry::replaceFromJsonStrict(R"([ {"id":"quest.intro","startPolicy":"manual","completePolicy":"auto", "objectives":[{"id":"talk","topic":"talk","target":"elder","count":1}]}, {"id":"quest.hunt","startPolicy":"auto","completePolicy":"claim","requires":["quest.intro"], "objectives":[{"id":"kill","topic":"kill","target":"slime","count":3}], "rewards":[{"type":"item","id":"potion","amount":2}], "tags":["main"],"extra":{"title":"Hunt"}} ])"); REQUIRE(replaced.ok()); CHECK_EQ(replaced.value(), 2); CHECK(QuestRegistry::find("old.quest") == nullptr); REQUIRE(QuestRegistry::find("quest.hunt") != nullptr); CHECK_EQ(QuestRegistry::find("quest.hunt")->getExtra("title"), std::string("Hunt")); QuestRegistry::clear(); } TEST_CASE("rpg.quest.strictReplacementRejectsInvalidBatchWithoutMutation") { QuestRegistry::clear(); auto initial = QuestRegistry::replaceFromJsonStrict(R"([{"id":"quest.stable"}])"); REQUIRE(initial.ok()); auto invalidPolicy = QuestRegistry::replaceFromJsonStrict( R"([{"id":"quest.new","startPolicy":"sometimes"}])"); CHECK(!invalidPolicy.ok()); CHECK(QuestRegistry::find("quest.stable") != nullptr); CHECK(QuestRegistry::find("quest.new") == nullptr); auto missingDependency = QuestRegistry::replaceFromJsonStrict( R"([{"id":"quest.new","requires":["quest.missing"]}])"); CHECK(!missingDependency.ok()); CHECK(QuestRegistry::find("quest.stable") != nullptr); auto partialBatch = QuestRegistry::replaceFromJsonStrict( R"([{"id":"quest.good"},{"id":"quest.bad","objectives":[{"id":"o","topic":"kill","count":0}]}])"); CHECK(!partialBatch.ok()); CHECK(QuestRegistry::find("quest.good") == nullptr); CHECK(QuestRegistry::find("quest.stable") != nullptr); QuestRegistry::clear(); }