From 04e0c136788177f6fd8e9f5c19cfac9f4a90e396 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Fri, 2 Oct 2026 09:42:25 -0500 Subject: [PATCH] fix(mission offer): offer the first mission of the giver's list, as the client lists them LWOMissionOfferComponent builds a player's missions for a giver (0x00c605f0, one entry 0x00bebf30) in priority order: 0 a mission to turn in here, 5 one in progress, 10 a new one, 15 a repeat. A ready mission is only listed where MissionNPCComponent.acceptsMission is set, a new one only where offersMission is set and its cooldown has run out, a mission in progress by every giver that has it. A random mission (0x00c5ffd0) lists one of its pool that the giver has: one to turn in first, then one in progress, else a random one. Missions are kept by mission ID; equal priorities keep that order. DLU sent an OfferMission for every mission that qualified, including one ready to turn in at a giver that does not accept it. In the live captures every use got one mission's OfferMission pair. A use now offers the head of the list, and a pick from the multi interaction menu the picked one. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/MissionOfferComponent.cpp | 243 ++++++++++-------- dGame/dComponents/MissionOfferComponent.h | 34 +++ .../dComponentsTests/CMakeLists.txt | 1 + .../dComponentsTests/MissionOfferTests.cpp | 220 ++++++++++++++++ 4 files changed, 397 insertions(+), 101 deletions(-) create mode 100644 tests/dGameTests/dComponentsTests/MissionOfferTests.cpp diff --git a/dGame/dComponents/MissionOfferComponent.cpp b/dGame/dComponents/MissionOfferComponent.cpp index 17d9a7857..45fb32dcd 100644 --- a/dGame/dComponents/MissionOfferComponent.cpp +++ b/dGame/dComponents/MissionOfferComponent.cpp @@ -3,6 +3,9 @@ * Copyright 2019 */ +#include +#include +#include #include #include "MissionOfferComponent.h" #include "ObjectMessages.h" @@ -15,6 +18,7 @@ #include "MissionComponent.h" #include "Logger.h" #include "Game.h" +#include "GeneralUtils.h" #include "MissionPrerequisites.h" #include "eMissionState.h" @@ -62,6 +66,12 @@ void MissionOfferComponent::LoadDataFromTemplate() { } namespace { + std::string_view Trimmed(std::string_view text) { + while (!text.empty() && std::isspace(static_cast(text.front()))) text.remove_prefix(1); + while (!text.empty() && std::isspace(static_cast(text.back()))) text.remove_suffix(1); + return text; + } + // "Why are we sending it twice, once to a non-player object?" // The first one (targeting the offerer) makes the client zoom into the object. // The second (targeting the player) actually makes the UI pop up so the mission can be offered. @@ -85,8 +95,99 @@ bool MissionOfferComponent::OnRequestUse(GameMessages::RequestUseEvent& event) { return true; } +std::optional MissionOfferComponent::GetOfferEntry(const uint32_t missionID, const int32_t state, const bool cooldownRunning, const bool offers, const bool accepts, const bool alsoCompleted) { + constexpr auto AVAILABLE = static_cast(eMissionState::AVAILABLE); + constexpr auto ACTIVE = static_cast(eMissionState::ACTIVE); + constexpr auto READY_TO_COMPLETE = static_cast(eMissionState::READY_TO_COMPLETE); + constexpr auto COMPLETE = static_cast(eMissionState::COMPLETE); + + // Ready to turn in: only where it is accepted + if (state & READY_TO_COMPLETE) { + if (offers && accepts) return MissionOfferEntry{ 5, missionID, state, false }; + if (accepts) return MissionOfferEntry{ 0, missionID, state, true }; + return std::nullopt; + } + + // In progress: shown by every giver that has it + if (state & ACTIVE) return MissionOfferEntry{ 5, missionID, state, false }; + + // Available: only where it is offered, and not while its cooldown runs + if (state & AVAILABLE) { + if (!offers || cooldownRunning) return std::nullopt; + return MissionOfferEntry{ static_cast((state & COMPLETE) ? 15 : 10), missionID, state, false }; + } + + if (alsoCompleted && (state & COMPLETE) && !cooldownRunning) return MissionOfferEntry{ 20, missionID, state, !offers && accepts }; + + return std::nullopt; +} + +void MissionOfferComponent::InsertByPriority(std::vector& entries, const MissionOfferEntry& entry) { + const auto after = std::ranges::find_if(entries, [&entry](const MissionOfferEntry& other) { return other.priority > entry.priority; }); + entries.insert(after, entry); +} + +std::vector MissionOfferComponent::ParseRandomPool(const std::string& randomPool) { + std::vector pool; + for (const auto& token : GeneralUtils::SplitString(randomPool, ',')) { + const auto sample = GeneralUtils::TryParse(Trimmed(token)); + if (sample) pool.push_back(*sample); + } + return pool; +} + +const OfferedMission* MissionOfferComponent::GetOfferedMission(const uint32_t missionID) const { + const auto offered = std::ranges::find_if(offeredMissions, [missionID](const OfferedMission& other) { return other.GetMissionId() == missionID; }); + return offered == offeredMissions.end() ? nullptr : &*offered; +} + +std::optional MissionOfferComponent::GetEntryFor(const MissionComponent& missionComponent, const OfferedMission& offered, const bool alsoCompleted) { + const auto missionID = offered.GetMissionId(); + const auto state = missionComponent.GetClientMissionState(missionID); + return GetOfferEntry(missionID, state, missionComponent.IsCooldownRunning(missionID), offered.GetOffersMission(), offered.GetAcceptsMission(), alsoCompleted); +} + +std::vector MissionOfferComponent::GetMissionsForPlayer(const MissionComponent& missionComponent, const bool alsoCompleted) const { + // The client keeps the giver's missions by mission ID + std::map byID; + for (const auto& offered : offeredMissions) byID.emplace(offered.GetMissionId(), &offered); + + std::vector entries; + for (const auto& [missionID, offered] : byID) { + CDMissions info{}; + if (!Mission::IsValidMission(missionID, info)) continue; + + if (!info.isRandom) { + if (const auto entry = GetEntryFor(missionComponent, *offered, alsoCompleted)) InsertByPriority(entries, *entry); + continue; + } + + // A random mission (0x00c5ffd0): of its pool, the missions this giver has. One to turn in here first, then one + // in progress, else a random one + std::vector pool; + for (const auto sample : ParseRandomPool(info.randomPool)) { + const auto sampleOffered = byID.find(sample); + if (sampleOffered == byID.end()) continue; + if (const auto entry = GetEntryFor(missionComponent, *sampleOffered->second, alsoCompleted)) InsertByPriority(pool, *entry); + } + if (pool.empty()) continue; + + const auto withPriority = [&pool](const uint8_t priority) { + return std::ranges::find_if(pool, [priority](const MissionOfferEntry& entry) { return entry.priority == priority; }); + }; + auto chosen = withPriority(0); + if (chosen == pool.end()) chosen = withPriority(5); + if (chosen == pool.end()) chosen = pool.begin() + GeneralUtils::GenerateRandomNumber(0, pool.size() - 1); + InsertByPriority(entries, *chosen); + } + + return entries; +} + +// The server offers the first mission of the giver's list (live: one OfferMission per use), or the one picked from the +// client's multi interaction menu void MissionOfferComponent::OfferMissions(Entity* entity, const uint32_t specifiedMissionId) { - // First, get the entity's MissionComponent. If there is not one, then we cannot offer missions to this entity. + if (!entity) return; auto* missionComponent = entity->GetComponent(); if (!missionComponent) { @@ -94,111 +195,51 @@ void MissionOfferComponent::OfferMissions(Entity* entity, const uint32_t specifi return; } - CDMissions info{}; + const auto entries = GetMissionsForPlayer(*missionComponent, false); - if (specifiedMissionId > 0 && !Mission::IsValidMission(specifiedMissionId, info)) { + if (specifiedMissionId == 0) { + if (!entries.empty()) OfferMissionToPlayer(*entity, entries.front().missionID, m_Parent->GetObjectID()); return; } - for (const auto offeredMission : this->offeredMissions) { - if (specifiedMissionId > 0) { - if (offeredMission.GetMissionId() != specifiedMissionId && !info.isRandom) { - continue; - } - } + if (std::ranges::any_of(entries, [specifiedMissionId](const MissionOfferEntry& entry) { return entry.missionID == specifiedMissionId; })) { + OfferMissionToPlayer(*entity, specifiedMissionId, m_Parent->GetObjectID()); + return; + } - // First, check if we already have the mission - const auto missionId = offeredMission.GetMissionId(); - - auto* mission = missionComponent->GetMission(missionId); - - if (mission != nullptr) { - if (specifiedMissionId <= 0) { - // Handles the odd case where the offer object should not display the mission again - if (!mission->IsComplete() && mission->GetClientInfo().offer_objectID == m_Parent->GetLOT() && mission->GetClientInfo().target_objectID != m_Parent->GetLOT() && mission->IsFetchMission()) { - continue; - } - } - - // We have the mission, if it is not complete, offer it - if (mission->IsActive() || mission->IsReadyToComplete()) { - OfferMissionToPlayer(*entity, missionId, m_Parent->GetObjectID()); - - continue; - } - } - - const auto canAccept = MissionPrerequisites::CanAccept(missionId, missionComponent->GetMissions()); - - // Mission has not yet been accepted - check the prereqs - if (!canAccept || !Mission::IsValidMission(missionId, info)) continue; - - const auto& randomPool = info.randomPool; - const auto isRandom = info.isRandom; - - // This means the mission is part of a random pool of missions. - if (isRandom && randomPool.empty()) continue; - - if (isRandom && !randomPool.empty()) { - std::istringstream stream(randomPool); - std::string token; - - std::vector randomMissionPool; - - while (std::getline(stream, token, ',')) { - try { - const auto value = std::stoul(token); - - randomMissionPool.push_back(value); - } catch (std::invalid_argument& exception) { - LOG("Failed to parse value (%s): (%s)!", token.c_str(), exception.what()); - } - } - - if (specifiedMissionId > 0) { - const auto& iter = std::find(randomMissionPool.begin(), randomMissionPool.end(), specifiedMissionId); - - if (iter != randomMissionPool.end() && MissionPrerequisites::CanAccept(specifiedMissionId, missionComponent->GetMissions())) { - OfferMissionToPlayer(*entity, specifiedMissionId, m_Parent->GetObjectID()); - - return; - } - } - - std::vector canAcceptPool; - - for (const auto sample : randomMissionPool) { - const auto state = missionComponent->GetMissionState(sample); - - if (state == eMissionState::ACTIVE || - state == eMissionState::COMPLETE_ACTIVE || - state == eMissionState::READY_TO_COMPLETE || - state == eMissionState::COMPLETE_READY_TO_COMPLETE || - sample == specifiedMissionId) { - mission = missionComponent->GetMission(sample); - - if (mission == nullptr || mission->IsAchievement()) continue; - - OfferMissionToPlayer(*entity, sample, m_Parent->GetObjectID()); - - canAcceptPool.clear(); - - break; - } - - if (MissionPrerequisites::CanAccept(sample, missionComponent->GetMissions())) { - canAcceptPool.push_back(sample); - } - } - - // If the mission is already active or we already completed one of them today - if (canAcceptPool.empty()) continue; - - const auto selected = canAcceptPool[GeneralUtils::GenerateRandomNumber(0, canAcceptPool.size() - 1)]; - - OfferMissionToPlayer(*entity, selected, m_Parent->GetObjectID()); - } else if (offeredMission.GetOffersMission()) { - OfferMissionToPlayer(*entity, missionId, m_Parent->GetObjectID()); + // The client rolls its own mission of a random pool; offer that one if it is in a pool of this giver and listable + const auto* const sampleOffered = GetOfferedMission(specifiedMissionId); + if (!sampleOffered || !GetEntryFor(*missionComponent, *sampleOffered, false)) return; + for (const auto& offered : offeredMissions) { + CDMissions info{}; + if (!Mission::IsValidMission(offered.GetMissionId(), info) || !info.isRandom) continue; + const auto pool = ParseRandomPool(info.randomPool); + if (std::ranges::find(pool, specifiedMissionId) != pool.end()) { + OfferMissionToPlayer(*entity, specifiedMissionId, m_Parent->GetObjectID()); + return; } } } + +// LWOMissionOfferComponent's RequestLinkedMission handler (0x00cd6eb0): the giver's list without the mission the window +// was for; a new mission at its head is offered. Live offered nothing after an accept, and the next mission after a +// turn in, once per RequestLinkedMission. +void MissionOfferComponent::OfferLinkedMission(Entity* entity, const uint32_t missionID) { + if (!entity) return; + const auto* const missionComponent = entity->GetComponent(); + if (!missionComponent) return; + + const auto next = GetLinkedMission(GetMissionsForPlayer(*missionComponent, false), missionID); + if (next) OfferMissionToPlayer(*entity, *next, m_Parent->GetObjectID()); +} + +std::optional MissionOfferComponent::GetLinkedMission(std::vector entries, const uint32_t missionID) { + if (!entries.empty() && entries.front().missionID == missionID) entries.erase(entries.begin()); + if (entries.empty()) return std::nullopt; + + const auto state = entries.front().state; + if ((state & static_cast(eMissionState::AVAILABLE)) && !(state & static_cast(eMissionState::COMPLETE))) { + return entries.front().missionID; + } + return std::nullopt; +} diff --git a/dGame/dComponents/MissionOfferComponent.h b/dGame/dComponents/MissionOfferComponent.h index d8a967678..b33ba47f8 100644 --- a/dGame/dComponents/MissionOfferComponent.h +++ b/dGame/dComponents/MissionOfferComponent.h @@ -8,11 +8,26 @@ #include "dCommonVars.h" #include "Component.h" +#include +#include #include #include #include "eReplicaComponentType.h" class Entity; +class MissionComponent; + +// One mission of a mission giver for a player, as the client lists them (LWOMissionOfferComponent 0x00c605f0): the +// lowest priority comes first. 0 a mission to turn in here, 5 one in progress, 10 a new one, 15 a repeat, 20 a +// completed one. +struct MissionOfferEntry { + uint8_t priority{}; + uint32_t missionID{}; + int32_t state{}; + bool isTurnIn{}; + + bool operator==(const MissionOfferEntry&) const = default; +}; /** * Light wrapper around missions that may be offered by an entity @@ -81,7 +96,26 @@ public: */ void OfferMissions(Entity* entity, uint32_t specifiedMissionId = 0); + // RequestLinkedMission after a mission window: offers the next mission of this giver, if there is a new one + void OfferLinkedMission(Entity* entity, uint32_t missionID); + + // The client's entry for one mission (0x00bebf30), from its state bit mask (MissionComponent::GetClientMissionState) + static std::optional GetOfferEntry(uint32_t missionID, int32_t state, bool cooldownRunning, bool offers, bool accepts, bool alsoCompleted); + + // Inserts after the entries of the same priority, as the client's multimap does + static void InsertByPriority(std::vector& entries, const MissionOfferEntry& entry); + + // The missions of this giver for a player, in the client's order (0x00c605f0) + std::vector GetMissionsForPlayer(const MissionComponent& missionComponent, bool alsoCompleted) const; + + // The mission a RequestLinkedMission offers: the head of the list without the mission the window was for, when new + static std::optional GetLinkedMission(std::vector entries, uint32_t missionID); + + static std::vector ParseRandomPool(const std::string& randomPool); + private: + const OfferedMission* GetOfferedMission(uint32_t missionID) const; + static std::optional GetEntryFor(const MissionComponent& missionComponent, const OfferedMission& offered, bool alsoCompleted); /** * The missions this entity has to offer diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index 6763870dd..c74ede502 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -14,6 +14,7 @@ set(DCOMPONENTS_TESTS "SavingTests.cpp" "CharacterSaveFieldsTests.cpp" "CollectibleComponentTests.cpp" + "MissionOfferTests.cpp" "ReplicaComponentOrderTests.cpp" "QuickBuildCompleteTests.cpp" "RocketLaunchTests.cpp" diff --git a/tests/dGameTests/dComponentsTests/MissionOfferTests.cpp b/tests/dGameTests/dComponentsTests/MissionOfferTests.cpp new file mode 100644 index 000000000..3bbc2fc7e --- /dev/null +++ b/tests/dGameTests/dComponentsTests/MissionOfferTests.cpp @@ -0,0 +1,220 @@ +// Which missions a mission giver lists for a player and offers, as the 1.10.64 client works it out +// (LWOMissionOfferComponent 0x00c605f0 / 0x00bebf30, LWOMissionComponent::getMissionState 0x00cd7400). +#include "GameDependencies.h" +#include "PacketTestUtils.h" + +#include "CDClientDatabase.h" +#include "CDClientManager.h" +#include "CDMissionNPCComponentTable.h" +#include "CDMissionsTable.h" +#include "Entity.h" +#include "GameMessages.h" +#include "MissionComponent.h" +#include "MissionOfferComponent.h" +#include "eMissionState.h" + +#include "tinyxml2.h" + +#include +#include + +namespace { + // Mission IDs only these tests use (the prerequisite cache is static) + constexpr uint32_t NEW = 991001; // no prerequisites + constexpr uint32_t ACTIVE = 991002; + constexpr uint32_t TURN_IN = 991003; // ready to complete, accepted here + constexpr uint32_t ELSEWHERE = 991004; // ready to complete, only offered here + constexpr uint32_t DONE = 991005; // completed, not repeatable + constexpr uint32_t DAILY = 991006; // completed, repeatable, 60 minute cooldown + constexpr uint32_t LOCKED = 991007; // needs DONE_LATER + constexpr uint32_t DONE_LATER = 991008; + constexpr uint32_t NEXT = 991009; // needs DONE + constexpr int32_t GIVER = 99100; + + CDMissions Row(const uint32_t id, const bool repeatable = false, const std::string& prereq = "", const int64_t cooldown = -1) { + CDMissions row{}; + row.id = id; + row.isMission = true; + row.repeatable = repeatable; + row.prereqMissionID = prereq; + row.cooldownTime = cooldown; + return row; + } + + int32_t State(const eMissionState state) { return static_cast(state); } +} + +class MissionOfferTests : public GameDependenciesTest { +protected: + void SetUp() override { + SetUpDependencies(); + CDClientDatabase::Connect(":memory:"); // MissionComponent counts the achievements + CDClientDatabase::ExecuteDML("CREATE TABLE Missions (id INTEGER, isMission INTEGER);"); + auto& missions = CDClientManager::GetEntriesMutable(); + for (const auto id : { NEW, ACTIVE, TURN_IN, ELSEWHERE, DONE, DONE_LATER }) missions.push_back(Row(id)); + missions.push_back(Row(DAILY, true, "", 60)); + missions.push_back(Row(LOCKED, false, std::to_string(DONE_LATER))); + missions.push_back(Row(NEXT, false, std::to_string(DONE))); + } + + void TearDown() override { + CDClientManager::GetEntriesMutable().clear(); + CDClientManager::GetEntriesMutable().clear(); + TearDownDependencies(); + } + + static void Load(MissionComponent& missions, const std::string& done, const std::string& cur) { + tinyxml2::XMLDocument doc; + const auto xml = R"()" + done + "" + cur + ""; + ASSERT_EQ(doc.Parse(xml.c_str()), tinyxml2::XML_SUCCESS); + missions.LoadFromSaveData(doc); + } + + static std::string Done(const uint32_t id, const uint32_t state, const int64_t at) { + return ""; + } + + static std::string Cur(const uint32_t id, const uint32_t state) { + return ""; + } + + static void Giver(const std::vector& rows) { + auto& table = CDClientManager::GetEntriesMutable(); + table = rows; + } + + static std::vector OfferedIDs(const std::vector& packets) { + std::vector ids; + for (const auto& packet : packets) { + // 8 byte header, the target, the message ID, then the mission ID + if (packet.bytes.size() < 22) continue; + const uint16_t messageID = packet.bytes[16] | (packet.bytes[17] << 8); + if (messageID != 248) continue; + ids.push_back(packet.bytes[18] | (packet.bytes[19] << 8) | (packet.bytes[20] << 16) | (static_cast(packet.bytes[21]) << 24)); + } + return ids; + } +}; + +TEST_F(MissionOfferTests, EntryPrioritiesAsTheClient) { + using C = MissionOfferComponent; + const auto ready = State(eMissionState::READY_TO_COMPLETE); + // Ready to turn in: 5 where offered and accepted, 0 (a turn in) where only accepted, nothing where only offered + EXPECT_EQ(C::GetOfferEntry(1, ready, false, true, true, false), (MissionOfferEntry{ 5, 1, ready, false })); + EXPECT_EQ(C::GetOfferEntry(1, ready, false, false, true, false), (MissionOfferEntry{ 0, 1, ready, true })); + EXPECT_FALSE(C::GetOfferEntry(1, ready, false, true, false, false)); + EXPECT_EQ(C::GetOfferEntry(1, State(eMissionState::COMPLETE_READY_TO_COMPLETE), false, false, true, false)->priority, 0); + // In progress: every giver that has it + EXPECT_EQ(C::GetOfferEntry(1, State(eMissionState::ACTIVE), false, false, false, false)->priority, 5); + EXPECT_EQ(C::GetOfferEntry(1, State(eMissionState::COMPLETE_ACTIVE), false, false, false, false)->priority, 5); + // Available: only where offered and off cooldown; a repeat after a new one + EXPECT_EQ(C::GetOfferEntry(1, State(eMissionState::AVAILABLE), false, true, false, false)->priority, 10); + EXPECT_EQ(C::GetOfferEntry(1, State(eMissionState::COMPLETE_AVAILABLE), false, true, false, false)->priority, 15); + EXPECT_FALSE(C::GetOfferEntry(1, State(eMissionState::AVAILABLE), false, false, true, false)); + EXPECT_FALSE(C::GetOfferEntry(1, State(eMissionState::COMPLETE_AVAILABLE), true, true, false, false)); + // Completed: only when asked for + EXPECT_FALSE(C::GetOfferEntry(1, State(eMissionState::COMPLETE), false, true, true, false)); + EXPECT_EQ(C::GetOfferEntry(1, State(eMissionState::COMPLETE), false, false, true, true), (MissionOfferEntry{ 20, 1, 8, true })); + EXPECT_FALSE(C::GetOfferEntry(1, 0, false, true, true, true)); +} + +TEST_F(MissionOfferTests, SamePriorityKeepsInsertionOrder) { + std::vector entries; + MissionOfferComponent::InsertByPriority(entries, { 10, 1 }); + MissionOfferComponent::InsertByPriority(entries, { 5, 2 }); + MissionOfferComponent::InsertByPriority(entries, { 10, 3 }); + MissionOfferComponent::InsertByPriority(entries, { 0, 4 }); + MissionOfferComponent::InsertByPriority(entries, { 5, 5 }); + std::vector ids; + for (const auto& entry : entries) ids.push_back(entry.missionID); + EXPECT_EQ(ids, (std::vector{ 4, 2, 5, 1, 3 })); +} + +TEST_F(MissionOfferTests, CooldownInWholeMinutes) { + constexpr int64_t at = 1000000; + EXPECT_TRUE(MissionComponent::IsCooldownRunning(at, 60, at + 59 * 60 + 59)); + EXPECT_FALSE(MissionComponent::IsCooldownRunning(at, 60, at + 60 * 60)); + EXPECT_FALSE(MissionComponent::IsCooldownRunning(at, 0, at)); // no cooldown + EXPECT_FALSE(MissionComponent::IsCooldownRunning(at, -1, at)); + EXPECT_TRUE(MissionComponent::IsCooldownRunning(at, 1, at - 120)); // completed in the future +} + +TEST_F(MissionOfferTests, ClientMissionStateIsABitMask) { + Entity player(42, info); + auto* const missions = player.AddComponent(-1); + const auto now = static_cast(std::time(nullptr)); + Load(*missions, Done(DONE, 8, now) + Done(DAILY, 8, now), Cur(ACTIVE, 2) + Cur(TURN_IN, 4)); + + EXPECT_EQ(missions->GetClientMissionState(NEW), State(eMissionState::AVAILABLE)); + EXPECT_EQ(missions->GetClientMissionState(ACTIVE), State(eMissionState::ACTIVE)); + EXPECT_EQ(missions->GetClientMissionState(TURN_IN), State(eMissionState::READY_TO_COMPLETE)); + EXPECT_EQ(missions->GetClientMissionState(DONE), State(eMissionState::COMPLETE)); + EXPECT_EQ(missions->GetClientMissionState(DAILY), State(eMissionState::COMPLETE_AVAILABLE)); // even on cooldown + EXPECT_EQ(missions->GetClientMissionState(LOCKED), 0); // prerequisite not met + EXPECT_EQ(missions->GetClientMissionState(NEXT), State(eMissionState::AVAILABLE)); + EXPECT_EQ(missions->GetClientMissionState(66666666), 0); // no such mission + EXPECT_EQ(missions->GetClientMissionState(0), 0); + EXPECT_TRUE(missions->IsCooldownRunning(DAILY)); + EXPECT_FALSE(missions->IsCooldownRunning(DONE)); // no cooldown + EXPECT_FALSE(missions->IsCooldownRunning(NEW)); // never completed + + // The message answers with the same mask (DLU answered -1 for a mission that cannot be taken) + GameMessages::GetMissionState locked; + locked.missionID = LOCKED; + player.HandleMsg(locked); + EXPECT_EQ(locked.missionState, static_cast(0)); + + GameMessages::GetMissionState daily; + daily.missionID = DAILY; + daily.cooldownInfoRequested = true; + player.HandleMsg(daily); + EXPECT_EQ(daily.missionState, eMissionState::COMPLETE_AVAILABLE); + EXPECT_TRUE(daily.cooldownFinished); // the client only checks the cooldown of a repeat in progress +} + +TEST_F(MissionOfferTests, GiverListsMissionsInTheClientsOrder) { + Giver({ + { GIVER, NEW, true, false, "" }, + { GIVER, ACTIVE, true, false, "" }, + { GIVER, TURN_IN, false, true, "" }, + { GIVER, ELSEWHERE, true, false, "" }, + { GIVER, DONE, true, true, "" }, + { GIVER, DAILY, true, true, "" }, + { GIVER, LOCKED, true, false, "" }, + }); + Entity npc(43, info); + auto* const giver = npc.AddComponent(GIVER); + giver->LoadDataFromTemplate(); + + Entity player(44, info); + auto* const missions = player.AddComponent(-1); + const auto now = static_cast(std::time(nullptr)); + Load(*missions, Done(DONE, 8, now) + Done(DAILY, 8, now), Cur(ACTIVE, 2) + Cur(TURN_IN, 4) + Cur(ELSEWHERE, 4)); + + std::vector> listed; + for (const auto& entry : giver->GetMissionsForPlayer(*missions, false)) listed.emplace_back(entry.missionID, entry.priority); + // The turn in first, then the mission in progress, then the new one; not the one ready to turn in elsewhere, not + // the daily on cooldown, not the done or locked ones + EXPECT_EQ(listed, (std::vector>{ { TURN_IN, 0 }, { ACTIVE, 5 }, { NEW, 10 } })); + + // A use offers only the first one (live sent one OfferMission pair per use) + const auto sent = PacketTestUtils::Capture([&] { giver->OfferMissions(&player); }); + EXPECT_EQ(OfferedIDs(sent), (std::vector{ TURN_IN, TURN_IN })); + + // A pick from the multi interaction menu offers that one, if it is listed + EXPECT_EQ(OfferedIDs(PacketTestUtils::Capture([&] { giver->OfferMissions(&player, NEW); })), (std::vector{ NEW, NEW })); + EXPECT_TRUE(OfferedIDs(PacketTestUtils::Capture([&] { giver->OfferMissions(&player, LOCKED); })).empty()); +} + +TEST_F(MissionOfferTests, LinkedMissionOnlyAfterATurnIn) { + const auto active = State(eMissionState::ACTIVE); + const auto available = State(eMissionState::AVAILABLE); + // Just accepted: the list starts with that mission; nothing else new + EXPECT_FALSE(MissionOfferComponent::GetLinkedMission({ { 5, 697, active } }, 697)); + // Just turned in: the next one + EXPECT_EQ(MissionOfferComponent::GetLinkedMission({ { 10, 698, available } }, 697), 698u); + // Not a repeat, and not a mission in progress + EXPECT_FALSE(MissionOfferComponent::GetLinkedMission({ { 15, 698, State(eMissionState::COMPLETE_AVAILABLE) } }, 697)); + EXPECT_FALSE(MissionOfferComponent::GetLinkedMission({ { 5, 700, active }, { 10, 698, available } }, 697)); + EXPECT_FALSE(MissionOfferComponent::GetLinkedMission({}, 697)); +}