mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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:
@@ -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
|
||||
|
||||
@@ -53,6 +53,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"Showcase.cpp"
|
||||
"FeaturedProperties.cpp"
|
||||
"ContrabandRoutes.cpp"
|
||||
"PropertyRentRoutes.cpp"
|
||||
"PasswordRecovery.cpp"
|
||||
"PrometheusMetrics.cpp"
|
||||
"../email/SmtpClient.cpp"
|
||||
|
||||
111
dDashboardServer/routes/PropertyRentRoutes.cpp
Normal file
111
dDashboardServer/routes/PropertyRentRoutes.cpp
Normal 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"} });
|
||||
});
|
||||
}
|
||||
}
|
||||
10
dDashboardServer/routes/PropertyRentRoutes.h
Normal file
10
dDashboardServer/routes/PropertyRentRoutes.h
Normal 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();
|
||||
}
|
||||
@@ -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), "", ""));
|
||||
|
||||
|
||||
57
dDashboardServer/static/js/property-rent.js
Normal file
57
dDashboardServer/static/js/property-rent.js
Normal 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();
|
||||
})();
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
25
dDashboardServer/templates/property_rent.jinja2
Normal file
25
dDashboardServer/templates/property_rent.jinja2
Normal 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 %}
|
||||
Reference in New Issue
Block a user