fix(missions): a mission is only turned in when its rewards fit, as the client

LWOMissionComponent::MissionWindowOK (0x00d501f0) turns a mission in only
when CanReceiveAllRewards (msgCanReceiveAllRewards 0x00d17e20,
sub_00d17750) passes for the reward the player picked: the reward items
(the repeat ones once completed, each at least 1) must fit after the items
the tasks take (a gather task's target unless it keeps them, one of a
delivery task's target) with the first completion's bag rewards added.
The fit is LWOInventoryComponent_Common::CheckCanAddItems (0x00d53de0):
turned in items leave first (an item of 1 settles the whole count), a
unique item fails while one is held outside the buyback, then each item
tops up stacks of its LOT (stack size 0 is no limit, LookupItemInfo
0x00c1c190) and takes free slots.

MissionDialogueOK now refuses a turn-in that does not pass, which the
client never sends. The client's max currency part is not checked: the
server writes no cm, so the client has no limit either.
InventoryComponent::GetDefaultInventorySize is split out of GetInventory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-10-02 15:47:35 -05:00
parent 2fe489d518
commit c42a7ba387
7 changed files with 401 additions and 19 deletions

View File

@@ -5,6 +5,9 @@
#include "EconomyLedger.h"
#include <sstream>
#include <algorithm>
#include <limits>
#include <optional>
#include "Entity.h"
#include "Item.h"
@@ -157,6 +160,17 @@ void InventoryComponent::LoadDataFromTemplate() {
}
}
uint32_t InventoryComponent::GetDefaultInventorySize(const eInventoryType type) {
switch (type) {
case eInventoryType::ITEMS: return 20u;
case eInventoryType::VAULT_MODELS:
case eInventoryType::VAULT_ITEMS: return 40u;
case eInventoryType::VENDOR_BUYBACK: return 27u;
case eInventoryType::DONATION: return 24u;
default: return 240u;
}
}
Inventory* InventoryComponent::GetInventory(const eInventoryType type) {
const auto index = m_Inventories.find(type);
@@ -165,25 +179,7 @@ Inventory* InventoryComponent::GetInventory(const eInventoryType type) {
}
// Create new empty inventory
uint32_t size = 240u;
switch (type) {
case eInventoryType::ITEMS:
size = 20u;
break;
case eInventoryType::VAULT_MODELS:
case eInventoryType::VAULT_ITEMS:
size = 40u;
break;
case eInventoryType::VENDOR_BUYBACK:
size = 27u;
break;
case eInventoryType::DONATION:
size = 24u;
break;
default:
break;
}
const auto size = GetDefaultInventorySize(type);
auto* inventory = new Inventory(type, size, {}, this);
@@ -646,6 +642,104 @@ bool InventoryComponent::HasSpaceForLoot(const Loot::Return& loot) {
return true;
}
bool InventoryComponent::CanAddItems(const std::vector<std::pair<LOT, uint32_t>>& items, const std::vector<std::pair<LOT, uint32_t>>& itemsToTurnIn,
const std::map<eInventoryType, uint32_t>& sizeIncrease) const {
// A copy of the inventories: per inventory its items in ID order (new ones last, as the client's new IDs)
struct SimItem {
LOT lot;
uint32_t count;
uint32_t slot;
LWOOBJID subKey;
};
std::map<eInventoryType, std::vector<SimItem>> inventories;
std::map<eInventoryType, uint32_t> sizes;
for (const auto& [type, inventory] : m_Inventories) {
if (!inventory) continue;
auto& simItems = inventories[type];
for (const auto& [id, item] : inventory->GetItems()) {
if (item) simItems.push_back({ item->GetLot(), item->GetCount(), item->GetSlot(), item->GetSubKey() });
}
sizes[type] = inventory->GetSize();
}
for (const auto& [type, increase] : sizeIncrease) sizes[type] += increase;
// Turned in items leave first; an item of 1 settles the whole count, as the client
for (auto [lot, count] : itemsToTurnIn) {
for (auto& [type, simItems] : inventories) {
for (auto it = simItems.begin(); count != 0 && it != simItems.end();) {
if (it->lot != lot) {
++it;
continue;
}
if (it->count < 2) {
it = simItems.erase(it);
count = 0;
continue;
}
const auto taken = std::min(count, it->count);
it->count -= taken;
count -= taken;
it = it->count == 0 ? simItems.erase(it) : it + 1;
}
if (count == 0) break;
}
}
// LWOInventoryComponent_Common::GetInvItemCount over every inventory
const auto heldCount = [this](const LOT lot, const std::optional<eInventoryType> only) {
uint32_t total = 0;
for (const auto& [type, inventory] : m_Inventories) {
if (!inventory || (only && type != *only)) continue;
for (const auto& [id, item] : inventory->GetItems()) {
if (item && item->GetLot() == lot) total += item->GetCount();
}
}
return total;
};
for (const auto& [lot, wanted] : items) {
const auto type = Inventory::FindInventoryTypeForLot(lot);
// An inventory the server has not made yet is empty (the client refuses one it does not have, but has every
// one the server sent)
if (!inventories.contains(type)) sizes[type] += GetDefaultInventorySize(type);
auto& simItems = inventories[type];
const auto& info = Inventory::FindItemComponent(lot);
// LookupItemInfo (0x00c1c190): a stack size of 0 is no limit
const uint32_t stack = info.stackSize == 0 ? std::numeric_limits<uint32_t>::max() : info.stackSize;
auto left = wanted;
if (info.isUnique) {
if (heldCount(lot, std::nullopt) != heldCount(lot, eInventoryType::VENDOR_BUYBACK)) return false; // UniqueItemStillExistsInAnInventory
left = 1;
}
for (auto& simItem : simItems) {
if (left == 0) break;
if (simItem.lot != lot || simItem.subKey != LWOOBJID_EMPTY || simItem.count >= stack) continue;
const auto added = std::min(left, stack - simItem.count);
simItem.count += added;
left -= added;
}
if (left == 0) continue;
std::set<uint32_t> takenSlots;
for (const auto& simItem : simItems) takenSlots.insert(simItem.slot);
const auto size = sizes[type];
while (left != 0) {
if (takenSlots.size() >= size) return false; // TargetInventoryOutOfRoom
uint32_t slot = 0;
while (slot < size && takenSlots.contains(slot)) slot++;
if (slot >= size) return false; // items sit beyond the size; the client would look for a slot forever
takenSlots.insert(slot);
const auto added = std::min(left, stack);
left -= added;
simItems.push_back({ lot, added, slot, LWOOBJID_EMPTY });
}
}
return true;
}
namespace {
/**
* Cars and rockets built before builds were recorded have no subkey, so the client has no blueprint id to ask for

View File

@@ -126,6 +126,9 @@ public:
*/
Inventory* GetInventory(eInventoryType type);
// The size an inventory starts with when the server makes it
static uint32_t GetDefaultInventorySize(eInventoryType type);
/**
* Returns all the inventories this entity has, indexed by type
* @return all the inventories this entity has, indexed by type
@@ -255,6 +258,18 @@ public:
*/
bool HasSpaceForLoot(const Loot::Return& loot);
/**
* Whether all of these items would fit, as the client works it out before turning in a mission
* (LWOInventoryComponent_Common::CheckCanAddItems 0x00d53de0): the items to turn in are taken out first, the bag
* sizes grow by sizeIncrease, a unique item fails while one is held outside the buyback, then each item tops up
* stacks of its LOT and takes free slots. Nothing changes.
* @param items LOTs and counts to add
* @param itemsToTurnIn LOTs and counts taken from any inventory first
* @param sizeIncrease extra slots per inventory
*/
bool CanAddItems(const std::vector<std::pair<LOT, uint32_t>>& items, const std::vector<std::pair<LOT, uint32_t>>& itemsToTurnIn,
const std::map<eInventoryType, uint32_t>& sizeIncrease) const;
/**
* Equips an item in the specified slot
* @param location the location to store the item (e.g. chest, left hand, etc.)

View File

@@ -160,6 +160,14 @@ namespace GameMessages {
if (iMissionState == eMissionState::AVAILABLE || iMissionState == eMissionState::COMPLETE_AVAILABLE) {
missionComponent->AcceptMission(missionID);
} else if (iMissionState == eMissionState::READY_TO_COMPLETE || iMissionState == eMissionState::COMPLETE_READY_TO_COMPLETE) {
// LWOMissionComponent::MissionWindowOK (0x00d501f0) only turns a mission in when every reward fits
// (CanReceiveAllRewards, with the reward the player picked)
const auto* const mission = missionComponent->GetMission(missionID);
const auto chosen = mission && mission->GetReward() > 0 ? mission->GetReward() : -1;
if (!mission || !mission->CanReceiveAllRewards(chosen)) {
LOG("Player %llu cannot receive all the rewards of mission %i, not turning it in", player->GetObjectID(), missionID);
return;
}
missionComponent->CompleteMission(missionID);
}

View File

@@ -2,6 +2,9 @@
#include "ChatServerLink.h"
#include <algorithm>
#include <array>
#include <map>
#include <vector>
#include <cctype>
#include <cstdlib>
#include <ctime>
@@ -464,6 +467,56 @@ void Mission::Catchup() {
}
}
bool Mission::CanReceiveAllRewards(const LOT chosenReward) const {
if (IsComplete()) return false;
auto* const entity = GetAssociate();
auto* const inventoryComponent = entity ? entity->GetComponent<InventoryComponent>() : nullptr;
if (!inventoryComponent) return false;
// The repeat rewards once ever completed; a reward item counts at least 1 (LWOMission 0x00fa35d0)
const bool repeat = m_Completions > 0;
const std::array<std::pair<LOT, int32_t>, 4> rewards = repeat
? std::array<std::pair<LOT, int32_t>, 4>{ { { info.reward_item1_repeatable, info.reward_item1_repeat_count }, { info.reward_item2_repeatable, info.reward_item2_repeat_count },
{ info.reward_item3_repeatable, info.reward_item3_repeat_count }, { info.reward_item4_repeatable, info.reward_item4_repeat_count } } }
: std::array<std::pair<LOT, int32_t>, 4>{ { { info.reward_item1, info.reward_item1_count }, { info.reward_item2, info.reward_item2_count },
{ info.reward_item3, info.reward_item3_count }, { info.reward_item4, info.reward_item4_count } } };
std::vector<std::pair<LOT, uint32_t>> items;
for (const auto& [lot, count] : rewards) {
if (lot == LOT_NULL || (chosenReward != -1 && lot != chosenReward)) continue;
items.emplace_back(lot, static_cast<uint32_t>(std::max(count, 1)));
}
// The bag rewards come with the first completion only
std::map<eInventoryType, uint32_t> sizeIncrease;
if (!repeat) {
if (info.reward_maxinventory > 0) sizeIncrease[eInventoryType::ITEMS] += info.reward_maxinventory;
if (info.reward_maxmodel > 0) sizeIncrease[eInventoryType::MODELS] += info.reward_maxmodel;
}
// The items the tasks take (sub_00fa2f20): a gather task's target unless it keeps them (LWOGatherTask 0x00f919c0),
// one of a delivery task's target
const bool noGiver = (info.offer_objectID == -1 || info.offer_objectID == 0) && (info.target_objectID == -1 || info.target_objectID == 0);
std::vector<std::pair<LOT, uint32_t>> itemsToTurnIn;
for (const auto* const task : m_Tasks) {
const auto& taskInfo = task->GetClientInfo();
const auto target = static_cast<LOT>(taskInfo.target);
if (task->GetType() == eMissionTaskType::GATHER) {
const auto& parameters = task->GetParameters();
int32_t param = parameters.empty() ? 2 : static_cast<int32_t>(parameters[0]);
if (noGiver && (param & 2) != 0) param = 1; // an achievement never takes items
const bool keep = (param & 1) != 0 && (param & 2) == 0;
if (!keep && target != LOT_NULL) itemsToTurnIn.emplace_back(target, static_cast<uint32_t>(taskInfo.targetValue));
} else if (task->GetType() == eMissionTaskType::DELIVERY) {
if (target != LOT_NULL) itemsToTurnIn.emplace_back(target, 1);
}
}
// The client also refuses when the coin reward would pass its max currency (the save's cm, no limit without it);
// the server writes no cm, so there is no limit to check
return inventoryComponent->CanAddItems(items, itemsToTurnIn, sizeIncrease);
}
void Mission::YieldRewards() {
auto* entity = GetAssociate();

View File

@@ -250,6 +250,14 @@ public:
* Whether the player has looked at this mission in progress (LWOMissionComponent::msgSetMissionViewed 0x00bec7a0),
* saved as v on the mission's <cur> entry
*/
/**
* Whether the player can take every reward of this mission in progress, as the client checks before turning it in
* (LWOMissionComponent::msgCanReceiveAllRewards 0x00d17e20, sub_00d17750): the reward items (all, or only the
* chosen one) must fit in the inventory after the items the tasks take, with the bag rewards added.
* @param chosenReward the reward the player picked, -1 for all
*/
bool CanReceiveAllRewards(LOT chosenReward = -1) const;
bool GetViewed() const { return m_Viewed; }
void SetViewed(const bool viewed) { m_Viewed = viewed; }

View File

@@ -16,6 +16,7 @@ set(DCOMPONENTS_TESTS
"CollectibleComponentTests.cpp"
"MissionOfferTests.cpp"
"MissionClientRulesTests.cpp"
"MissionRewardSpaceTests.cpp"
"ReplicaComponentOrderTests.cpp"
"QuickBuildCompleteTests.cpp"
"RocketLaunchTests.cpp"

View File

@@ -0,0 +1,203 @@
// Whether a mission's rewards fit before it is turned in, as the 1.10.64 client checks
// (LWOMissionComponent::msgCanReceiveAllRewards 0x00d17e20, LWOInventoryComponent_Common::CheckCanAddItems 0x00d53de0).
#include "GameDependencies.h"
#include "CDClientDatabase.h"
#include "CDClientManager.h"
#include "CDComponentsRegistryTable.h"
#include "CDItemComponentTable.h"
#include "CDMissionTasksTable.h"
#include "CDMissionsTable.h"
#include "Entity.h"
#include "GameMessages.h"
#include "InventoryComponent.h"
#include "Item.h"
#include "Mission.h"
#include "MissionComponent.h"
#include "eItemType.h"
#include "eMissionTaskType.h"
#include "eReplicaComponentType.h"
#include "tinyxml2.h"
#include <gtest/gtest.h>
#include <memory>
#include <string>
class MissionRewardSpaceTests : public GameDependenciesTest {
protected:
static constexpr LOT HAT = 8100; // stack 1
static constexpr LOT GEM = 8101; // stack 10
static constexpr LOT UNIQUE = 8102; // unique
static constexpr LOT DEED = 8103; // stack 0: no limit
static constexpr uint32_t PLAIN = 993001; // rewards a hat; the repeat rewards 2 hats
static constexpr uint32_t GATHER = 993002; // takes 2 gems, rewards a hat
static constexpr uint32_t KEEP = 993003; // gathers 2 gems but keeps them, rewards a hat
static constexpr uint32_t BAG = 993004; // rewards a hat and one more slot
static constexpr uint32_t GEMS = 993005; // rewards 5 gems
static constexpr uint32_t ONE = 993006; // rewards the unique item
static constexpr uint32_t CHOICE = 993007; // rewards a hat or 5 gems
std::unique_ptr<Entity> player;
InventoryComponent* inventory{};
MissionComponent* missions{};
LWOOBJID nextId = 0x9000;
void SetUp() override {
SetUpDependencies();
CDClientDatabase::Connect(":memory:"); // MissionComponent counts the achievements
CDClientDatabase::ExecuteDML("CREATE TABLE Missions (id INTEGER, isMission INTEGER);");
RegisterLot(HAT, 1, false);
RegisterLot(GEM, 10, false);
RegisterLot(UNIQUE, 1, true);
RegisterLot(DEED, 0, false);
CDClientManager::GetEntriesMutable<CDComponentsRegistryTable>().insert_or_assign(static_cast<uint64_t>(info.lot), 0);
auto& rows = CDClientManager::GetEntriesMutable<CDMissionsTable>();
rows.push_back(Rewards(PLAIN, HAT, 1));
rows.back().repeatable = true;
rows.back().reward_item1_repeatable = HAT;
rows.back().reward_item1_repeat_count = 2;
rows.push_back(Rewards(GATHER, HAT, 1));
rows.push_back(Rewards(KEEP, HAT, 1));
rows.push_back(Rewards(BAG, HAT, 1));
rows.back().reward_maxinventory = 1;
rows.push_back(Rewards(GEMS, GEM, 5));
rows.push_back(Rewards(ONE, UNIQUE, 1));
rows.push_back(Rewards(CHOICE, HAT, 1));
rows.back().reward_item2 = GEM;
rows.back().reward_item2_count = 5;
auto& tasks = CDClientManager::GetEntriesMutable<CDMissionTasksTable>();
tasks.push_back(GatherTask(GATHER, ""));
tasks.push_back(GatherTask(KEEP, "1"));
player = std::make_unique<Entity>(48, info);
inventory = player->AddComponent<InventoryComponent>(-1);
missions = player->AddComponent<MissionComponent>(-1);
}
void TearDown() override {
player.reset();
CDClientManager::GetEntriesMutable<CDMissionsTable>().clear();
CDClientManager::GetEntriesMutable<CDMissionTasksTable>().clear();
TearDownDependencies();
}
static CDMissions Rewards(const uint32_t id, const LOT lot, const int32_t count) {
CDMissions row{};
row.id = id;
row.isMission = true;
row.cooldownTime = -1;
row.offer_objectID = 1000;
row.target_objectID = 1000;
for (auto* const item : { &row.reward_item1, &row.reward_item2, &row.reward_item3, &row.reward_item4,
&row.reward_item1_repeatable, &row.reward_item2_repeatable, &row.reward_item3_repeatable, &row.reward_item4_repeatable }) *item = LOT_NULL;
row.reward_item1 = lot;
row.reward_item1_count = count;
return row;
}
static CDMissionTasks GatherTask(const uint32_t missionId, const std::string& param) {
CDMissionTasks task{};
task.id = missionId;
task.taskType = static_cast<uint32_t>(eMissionTaskType::GATHER);
task.target = GEM;
task.targetValue = 2;
task.taskParam1 = param;
return task;
}
static void RegisterLot(const LOT lot, const uint32_t stack, const bool unique) {
const auto componentID = static_cast<uint32_t>(97000 + lot);
auto& registry = CDClientManager::GetEntriesMutable<CDComponentsRegistryTable>();
registry.insert_or_assign(static_cast<uint64_t>(lot), componentID);
registry.insert_or_assign(static_cast<uint64_t>(eReplicaComponentType::ITEM) << 32 | static_cast<uint64_t>(lot), componentID);
CDItemComponent component{};
component.id = componentID;
component.itemType = static_cast<uint32_t>(eItemType::HAT);
component.stackSize = stack;
component.isUnique = unique;
CDClientManager::GetEntriesMutable<CDItemComponentTable>().insert_or_assign(componentID, component);
}
void Give(const LOT lot, const uint32_t count, const eInventoryType type = eInventoryType::ITEMS) {
auto* const bag = inventory->GetInventory(type);
new Item(nextId++, lot, bag, bag->FindEmptySlot(), count, false, {}, LWOOBJID_EMPTY, LWOOBJID_EMPTY, eLootSourceType::NONE);
}
// Fills the items bag with hats up to all but `free` slots
void Fill(const int32_t free) {
auto* const bag = inventory->GetInventory(eInventoryType::ITEMS);
while (bag->GetEmptySlots() > free) Give(HAT, 1);
}
// The mission ready to turn in (the first time, or a repeat)
const Mission& Ready(const uint32_t id, const bool repeat = false) {
tinyxml2::XMLDocument doc;
const auto ids = std::to_string(id);
const auto done = repeat ? "<m id=\"" + ids + "\" state=\"8\" cct=\"1\" cts=\"1000\"/>" : std::string();
const auto xml = "<obj v=\"1\"><mis><done>" + done + "</done><cur><m id=\"" + ids + "\" state=\"" + (repeat ? "12" : "4") + "\"/></cur></mis></obj>";
doc.Parse(xml.c_str());
missions->LoadFromSaveData(doc);
return *missions->GetMission(id);
}
};
TEST_F(MissionRewardSpaceTests, RewardNeedsAFreeSlot) {
const auto& mission = Ready(PLAIN);
Fill(1);
EXPECT_TRUE(mission.CanReceiveAllRewards());
Fill(0);
EXPECT_FALSE(mission.CanReceiveAllRewards());
}
TEST_F(MissionRewardSpaceTests, RepeatRewardsAndTheBagReward) {
Fill(1);
EXPECT_FALSE(Ready(PLAIN, true).CanReceiveAllRewards()); // 2 hats the second time
Fill(0);
EXPECT_TRUE(Ready(BAG).CanReceiveAllRewards()); // the reward's own extra slot
}
TEST_F(MissionRewardSpaceTests, TakenItemsMakeRoom) {
Fill(1);
Give(GEM, 1); // the last slot; taking one gem settles the whole count, as the client
EXPECT_TRUE(Ready(GATHER).CanReceiveAllRewards());
EXPECT_FALSE(Ready(KEEP).CanReceiveAllRewards());
}
TEST_F(MissionRewardSpaceTests, StacksAreToppedUp) {
Fill(1);
Give(GEM, 5);
EXPECT_TRUE(Ready(GEMS).CanReceiveAllRewards());
EXPECT_TRUE(inventory->CanAddItems({ { GEM, 5 } }, {}, {}));
EXPECT_FALSE(inventory->CanAddItems({ { GEM, 6 } }, {}, {}));
EXPECT_FALSE(inventory->CanAddItems({ { GEM, 5 }, { GEM, 1 } }, {}, {}));
EXPECT_FALSE(inventory->CanAddItems({ { DEED, 1000 } }, { { GEM, 4 } }, {})); // the gems keep their slot
EXPECT_TRUE(inventory->CanAddItems({ { DEED, 1000 } }, { { GEM, 5 } }, {})); // a stack size of 0 is no limit
EXPECT_TRUE(inventory->CanAddItems({ { HAT, 1 } }, {}, { { eInventoryType::ITEMS, 1 } }));
}
TEST_F(MissionRewardSpaceTests, OneUniqueItem) {
const auto& mission = Ready(ONE);
EXPECT_TRUE(mission.CanReceiveAllRewards());
Give(UNIQUE, 1, eInventoryType::VENDOR_BUYBACK);
EXPECT_TRUE(mission.CanReceiveAllRewards()); // only sold back
Give(UNIQUE, 1, eInventoryType::VAULT_ITEMS);
EXPECT_FALSE(mission.CanReceiveAllRewards());
}
TEST_F(MissionRewardSpaceTests, OnlyTheChosenReward) {
const auto& mission = Ready(CHOICE);
Fill(1);
EXPECT_FALSE(mission.CanReceiveAllRewards()); // a hat and the gems need two slots
EXPECT_TRUE(mission.CanReceiveAllRewards(HAT));
EXPECT_TRUE(mission.CanReceiveAllRewards(GEM));
}
TEST_F(MissionRewardSpaceTests, NotInProgress) {
tinyxml2::XMLDocument doc;
doc.Parse(R"(<obj v="1"><mis><done><m id="993001" state="8" cct="1" cts="1000"/></done><cur/></mis></obj>)");
missions->LoadFromSaveData(doc);
EXPECT_FALSE(missions->GetMission(PLAIN)->CanReceiveAllRewards());
}