feat: property reputation from visitors, resistant to farming

Property worlds now give their property reputation for the time other people spend on it, which
fills the property lists and the news screen's Today's Top Properties. Visitors are counted per
account; the owner's account, accounts linked to it and staff don't count. A visit earns nothing
for the first property_reputation_min_visit seconds (WorldConfig's propertyReputationDelay), then
each active minute (the visitor moved) earns reputationPerMinute times a multiplier, for a capped
number of minutes per visit. Repeat visitors earn less the more recent days they already gave
reputation, and each visitor and each property have a daily cap. Every parameter is a setting;
what each account gave each property per day is kept in property_reputation_visits. The rules are
pure functions with unit tests.

Fixes #636
Fixes #637

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

View File

@@ -0,0 +1,105 @@
#ifndef __PROPERTYREPUTATIONRULES__H__
#define __PROPERTYREPUTATIONRULES__H__
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <set>
/**
* Property reputation (issues #636 and #637), the rules without the game around them so they can be unit tested.
*
* Early live gave a property 1 point per minute a visitor spent on it (PropertyTemplate.reputationPerMinute), which
* was easy to farm, and later changed to an unpublished algorithm to stop that. This one keeps "time spent by other
* people" as the signal but makes every way of inflating it expensive:
*
* - Only other people count: not the owner's account (so none of the owner's characters), not accounts linked to it
* (same play key, email or login address; property_reputation_ignore_linked) and not staff
* (property_reputation_ignore_staff).
* - A visit earns nothing for its first property_reputation_min_visit seconds (WorldConfig's propertyReputationDelay,
* 120), so hopping in and out does nothing.
* - After that each minute the visitor was active (moved at least MIN_MOVE units since the last minute;
* property_reputation_require_activity) earns reputationPerMinute x property_reputation_multiplier, for at most
* property_reputation_max_minutes minutes per visit, so parking an idle alt on a property earns nothing.
* - Repeat visitors count less: a visitor who already gave the property reputation on d of the last
* property_reputation_repeat_days days earns 1 / (1 + property_reputation_repeat_falloff x d) as much. Many different
* visitors are worth more than one visitor coming back every day.
* - Caps per UTC day: one visitor account gives a property at most property_reputation_visitor_daily_cap points, and a
* property gets at most property_reputation_daily_cap from everyone together.
*
* Fractions carry over between minutes of a visit; points over a cap are dropped, not carried.
*/
namespace PropertyReputationRules {
constexpr float MIN_MOVE = 2.0f; // about one minifigure width
struct Params {
int64_t minVisitSeconds{ 120 };
double multiplier{ 1.0 };
int64_t maxMinutesPerVisit{ 30 };
int64_t visitorDailyCap{ 30 };
int64_t propertyDailyCap{ 300 };
double repeatFalloff{ 0.5 };
bool requireActivity{ true };
bool ignoreStaff{ true };
bool ignoreLinked{ true };
};
struct Position {
float x{}, y{}, z{};
};
// Whether a visitor counts at all
inline bool Eligible(uint32_t visitorAccount, uint32_t ownerAccount, const std::set<uint32_t>& linkedToOwner, bool visitorIsStaff, const Params& params) {
if (visitorAccount == 0 || visitorAccount == ownerAccount) return false;
if (params.ignoreLinked && linkedToOwner.contains(visitorAccount)) return false;
if (params.ignoreStaff && visitorIsStaff) return false;
return true;
}
// How much a repeat visitor's minutes are worth: previousDays is how many other recent days they gave reputation
inline double RepeatFactor(uint32_t previousDays, double falloff) {
return 1.0 / (1.0 + std::max(falloff, 0.0) * previousDays);
}
inline bool Moved(const Position& a, const Position& b, float minDistance = MIN_MOVE) {
const float dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
return dx * dx + dy * dy + dz * dz >= minDistance * minDistance;
}
// One visitor's visit, and what they gave the property today
struct Visit {
int64_t enteredAt{};
uint32_t day{}; // UTC day the counters below are for
int64_t creditedMinutes{};
double carry{};
double repeatFactor{ 1.0 };
int64_t visitorToday{}; // points this visitor's account gave this property today
};
/**
* A minute of a visit went by. propertyToday: the points the property got today from everyone (updated). Returns
* the whole points to give now. A new day restarts the visitor's daily count (the caller resets propertyToday).
*/
inline int64_t OnMinute(Visit& visit, const Params& params, int32_t perMinute, int64_t now, uint32_t day, bool active, int64_t& propertyToday) {
if (day != visit.day) {
visit.day = day;
visit.visitorToday = 0;
}
if (perMinute <= 0 || params.multiplier <= 0.0) return 0;
if (now - visit.enteredAt < params.minVisitSeconds) return 0;
if (params.requireActivity && !active) return 0;
if (visit.creditedMinutes >= params.maxMinutesPerVisit) return 0;
visit.creditedMinutes++;
visit.carry += perMinute * params.multiplier * visit.repeatFactor;
const auto whole = static_cast<int64_t>(std::floor(visit.carry));
visit.carry -= static_cast<double>(whole);
const auto room = std::min(std::max<int64_t>(params.visitorDailyCap - visit.visitorToday, 0), std::max<int64_t>(params.propertyDailyCap - propertyToday, 0));
const auto granted = std::min(whole, room);
visit.visitorToday += granted;
propertyToday += granted;
return granted;
}
}
#endif //!__PROPERTYREPUTATIONRULES__H__

View File

@@ -97,6 +97,16 @@ namespace PropertyRentRoutes {
JsonSuccess(reply, { {"message", "Rent for " + name + " saved"} });
});
Route(eHTTPMethod::GET, "/api/properties/:id/reputation", Perm("properties_view"),
"Reputation a property got from visitors in the last 30 days: {reputation, days: [{day, visitors, points, seconds}]} (days since the Unix epoch, newest first)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto propertyId = PathId<LWOOBJID>(context.path, 2);
const auto info = propertyId ? Database::Get()->GetPropertyInfo(*propertyId) : std::nullopt;
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Property not found");
const auto today = static_cast<uint32_t>(std::time(nullptr) / PropertyRentRules::DAY);
JsonSuccess(reply, { {"reputation", info->reputation}, {"days", Database::Get()->GetPropertyReputationDays(*propertyId, today >= 30 ? today - 30 : 0)} });
});
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);

View File

@@ -187,6 +187,20 @@ namespace {
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("Property reputation", "Visitors earn properties reputation, which orders the property lists and the news screen's Today's Top Properties. "
"Only time other people spend moving around a property counts, with limits against farming it (see the dashboard documentation).");
c.Add(Bool(WORLD, "property_reputation_enabled", "Properties earn reputation", "", true));
c.Add(When(Unit(Int(WORLD, "property_reputation_min_visit", "Minimum visit", "A visit earns nothing before this (live's property reputation delay).", "120", 0, 3600), "seconds"), WORLD, "property_reputation_enabled", { "1" }));
c.Add(When(Unit(Float(WORLD, "property_reputation_multiplier", "Points per minute", "Times the property's reputationPerMinute (1). Raise it for small servers.", "1", 0, 1000), "×"), WORLD, "property_reputation_enabled", { "1" }));
c.Add(When(Unit(Int(WORLD, "property_reputation_max_minutes", "Minutes per visit", "At most this many minutes of one visit count.", "30", 1, 1440), "minutes"), WORLD, "property_reputation_enabled", { "1" }));
c.Add(When(Unit(Int(WORLD, "property_reputation_visitor_daily_cap", "Per visitor per day", "Most one account can give one property in a day.", "30", 0, 100000), "points"), WORLD, "property_reputation_enabled", { "1" }));
c.Add(When(Unit(Int(WORLD, "property_reputation_daily_cap", "Per property per day", "Most a property can get in a day from everyone.", "300", 0, 10000000), "points"), WORLD, "property_reputation_enabled", { "1" }));
c.Add(When(Float(WORLD, "property_reputation_repeat_falloff", "Repeat visitor falloff", "A visitor who gave the property reputation on d recent days earns 1 / (1 + this × d) as much.", "0.5", 0, 100), WORLD, "property_reputation_enabled", { "1" }));
c.Add(When(Unit(Int(WORLD, "property_reputation_repeat_days", "Recent days", "How far back repeat visits are counted.", "30", 1, 365), "days"), WORLD, "property_reputation_enabled", { "1" }));
c.Add(When(Bool(WORLD, "property_reputation_require_activity", "Only active visitors", "A minute only counts if the visitor moved; idle characters earn nothing.", true), WORLD, "property_reputation_enabled", { "1" }));
c.Add(When(Bool(WORLD, "property_reputation_ignore_staff", "Ignore staff", "Accounts with a GM level don't give reputation.", true), WORLD, "property_reputation_enabled", { "1" }));
c.Add(When(Bool(WORLD, "property_reputation_ignore_linked", "Ignore linked accounts", "Accounts sharing a play key, email or login address with the owner's don't give reputation.", true), WORLD, "property_reputation_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

@@ -49,6 +49,7 @@
#include "IMessageCaptures.h"
#include "IContraband.h"
#include "IPropertyRent.h"
#include "IPropertyReputation.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)
@@ -62,7 +63,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 IPropertyRent {
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 IPropertyReputation {
public:
virtual ~GameDatabase() = default;
// TODO: These should be made private.

View File

@@ -0,0 +1,32 @@
#ifndef __IPROPERTYREPUTATION__H__
#define __IPROPERTYREPUTATION__H__
#include <cstdint>
#include "dCommonVars.h"
#include "json.hpp"
/**
* Property reputation (see dCommon/PropertyReputationRules.h): what visitors gave each property per day.
*/
class IPropertyReputation {
public:
struct VisitorHistory {
int64_t today{}; // points the account gave the property today
uint32_t previousDays{}; // other days in the window it gave the property any
};
// days: how many days before `day` count as recent
virtual VisitorHistory GetPropertyVisitorHistory(LWOOBJID propertyId, uint32_t accountId, uint32_t day, uint32_t days) = 0;
// Points the property got from everyone on a day
virtual int64_t GetPropertyReputationOnDay(LWOOBJID propertyId, uint32_t day) = 0;
// Add a visitor's points and time for a day, and the points to properties.reputation
virtual void AddPropertyReputation(LWOOBJID propertyId, uint32_t accountId, uint32_t day, int64_t points, int64_t seconds) = 0;
// For the property page: [{day, visitors, points, seconds}] of the last `days` days, newest first
virtual nlohmann::json GetPropertyReputationDays(LWOOBJID propertyId, uint32_t fromDay) = 0;
};
#endif //!__IPROPERTYREPUTATION__H__

View File

@@ -215,6 +215,12 @@ public:
std::vector<ZoneLimit> GetZoneLimits() override;
void SetZoneLimit(const ZoneLimit& limit) override;
void DeleteZoneLimit(uint32_t zoneId) override;
// IPropertyReputation
VisitorHistory GetPropertyVisitorHistory(LWOOBJID propertyId, uint32_t accountId, uint32_t day, uint32_t days) override;
int64_t GetPropertyReputationOnDay(LWOOBJID propertyId, uint32_t day) override;
void AddPropertyReputation(LWOOBJID propertyId, uint32_t accountId, uint32_t day, int64_t points, int64_t seconds) override;
nlohmann::json GetPropertyReputationDays(LWOOBJID propertyId, uint32_t fromDay) override;
// IPropertyRent
std::vector<RentRate> GetPropertyRentRates() override;
void SetPropertyRentRate(const RentRate& rate) override;

View File

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

View File

@@ -0,0 +1,39 @@
#include "MySQLDatabase.h"
IPropertyReputation::VisitorHistory MySQLDatabase::GetPropertyVisitorHistory(LWOOBJID propertyId, uint32_t accountId, uint32_t day, uint32_t days) {
VisitorHistory history;
auto result = ExecuteSelect(
"SELECT COALESCE(SUM(CASE WHEN day = ? THEN points ELSE 0 END), 0) AS today, "
"COALESCE(SUM(CASE WHEN day < ? AND points > 0 THEN 1 ELSE 0 END), 0) AS previous_days "
"FROM property_reputation_visits WHERE property_id = ? AND account_id = ? AND day >= ? AND day <= ?;",
day, day, propertyId, accountId, day >= days ? day - days : 0, day);
if (result->next()) {
history.today = result->getInt64("today");
history.previousDays = static_cast<uint32_t>(result->getInt64("previous_days"));
}
return history;
}
int64_t MySQLDatabase::GetPropertyReputationOnDay(LWOOBJID propertyId, uint32_t day) {
auto result = ExecuteSelect("SELECT COALESCE(SUM(points), 0) AS points FROM property_reputation_visits WHERE property_id = ? AND day = ?;", propertyId, day);
return result->next() ? result->getInt64("points") : 0;
}
void MySQLDatabase::AddPropertyReputation(LWOOBJID propertyId, uint32_t accountId, uint32_t day, int64_t points, int64_t seconds) {
ExecuteInsert(
"INSERT INTO property_reputation_visits (property_id, account_id, day, points, seconds) VALUES (?, ?, ?, ?, ?) "
"ON DUPLICATE KEY UPDATE points = points + VALUES(points), seconds = seconds + VALUES(seconds);",
propertyId, accountId, day, points, seconds);
if (points > 0) ExecuteUpdate("UPDATE properties SET reputation = reputation + ? WHERE id = ?;", points, propertyId);
}
nlohmann::json MySQLDatabase::GetPropertyReputationDays(LWOOBJID propertyId, uint32_t fromDay) {
auto result = ExecuteSelect(
"SELECT day, COUNT(*) AS visitors, SUM(points) AS points, SUM(seconds) AS seconds FROM property_reputation_visits "
"WHERE property_id = ? AND day >= ? GROUP BY day ORDER BY day DESC;", propertyId, fromDay);
nlohmann::json days = nlohmann::json::array();
while (result->next()) {
days.push_back({ {"day", result->getInt("day")}, {"visitors", result->getInt64("visitors")}, {"points", result->getInt64("points")}, {"seconds", result->getInt64("seconds")} });
}
return days;
}

View File

@@ -199,6 +199,12 @@ public:
std::vector<ZoneLimit> GetZoneLimits() override;
void SetZoneLimit(const ZoneLimit& limit) override;
void DeleteZoneLimit(uint32_t zoneId) override;
// IPropertyReputation
VisitorHistory GetPropertyVisitorHistory(LWOOBJID propertyId, uint32_t accountId, uint32_t day, uint32_t days) override;
int64_t GetPropertyReputationOnDay(LWOOBJID propertyId, uint32_t day) override;
void AddPropertyReputation(LWOOBJID propertyId, uint32_t accountId, uint32_t day, int64_t points, int64_t seconds) override;
nlohmann::json GetPropertyReputationDays(LWOOBJID propertyId, uint32_t fromDay) override;
// IPropertyRent
std::vector<RentRate> GetPropertyRentRates() override;
void SetPropertyRentRate(const RentRate& rate) override;

View File

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

View File

@@ -0,0 +1,39 @@
#include "SQLiteDatabase.h"
IPropertyReputation::VisitorHistory SQLiteDatabase::GetPropertyVisitorHistory(LWOOBJID propertyId, uint32_t accountId, uint32_t day, uint32_t days) {
VisitorHistory history;
auto [_, result] = ExecuteSelect(
"SELECT COALESCE(SUM(CASE WHEN day = ? THEN points ELSE 0 END), 0) AS today, "
"COALESCE(SUM(CASE WHEN day < ? AND points > 0 THEN 1 ELSE 0 END), 0) AS previous_days "
"FROM property_reputation_visits WHERE property_id = ? AND account_id = ? AND day >= ? AND day <= ?;",
day, day, propertyId, accountId, day >= days ? day - days : 0, day);
if (!result.eof()) {
history.today = result.getInt64Field("today");
history.previousDays = static_cast<uint32_t>(result.getInt64Field("previous_days"));
}
return history;
}
int64_t SQLiteDatabase::GetPropertyReputationOnDay(LWOOBJID propertyId, uint32_t day) {
auto [_, result] = ExecuteSelect("SELECT COALESCE(SUM(points), 0) AS points FROM property_reputation_visits WHERE property_id = ? AND day = ?;", propertyId, day);
return result.eof() ? 0 : result.getInt64Field("points");
}
void SQLiteDatabase::AddPropertyReputation(LWOOBJID propertyId, uint32_t accountId, uint32_t day, int64_t points, int64_t seconds) {
ExecuteInsert(
"INSERT INTO property_reputation_visits (property_id, account_id, day, points, seconds) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(property_id, account_id, day) DO UPDATE SET points = points + excluded.points, seconds = seconds + excluded.seconds;",
propertyId, accountId, day, points, seconds);
if (points > 0) ExecuteUpdate("UPDATE properties SET reputation = reputation + ? WHERE id = ?;", points, propertyId);
}
nlohmann::json SQLiteDatabase::GetPropertyReputationDays(LWOOBJID propertyId, uint32_t fromDay) {
auto [_, result] = ExecuteSelect(
"SELECT day, COUNT(*) AS visitors, SUM(points) AS points, SUM(seconds) AS seconds FROM property_reputation_visits "
"WHERE property_id = ? AND day >= ? GROUP BY day ORDER BY day DESC;", propertyId, fromDay);
nlohmann::json days = nlohmann::json::array();
for (; !result.eof(); result.nextRow()) {
days.push_back({ {"day", result.getIntField("day")}, {"visitors", result.getInt64Field("visitors")}, {"points", result.getInt64Field("points")}, {"seconds", result.getInt64Field("seconds")} });
}
return days;
}

View File

@@ -245,6 +245,12 @@ class TestSQLDatabase : public GameDatabase {
std::vector<ZoneLimit> GetZoneLimits() override { return {}; }
void SetZoneLimit(const ZoneLimit& limit) override {}
void DeleteZoneLimit(uint32_t zoneId) override {}
// IPropertyReputation
VisitorHistory GetPropertyVisitorHistory(LWOOBJID propertyId, uint32_t accountId, uint32_t day, uint32_t days) override { return {}; }
int64_t GetPropertyReputationOnDay(LWOOBJID propertyId, uint32_t day) override { return 0; }
void AddPropertyReputation(LWOOBJID propertyId, uint32_t accountId, uint32_t day, int64_t points, int64_t seconds) override {}
nlohmann::json GetPropertyReputationDays(LWOOBJID propertyId, uint32_t fromDay) override { return nlohmann::json::array(); }
// IPropertyRent
std::vector<RentRate> GetPropertyRentRates() override { return {}; }
void SetPropertyRentRate(const RentRate& rate) override {}

View File

@@ -148,6 +148,10 @@ public:
LWOOBJID GetId() const noexcept { return propertyId; }
// Reputation visitors just gave (PropertyReputation.h; the database is updated there), so property data shows it
void AddReputation(uint32_t points) { reputation += points; }
uint32_t GetReputation() const noexcept { return reputation; }
void OnChatMessageReceived(const std::string& sMessage) const;
private:

View File

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

View File

@@ -0,0 +1,169 @@
#include "PropertyReputation.h"
#include <chrono>
#include <ctime>
#include <map>
#include <set>
#include "Character.h"
#include "Database.h"
#include "DashboardNotify.h"
#include "dConfig.h"
#include "dZoneManager.h"
#include "eGameMasterLevel.h"
#include "EconomyLedger.h"
#include "Entity.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "Logger.h"
#include "PlayerManager.h"
#include "PropertyManagementComponent.h"
#include "PropertyRent.h"
#include "User.h"
namespace {
using namespace PropertyReputationRules;
constexpr auto INTERVAL = std::chrono::seconds(60);
constexpr uint32_t REPEAT_DAYS_DEFAULT = 30;
struct Tracked {
LWOOBJID playerId{};
Visit visit;
Position last;
bool eligible{};
int64_t pendingSeconds{};
};
std::chrono::steady_clock::time_point g_LastTick{};
LWOOBJID g_PropertyId{};
uint32_t g_OwnerAccount{};
std::set<uint32_t> g_Linked;
uint32_t g_Day{};
int64_t g_PropertyToday{};
std::map<uint32_t, Tracked> g_Visitors; // by account
template<typename T>
T Setting(const std::string& key, T fallback) {
return GeneralUtils::TryParse<T>(Game::config->GetValue(key)).value_or(fallback);
}
uint32_t RepeatDays() {
return Setting<uint32_t>("property_reputation_repeat_days", REPEAT_DAYS_DEFAULT);
}
Position PositionOf(const Entity* entity) {
const auto p = entity->GetPosition();
return { p.x, p.y, p.z };
}
// Load who owns the property and which accounts are linked to the owner's
void LoadOwner(LWOOBJID propertyId) {
g_PropertyId = propertyId;
g_OwnerAccount = 0;
g_Linked.clear();
g_Visitors.clear();
g_Day = 0;
const auto info = Database::Get()->GetPropertyInfo(propertyId);
const auto owner = info ? Database::Get()->GetCharacterInfo(info->ownerId) : std::nullopt;
if (!owner) return;
g_OwnerAccount = owner->accountId;
for (const auto& linked : Database::Get()->GetLinkedAccounts(g_OwnerAccount)) g_Linked.insert(linked.accountId);
}
void Flush(uint32_t accountId, Tracked& tracked, int64_t points) {
if (!tracked.eligible || (points <= 0 && tracked.pendingSeconds <= 0)) return;
Database::Get()->AddPropertyReputation(g_PropertyId, accountId, g_Day, points, tracked.pendingSeconds);
tracked.pendingSeconds = 0;
}
}
namespace PropertyReputation {
PropertyReputationRules::Params LoadParams() {
Params params;
params.minVisitSeconds = Setting<int64_t>("property_reputation_min_visit", params.minVisitSeconds);
params.multiplier = Setting<double>("property_reputation_multiplier", params.multiplier);
params.maxMinutesPerVisit = Setting<int64_t>("property_reputation_max_minutes", params.maxMinutesPerVisit);
params.visitorDailyCap = Setting<int64_t>("property_reputation_visitor_daily_cap", params.visitorDailyCap);
params.propertyDailyCap = Setting<int64_t>("property_reputation_daily_cap", params.propertyDailyCap);
params.repeatFalloff = Setting<double>("property_reputation_repeat_falloff", params.repeatFalloff);
params.requireActivity = Game::config->GetValue("property_reputation_require_activity") != "0";
params.ignoreStaff = Game::config->GetValue("property_reputation_ignore_staff") != "0";
params.ignoreLinked = Game::config->GetValue("property_reputation_ignore_linked") != "0";
return params;
}
void Tick() {
const auto nowSteady = std::chrono::steady_clock::now();
if (nowSteady - g_LastTick < INTERVAL) return;
g_LastTick = nowSteady;
auto* property = PropertyManagementComponent::Instance();
if (!property || property->GetId() == LWOOBJID_EMPTY || property->GetOwnerId() == LWOOBJID_EMPTY) return;
if (Game::config->GetValue("property_reputation_enabled") == "0") return;
if (property->GetId() != g_PropertyId) LoadOwner(property->GetId());
if (g_OwnerAccount == 0) return;
const auto params = LoadParams();
const auto* row = PropertyRent::WorldTemplate(Game::zoneManager->GetZoneID().GetMapID());
const int32_t perMinute = row ? row->reputationPerMinute : 1;
const auto now = static_cast<int64_t>(std::time(nullptr));
const auto day = EconomyLedger::Today();
if (day != g_Day) {
g_Day = day;
g_PropertyToday = Database::Get()->GetPropertyReputationOnDay(g_PropertyId, day);
}
std::set<uint32_t> present;
int64_t gained = 0;
for (auto* player : PlayerManager::GetAllPlayers()) {
auto* character = player ? player->GetCharacter() : nullptr;
auto* user = character ? character->GetParentUser() : nullptr;
if (!user) continue;
const auto accountId = user->GetAccountID();
// One visitor per account, however many of its characters are here
if (!present.insert(accountId).second) continue;
auto it = g_Visitors.find(accountId);
if (it == g_Visitors.end() || it->second.playerId != player->GetObjectID()) {
Tracked tracked;
tracked.playerId = player->GetObjectID();
tracked.last = PositionOf(player);
tracked.visit.enteredAt = now;
tracked.visit.day = day;
tracked.eligible = Eligible(accountId, g_OwnerAccount, g_Linked, user->GetMaxGMLevel() > eGameMasterLevel::CIVILIAN, params);
if (tracked.eligible) {
const auto history = Database::Get()->GetPropertyVisitorHistory(g_PropertyId, accountId, day, RepeatDays());
tracked.visit.visitorToday = history.today;
tracked.visit.repeatFactor = RepeatFactor(history.previousDays, params.repeatFalloff);
}
if (it != g_Visitors.end()) Flush(accountId, it->second, 0);
g_Visitors[accountId] = tracked;
continue;
}
auto& tracked = it->second;
if (!tracked.eligible) continue;
const auto position = PositionOf(player);
const bool active = Moved(tracked.last, position);
tracked.last = position;
tracked.pendingSeconds += std::chrono::duration_cast<std::chrono::seconds>(INTERVAL).count();
const auto points = OnMinute(tracked.visit, params, perMinute, now, day, active, g_PropertyToday);
if (points > 0) {
Flush(accountId, tracked, points);
gained += points;
}
}
// Visitors who left: keep the time they spent
for (auto it = g_Visitors.begin(); it != g_Visitors.end();) {
if (present.contains(it->first)) { ++it; continue; }
Flush(it->first, it->second, 0);
it = g_Visitors.erase(it);
}
if (gained > 0) {
property->AddReputation(static_cast<uint32_t>(gained));
DashboardNotify::Changed("properties", g_PropertyId);
}
}
}

View File

@@ -0,0 +1,20 @@
#ifndef __PROPERTYREPUTATION__H__
#define __PROPERTYREPUTATION__H__
#include "PropertyReputationRules.h"
/**
* Property reputation in a property world (issues #636 and #637; the algorithm is in PropertyReputationRules.h).
* Visitors are followed per account once a minute; what they earn the property is written to
* property_reputation_visits and added to properties.reputation, which the property lists, the news screen's
* "Today's Top Properties" and the dashboard show. Off when property_reputation_enabled is 0.
*/
namespace PropertyReputation {
// The settings (property_reputation_*)
PropertyReputationRules::Params LoadParams();
// Called every frame on property worlds; does its work once a minute
void Tick();
}
#endif //!__PROPERTYREPUTATION__H__

View File

@@ -8,6 +8,7 @@
#include "MessageInspector.h"
#include "Contraband.h"
#include "PropertyRent.h"
#include "PropertyReputation.h"
#include "LiveEvents.h"
#include <iostream>
#include <string>
@@ -543,6 +544,9 @@ int main(int argc, char** argv) {
framesSinceLastUser = 0;
}
// Visitors earn properties reputation (once a minute)
if (PropertyManagementComponent::Instance() != nullptr) PropertyReputation::Tick();
//Save all connected users every 10 minutes:
if (framesSinceLastUsersSave >= saveTime && zoneID != 0) {
UserManager::Instance()->SaveAllActiveCharacters();

View File

@@ -899,6 +899,36 @@ property is made private. It can't be made public or best friends only again unt
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.
### Property reputation
Properties earn reputation from visitors; it orders the in-game property lists and the news screen's **Today's Top
Properties**, and the showcase. Live first gave 1 point per minute a visitor spent on a property (each
PropertyTemplate's `reputationPerMinute` is 1) and later replaced that with an unpublished algorithm because it was
easy to farm. The client only displays the number the server sends, so the server decides. This one keeps "time
other people spend on the property" as the signal and makes farming it expensive (settings under Gameplay, Property
reputation; the code is `dCommon/PropertyReputationRules.h`):
1. **Only other people.** Visitors are counted per account, so the owner's characters never count and one account
with several characters on the property is one visitor. Accounts linked to the owner's (same play key, email or
login address, as on the Linked accounts panel; `property_reputation_ignore_linked`) and staff
(`property_reputation_ignore_staff`) don't count either.
2. **A minimum visit.** Nothing for the first `property_reputation_min_visit` seconds (120, WorldConfig's
`propertyReputationDelay`), so hopping in and out does nothing.
3. **Active minutes.** Once a minute, a visitor who moved at least 2 units since the last minute
(`property_reputation_require_activity`) earns `reputationPerMinute` × `property_reputation_multiplier` points,
for at most `property_reputation_max_minutes` (30) minutes per visit. An idle alt parked on a property earns nothing.
4. **Diminishing returns for regulars.** A visitor who gave the property reputation on *d* of the last
`property_reputation_repeat_days` (30) days earns 1 / (1 + `property_reputation_repeat_falloff` × *d*) as much
(0.5: half on the third day, a fifth after eight). Fractions carry over between the minutes of a visit.
5. **Daily caps** (UTC days): one account gives one property at most `property_reputation_visitor_daily_cap` (30)
points a day, and a property gets at most `property_reputation_daily_cap` (300) a day from everyone.
With the defaults a stranger spending 20 active minutes gives 19 points; the same account coming back every day for a
week gives about 100 in total, less than seven different visitors staying 20 minutes each. Small servers can raise the
multiplier. Nothing decays. What each account gave each property per day is kept in `property_reputation_visits`
(`GET /api/properties/:id/reputation` sums the last 30 days); the character's own reputation (from missions) is not
changed.
### 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,12 @@
/* Property reputation: what each visitor account gave each property per UTC day (days since the Unix epoch), for the
per-visitor and per-property daily caps and to count repeat visitors (see dCommon/PropertyReputationRules.h).
properties.reputation holds the total. */
CREATE TABLE IF NOT EXISTS property_reputation_visits (
property_id BIGINT NOT NULL,
account_id INT NOT NULL,
day INT NOT NULL,
points BIGINT NOT NULL DEFAULT 0,
seconds BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (property_id, account_id, day),
INDEX property_reputation_visits_day (property_id, day)
);

View File

@@ -0,0 +1,10 @@
/* property_reputation_visits: see the MySQL migration. */
CREATE TABLE IF NOT EXISTS property_reputation_visits (
property_id BIGINT NOT NULL,
account_id INTEGER NOT NULL,
day INTEGER NOT NULL,
points BIGINT NOT NULL DEFAULT 0,
seconds BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (property_id, account_id, day)
);
CREATE INDEX IF NOT EXISTS property_reputation_visits_day ON property_reputation_visits (property_id, day);

View File

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

View File

@@ -0,0 +1,150 @@
#include <gtest/gtest.h>
#include "PropertyReputationRules.h"
using namespace PropertyReputationRules;
namespace {
constexpr int64_t START = 1'800'000'000;
constexpr uint32_t DAY = 20000;
// Minutes of one visit (active unless given); returns the points given
int64_t Minutes(Visit& visit, const Params& params, int minutes, int64_t& propertyToday, bool active = true, int32_t perMinute = 1) {
int64_t total = 0;
for (int minute = 1; minute <= minutes; minute++) total += OnMinute(visit, params, perMinute, START + minute * 60, DAY, active, propertyToday);
return total;
}
Visit NewVisit(double repeatFactor = 1.0, int64_t visitorToday = 0) {
Visit visit;
visit.enteredAt = START;
visit.day = DAY;
visit.repeatFactor = repeatFactor;
visit.visitorToday = visitorToday;
return visit;
}
}
TEST(PropertyReputationRulesTests, OnlyOtherPeopleCount) {
Params params;
const std::set<uint32_t> linked{ 7 };
EXPECT_TRUE(Eligible(2, 1, linked, false, params));
EXPECT_FALSE(Eligible(1, 1, linked, false, params)); // the owner's account: any of their characters
EXPECT_FALSE(Eligible(7, 1, linked, false, params)); // shares a play key, email or address with the owner
EXPECT_FALSE(Eligible(2, 1, linked, true, params)); // staff
EXPECT_FALSE(Eligible(0, 1, linked, false, params));
params.ignoreLinked = false;
params.ignoreStaff = false;
EXPECT_TRUE(Eligible(7, 1, linked, false, params));
EXPECT_TRUE(Eligible(2, 1, linked, true, params));
}
TEST(PropertyReputationRulesTests, ShortVisitsEarnNothing) {
Params params;
auto visit = NewVisit();
int64_t today = 0;
// The first two minutes are before the minimum visit
EXPECT_EQ(OnMinute(visit, params, 1, START + 60, DAY, true, today), 0);
EXPECT_EQ(OnMinute(visit, params, 1, START + 119, DAY, true, today), 0);
EXPECT_EQ(OnMinute(visit, params, 1, START + 120, DAY, true, today), 1);
EXPECT_EQ(visit.creditedMinutes, 1);
}
TEST(PropertyReputationRulesTests, OnePointPerActiveMinuteLikeLive) {
Params params;
auto visit = NewVisit();
int64_t today = 0;
EXPECT_EQ(Minutes(visit, params, 12, today), 11); // minutes 2..12
EXPECT_EQ(today, 11);
}
TEST(PropertyReputationRulesTests, IdleVisitorsEarnNothing) {
Params params;
auto visit = NewVisit();
int64_t today = 0;
EXPECT_EQ(Minutes(visit, params, 20, today, false), 0);
EXPECT_EQ(visit.creditedMinutes, 0);
params.requireActivity = false;
EXPECT_EQ(Minutes(visit, params, 20, today, false), 19);
}
TEST(PropertyReputationRulesTests, VisitLengthIsCapped) {
Params params;
params.visitorDailyCap = 1000;
auto visit = NewVisit();
int64_t today = 0;
EXPECT_EQ(Minutes(visit, params, 120, today), params.maxMinutesPerVisit);
}
TEST(PropertyReputationRulesTests, VisitorDailyCap) {
Params params;
params.maxMinutesPerVisit = 1000;
auto visit = NewVisit(1.0, 25); // already gave 25 today in earlier visits
int64_t today = 25;
EXPECT_EQ(Minutes(visit, params, 60, today), 5);
EXPECT_EQ(visit.visitorToday, 30);
// A new day starts the count again
EXPECT_EQ(OnMinute(visit, params, 1, START + 3600, DAY + 1, true, today), 1);
EXPECT_EQ(visit.visitorToday, 1);
}
TEST(PropertyReputationRulesTests, PropertyDailyCap) {
Params params;
auto visit = NewVisit();
int64_t today = params.propertyDailyCap - 3;
EXPECT_EQ(Minutes(visit, params, 30, today), 3);
EXPECT_EQ(today, params.propertyDailyCap);
}
TEST(PropertyReputationRulesTests, RepeatVisitorsCountLess) {
EXPECT_DOUBLE_EQ(RepeatFactor(0, 0.5), 1.0);
EXPECT_DOUBLE_EQ(RepeatFactor(2, 0.5), 0.5);
EXPECT_DOUBLE_EQ(RepeatFactor(8, 0.5), 0.2);
EXPECT_DOUBLE_EQ(RepeatFactor(8, 0.0), 1.0);
Params params;
// Came on 2 recent days: half as much, fractions carried between minutes
auto visit = NewVisit(RepeatFactor(2, 0.5));
int64_t today = 0;
EXPECT_EQ(Minutes(visit, params, 21, today), 10); // 20 counted minutes x 0.5
}
TEST(PropertyReputationRulesTests, ManyVisitorsBeatOneFarmer) {
Params params;
int64_t today = 0;
// One account visiting every day for a week, as long as it can
int64_t farmer = 0;
for (uint32_t d = 0; d < 7; d++) {
auto visit = NewVisit(RepeatFactor(d, params.repeatFalloff));
farmer += Minutes(visit, params, 60, today);
}
// Seven different people once each, 20 minutes
int64_t visitors = 0;
int64_t today2 = 0;
for (int i = 0; i < 7; i++) {
auto visit = NewVisit();
visitors += Minutes(visit, params, 21, today2);
}
EXPECT_LT(farmer, visitors);
EXPECT_EQ(visitors, 140);
}
TEST(PropertyReputationRulesTests, MultiplierAndTemplateRate) {
Params params;
params.multiplier = 2.5;
params.visitorDailyCap = 1000;
auto visit = NewVisit();
int64_t today = 0;
EXPECT_EQ(Minutes(visit, params, 5, today), 10); // 4 minutes x 2.5
params.multiplier = 0;
EXPECT_EQ(Minutes(visit, params, 5, today), 0);
auto other = NewVisit();
params.multiplier = 1;
EXPECT_EQ(Minutes(other, params, 5, today, true, 0), 0); // a template without reputationPerMinute
}
TEST(PropertyReputationRulesTests, Movement) {
EXPECT_FALSE(Moved({ 0, 0, 0 }, { 1, 0, 1 }));
EXPECT_TRUE(Moved({ 0, 0, 0 }, { 2, 0, 0 }));
EXPECT_TRUE(Moved({ 0, 0, 0 }, { 0, 5, 0 }));
}

View File

@@ -1417,6 +1417,28 @@ TEST_F(ParitySeeded, PropertyRent) {
});
}
TEST_F(ParitySeeded, PropertyReputation) {
const auto before = Both("reputation before", [](GameDatabase& db) { return db.GetPropertyInfo(PROP2)->reputation; });
Both("AddPropertyReputation", [](GameDatabase& db) {
db.AddPropertyReputation(PROP2, 1, 20000, 5, 300);
db.AddPropertyReputation(PROP2, 1, 20000, 3, 120);
db.AddPropertyReputation(PROP2, 1, 19990, 7, 600);
db.AddPropertyReputation(PROP2, 1, 19950, 9, 600); // outside a 30 day window
db.AddPropertyReputation(PROP2, 1, 19995, 0, 60); // time without points: not a repeat day
db.AddPropertyReputation(PROP2, 2, 20000, 4, 240);
return db.GetPropertyInfo(PROP2)->reputation;
});
EXPECT_EQ(Both("reputation after", [](GameDatabase& db) { return db.GetPropertyInfo(PROP2)->reputation; }).get<int64_t>() - before.get<int64_t>(), 28);
const auto history = Both("GetPropertyVisitorHistory", [](GameDatabase& db) {
const auto h = db.GetPropertyVisitorHistory(PROP2, 1, 20000, 30);
const auto none = db.GetPropertyVisitorHistory(PROP2, 3, 20000, 30);
return json{ h.today, h.previousDays, none.today, none.previousDays };
});
EXPECT_EQ(history, json({ 8, 1, 0, 0 }));
EXPECT_EQ(Both("GetPropertyReputationOnDay", [](GameDatabase& db) { return db.GetPropertyReputationOnDay(PROP2, 20000); }), 12);
Both("GetPropertyReputationDays", [](GameDatabase& db) { return db.GetPropertyReputationDays(PROP2, 19980); });
}
TEST_F(ParitySeeded, Contraband) {
Both("GetContrabandItems empty", [](GameDatabase& db) { return db.GetContrabandItems(); });
Both("SetContrabandItem", [](GameDatabase& db) {