feat: optional property rent

Off unless property_rent_enabled is on. Each property world's rent comes from its PropertyTemplate
row (minimumPrice every rentDuration x durationType; Block Yard is free), unless the new Property
Rent dashboard page sets another price or period (property_rent_manage). Rent is taken from the
owner's coins shortly after their character loads, with a mail receipt; unpaid rent is mailed and,
after property_rent_grace_days, makes the property private until it is paid, like live. The
property management component refuses public or best friends privacy while rent is overdue and a
property world that loads overdue makes itself private. Property game messages are unchanged.

Fixes #943

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 23:29:57 -05:00
parent b2403b09ea
commit 680615ba9f
31 changed files with 784 additions and 3 deletions

View File

@@ -57,6 +57,11 @@ namespace HotPropertySlots {
uint32_t id{};
uint32_t mapId{};
std::string spawnName;
// Rent (PropertyRent.h) and reputation (PropertyReputation.h) of the template
int64_t minimumPrice{};
int32_t rentDuration{};
int32_t durationType{};
int32_t reputationPerMinute{};
};
struct EntranceRow { // PropertyEntranceComponent
@@ -112,6 +117,24 @@ namespace HotPropertySlots {
return worlds;
}
/**
* The PropertyTemplate row a property world uses: the lowest id among its rows a property entrance leads to (as
* PropertyWorlds), or its lowest id row when no entrance leads there. nullptr when the map has no row.
*/
inline const TemplateRow* WorldTemplate(const std::vector<TemplateRow>& templates, const std::vector<EntranceRow>& entrances, uint32_t mapId) {
const TemplateRow* entered = nullptr;
const TemplateRow* any = nullptr;
for (const auto& row : templates) {
if (row.mapId != mapId) continue;
if (!any || row.id < any->id) any = &row;
const bool isEntered = std::any_of(entrances.begin(), entrances.end(), [&](const EntranceRow& entrance) {
return entrance.mapId == row.mapId && entrance.propertyName == row.spawnName;
});
if (isEntered && (!entered || row.id < entered->id)) entered = &row;
}
return entered ? entered : any;
}
// A slot's location as stored (0: none) if it is still a property world, else the slot's own world
inline uint32_t Location(uint32_t stored, const Slot& slot, const std::vector<uint32_t>& worlds) {
return stored != 0 && std::find(worlds.begin(), worlds.end(), stored) != worlds.end() ? stored : slot.mapId;

View File

@@ -86,6 +86,7 @@ namespace {
{ "reports_review_flags", "Economy and map", "Review flags", "Mark economy flags dismissed or actioned", 3 },
{ "items_restore", "Economy and map", "Give items back", "Mail a traced item back to a player, or what a character lost since one of its snapshots (with original IDs when they no longer exist)", 8 },
{ "reports_run_checks", "Economy and map", "Run economy checks", "Run the anomaly checks by hand", 8 },
{ "property_rent_manage", "Economy and map", "Property rent", "Change the rent of property worlds on the Property Rent page", 8 },
{ "contraband_manage", "Economy and map", "Contraband list", "Add, change and remove contraband items (flagged, or removed from players, when a character has one)", 8 },
{ "play_keys_manage", "Server", "Play keys", "Create, edit and delete play keys, and see the key an account used", 8 },

View File

@@ -0,0 +1,85 @@
#ifndef __PROPERTYRENTRULES__H__
#define __PROPERTYRENTRULES__H__
#include <algorithm>
#include <cstdint>
#include <optional>
/**
* Property rent (issue #943), the rules without the game around them so they can be unit tested. Off unless
* property_rent_enabled is on. A property world's rent comes from its PropertyTemplate row (minimumPrice coins every
* rentDuration x durationType), unless the dashboard's Property Rent page sets another price or period for the world.
* A price of 0 (Block Yard's) means the property is free.
*
* Rent is charged when the owner's character loads in any world. Paying moves the due date one period on from now
* (missed periods are not charged afterwards). If the owner can't pay, the rent is overdue: after the grace period
* the property is made private and can't be made public (or best friends only) again until the rent is paid, like
* live. Nothing on the property changes.
*/
namespace PropertyRentRules {
constexpr int64_t DAY = 24 * 60 * 60;
struct Rate {
int64_t price{}; // coins per period; 0: free
int64_t periodSeconds{};
bool operator==(const Rate&) const = default;
};
/**
* PropertyTemplate's durationType as seconds: 1 is days (the test templates rent for 7 of them) and 6 is months
* (every property world rents for 1, and live charged monthly), counted as 30 days. The client only shows the
* number (UI_PROPERTY_LEASE_VALUE: "%(every) %(count) %(timeUnits)"), so other values are read as months too.
*/
inline int64_t DurationUnit(int32_t durationType) {
return durationType == 1 ? DAY : 30 * DAY;
}
// The template's rent; nullopt when it has none (no price, or no period)
inline std::optional<Rate> TemplateRate(int64_t minimumPrice, int32_t rentDuration, int32_t durationType) {
if (minimumPrice <= 0 || rentDuration <= 0) return std::nullopt;
return Rate{ minimumPrice, static_cast<int64_t>(rentDuration) * DurationUnit(durationType) };
}
// A dashboard override (price 0 makes the world free); a period of 0 days keeps the template's
inline std::optional<Rate> Resolve(const std::optional<Rate>& fromTemplate, std::optional<int64_t> overridePrice, std::optional<int64_t> overrideDays) {
if (!overridePrice && !overrideDays) return fromTemplate;
const int64_t price = overridePrice.value_or(fromTemplate ? fromTemplate->price : 0);
int64_t period = overrideDays && *overrideDays > 0 ? *overrideDays * DAY : (fromTemplate ? fromTemplate->periodSeconds : 30 * DAY);
if (price <= 0 || period <= 0) return std::nullopt;
return Rate{ price, period };
}
enum class eOutcome : uint8_t {
NOT_DUE, // nothing to do
PAID, // charge `charge` coins; the next rent is due at newDue
UNPAID, // due but the owner can't pay; overdue says whether the grace period is over
};
struct Decision {
eOutcome outcome{};
int64_t charge{};
int64_t newDue{};
bool overdue{};
bool operator==(const Decision&) const = default;
};
/**
* What to do when the owner loads. due: when the rent is due (0: never charged yet, due now); coins: what the owner
* has; grace: seconds after the due date before an unpaid property is made private.
*/
inline Decision Decide(const std::optional<Rate>& rate, int64_t due, int64_t now, int64_t coins, int64_t grace) {
if (!rate || rate->price <= 0) return { eOutcome::NOT_DUE, 0, due, false };
if (due > now) return { eOutcome::NOT_DUE, 0, due, false };
if (coins >= rate->price) return { eOutcome::PAID, rate->price, now + rate->periodSeconds, false };
// Never charged before counts as due from now, so the grace period starts now
const int64_t dueFrom = due == 0 ? now : due;
return { eOutcome::UNPAID, 0, dueFrom, now >= dueFrom + std::max<int64_t>(grace, 0) };
}
// Whether an unpaid property must be private: rent that was due (a charge was attempted) and the grace period is over
inline bool IsOverdue(const std::optional<Rate>& rate, int64_t due, int64_t now, int64_t grace) {
return rate && rate->price > 0 && due > 0 && now >= due + std::max<int64_t>(grace, 0);
}
}
#endif //!__PROPERTYRENTRULES__H__