fix: a property is rented only with the level's required mission

LWOPropertyVendorComponent::onCheckUseRequirements (0x00c2e210) lets a
player use the property guard only when the level's
property_required_mission (LoadConfigData 0x00c2e550) is finished. The
server opened the vendor and rented the property on any request.

The guard is now not opened without the mission, and a rent request
without it is answered with PropertyRentalResponse code 9, which
onPropertyRentalResponse (0x00c56d70) shows as "you failed to rent" with
UGG_PROPRENT_Dont_have_achievement. A mission that was finished and is
being repeated counts, as in the client's list of finished missions. A
guard without the key asks for nothing.

The six property maps name missions 768, 872, 873, 874, 1293 and 1322.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-10 15:28:18 -06:00
parent 53968599d2
commit baf8b24ad1
4 changed files with 153 additions and 0 deletions

View File

@@ -10,14 +10,32 @@
#include "Logger.h"
#include "PropertyManagementComponent.h"
#include "UserManager.h"
#include "Mission.h"
#include "MissionComponent.h"
PropertyVendorComponent::PropertyVendorComponent(Entity* parent, const int32_t componentID) : Component(parent, componentID) {
RegisterMsg(&PropertyVendorComponent::OnRequestUse);
}
uint32_t PropertyVendorComponent::GetRequiredMission() const {
return m_Parent->GetVarAs<uint32_t>(u"property_required_mission");
}
bool PropertyVendorComponent::MayRent(Entity& player) const {
const auto required = GetRequiredMission();
if (required == 0) return true;
const auto* const missions = player.GetComponent<MissionComponent>();
const auto* const mission = missions ? missions->GetMission(required) : nullptr;
// The client asks its list of finished missions: one that was completed and is repeated counts as well
return mission != nullptr && (mission->IsComplete() || mission->GetCompletions() > 0);
}
bool PropertyVendorComponent::OnRequestUse(GameMessages::RequestUseEvent& event) {
if (event.msg.IsMissionChoice()) return false;
auto* const originator = event.user;
// LWOPropertyVendorComponent::onCheckUseRequirements (0x00c2e210): no use without the level's mission
if (!MayRent(*originator)) return true;
if (PropertyManagementComponent::Instance() == nullptr) return true;
OnQueryPropertyData(originator, originator->GetSystemAddress());
@@ -41,6 +59,15 @@ void PropertyVendorComponent::OnQueryPropertyData(Entity* originator, const Syst
}
void PropertyVendorComponent::OnBuyFromVendor(Entity* originator, const bool confirmed, const LOT lot, const uint32_t count) {
if (!MayRent(*originator)) {
LOG("Player %llu rents a property without mission %u", originator->GetObjectID(), GetRequiredMission());
GameMessages::PropertyRentalResponse refused;
refused.target = m_Parent->GetObjectID();
refused.code = RENTAL_MISSING_ACHIEVEMENT;
refused.Send(originator->GetSystemAddress());
return;
}
if (PropertyManagementComponent::Instance() == nullptr) return;
if (PropertyManagementComponent::Instance()->Claim(originator->GetObjectID()) == false) {

View File

@@ -34,4 +34,16 @@ public:
* @param count unused
*/
void OnBuyFromVendor(Entity* originator, bool confirmed, LOT lot, uint32_t count);
// PropertyRentalResponse code the client shows as "you failed to rent" with UGG_PROPRENT_Dont_have_achievement
// (LWOPropertyVendorComponent::onPropertyRentalResponse 0x00c56d70; 0 is a rented property)
static constexpr uint32_t RENTAL_MISSING_ACHIEVEMENT = 9;
// The level's property_required_mission (LWOPropertyVendorComponent::LoadConfigData 0x00c2e550), 0 without one
uint32_t GetRequiredMission() const;
// Whether the player has finished the level's property_required_mission, which the client asks for before the
// vendor can be used (LWOPropertyVendorComponent::onCheckUseRequirements 0x00c2e210). A vendor without the key
// asks for nothing here.
bool MayRent(Entity& player) const;
};

View File

@@ -6,6 +6,7 @@ set(DCOMPONENTS_TESTS
"DestroyableLevelConfigTests.cpp"
"ProximityMonitorComponentTests.cpp"
"RailActivatorComponentTests.cpp"
"PropertyVendorComponentTests.cpp"
"VendorBuybackTests.cpp"
"RacingWrongWayTests.cpp"
"DestroyableComponentTests.cpp"

View File

@@ -0,0 +1,113 @@
// The property guard's mission: LWOPropertyVendorComponent::onCheckUseRequirements (0x00c2e210) lets a player use the
// guard only with the level's property_required_mission finished.
#include "GameDependencies.h"
#include "dGameMessagesTests/GameMessageTestUtils.h"
#include "CDClientDatabase.h"
#include "CDClientManager.h"
#include "CDMissionsTable.h"
#include "Entity.h"
#include "MissionComponent.h"
#include "PropertyMessages.h"
#include "PropertyVendorComponent.h"
#include "tinyxml2.h"
#include <gtest/gtest.h>
using namespace GameMessageTestUtils;
namespace {
// Mission IDs only these tests use
constexpr uint32_t REQUIRED = 993001;
constexpr uint32_t REPEATED = 993002;
}
class PropertyVendorComponentTests : 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 : { REQUIRED, REPEATED }) {
CDMissions row{};
row.id = id;
row.isMission = true;
row.repeatable = id == REPEATED;
missions.push_back(row);
}
guard = std::make_unique<Entity>(GUARD, info);
vendor = guard->AddComponent<PropertyVendorComponent>(-1);
player = std::make_unique<Entity>(PLAYER, info);
missions_ = player->AddComponent<MissionComponent>(-1);
}
void TearDown() override {
player.reset();
guard.reset();
CDClientManager::GetEntriesMutable<CDMissionsTable>().clear();
TearDownDependencies();
}
void Load(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 constexpr LWOOBJID GUARD = 0x0000400000112233LL;
static constexpr LWOOBJID PLAYER = 0x1000000000000001LL;
std::unique_ptr<Entity> guard;
std::unique_ptr<Entity> player;
PropertyVendorComponent* vendor = nullptr;
MissionComponent* missions_ = nullptr;
};
TEST_F(PropertyVendorComponentTests, AGuardWithoutAMissionAsksForNone) {
EXPECT_EQ(vendor->GetRequiredMission(), 0u);
EXPECT_TRUE(vendor->MayRent(*player));
}
TEST_F(PropertyVendorComponentTests, TheMissionMustBeFinished) {
guard->SetVar<uint32_t>(u"property_required_mission", REQUIRED);
EXPECT_EQ(vendor->GetRequiredMission(), REQUIRED);
EXPECT_FALSE(vendor->MayRent(*player)); // never taken
Load("", R"(<m id="993001" state="2"/>)");
EXPECT_FALSE(vendor->MayRent(*player)); // in progress
}
TEST_F(PropertyVendorComponentTests, AFinishedMissionRents) {
guard->SetVar<uint32_t>(u"property_required_mission", REQUIRED);
Load(R"(<m id="993001" cct="1" cts="1000"/>)", "");
EXPECT_TRUE(vendor->MayRent(*player));
}
// The client's list of finished missions keeps a mission that is being repeated
TEST_F(PropertyVendorComponentTests, AFinishedMissionThatIsRepeatedRents) {
guard->SetVar<uint32_t>(u"property_required_mission", REPEATED);
Load(R"(<m id="993002" cct="1" cts="1000"/>)", R"(<m id="993002" state="10"/>)");
EXPECT_TRUE(vendor->MayRent(*player));
}
// A rent request without the mission is answered with code 9, which the client shows as "you failed to rent" and
// UGG_PROPRENT_Dont_have_achievement (LWOPropertyVendorComponent::onPropertyRentalResponse 0x00c56d70):
// clone u32 0, code u32 9, property u64 0, rent due i64 0
TEST_F(PropertyVendorComponentTests, RentingWithoutTheMissionIsRefused) {
guard->SetVar<uint32_t>(u"property_required_mission", REQUIRED);
const auto packets = Capture([&] { vendor->OnBuyFromVendor(player.get(), true, 0, 1); });
ASSERT_EQ(packets.size(), 1u);
EXPECT_PACKET_EQ(FromHex(
"53 05 00 0c 00 00 00 00 33 22 11 00 00 40 00 00 ee 02 "
"00 00 00 00 09 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00", 336), FromCapture(packets[0]));
}
// With the mission, the request goes on to the property (none in this test: nothing is sent, nothing refused)
TEST_F(PropertyVendorComponentTests, RentingWithTheMissionIsNotRefused) {
guard->SetVar<uint32_t>(u"property_required_mission", REQUIRED);
Load(R"(<m id="993001" cct="1" cts="1000"/>)", "");
const auto packets = Capture([&] { vendor->OnBuyFromVendor(player.get(), true, 0, 1); });
EXPECT_TRUE(packets.empty());
}