diff --git a/dCommon/PropertyReputationRules.h b/dCommon/PropertyReputationRules.h new file mode 100644 index 000000000..8e3e56949 --- /dev/null +++ b/dCommon/PropertyReputationRules.h @@ -0,0 +1,105 @@ +#ifndef __PROPERTYREPUTATIONRULES__H__ +#define __PROPERTYREPUTATIONRULES__H__ + +#include +#include +#include +#include + +/** + * 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& 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(std::floor(visit.carry)); + visit.carry -= static_cast(whole); + const auto room = std::min(std::max(params.visitorDailyCap - visit.visitorToday, 0), std::max(params.propertyDailyCap - propertyToday, 0)); + const auto granted = std::min(whole, room); + visit.visitorToday += granted; + propertyToday += granted; + return granted; + } +} + +#endif //!__PROPERTYREPUTATIONRULES__H__ diff --git a/dDashboardServer/routes/PropertyRentRoutes.cpp b/dDashboardServer/routes/PropertyRentRoutes.cpp index ef6699549..be2a804fa 100644 --- a/dDashboardServer/routes/PropertyRentRoutes.cpp +++ b/dDashboardServer/routes/PropertyRentRoutes.cpp @@ -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(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(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); diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 3ce5aaf28..41b62a62b 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -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), "", "")); diff --git a/dDatabase/GameDatabase/GameDatabase.h b/dDatabase/GameDatabase/GameDatabase.h index 5215d2350..acbc2d6fa 100644 --- a/dDatabase/GameDatabase/GameDatabase.h +++ b/dDatabase/GameDatabase/GameDatabase.h @@ -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. diff --git a/dDatabase/GameDatabase/ITables/IPropertyReputation.h b/dDatabase/GameDatabase/ITables/IPropertyReputation.h new file mode 100644 index 000000000..d70b63b17 --- /dev/null +++ b/dDatabase/GameDatabase/ITables/IPropertyReputation.h @@ -0,0 +1,32 @@ +#ifndef __IPROPERTYREPUTATION__H__ +#define __IPROPERTYREPUTATION__H__ + +#include + +#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__ diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h index 4845becdc..84bfdb16f 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h @@ -215,6 +215,12 @@ public: std::vector 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 GetPropertyRentRates() override; void SetPropertyRentRate(const RentRate& rate) override; diff --git a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt index cdfce0efe..8384b6f2e 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt @@ -20,6 +20,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES "FeaturedProperties.cpp" "Contraband.cpp" "PropertyRent.cpp" + "PropertyReputation.cpp" "MessageCaptures.cpp" "ChatLog.cpp" "RelatedData.cpp" diff --git a/dDatabase/GameDatabase/MySQL/Tables/PropertyReputation.cpp b/dDatabase/GameDatabase/MySQL/Tables/PropertyReputation.cpp new file mode 100644 index 000000000..4dc027803 --- /dev/null +++ b/dDatabase/GameDatabase/MySQL/Tables/PropertyReputation.cpp @@ -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(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; +} diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h index c453e809e..cda50f010 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h @@ -199,6 +199,12 @@ public: std::vector 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 GetPropertyRentRates() override; void SetPropertyRentRate(const RentRate& rate) override; diff --git a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt index 266bc7643..b2bad1825 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt @@ -20,6 +20,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES "FeaturedProperties.cpp" "Contraband.cpp" "PropertyRent.cpp" + "PropertyReputation.cpp" "MessageCaptures.cpp" "ChatLog.cpp" "RelatedData.cpp" diff --git a/dDatabase/GameDatabase/SQLite/Tables/PropertyReputation.cpp b/dDatabase/GameDatabase/SQLite/Tables/PropertyReputation.cpp new file mode 100644 index 000000000..a7b376f47 --- /dev/null +++ b/dDatabase/GameDatabase/SQLite/Tables/PropertyReputation.cpp @@ -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(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; +} diff --git a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h index 022c6924c..92373718f 100644 --- a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h +++ b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h @@ -245,6 +245,12 @@ class TestSQLDatabase : public GameDatabase { std::vector 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 GetPropertyRentRates() override { return {}; } void SetPropertyRentRate(const RentRate& rate) override {} diff --git a/dGame/dComponents/PropertyManagementComponent.h b/dGame/dComponents/PropertyManagementComponent.h index 5f71de451..9e6c7d408 100644 --- a/dGame/dComponents/PropertyManagementComponent.h +++ b/dGame/dComponents/PropertyManagementComponent.h @@ -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: diff --git a/dGame/dUtilities/CMakeLists.txt b/dGame/dUtilities/CMakeLists.txt index a8a01e972..bab3ea9fc 100644 --- a/dGame/dUtilities/CMakeLists.txt +++ b/dGame/dUtilities/CMakeLists.txt @@ -8,6 +8,7 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp" "LiveEvents.cpp" "Contraband.cpp" "PropertyRent.cpp" + "PropertyReputation.cpp" "Loot.cpp" "Mail.cpp" "MessageInspector.cpp" diff --git a/dGame/dUtilities/PropertyReputation.cpp b/dGame/dUtilities/PropertyReputation.cpp new file mode 100644 index 000000000..00111b076 --- /dev/null +++ b/dGame/dUtilities/PropertyReputation.cpp @@ -0,0 +1,169 @@ +#include "PropertyReputation.h" + +#include +#include +#include +#include + +#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 g_Linked; + uint32_t g_Day{}; + int64_t g_PropertyToday{}; + std::map g_Visitors; // by account + + template + T Setting(const std::string& key, T fallback) { + return GeneralUtils::TryParse(Game::config->GetValue(key)).value_or(fallback); + } + + uint32_t RepeatDays() { + return Setting("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("property_reputation_min_visit", params.minVisitSeconds); + params.multiplier = Setting("property_reputation_multiplier", params.multiplier); + params.maxMinutesPerVisit = Setting("property_reputation_max_minutes", params.maxMinutesPerVisit); + params.visitorDailyCap = Setting("property_reputation_visitor_daily_cap", params.visitorDailyCap); + params.propertyDailyCap = Setting("property_reputation_daily_cap", params.propertyDailyCap); + params.repeatFalloff = Setting("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(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 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(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(gained)); + DashboardNotify::Changed("properties", g_PropertyId); + } + } +} diff --git a/dGame/dUtilities/PropertyReputation.h b/dGame/dUtilities/PropertyReputation.h new file mode 100644 index 000000000..4c57fbe2a --- /dev/null +++ b/dGame/dUtilities/PropertyReputation.h @@ -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__ diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index 862bc03be..5be9e2885 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -8,6 +8,7 @@ #include "MessageInspector.h" #include "Contraband.h" #include "PropertyRent.h" +#include "PropertyReputation.h" #include "LiveEvents.h" #include #include @@ -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(); diff --git a/docs/Dashboard.md b/docs/Dashboard.md index 888084e26..1302d45f6 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -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, diff --git a/migrations/dlu/mysql/79_property_reputation.sql b/migrations/dlu/mysql/79_property_reputation.sql new file mode 100644 index 000000000..9959b5218 --- /dev/null +++ b/migrations/dlu/mysql/79_property_reputation.sql @@ -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) +); diff --git a/migrations/dlu/sqlite/62_property_reputation.sql b/migrations/dlu/sqlite/62_property_reputation.sql new file mode 100644 index 000000000..24f350692 --- /dev/null +++ b/migrations/dlu/sqlite/62_property_reputation.sql @@ -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); diff --git a/tests/dCommonTests/CMakeLists.txt b/tests/dCommonTests/CMakeLists.txt index fc632e6ec..98e9bdc07 100644 --- a/tests/dCommonTests/CMakeLists.txt +++ b/tests/dCommonTests/CMakeLists.txt @@ -27,6 +27,7 @@ set(DCOMMONTEST_SOURCES "ProcessAndLimitsTests.cpp" "HotPropertySlotsTests.cpp" "PropertyRentRulesTests.cpp" + "PropertyReputationRulesTests.cpp" ) add_subdirectory(dEnumsTests) diff --git a/tests/dCommonTests/PropertyReputationRulesTests.cpp b/tests/dCommonTests/PropertyReputationRulesTests.cpp new file mode 100644 index 000000000..71d623b9f --- /dev/null +++ b/tests/dCommonTests/PropertyReputationRulesTests.cpp @@ -0,0 +1,150 @@ +#include + +#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 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 })); +} diff --git a/tests/dDatabaseTests/DatabaseParityTests.cpp b/tests/dDatabaseTests/DatabaseParityTests.cpp index a03b6a697..d4f392757 100644 --- a/tests/dDatabaseTests/DatabaseParityTests.cpp +++ b/tests/dDatabaseTests/DatabaseParityTests.cpp @@ -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() - before.get(), 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) {