diff --git a/dGame/dComponents/Character/MissionComponent.cpp b/dGame/dComponents/Character/MissionComponent.cpp index a2810f330..82b17a6f7 100644 --- a/dGame/dComponents/Character/MissionComponent.cpp +++ b/dGame/dComponents/Character/MissionComponent.cpp @@ -76,24 +76,7 @@ eMissionState MissionComponent::GetMissionState(const uint32_t missionId) const int32_t MissionComponent::GetClientMissionState(const uint32_t missionId) const { - // A mission ID below 1 is no mission - if (missionId == 0 || missionId > static_cast(std::numeric_limits::max())) return 0; - - const auto* const mission = GetMission(missionId); - const bool done = mission && (mission->GetCompletions() > 0 || mission->IsComplete()); - int32_t state = done ? static_cast(eMissionState::COMPLETE) : 0; - - // In progress: its own state - if (mission && !mission->IsComplete()) return state | static_cast(mission->GetMissionState()); - - CDMissions info{}; - if (!Mission::IsValidMission(missionId, info)) return 0; - - if ((!done || info.repeatable) && MissionPrerequisites::CheckPrerequisites(missionId, m_Missions)) { - state |= static_cast(eMissionState::AVAILABLE); - } - - return state; + return MissionPrerequisites::GetClientMissionState(missionId, m_Missions); } bool MissionComponent::IsCooldownRunning(const int64_t completedAt, const int64_t cooldownMinutes, const int64_t now) { diff --git a/dGame/dMission/MissionPrerequisites.cpp b/dGame/dMission/MissionPrerequisites.cpp index b5b811604..fe14dd76f 100644 --- a/dGame/dMission/MissionPrerequisites.cpp +++ b/dGame/dMission/MissionPrerequisites.cpp @@ -1,134 +1,172 @@ #include "MissionPrerequisites.h" -#include +#include #include +#include #include "CDClientManager.h" -#include "Logger.h" +#include "CDMissionsTable.h" +#include "MissionComponent.h" +#include "eMissionState.h" +namespace { + // The token classes of the client's expression reader (sub_00a32720) + enum class eToken { End, Open, Close, And, Or, Xor, Not, Word, Space, None }; -PrerequisiteExpression::PrerequisiteExpression(const std::string& str) { - std::stringstream a; - std::stringstream b; - std::stringstream s; - - auto bor = false; - - auto sub = false; - - auto done = false; - - for (auto i = 0u; i < str.size(); ++i) { - if (done) { - break; + eToken Classify(const std::string_view text, const size_t pos) { + if (pos >= text.size()) return eToken::End; + switch (text[pos]) { + case '\0': return eToken::End; + case '\t': case '\n': case '\r': case ' ': return eToken::Space; + case '!': case '~': return eToken::Not; + case '&': case ',': case ';': return eToken::And; + case '(': return eToken::Open; + case ')': return eToken::Close; + case '^': return eToken::Xor; + case '|': return eToken::Or; + default: return eToken::Word; } + } - const auto character = str[i]; + // The next token from pos (sub_00a328d0), skipping spaces; a word is read whole + eToken NextToken(const std::string_view text, size_t& pos, std::string_view& word) { + auto token = eToken::Space; + while (token == eToken::Space) token = Classify(text, pos++); + if (token != eToken::Word) return token; - switch (character) { - case '|': - bor = true; - b << str.substr(i + 1); - done = true; - break; - case ' ': - case ')': - break; - case ',': - case '&': - case '(': - b << str.substr(i + 1); - done = true; - break; - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - if (sub) { - s << character; - } else { - a << character; + const auto start = pos - 1; + while (Classify(text, pos) == eToken::Word) pos++; + word = text.substr(start, pos - start); + return token; + } + + // Moves past the ')' closing the group just opened (sub_00a32840); false when the text ends first + bool SkipGroup(const std::string_view text, size_t& pos) { + int32_t depth = 1; + while (depth > 0) { + const auto token = Classify(text, pos++); + if (token == eToken::End) return false; + if (token == eToken::Open) depth++; + else if (token == eToken::Close) depth--; + } + return true; + } + + // sub_00a32980: one level, up to its ')' or the end of the text + bool Evaluate(const std::string_view text, size_t& pos, const std::function& isMet) { + bool result = false; + auto op = eToken::Space; // the first operand sets the result + bool positive = true; + bool seenOperand = false; + + const auto apply = [&](const std::function& value) { + switch (op) { + case eToken::Or: if (!result) result = value(); break; + case eToken::And: if (result) result = value(); break; + case eToken::Xor: result = result ^ value(); break; + case eToken::Space: result = value(); break; + default: break; // no operator: ignored } - break; - case ':': - sub = true; - break; - default: - break; - } - } + }; - this->m_or = bor; - - const auto aString = a.str(); - - if (!aString.empty()) { - this->a = std::stoul(a.str()); - } else { - this->a = 0; - } - - const auto subString = s.str(); - - if (!subString.empty()) { - this->sub = std::stoul(s.str()); - } else { - this->sub = 0; - } - - const auto bString = b.str(); - - if (!bString.empty()) { - this->b = new PrerequisiteExpression(bString); - } else { - this->b = nullptr; - } -} - - -bool PrerequisiteExpression::Execute(const std::unordered_map& missions) const { - auto a = this->a == 0; - - auto b = this->b == nullptr; - - if (!a) { - const auto index = missions.find(this->a); - - if (index != missions.end()) { - const auto* mission = index->second; - - if (this->sub != 0) { - // Special case for one Wisp Lee repeatable mission. - a = mission->GetMissionState() == static_cast(this->sub); - } else if (mission->IsComplete()) { - a = true; + while (true) { + std::string_view word; + const auto token = NextToken(text, pos, word); + switch (token) { + case eToken::End: + case eToken::Close: + return result; + case eToken::Open: { + auto inner = pos; + apply([&] { return Evaluate(text, inner, isMet) == positive; }); + seenOperand = true; + positive = true; + op = eToken::None; + if (!SkipGroup(text, pos)) return result; + break; + } + case eToken::And: + case eToken::Or: + case eToken::Xor: + if (seenOperand) op = token; + break; + case eToken::Not: + positive = !positive; + break; + case eToken::Word: + apply([&] { return isMet(word) == positive; }); + seenOperand = true; + positive = true; + op = eToken::None; + break; + default: + break; } } } - if (!b) { - b = this->b->Execute(missions); + // wcstol as the client reads an ID: leading digits, 0 when there are none + int32_t ReadInt(const std::string_view text) { + const std::string copy(text); + const auto value = std::strtol(copy.c_str(), nullptr, 10); + if (value > std::numeric_limits::max()) return std::numeric_limits::max(); + if (value < std::numeric_limits::min()) return std::numeric_limits::min(); + return static_cast(value); } - if (this->m_or) { - return a || b; + // LWOMissionComponent::IsMissionDone: the mission is in the completed list + bool EverCompleted(const int32_t missionId, const std::unordered_map& missions) { + if (missionId <= 0) return false; + const auto it = missions.find(static_cast(missionId)); + return it != missions.end() && it->second && (it->second->GetCompletions() > 0 || it->second->IsComplete()); + } +} + +bool MissionPrerequisites::EvaluateExpression(const std::string_view expression, const std::function& isMet) { + size_t pos = 0; + return Evaluate(expression, pos, isMet); +} + +bool MissionPrerequisites::IsOperandMet(const std::string_view operand, const uint32_t missionId, const std::unordered_map& missions) { + constexpr auto COMPLETE = static_cast(eMissionState::COMPLETE); + + const auto colon = operand.find(':'); + const auto id = ReadInt(operand.substr(0, colon)); + const auto wanted = colon == std::string_view::npos ? COMPLETE : ReadInt(operand.substr(colon + 1)); + + int32_t state = 0; + if (wanted == COMPLETE) { + state = EverCompleted(id, missions) ? COMPLETE : 0; + } else if (id == static_cast(missionId)) { + state = static_cast(eMissionState::REWARDING); // the client logs a mission naming itself + } else if (id > 0) { + state = GetClientMissionState(static_cast(id), missions); + } + return state == wanted; +} + +int32_t MissionPrerequisites::GetClientMissionState(const uint32_t missionId, const std::unordered_map& missions) { + // A mission ID below 1 is no mission + if (missionId == 0 || missionId > static_cast(std::numeric_limits::max())) return 0; + + const auto it = missions.find(missionId); + const auto* const mission = it != missions.end() ? it->second : nullptr; + const bool done = mission && (mission->GetCompletions() > 0 || mission->IsComplete()); + int32_t state = done ? static_cast(eMissionState::COMPLETE) : 0; + + // In progress: its own state + if (mission && !mission->IsComplete()) return state | static_cast(mission->GetMissionState()); + + CDMissions info{}; + if (!Mission::IsValidMission(missionId, info)) return 0; + + if ((!done || info.repeatable) && CheckPrerequisites(missionId, missions)) { + state |= static_cast(eMissionState::AVAILABLE); } - return a && b; + return state; } - -PrerequisiteExpression::~PrerequisiteExpression() { - delete b; -} - - bool MissionPrerequisites::CanAccept(const uint32_t missionId, const std::unordered_map& missions) { const auto& missionIndex = missions.find(missionId); @@ -155,24 +193,25 @@ bool MissionPrerequisites::CanAccept(const uint32_t missionId, const std::unorde return CheckPrerequisites(missionId, missions); } -bool MissionPrerequisites::CheckPrerequisites(uint32_t missionId, const std::unordered_map& missions) { - const auto& index = expressions.find(missionId); - if (index != expressions.end()) { - return index->second->Execute(missions); +bool MissionPrerequisites::CheckPrerequisites(const uint32_t missionId, const std::unordered_map& missions) { + auto index = expressions.find(missionId); + if (index == expressions.end()) { + CDMissions info{}; + if (!Mission::IsValidMission(missionId, info)) return false; + index = expressions.insert_or_assign(missionId, info.prereqMissionID).first; } - auto* missionsTable = CDClientManager::GetTable(); - const auto missionEntries = missionsTable->Query([=](const CDMissions& entry) { - return entry.id == static_cast(missionId); - }); + const std::string_view text = index->second; + if (text.empty()) return true; - if (missionEntries.empty()) - return false; + // Without any of the client's operator characters (L"!&^;,|():") the text is one mission ID (wcstol), met when 0 + // or completed + if (text.find_first_of("!&^;,|():") == std::string_view::npos) { + const auto id = ReadInt(text); + return id == 0 || EverCompleted(id, missions); + } - auto* expression = new PrerequisiteExpression(missionEntries[0].prereqMissionID); - expressions.insert_or_assign(missionId, expression); - - return expression->Execute(missions); + return EvaluateExpression(text, [&](const std::string_view operand) { return IsOperandMet(operand, missionId, missions); }); } -std::unordered_map MissionPrerequisites::expressions = {}; +std::unordered_map MissionPrerequisites::expressions = {}; diff --git a/dGame/dMission/MissionPrerequisites.h b/dGame/dMission/MissionPrerequisites.h index 422c3093c..9b91bb329 100644 --- a/dGame/dMission/MissionPrerequisites.h +++ b/dGame/dMission/MissionPrerequisites.h @@ -1,32 +1,12 @@ #pragma once -#include +#include +#include #include -#include +#include +#include #include "Mission.h" -/** - * An expression that checks if a mission may be accepted or not - */ -class PrerequisiteExpression final -{ - bool m_or; - uint32_t a; - uint32_t sub; - PrerequisiteExpression* b; - -public: - /** - * Executes the prerequisite, checking its contents and returning whether or not the mission may be accepted - * @param missions the list of missions to check the prerequisites against (f.e. whether they're completed) - * @return whether or not all the prerequisites are met - */ - bool Execute(const std::unordered_map& missions) const; - - explicit PrerequisiteExpression(const std::string& str); - ~PrerequisiteExpression(); -}; - /** * Utility class for checking whether or not a mission can be accepted using its prerequisites */ @@ -43,17 +23,38 @@ public: static bool CanAccept(uint32_t missionId, const std::unordered_map& missions); /** - * Checks the prerequisites for a mission + * Checks the prerequisites (Missions.prereqMissionID) of a mission, as the client + * (LWOMissionComponent::getMissionState 0x00cd7400): an expression is evaluated by EvaluateExpression with + * IsOperandMet for each operand; text without any of !&^;,|(): is one mission ID, met when 0 or once ever completed. * @param missionId the mission ID to check prerequisites for * @param missions the mission inventory to check the prerequisites against * @return whether or not the prerequisites of the mission identified by the specified ID are met */ static bool CheckPrerequisites(uint32_t missionId, const std::unordered_map& missions); -private: /** - * Cache of all the executed prerequisites + * The client's state bit mask for a mission (LWOMissionComponent::getMissionState 0x00cd7400): 8 once ever + * completed; in progress, its state (with 8 if completed before); else 1 when never completed (or repeatable) and + * the prerequisites are met; 0 for no mission. */ - static std::unordered_map expressions; + static int32_t GetClientMissionState(uint32_t missionId, const std::unordered_map& missions); + /** + * One operand of a prerequisite expression (sub_00c90a80): "id" is met once the mission was ever completed; + * "id:state" when the client's state mask of that mission is state (a mission naming itself counts as state 0). + */ + static bool IsOperandMet(std::string_view operand, uint32_t missionId, const std::unordered_map& missions); + + /** + * The client's boolean expression evaluator (sub_00a32980): read left to right, no precedence; & , ; and, | or, + * ^ xor, ! ~ negate the next operand, ( ) group. And/or skip their right side once the result is known; an operand + * or group with no operator before it (other than the first) is ignored. + */ + static bool EvaluateExpression(std::string_view expression, const std::function& isMet); + +private: + /** + * Cache of the prerequisite text of each mission + */ + static std::unordered_map expressions; }; diff --git a/tests/dGameTests/dComponentsTests/MissionClientRulesTests.cpp b/tests/dGameTests/dComponentsTests/MissionClientRulesTests.cpp index d2b86896d..60a7bf0c2 100644 --- a/tests/dGameTests/dComponentsTests/MissionClientRulesTests.cpp +++ b/tests/dGameTests/dComponentsTests/MissionClientRulesTests.cpp @@ -10,17 +10,28 @@ #include "Mission.h" #include "MissionComponent.h" #include "MissionMessages.h" +#include "MissionPrerequisites.h" #include "eMissionState.h" #include "tinyxml2.h" #include +#include +#include +#include namespace { // Mission IDs only these tests use (the prerequisite cache is static) constexpr uint32_t VIEWED = 992001; constexpr uint32_t NOT_VIEWED = 992002; constexpr uint32_t FINISHED = 992003; + constexpr uint32_t REPEAT = 992004; // repeatable, completed once, repeated now + constexpr uint32_t AFTER_REPEAT = 992005; // needs REPEAT + constexpr uint32_t DAILY = 992006; // repeatable, completed, no cooldown + constexpr uint32_t AFTER_DAILY = 992007; // needs DAILY:9 (completed and available again) + constexpr uint32_t AFTER_ACTIVE = 992008; // needs NOT_VIEWED:2 (in progress) + constexpr uint32_t GROUPED = 992009; // needs (FINISHED|VIEWED)&(992010|992011) + constexpr uint32_t ZERO = 992012; // prerequisite "0" CDMissions Row(const uint32_t id, const bool repeatable = false, const std::string& prereq = "", const int64_t cooldown = -1) { CDMissions row{}; @@ -40,7 +51,14 @@ protected: CDClientDatabase::Connect(":memory:"); // MissionComponent counts the achievements CDClientDatabase::ExecuteDML("CREATE TABLE Missions (id INTEGER, isMission INTEGER);"); auto& missions = CDClientManager::GetEntriesMutable(); - for (const auto id : { VIEWED, NOT_VIEWED, FINISHED }) missions.push_back(Row(id)); + for (const auto id : { VIEWED, NOT_VIEWED, FINISHED, 992010u, 992011u }) missions.push_back(Row(id)); + missions.push_back(Row(REPEAT, true)); + missions.push_back(Row(AFTER_REPEAT, false, std::to_string(REPEAT))); + missions.push_back(Row(DAILY, true, "", 0)); + missions.push_back(Row(AFTER_DAILY, false, std::to_string(DAILY) + ":9")); + missions.push_back(Row(AFTER_ACTIVE, false, std::to_string(NOT_VIEWED) + ":2")); + missions.push_back(Row(GROUPED, false, "(992003|992001)&(992010|992011)")); + missions.push_back(Row(ZERO, false, "0")); } void TearDown() override { @@ -110,3 +128,64 @@ TEST_F(MissionClientRulesTests, SetMissionViewedWire) { EXPECT_TRUE(in.viewed); EXPECT_EQ(stream.GetNumberOfBitsUsed(), 33); } + +namespace { + bool Eval(const std::string& expression, const std::set& met, std::vector* asked = nullptr) { + return MissionPrerequisites::EvaluateExpression(expression, [&](const std::string_view operand) { + if (asked) asked->emplace_back(operand); + return met.contains(std::string(operand)); + }); + } +} + +TEST_F(MissionClientRulesTests, ExpressionsReadLeftToRightAsTheClient) { + // Groups are their own value (DLU read "(509|229)&(812|...)" as 509 | (229 & (812|...))) + EXPECT_FALSE(Eval("(509|229)&(812|813|814|815)", { "509" })); + EXPECT_TRUE(Eval("(509|229)&(812|813|814|815)", { "229", "814" })); + EXPECT_TRUE(Eval("1203, (1685|1321|1448|1292)", { "1203", "1448" })); + EXPECT_FALSE(Eval("1203, (1685|1321|1448|1292)", { "1448" })); + // No precedence: a | b & c is (a | b) & c + EXPECT_FALSE(Eval("1|2&3", { "1" })); + EXPECT_TRUE(Eval("1|2&3", { "1", "3" })); + EXPECT_TRUE(Eval("1;2", { "1", "2" })); + EXPECT_TRUE(Eval("1^2", { "2" })); + EXPECT_FALSE(Eval("1^2", { "1", "2" })); + EXPECT_TRUE(Eval("!1", {})); + EXPECT_TRUE(Eval("~1 & 2", { "2" })); + EXPECT_TRUE(Eval("!(1|2)", {})); + EXPECT_TRUE(Eval(" 1882:10 | 1882:2", { "1882:2" })); + // An operand with no operator before it is ignored + EXPECT_TRUE(Eval("1 2", { "1" })); + EXPECT_FALSE(Eval("", { "1" })); + // And/or stop once the result is known + std::vector asked; + EXPECT_TRUE(Eval("1|2|3", { "1" }, &asked)); + EXPECT_EQ(asked, (std::vector{ "1" })); + asked.clear(); + EXPECT_FALSE(Eval("1&(2|3)", {}, &asked)); + EXPECT_EQ(asked, (std::vector{ "1" })); +} + +TEST_F(MissionClientRulesTests, PrerequisitesAsTheClient) { + Entity player(44, info); + auto* const missions = player.AddComponent(-1); + Load(*missions, + R"()", + R"()"); + + // A plain ID is met once the mission was ever completed, also while it is being repeated (DLU wanted state 8) + EXPECT_TRUE(missions->CanAccept(AFTER_REPEAT)); + // id:state is the client's state mask: 9 = completed and available again (DLU compared the mission's own state) + EXPECT_EQ(missions->GetClientMissionState(DAILY), 9); + EXPECT_TRUE(missions->CanAccept(AFTER_DAILY)); + EXPECT_TRUE(missions->CanAccept(AFTER_ACTIVE)); + EXPECT_FALSE(missions->CanAccept(GROUPED)); // FINISHED done, but neither of the second group + EXPECT_TRUE(missions->CanAccept(ZERO)); + + Entity other(45, info); + auto* const fresh = other.AddComponent(-1); + EXPECT_FALSE(fresh->CanAccept(AFTER_REPEAT)); + EXPECT_FALSE(fresh->CanAccept(AFTER_DAILY)); + EXPECT_FALSE(fresh->CanAccept(AFTER_ACTIVE)); + EXPECT_EQ(fresh->GetClientMissionState(AFTER_DAILY), 0); +}