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__

View File

@@ -73,6 +73,7 @@
#include "PublicRoutes.h"
#include "Showcase.h"
#include "ContrabandRoutes.h"
#include "PropertyRentRoutes.h"
#include "FeaturedProperties.h"
#include "PasswordRecovery.h"
#include "SettingsRoutes.h"
@@ -526,6 +527,7 @@ int main(int argc, char** argv) {
RegisterShowcaseRoutes();
FeaturedProperties::RegisterRoutes();
ContrabandRoutes::RegisterRoutes();
PropertyRentRoutes::RegisterRoutes();
RegisterPasswordRecoveryRoutes();
RegisterWSRoutes();
RegisterDashboardRoutes(); // Must be last - catches all unmatched routes

View File

@@ -53,6 +53,7 @@ set(DASHBOARDROUTES_SOURCES
"Showcase.cpp"
"FeaturedProperties.cpp"
"ContrabandRoutes.cpp"
"PropertyRentRoutes.cpp"
"PasswordRecovery.cpp"
"PrometheusMetrics.cpp"
"../email/SmtpClient.cpp"

View File

@@ -0,0 +1,111 @@
#include "PropertyRentRoutes.h"
#include <ctime>
#include "CDClientDatabase.h"
#include "Database.h"
#include "DashboardRoutes.h"
#include "dConfig.h"
#include "EconomyPlaces.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "HotPropertySlots.h"
#include "PropertyRentRules.h"
#include "RouteUtils.h"
#include "WSRoutes.h"
#include "eHTTPMethod.h"
using namespace RouteUtils;
namespace {
struct Templates {
std::vector<HotPropertySlots::TemplateRow> rows;
std::vector<HotPropertySlots::EntranceRow> entrances;
std::vector<uint32_t> worlds;
};
const Templates& GetTemplates() {
static const auto templates = [] {
Templates t;
auto rows = CDClientDatabase::ExecuteQuery("SELECT id, mapID, spawnName, minimumPrice, rentDuration, durationType, reputationPerMinute FROM PropertyTemplate;");
for (; !rows.eof(); rows.nextRow()) {
t.rows.push_back({ static_cast<uint32_t>(rows.getIntField("id")), static_cast<uint32_t>(rows.getIntField("mapID")), rows.getStringField("spawnName", ""),
rows.getIntField("minimumPrice", 0), rows.getIntField("rentDuration", 0), rows.getIntField("durationType", 0), rows.getIntField("reputationPerMinute", 0) });
}
auto entrances = CDClientDatabase::ExecuteQuery("SELECT mapID, propertyName FROM PropertyEntranceComponent;");
for (; !entrances.eof(); entrances.nextRow()) {
t.entrances.push_back({ static_cast<uint32_t>(entrances.getIntField("mapID")), entrances.getStringField("propertyName", "") });
}
t.worlds = HotPropertySlots::PropertyWorlds(t.rows, t.entrances);
return t;
}();
return templates;
}
nlohmann::json RateJson(const std::optional<PropertyRentRules::Rate>& rate) {
if (!rate) return nullptr;
return { {"price", rate->price}, {"periodDays", rate->periodSeconds / PropertyRentRules::DAY} };
}
}
namespace PropertyRentRoutes {
void RegisterRoutes() {
Route(eHTTPMethod::GET, "/property_rent", Perm("properties_view"), "What owners pay for each property world",
[](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "property_rent.jinja2", "property_rent"); });
Route(eHTTPMethod::GET, "/api/property_rent", Perm("properties_view"),
"Each property world's rent: {enabled, graceDays, canManage, worlds: [{mapId, name, template: {price, periodDays}|null, "
"override: {price, periodDays, updated_by, updated_at}|null, rent: {price, periodDays}|null}]} (rent null: free)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto& templates = GetTemplates();
const auto overrides = Database::Get()->GetPropertyRentRates();
nlohmann::json worlds = nlohmann::json::array();
for (const auto mapId : templates.worlds) {
const auto* row = HotPropertySlots::WorldTemplate(templates.rows, templates.entrances, mapId);
const auto fromTemplate = row ? PropertyRentRules::TemplateRate(row->minimumPrice, row->rentDuration, row->durationType) : std::nullopt;
nlohmann::json world{ {"mapId", mapId}, {"name", EconomyPlaces::ZoneName(mapId)}, {"template", RateJson(fromTemplate)}, {"override", nullptr},
{"rent", RateJson(fromTemplate)} };
for (const auto& rate : overrides) {
if (rate.mapId != mapId) continue;
world["override"] = { {"price", rate.price}, {"periodDays", rate.periodDays}, {"updated_by", rate.updatedBy}, {"updated_at", rate.updatedAt} };
world["rent"] = RateJson(PropertyRentRules::Resolve(fromTemplate, rate.price, rate.periodDays));
}
worlds.push_back(world);
}
JsonSuccess(reply, { {"enabled", Game::config->GetValue("property_rent_enabled") == "1"},
{"graceDays", GeneralUtils::TryParse<int64_t>(Game::config->GetValue("property_rent_grace_days")).value_or(3)},
{"canManage", Can(context, "property_rent_manage")}, {"worlds", worlds} });
});
Route(eHTTPMethod::POST, "/api/property_rent", Perm("property_rent_manage"),
"Set a property world's rent instead of its template's. Body: {mapId, price (coins, 0: free), periodDays (0: the template's)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto mapId = body->value("mapId", 0u);
const auto& worlds = GetTemplates().worlds;
if (std::find(worlds.begin(), worlds.end(), mapId) == worlds.end()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Not a property world");
const auto price = body->value("price", int64_t{ -1 });
const auto periodDays = body->value("periodDays", -1);
if (price < 0 || price > 1000000000) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The price must be 0 to 1000000000 coins");
if (periodDays < 0 || periodDays > 3650) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The period must be 0 to 3650 days");
Database::Get()->SetPropertyRentRate({ mapId, price, periodDays, context.authenticatedUser, static_cast<int64_t>(std::time(nullptr)) });
const auto name = EconomyPlaces::ZoneName(mapId);
Audit(context, "property_rent_set", name + " (" + std::to_string(mapId) + "): rent " + (price == 0 ? std::string("free") :
std::to_string(price) + " coins every " + (periodDays ? std::to_string(periodDays) + " days" : std::string("template period"))));
BroadcastTableChanged("property_rent");
JsonSuccess(reply, { {"message", "Rent for " + name + " saved"} });
});
Route(eHTTPMethod::POST, "/api/property_rent/delete", Perm("property_rent_manage"), "Go back to the template's rent for a property world. Body: {mapId}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
const auto mapId = body ? body->value("mapId", 0u) : 0u;
if (!Database::Get()->DeletePropertyRentRate(mapId)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "That world uses its template's rent already");
const auto name = EconomyPlaces::ZoneName(mapId);
Audit(context, "property_rent_reset", name + " (" + std::to_string(mapId) + "): rent back to its template's");
BroadcastTableChanged("property_rent");
JsonSuccess(reply, { {"message", name + " uses its template's rent again"} });
});
}
}

View File

@@ -0,0 +1,10 @@
#pragma once
/**
* The Property Rent page: each property world's rent from its PropertyTemplate row and the price and period staff
* set instead (IPropertyRent). Viewing needs properties_view, changing property_rent_manage. Rent is only charged
* while property_rent_enabled is on (see dCommon/PropertyRentRules.h).
*/
namespace PropertyRentRoutes {
void RegisterRoutes();
}

View File

@@ -183,6 +183,10 @@ namespace {
c.Add(When(List(WORLD, "hardcore_uscore_reduced_lots", "Reduced U-score enemies", "", eListOf::LOT), WORLD, "hardcore_mode", { "1" }));
c.Add(When(List(WORLD, "hardcore_uscore_excluded_enemies", "Enemies without U-score", "", eListOf::LOT), WORLD, "hardcore_mode", { "1" }));
c.AddSection("Property rent", "Owners pay rent for their properties, like live: each property world's PropertyTemplate price and period, or what the Property Rent page sets. Block Yard is free.");
c.Add(Bool(WORLD, "property_rent_enabled", "Charge rent", "Rent is taken from the owner's coins when they log in. Unpaid rent makes the property private until it is paid.", false));
c.Add(When(Unit(Int(WORLD, "property_rent_grace_days", "Grace period", "How long rent can be unpaid before the property is made private.", "3", 0, 365), "days"), WORLD, "property_rent_enabled", { "1" }));
c.AddSection("Events", "Event flags sent at login; they switch on event content in levels (e.g. Talk_Like_A_Pirate).", eLayout::GRID);
for (int i = 1; i <= 8; i++) c.Add(Text(SHARED, "event_" + std::to_string(i), "Event " + std::to_string(i), "", ""));

View File

@@ -0,0 +1,57 @@
/**
* The Property Rent page: each property world's rent from its template, and (with property_rent_manage) a price and
* period to charge instead.
*/
(function () {
'use strict';
var canManage = false;
function rate(r) {
if (!r) return '<span class="text-body-secondary">Free</span>';
return esc(r.price) + ' coins / ' + esc(r.periodDays) + ' days';
}
function row(w) {
var o = w.override;
var set = o ? esc(o.price) + ' coins' + (o.periodDays ? ' / ' + esc(o.periodDays) + ' days' : '') +
'<div class="small text-body-secondary">' + esc(o.updated_by) + ', ' + esc(fmt.unix(o.updated_at)) + '</div>' : '<span class="text-body-secondary">Template</span>';
var edit = canManage ? '<form class="d-flex gap-1 justify-content-end" data-map="' + esc(w.mapId) + '">' +
'<input type="number" class="form-control form-control-sm" style="width:7rem" min="0" name="price" placeholder="Coins" value="' + (o ? esc(o.price) : '') + '" aria-label="Price">' +
'<input type="number" class="form-control form-control-sm" style="width:6rem" min="0" name="days" placeholder="Days" value="' + (o && o.periodDays ? esc(o.periodDays) : '') + '" aria-label="Period in days">' +
'<button type="submit" class="btn btn-sm btn-primary">Save</button>' +
(o ? '<button type="button" class="btn btn-sm btn-outline-secondary" data-reset="' + esc(w.mapId) + '">Template</button>' : '') + '</form>' : '';
return '<tr><td>' + esc(w.name) + ' <span class="text-body-secondary small">' + esc(w.mapId) + '</span></td><td>' + rate(w.template) + '</td><td>' + set +
'</td><td>' + rate(w.rent) + '</td><td>' + edit + '</td></tr>';
}
function load() {
api.get('/api/property_rent').then(function (d) {
if (!d.success) return;
canManage = d.canManage;
document.getElementById('offNote').classList.toggle('d-none', d.enabled);
document.getElementById('rows').innerHTML = d.worlds.map(row).join('') || '<tr><td colspan="5" class="text-body-secondary">No property worlds found in the CDClient.</td></tr>';
}).catch(function () {});
}
function done(d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
toast(d.message, 'success');
load();
}
var rows = document.getElementById('rows');
rows.addEventListener('submit', function (e) {
e.preventDefault();
var form = e.target;
var price = parseInt(form.price.value, 10), days = parseInt(form.days.value || '0', 10);
if (!(price >= 0)) { toast('Enter a price in coins (0: free)', 'warning'); return; }
api.post('/api/property_rent', { mapId: parseInt(form.dataset.map, 10), price: price, periodDays: days >= 0 ? days : 0 }).then(done);
});
rows.addEventListener('click', function (e) {
var reset = e.target.closest('[data-reset]');
if (reset) api.post('/api/property_rent/delete', { mapId: parseInt(reset.dataset.reset, 10) }).then(done);
});
load();
})();

View File

@@ -48,8 +48,8 @@
</div>
{% endif %}
{% set worldPages = ["world3d", "reports", "contraband"] %}
{% if can.players_view or can.reports_view %}
{% set worldPages = ["world3d", "reports", "contraband", "property_rent"] %}
{% if can.players_view or can.reports_view or can.properties_view %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-world" aria-expanded="{% if current_page in worldPages %}true{% else %}false{% endif %}" aria-controls="nav-world">
World &amp; Economy<span class="nav-group-dot" title="Something in here needs attention"></span>
@@ -59,6 +59,7 @@
{% if can.players_view %}<a href="/world3d" class="list-group-item list-group-item-action{% if current_page == "world3d" %} active{% endif %}">World 3D</a>{% endif %}
{% if can.reports_view %}<a href="/reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "reports" %} active{% endif %}">Economy &amp; Map <span class="badge rounded-pill text-bg-danger d-none" data-badge="reports" title="Open economy flags"></span></a>{% endif %}
{% if can.reports_view %}<a href="/contraband" class="list-group-item list-group-item-action{% if current_page == "contraband" %} active{% endif %}">Contraband</a>{% endif %}
{% if can.properties_view %}<a href="/property_rent" class="list-group-item list-group-item-action{% if current_page == "property_rent" %} active{% endif %}">Property Rent</a>{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,25 @@
{% extends "base.jinja2" %}
{% block title %}Property Rent - DarkflameServer{% endblock %}
{% block content %}
<div id="propertyRentPage">
<div class="mb-3">
<h2 class="mb-1">Property Rent</h2>
<p class="text-body-secondary mb-0">What owners pay for each property world, like live. Rent is taken from the owner's coins when they log in;
if they can't pay, their property is made private after the grace period and stays private until it is paid (nothing on it changes).
Each world starts with its PropertyTemplate rent (Block Yard is free); set a price here to change it, 0 to make it free.</p>
</div>
<div class="alert alert-secondary d-none" id="offNote">Rent is off: turn on <a href="/settings#property_rent_enabled"><code>property_rent_enabled</code></a> in Settings to charge it.</div>
<div class="card">
<table class="table table-sm align-middle mb-0 table-stack">
<thead><tr><th>World</th><th>Template</th><th>Set here</th><th>Charged</th><th></th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/property-rent.js"></script>
{% endblock %}

View File

@@ -48,6 +48,7 @@
#include "IFeaturedProperties.h"
#include "IMessageCaptures.h"
#include "IContraband.h"
#include "IPropertyRent.h"
#ifdef _DEBUG
# define DLU_SQL_TRY_CATCH_RETHROW(x) do { try { x; } catch (std::exception& ex) { LOG("SQL Error: %s", ex.what()); throw; } } while(0)
@@ -61,7 +62,7 @@ class GameDatabase :
public IPropertyContents, public IProperty, public IPetNames, public ICharXml,
public IMigrationHistory, public IUgc, public IFriends, public ICharInfo,
public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList,
public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband {
public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent {
public:
virtual ~GameDatabase() = default;
// TODO: These should be made private.

View File

@@ -0,0 +1,45 @@
#ifndef __IPROPERTYRENT__H__
#define __IPROPERTYRENT__H__
#include <cstdint>
#include <string>
#include <vector>
#include "dCommonVars.h"
/**
* Property rent (see dCommon/PropertyRentRules.h): the dashboard's per-world prices and what an owner's properties owe.
*/
class IPropertyRent {
public:
struct RentRate {
uint32_t mapId{};
int64_t price{}; // 0: free
int32_t periodDays{}; // 0: the PropertyTemplate's period
std::string updatedBy;
int64_t updatedAt{};
};
struct OwnedProperty {
LWOOBJID id{};
uint32_t zoneId{};
int32_t privacyOption{};
int64_t rentDue{}; // 0: never charged
std::string name;
};
virtual std::vector<RentRate> GetPropertyRentRates() = 0;
// Insert or replace the row of rate.mapId
virtual void SetPropertyRentRate(const RentRate& rate) = 0;
virtual bool DeletePropertyRentRate(uint32_t mapId) = 0;
// The properties a character owns
virtual std::vector<OwnedProperty> GetPropertiesOfOwner(LWOOBJID ownerId) = 0;
// The property's rent_due (0 when there is no such property)
virtual int64_t GetPropertyRentDue(LWOOBJID propertyId) = 0;
// Record a payment (or where the grace period starts): rent_amount and rent_due
virtual void SetPropertyRent(LWOOBJID propertyId, int64_t amount, int64_t due) = 0;
virtual void SetPropertyPrivacy(LWOOBJID propertyId, int32_t privacyOption) = 0;
};
#endif //!__IPROPERTYRENT__H__

View File

@@ -215,6 +215,15 @@ public:
std::vector<ZoneLimit> GetZoneLimits() override;
void SetZoneLimit(const ZoneLimit& limit) override;
void DeleteZoneLimit(uint32_t zoneId) override;
// IPropertyRent
std::vector<RentRate> GetPropertyRentRates() override;
void SetPropertyRentRate(const RentRate& rate) override;
bool DeletePropertyRentRate(uint32_t mapId) override;
std::vector<OwnedProperty> GetPropertiesOfOwner(LWOOBJID ownerId) override;
int64_t GetPropertyRentDue(LWOOBJID propertyId) override;
void SetPropertyRent(LWOOBJID propertyId, int64_t amount, int64_t due) override;
void SetPropertyPrivacy(LWOOBJID propertyId, int32_t privacyOption) override;
// IContraband
std::vector<ContrabandItem> GetContrabandItems() override;
void SetContrabandItem(const ContrabandItem& item) override;

View File

@@ -19,6 +19,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES
"LiveOps.cpp"
"FeaturedProperties.cpp"
"Contraband.cpp"
"PropertyRent.cpp"
"MessageCaptures.cpp"
"ChatLog.cpp"
"RelatedData.cpp"

View File

@@ -0,0 +1,43 @@
#include "MySQLDatabase.h"
std::vector<IPropertyRent::RentRate> MySQLDatabase::GetPropertyRentRates() {
std::vector<RentRate> rates;
auto result = ExecuteSelect("SELECT * FROM property_rent_rates ORDER BY map_id;");
while (result->next()) {
rates.push_back({ result->getUInt("map_id"), result->getInt64("price"), result->getInt("period_days"), result->getString("updated_by").c_str(), result->getInt64("updated_at") });
}
return rates;
}
void MySQLDatabase::SetPropertyRentRate(const RentRate& rate) {
ExecuteInsert(
"INSERT INTO property_rent_rates (map_id, price, period_days, updated_by, updated_at) VALUES (?, ?, ?, ?, ?) "
"ON DUPLICATE KEY UPDATE price = VALUES(price), period_days = VALUES(period_days), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at);",
rate.mapId, rate.price, rate.periodDays, rate.updatedBy, rate.updatedAt);
}
bool MySQLDatabase::DeletePropertyRentRate(uint32_t mapId) {
return ExecuteUpdate("DELETE FROM property_rent_rates WHERE map_id = ?;", mapId) > 0;
}
std::vector<IPropertyRent::OwnedProperty> MySQLDatabase::GetPropertiesOfOwner(LWOOBJID ownerId) {
std::vector<OwnedProperty> properties;
auto result = ExecuteSelect("SELECT id, zone_id, privacy_option, rent_due, name FROM properties WHERE owner_id = ? ORDER BY id;", ownerId);
while (result->next()) {
properties.push_back({ result->getInt64("id"), result->getUInt("zone_id"), result->getInt("privacy_option"), result->getInt64("rent_due"), result->getString("name").c_str() });
}
return properties;
}
int64_t MySQLDatabase::GetPropertyRentDue(LWOOBJID propertyId) {
auto result = ExecuteSelect("SELECT rent_due FROM properties WHERE id = ? LIMIT 1;", propertyId);
return result->next() ? result->getInt64("rent_due") : 0;
}
void MySQLDatabase::SetPropertyRent(LWOOBJID propertyId, int64_t amount, int64_t due) {
ExecuteUpdate("UPDATE properties SET rent_amount = ?, rent_due = ? WHERE id = ?;", amount, due, propertyId);
}
void MySQLDatabase::SetPropertyPrivacy(LWOOBJID propertyId, int32_t privacyOption) {
ExecuteUpdate("UPDATE properties SET privacy_option = ? WHERE id = ?;", privacyOption, propertyId);
}

View File

@@ -199,6 +199,15 @@ public:
std::vector<ZoneLimit> GetZoneLimits() override;
void SetZoneLimit(const ZoneLimit& limit) override;
void DeleteZoneLimit(uint32_t zoneId) override;
// IPropertyRent
std::vector<RentRate> GetPropertyRentRates() override;
void SetPropertyRentRate(const RentRate& rate) override;
bool DeletePropertyRentRate(uint32_t mapId) override;
std::vector<OwnedProperty> GetPropertiesOfOwner(LWOOBJID ownerId) override;
int64_t GetPropertyRentDue(LWOOBJID propertyId) override;
void SetPropertyRent(LWOOBJID propertyId, int64_t amount, int64_t due) override;
void SetPropertyPrivacy(LWOOBJID propertyId, int32_t privacyOption) override;
// IContraband
std::vector<ContrabandItem> GetContrabandItems() override;
void SetContrabandItem(const ContrabandItem& item) override;

View File

@@ -19,6 +19,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES
"LiveOps.cpp"
"FeaturedProperties.cpp"
"Contraband.cpp"
"PropertyRent.cpp"
"MessageCaptures.cpp"
"ChatLog.cpp"
"RelatedData.cpp"

View File

@@ -0,0 +1,45 @@
#include "SQLiteDatabase.h"
std::vector<IPropertyRent::RentRate> SQLiteDatabase::GetPropertyRentRates() {
std::vector<RentRate> rates;
auto [_, result] = ExecuteSelect("SELECT * FROM property_rent_rates ORDER BY map_id;");
for (; !result.eof(); result.nextRow()) {
rates.push_back({ static_cast<uint32_t>(result.getIntField("map_id")), result.getInt64Field("price"), result.getIntField("period_days"),
result.getStringField("updated_by"), result.getInt64Field("updated_at") });
}
return rates;
}
void SQLiteDatabase::SetPropertyRentRate(const RentRate& rate) {
ExecuteInsert(
"INSERT INTO property_rent_rates (map_id, price, period_days, updated_by, updated_at) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(map_id) DO UPDATE SET price = excluded.price, period_days = excluded.period_days, updated_by = excluded.updated_by, updated_at = excluded.updated_at;",
rate.mapId, rate.price, rate.periodDays, rate.updatedBy, rate.updatedAt);
}
bool SQLiteDatabase::DeletePropertyRentRate(uint32_t mapId) {
return ExecuteUpdate("DELETE FROM property_rent_rates WHERE map_id = ?;", mapId) > 0;
}
std::vector<IPropertyRent::OwnedProperty> SQLiteDatabase::GetPropertiesOfOwner(LWOOBJID ownerId) {
std::vector<OwnedProperty> properties;
auto [_, result] = ExecuteSelect("SELECT id, zone_id, privacy_option, rent_due, name FROM properties WHERE owner_id = ? ORDER BY id;", ownerId);
for (; !result.eof(); result.nextRow()) {
properties.push_back({ result.getInt64Field("id"), static_cast<uint32_t>(result.getIntField("zone_id")), result.getIntField("privacy_option"),
result.getInt64Field("rent_due"), result.getStringField("name") });
}
return properties;
}
int64_t SQLiteDatabase::GetPropertyRentDue(LWOOBJID propertyId) {
auto [_, result] = ExecuteSelect("SELECT rent_due FROM properties WHERE id = ? LIMIT 1;", propertyId);
return result.eof() ? 0 : result.getInt64Field("rent_due");
}
void SQLiteDatabase::SetPropertyRent(LWOOBJID propertyId, int64_t amount, int64_t due) {
ExecuteUpdate("UPDATE properties SET rent_amount = ?, rent_due = ? WHERE id = ?;", amount, due, propertyId);
}
void SQLiteDatabase::SetPropertyPrivacy(LWOOBJID propertyId, int32_t privacyOption) {
ExecuteUpdate("UPDATE properties SET privacy_option = ? WHERE id = ?;", privacyOption, propertyId);
}

View File

@@ -245,6 +245,15 @@ class TestSQLDatabase : public GameDatabase {
std::vector<ZoneLimit> GetZoneLimits() override { return {}; }
void SetZoneLimit(const ZoneLimit& limit) override {}
void DeleteZoneLimit(uint32_t zoneId) override {}
// IPropertyRent
std::vector<RentRate> GetPropertyRentRates() override { return {}; }
void SetPropertyRentRate(const RentRate& rate) override {}
bool DeletePropertyRentRate(uint32_t mapId) override { return false; }
std::vector<OwnedProperty> GetPropertiesOfOwner(LWOOBJID ownerId) override { return {}; }
int64_t GetPropertyRentDue(LWOOBJID propertyId) override { return 0; }
void SetPropertyRent(LWOOBJID propertyId, int64_t amount, int64_t due) override {}
void SetPropertyPrivacy(LWOOBJID propertyId, int32_t privacyOption) override {}
// IContraband
std::vector<ContrabandItem> GetContrabandItems() override { return {}; }
void SetContrabandItem(const ContrabandItem& item) override {}

View File

@@ -1,4 +1,6 @@
#include "PropertyManagementComponent.h"
#include "PropertyRent.h"
#include "ChatPackets.h"
#include "DashboardNotify.h"
#include <sstream>
@@ -77,6 +79,13 @@ PropertyManagementComponent::PropertyManagementComponent(Entity* parent, const i
this->claimedTime = propertyInfo->claimedTime;
this->reputation = propertyInfo->reputation;
// Rent that went unpaid while the owner was away makes the property private
if (privacyOption != PropertyPrivacyOption::Private && PropertyRent::IsOverdue(propertyId, zoneId)) {
privacyOption = PropertyPrivacyOption::Private;
Database::Get()->SetPropertyPrivacy(propertyId, static_cast<int32_t>(privacyOption));
LOG("Property %llu is private: its rent is unpaid", propertyId);
}
Load();
}
}
@@ -134,6 +143,13 @@ void PropertyManagementComponent::SetPrivacyOption(PropertyPrivacyOption value)
value = PropertyPrivacyOption::Private;
}
// Unpaid rent keeps the property private, like live
if (value != PropertyPrivacyOption::Private && PropertyRent::IsOverdue(propertyId, Game::zoneManager->GetZoneID().GetMapID())) {
value = PropertyPrivacyOption::Private;
auto* ownerEntity = GetOwner();
if (ownerEntity) ChatPackets::SendSystemMessage(ownerEntity->GetSystemAddress(), u"Your property's rent is unpaid, so it stays private until the rent is paid.");
}
if (value == PropertyPrivacyOption::Public && privacyOption != PropertyPrivacyOption::Public) {
rejectionReason = "";
moderatorRequested = true;

View File

@@ -7,6 +7,7 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp"
"GUID.cpp"
"LiveEvents.cpp"
"Contraband.cpp"
"PropertyRent.cpp"
"Loot.cpp"
"Mail.cpp"
"MessageInspector.cpp"

View File

@@ -0,0 +1,127 @@
#include "PropertyRent.h"
#include <ctime>
#include <vector>
#include "CDClientDatabase.h"
#include "Character.h"
#include "ChatPackets.h"
#include "Database.h"
#include "DashboardNotify.h"
#include "dConfig.h"
#include "eLootSourceType.h"
#include "ePropertyPrivacyOption.h"
#include "Entity.h"
#include "EntityManager.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "Logger.h"
#include "Mail.h"
namespace {
struct Templates {
std::vector<HotPropertySlots::TemplateRow> rows;
std::vector<HotPropertySlots::EntranceRow> entrances;
};
const Templates& GetTemplates() {
static const auto templates = [] {
Templates t;
auto rows = CDClientDatabase::ExecuteQuery("SELECT id, mapID, spawnName, minimumPrice, rentDuration, durationType, reputationPerMinute FROM PropertyTemplate;");
for (; !rows.eof(); rows.nextRow()) {
t.rows.push_back({ static_cast<uint32_t>(rows.getIntField("id")), static_cast<uint32_t>(rows.getIntField("mapID")), rows.getStringField("spawnName", ""),
rows.getIntField("minimumPrice", 0), rows.getIntField("rentDuration", 0), rows.getIntField("durationType", 0), rows.getIntField("reputationPerMinute", 0) });
}
auto entrances = CDClientDatabase::ExecuteQuery("SELECT mapID, propertyName FROM PropertyEntranceComponent;");
for (; !entrances.eof(); entrances.nextRow()) {
t.entrances.push_back({ static_cast<uint32_t>(entrances.getIntField("mapID")), entrances.getStringField("propertyName", "") });
}
return t;
}();
return templates;
}
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
int64_t GraceSeconds() {
return GeneralUtils::TryParse<int64_t>(Game::config->GetValue("property_rent_grace_days")).value_or(3) * PropertyRentRules::DAY;
}
std::string Coins(int64_t coins) { return std::to_string(coins) + " coins"; }
std::string Days(int64_t seconds) { return std::to_string(seconds / PropertyRentRules::DAY) + " days"; }
void Charge(LWOOBJID playerId) {
auto* player = Game::entityManager->GetEntity(playerId);
auto* character = player ? player->GetCharacter() : nullptr;
if (!character || !PropertyRent::Enabled()) return;
const auto grace = GraceSeconds();
for (const auto& property : Database::Get()->GetPropertiesOfOwner(character->GetID())) {
const auto rate = PropertyRent::RateFor(property.zoneId);
const auto now = Now();
const auto decision = PropertyRentRules::Decide(rate, property.rentDue, now, character->GetCoins(), grace);
const auto name = property.name.empty() ? "your property" : property.name;
switch (decision.outcome) {
case PropertyRentRules::eOutcome::NOT_DUE:
break;
case PropertyRentRules::eOutcome::PAID:
character->SetCoins(character->GetCoins() - decision.charge, eLootSourceType::PROPERTY);
Database::Get()->SetPropertyRent(property.id, decision.charge, decision.newDue);
DashboardNotify::Changed("properties", property.id);
LOG("Charged %lld coins rent for property %llu of %llu:%s", decision.charge, property.id, character->GetID(), character->GetName().c_str());
Mail::SendMail(player, "Rent paid", "You paid " + Coins(decision.charge) + " rent for " + name + ". The next rent is due in " +
Days(rate->periodSeconds) + "; it is taken from your coins when you log in.", LOT_NULL, 0);
break;
case PropertyRentRules::eOutcome::UNPAID: {
const bool firstTime = property.rentDue != decision.newDue;
if (firstTime) Database::Get()->SetPropertyRent(property.id, rate->price, decision.newDue);
const bool makePrivate = decision.overdue && property.privacyOption != static_cast<int32_t>(PropertyPrivacyOption::Private);
if (makePrivate) {
Database::Get()->SetPropertyPrivacy(property.id, static_cast<int32_t>(PropertyPrivacyOption::Private));
DashboardNotify::Changed("properties", property.id);
LOG("Property %llu of %llu:%s is private: its rent of %lld coins is unpaid", property.id, character->GetID(), character->GetName().c_str(), rate->price);
}
// Tell them when it first became due and when the property is made private, not on every login
if (firstTime || makePrivate) {
Mail::SendMail(player, "Rent due", "The rent for " + name + " is " + Coins(rate->price) + ", but you don't have enough coins. " +
(decision.overdue ? std::string("Your property is private until the rent is paid; it is taken from your coins the next time you log in with enough.")
: "If it isn't paid within " + Days(decision.newDue + grace - now) + ", your property becomes private until it is."), LOT_NULL, 0);
}
break;
}
}
}
}
}
namespace PropertyRent {
bool Enabled() {
return Game::config && Game::config->GetValue("property_rent_enabled") == "1";
}
const HotPropertySlots::TemplateRow* WorldTemplate(uint32_t mapId) {
const auto& templates = GetTemplates();
return HotPropertySlots::WorldTemplate(templates.rows, templates.entrances, mapId);
}
std::optional<PropertyRentRules::Rate> RateFor(uint32_t mapId) {
const auto* row = WorldTemplate(mapId);
const auto fromTemplate = row ? PropertyRentRules::TemplateRate(row->minimumPrice, row->rentDuration, row->durationType) : std::nullopt;
for (const auto& rate : Database::Get()->GetPropertyRentRates()) {
if (rate.mapId == mapId) return PropertyRentRules::Resolve(fromTemplate, rate.price, rate.periodDays);
}
return fromTemplate;
}
void OnOwnerLoaded(Entity* player) {
if (!player || !Enabled()) return;
const auto playerId = player->GetObjectID();
player->AddCallbackTimer(5.0f, [playerId]() { Charge(playerId); });
}
bool IsOverdue(LWOOBJID propertyId, uint32_t mapId) {
if (!Enabled()) return false;
return PropertyRentRules::IsOverdue(RateFor(mapId), Database::Get()->GetPropertyRentDue(propertyId), Now(), GraceSeconds());
}
}

View File

@@ -0,0 +1,34 @@
#ifndef __PROPERTYRENT__H__
#define __PROPERTYRENT__H__
#include <cstdint>
#include <optional>
#include "dCommonVars.h"
#include "HotPropertySlots.h"
#include "PropertyRentRules.h"
class Entity;
/**
* Property rent in this world (issue #943; the rules are in PropertyRentRules.h). Off unless property_rent_enabled
* is on.
*/
namespace PropertyRent {
bool Enabled();
// The PropertyTemplate row a property world uses (see HotPropertySlots::WorldTemplate); nullptr when it has none
const HotPropertySlots::TemplateRow* WorldTemplate(uint32_t mapId);
// The world's rent: the template's, or the dashboard's price and period for it. nullopt: free
std::optional<PropertyRentRules::Rate> RateFor(uint32_t mapId);
// Charge the rent of every property the player owns that is due (a few seconds after their character loads, so
// the client shows the coins and the mail), and make unpaid ones private once the grace period is over
void OnOwnerLoaded(Entity* player);
// Whether the property's rent is overdue, so it has to stay private
bool IsOverdue(LWOOBJID propertyId, uint32_t mapId);
}
#endif //!__PROPERTYRENT__H__

View File

@@ -7,6 +7,7 @@
#include "master/MessageCapture.h"
#include "MessageInspector.h"
#include "Contraband.h"
#include "PropertyRent.h"
#include "LiveEvents.h"
#include <iostream>
#include <string>
@@ -955,6 +956,7 @@ void LoadPlayer(const SystemAddress& sysAddr) {
// Contraband is flagged (and removed if its entry says so) before the character is saved and sent
Contraband::CheckOnLoad(player);
PropertyRent::OnOwnerLoaded(player);
// Update the characters xml to ensure the update above is not only saved, but so the client picks up on the changes.
c->SaveXMLToDatabase();

View File

@@ -884,6 +884,21 @@ name. Players see changes the next time they open the news screen, within 30 sec
`GET /api/featured_properties/:template/candidates?location=&search=`,
`POST /api/featured_properties/:template {location, mode, property_id}`.
### Property rent
Off unless `property_rent_enabled` is on (Settings, Gameplay, Property rent). Owners then pay rent for their
properties like live: each property world costs its PropertyTemplate's `minimumPrice` every `rentDuration`
`durationType` (type 1 counts days, the rest months of 30 days; every property world is 1 month, Block Yard is free),
unless the **Property Rent** page (World & Economy; `properties_view` to see it, `property_rent_manage` (GM 8) to
change it) sets another price (0: free) or period for the world.
Rent is taken from the owner's coins a few seconds after their character loads in any world; paying moves the due
date one period on from then (missed periods aren't charged later) and the owner gets a mail receipt. If they don't
have enough coins they get a mail, and once `property_rent_grace_days` (3) have passed since the rent was due the
property is made private. It can't be made public or best friends only again until the rent is paid (the owner is
told in chat when they try), and a property world that loads with overdue rent makes itself private too. Nothing on
the property changes. The property's page shows the rent last charged and when the next is due.
### Properties in 3D
**Open 3D view** on a property page (or *View in 3D* next to a model) shows every placed model on the zone's terrain,

View File

@@ -0,0 +1,9 @@
/* Property rent: a price (coins, 0: free) and period (days, 0: the template's) per property world set on the
dashboard, overriding PropertyTemplate's. properties.rent_due (unused until now) is when the next rent is due. */
CREATE TABLE IF NOT EXISTS property_rent_rates (
map_id INT NOT NULL PRIMARY KEY,
price BIGINT NOT NULL DEFAULT 0,
period_days INT NOT NULL DEFAULT 0,
updated_by VARCHAR(64) NOT NULL DEFAULT '',
updated_at BIGINT NOT NULL DEFAULT 0
);

View File

@@ -0,0 +1,8 @@
/* property_rent_rates: see the MySQL migration. */
CREATE TABLE IF NOT EXISTS property_rent_rates (
map_id INTEGER NOT NULL PRIMARY KEY,
price BIGINT NOT NULL DEFAULT 0,
period_days INTEGER NOT NULL DEFAULT 0,
updated_by TEXT NOT NULL DEFAULT '',
updated_at BIGINT NOT NULL DEFAULT 0
);

View File

@@ -26,6 +26,7 @@ set(DCOMMONTEST_SOURCES
"LiveOpsRulesTests.cpp"
"ProcessAndLimitsTests.cpp"
"HotPropertySlotsTests.cpp"
"PropertyRentRulesTests.cpp"
)
add_subdirectory(dEnumsTests)

View File

@@ -0,0 +1,64 @@
#include <gtest/gtest.h>
#include "HotPropertySlots.h"
#include "PropertyRentRules.h"
using namespace PropertyRentRules;
TEST(PropertyRentRulesTests, TemplateRates) {
// Block Yard: free
EXPECT_EQ(TemplateRate(0, 1, 0), std::nullopt);
// Property worlds: 1 month
EXPECT_EQ(TemplateRate(1000, 1, 6), (Rate{ 1000, 30 * DAY }));
// The test templates: 7 days
EXPECT_EQ(TemplateRate(10000, 7, 1), (Rate{ 10000, 7 * DAY }));
EXPECT_EQ(TemplateRate(500, 0, 6), std::nullopt);
}
TEST(PropertyRentRulesTests, OverridesWin) {
const auto month = TemplateRate(1000, 1, 6);
EXPECT_EQ(Resolve(month, std::nullopt, std::nullopt), month);
EXPECT_EQ(Resolve(month, 0, 0), std::nullopt); // made free
EXPECT_EQ(Resolve(month, 250, 0), (Rate{ 250, 30 * DAY })); // the template's period
EXPECT_EQ(Resolve(month, 250, 7), (Rate{ 250, 7 * DAY }));
EXPECT_EQ(Resolve(std::nullopt, 100, 0), (Rate{ 100, 30 * DAY })); // a free world given a price
}
TEST(PropertyRentRulesTests, Charging) {
const Rate rate{ 1000, 30 * DAY };
const int64_t now = 1'800'000'000, grace = 3 * DAY;
// Free and not yet due
EXPECT_EQ(Decide(std::nullopt, 0, now, 0, grace).outcome, eOutcome::NOT_DUE);
EXPECT_EQ(Decide(rate, now + 10, now, 5000, grace).outcome, eOutcome::NOT_DUE);
// Never charged: due now; paying moves it one period on
EXPECT_EQ(Decide(rate, 0, now, 5000, grace), (Decision{ eOutcome::PAID, 1000, now + 30 * DAY, false }));
// Long overdue: only one period is charged, counted from now
EXPECT_EQ(Decide(rate, now - 90 * DAY, now, 5000, grace), (Decision{ eOutcome::PAID, 1000, now + 30 * DAY, false }));
// Can't pay: the grace period starts at the due date (now, when never charged)
EXPECT_EQ(Decide(rate, 0, now, 999, grace), (Decision{ eOutcome::UNPAID, 0, now, false }));
EXPECT_EQ(Decide(rate, now - DAY, now, 0, grace), (Decision{ eOutcome::UNPAID, 0, now - DAY, false }));
EXPECT_EQ(Decide(rate, now - 3 * DAY, now, 0, grace), (Decision{ eOutcome::UNPAID, 0, now - 3 * DAY, true }));
EXPECT_TRUE(Decide(rate, 0, now, 0, 0).overdue);
}
TEST(PropertyRentRulesTests, Overdue) {
const Rate rate{ 1000, 30 * DAY };
const int64_t now = 1'800'000'000, grace = 3 * DAY;
EXPECT_FALSE(IsOverdue(std::nullopt, now - 90 * DAY, now, grace));
EXPECT_FALSE(IsOverdue(rate, 0, now, grace)); // never charged
EXPECT_FALSE(IsOverdue(rate, now + DAY, now, grace)); // paid
EXPECT_FALSE(IsOverdue(rate, now - DAY, now, grace)); // in the grace period
EXPECT_TRUE(IsOverdue(rate, now - 3 * DAY, now, grace));
}
TEST(PropertyRentRulesTests, WorldTemplate) {
using namespace HotPropertySlots;
const std::vector<TemplateRow> rows{ { 25218, 58001, "AGSmallProperty", 1, 1, 0 }, { 25166, 1150, "AGSmallProperty", 0, 1, 0 },
{ 25168, 1151, "AGMedProperty", 1000, 1, 6 }, { 25250, 1150, "Other", 5, 1, 1 } };
const std::vector<EntranceRow> entrances{ { 1150, "AGSmallProperty" }, { 1151, "AGMedProperty" } };
ASSERT_NE(WorldTemplate(rows, entrances, 1150), nullptr);
EXPECT_EQ(WorldTemplate(rows, entrances, 1150)->id, 25166u);
EXPECT_EQ(WorldTemplate(rows, entrances, 1151)->minimumPrice, 1000);
EXPECT_EQ(WorldTemplate(rows, entrances, 58001)->id, 25218u); // no entrance: its lowest row
EXPECT_EQ(WorldTemplate(rows, entrances, 1), nullptr);
}

View File

@@ -99,6 +99,8 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILiveOps::Challenge, id, title, description,
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILiveOps::ChallengeTotal, total, contributors);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILiveOps::Reward, challengeId, characterId, amount, coins, rewardedAt, claimedAt);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IFeaturedProperties::FeaturedSlot, templateId, mode, propertyId, updatedAt, updatedBy, zoneId);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IPropertyRent::RentRate, mapId, price, periodDays, updatedBy, updatedAt);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IPropertyRent::OwnedProperty, id, zoneId, privacyOption, rentDue, name);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IContraband::ContrabandItem, lot, reason, action, addedBy, addedAt);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IFeaturedProperties::FeaturedSettings, fullAuto, updatedAt, updatedBy);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IMessageCaptures::MessageCaptureSession, id, characterId, characterName, accountId, accountName, startedById, startedBy, startedAt, endsAt, endedAt, endReason, toServer, toClient, onlyMessages, skipMessages, zoneId, instanceId, cloneId, zones, messageCount, byteCount, dropped);
@@ -1396,6 +1398,25 @@ TEST_F(ParitySeeded, FeaturedProperties) {
});
}
TEST_F(ParitySeeded, PropertyRent) {
Both("SetPropertyRentRate", [](GameDatabase& db) {
db.SetPropertyRentRate({ 1151, 1000, 0, "admin", 1700000000 });
db.SetPropertyRentRate({ 1250, 500, 7, "admin", 1700000001 });
db.SetPropertyRentRate({ 1151, 0, 30, "mod", 1700000002 });
return db.GetPropertyRentRates();
});
Both("DeletePropertyRentRate", [](GameDatabase& db) { return json{ db.DeletePropertyRentRate(1250), db.DeletePropertyRentRate(1250), db.GetPropertyRentRates() }; });
const auto owned = Both("GetPropertiesOfOwner", [](GameDatabase& db) { return db.GetPropertiesOfOwner(CHAR_ALICE); });
Both("GetPropertiesOfOwner nobody", [](GameDatabase& db) { return db.GetPropertiesOfOwner(42); });
ASSERT_FALSE(owned.empty());
const auto propertyId = owned[0]["id"].get<LWOOBJID>();
Both("SetPropertyRent", [&](GameDatabase& db) {
db.SetPropertyRent(propertyId, 1000, 1800000000);
db.SetPropertyPrivacy(propertyId, 0);
return json{ db.GetPropertyRentDue(propertyId), db.GetPropertyRentDue(42), db.GetPropertiesOfOwner(CHAR_ALICE) };
});
}
TEST_F(ParitySeeded, Contraband) {
Both("GetContrabandItems empty", [](GameDatabase& db) { return db.GetContrabandItems(); });
Both("SetContrabandItem", [](GameDatabase& db) {