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

@@ -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 %}