Files
DarkflameServer/tests/dGameTests/dComponentsTests/MissionOfferTests.cpp
Aaron Kimbrell 04e0c13678 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 <noreply@anthropic.com>
2026-10-02 09:43:08 -05:00

221 lines
11 KiB
C++

// 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 <ctime>
#include <gtest/gtest.h>
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<int32_t>(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<CDMissionsTable>();
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<CDMissionsTable>().clear();
CDClientManager::GetEntriesMutable<CDMissionNPCComponentTable>().clear();
TearDownDependencies();
}
static void Load(MissionComponent& missions, const std::string& done, const std::string& cur) {
tinyxml2::XMLDocument doc;
const auto xml = R"(<obj v="1"><mis><done>)" + done + "</done><cur>" + cur + "</cur></mis></obj>";
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 "<m id=\"" + std::to_string(id) + "\" state=\"" + std::to_string(state) + "\" cct=\"1\" cts=\"" + std::to_string(at) + "\"/>";
}
static std::string Cur(const uint32_t id, const uint32_t state) {
return "<m id=\"" + std::to_string(id) + "\" state=\"" + std::to_string(state) + "\"/>";
}
static void Giver(const std::vector<CDMissionNPCComponent>& rows) {
auto& table = CDClientManager::GetEntriesMutable<CDMissionNPCComponentTable>();
table = rows;
}
static std::vector<uint32_t> OfferedIDs(const std::vector<CapturedPacket>& packets) {
std::vector<uint32_t> 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<uint32_t>(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<MissionOfferEntry> 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<uint32_t> ids;
for (const auto& entry : entries) ids.push_back(entry.missionID);
EXPECT_EQ(ids, (std::vector<uint32_t>{ 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<MissionComponent>(-1);
const auto now = static_cast<int64_t>(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<eMissionState>(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<MissionOfferComponent>(GIVER);
giver->LoadDataFromTemplate();
Entity player(44, info);
auto* const missions = player.AddComponent<MissionComponent>(-1);
const auto now = static_cast<int64_t>(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<std::pair<uint32_t, uint8_t>> 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<std::pair<uint32_t, uint8_t>>{ { 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<uint32_t>{ 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<uint32_t>{ 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));
}