mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
The routes' own zone name helpers and the CDClient's English zone and leaderboard header copies give way to GameText, so every name comes from the locale in the viewer's language; the Network page's world labels, mail, missions, leaderboards, item info (cached per language) and the settings/vanity help strings too. Reward code 4 and the plaque text name their zone from the locale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
14 KiB
C++
270 lines
14 KiB
C++
#include "FeaturedProperties.h"
|
|
#include "GameText.h"
|
|
|
|
#include <algorithm>
|
|
#include <ctime>
|
|
#include <map>
|
|
|
|
#include "RouteUtils.h"
|
|
#include "DashboardRoutes.h"
|
|
#include "WSRoutes.h"
|
|
#include "CDClientDatabase.h"
|
|
#include "Database.h"
|
|
#include "HotPropertySlots.h"
|
|
#include "GeneralUtils.h"
|
|
#include "eHTTPMethod.h"
|
|
|
|
using namespace RouteUtils;
|
|
|
|
namespace {
|
|
using HotPropertySlots::eMode;
|
|
constexpr uint32_t MAX_MATCHES = 25;
|
|
constexpr size_t MAX_SEARCH = 64;
|
|
|
|
// The news screen's slots and the property worlds they can show properties of, from the CDClient
|
|
struct NewsWorlds {
|
|
std::vector<HotPropertySlots::Slot> slots;
|
|
std::vector<uint32_t> worlds;
|
|
};
|
|
|
|
const NewsWorlds& GetNewsWorlds() {
|
|
static const auto news = [] {
|
|
std::vector<HotPropertySlots::TemplateRow> templates;
|
|
auto rows = CDClientDatabase::ExecuteQuery("SELECT id, mapID, spawnName FROM PropertyTemplate;");
|
|
for (; !rows.eof(); rows.nextRow()) {
|
|
templates.push_back({ static_cast<uint32_t>(rows.getIntField("id")), static_cast<uint32_t>(rows.getIntField("mapID")), rows.getStringField("spawnName", "") });
|
|
}
|
|
std::vector<HotPropertySlots::EntranceRow> entrances;
|
|
auto entranceRows = CDClientDatabase::ExecuteQuery("SELECT mapID, propertyName FROM PropertyEntranceComponent;");
|
|
for (; !entranceRows.eof(); entranceRows.nextRow()) {
|
|
entrances.push_back({ static_cast<uint32_t>(entranceRows.getIntField("mapID")), entranceRows.getStringField("propertyName", "") });
|
|
}
|
|
return NewsWorlds{ HotPropertySlots::ResolveSlots(templates, entrances), HotPropertySlots::PropertyWorlds(templates, entrances) };
|
|
}();
|
|
return news;
|
|
}
|
|
|
|
const HotPropertySlots::Slot* FindSlot(std::optional<uint32_t> templateId) {
|
|
const auto& slots = GetNewsWorlds().slots;
|
|
const auto slot = std::find_if(slots.begin(), slots.end(), [&](const auto& s) { return templateId && s.templateId == *templateId; });
|
|
return slot == slots.end() ? nullptr : &*slot;
|
|
}
|
|
|
|
// A location given by the dashboard: only the slot's own world (missing means that too), since the news screen
|
|
// names the slot's world whatever property is sent for it (HotPropertySlots::Location)
|
|
std::optional<uint32_t> ParseLocation(const nlohmann::json& value, const HotPropertySlots::Slot& slot) {
|
|
if (value.is_null()) return slot.mapId;
|
|
const auto mapId = value.is_string() ? GeneralUtils::TryParse<uint32_t>(value.get<std::string>())
|
|
: value.is_number_unsigned() ? std::optional<uint32_t>(value.get<uint32_t>()) : std::nullopt;
|
|
if (mapId != slot.mapId) return std::nullopt;
|
|
return mapId;
|
|
}
|
|
|
|
|
|
nlohmann::json PropertyJson(const IProperty::Info& info, const std::string& ownerName) {
|
|
return {
|
|
{"id", std::to_string(info.id)},
|
|
{"name", info.name},
|
|
{"owner_id", std::to_string(info.ownerId)},
|
|
{"owner_name", ownerName},
|
|
{"reputation", info.reputation},
|
|
{"performance_cost", info.performanceCost},
|
|
{"last_updated", info.lastUpdatedTime},
|
|
{"zone_id", info.zoneId},
|
|
{"zone_name", GameText::ZoneName(info.zoneId)},
|
|
};
|
|
}
|
|
|
|
// The world's (0: every world's) approved public properties matching `search` (name, description or owner), most reputation first
|
|
IProperty::ShowcaseResult Candidates(uint32_t mapId, uint32_t length, const std::string& search = "") {
|
|
IProperty::ShowcaseQuery query;
|
|
query.search = search;
|
|
query.zoneId = mapId;
|
|
query.sort = IProperty::ShowcaseSort::REPUTATION;
|
|
query.length = length;
|
|
return Database::Get()->GetShowcaseProperties(query);
|
|
}
|
|
|
|
// A picked property with its owner's name, if it may be shown in a slot at that location
|
|
std::optional<std::pair<IProperty::Info, std::string>> Featurable(LWOOBJID propertyId, uint32_t location) {
|
|
const auto info = Database::Get()->GetPropertyInfo(propertyId);
|
|
if (!info || !HotPropertySlots::Featurable(info->modApproved, info->privacyOption, info->zoneId, location)) return std::nullopt;
|
|
const auto owner = Database::Get()->GetCharacterInfo(info->ownerId);
|
|
if (!owner) return std::nullopt;
|
|
return std::make_pair(*info, owner->name);
|
|
}
|
|
|
|
// Everything the dashboard shows; what each slot shows is resolved the way the world servers do it (see
|
|
// LoadHotProperties in GameMessages.cpp)
|
|
nlohmann::json StateJson() {
|
|
const auto& news = GetNewsWorlds();
|
|
const auto settings = Database::Get()->GetFeaturedPropertiesSettings();
|
|
std::map<uint32_t, IFeaturedProperties::FeaturedSlot> chosen;
|
|
for (const auto& row : Database::Get()->GetFeaturedPropertySlots()) chosen[row.templateId] = row;
|
|
|
|
std::vector<HotPropertySlots::Choice> choices;
|
|
for (const auto& slot : news.slots) {
|
|
const auto it = chosen.find(slot.templateId);
|
|
HotPropertySlots::Choice choice{ eMode::AUTO, slot.mapId };
|
|
if (it != chosen.end()) choice = { HotPropertySlots::ModeFromInt(it->second.mode), HotPropertySlots::Location(it->second.zoneId, slot, news.worlds), it->second.propertyId };
|
|
choices.push_back(choice);
|
|
}
|
|
|
|
std::vector<HotPropertySlots::Candidate> candidates;
|
|
std::map<LWOOBJID, nlohmann::json> properties;
|
|
for (const auto world : HotPropertySlots::CandidateWorlds(choices, settings.fullAuto)) {
|
|
for (const auto& entry : Candidates(world, HotPropertySlots::CANDIDATES_PER_WORLD).entries) {
|
|
candidates.push_back({ entry.info.id, entry.info.zoneId, entry.info.reputation });
|
|
properties.emplace(entry.info.id, PropertyJson(entry.info, entry.ownerName));
|
|
}
|
|
}
|
|
// The picks, shown or not, if they may still be shown at their slot's location
|
|
std::vector<nlohmann::json> picked(choices.size());
|
|
for (size_t i = 0; i < choices.size(); i++) {
|
|
if (choices[i].mode != eMode::PICKED) continue;
|
|
const auto property = Featurable(choices[i].propertyId, choices[i].mapId);
|
|
if (!property) continue;
|
|
picked[i] = PropertyJson(property->first, property->second);
|
|
if (!settings.fullAuto && !properties.contains(property->first.id)) {
|
|
candidates.push_back({ property->first.id, property->first.zoneId, property->first.reputation });
|
|
properties.emplace(property->first.id, picked[i]);
|
|
}
|
|
}
|
|
const auto showing = HotPropertySlots::Resolve(choices, settings.fullAuto, candidates);
|
|
|
|
nlohmann::json list = nlohmann::json::array();
|
|
for (size_t i = 0; i < news.slots.size(); i++) {
|
|
const auto& slot = news.slots[i];
|
|
const auto& choice = choices[i];
|
|
const auto it = chosen.find(slot.templateId);
|
|
nlohmann::json entry{
|
|
{"template_id", slot.templateId},
|
|
{"map_id", slot.mapId},
|
|
{"zone_name", GameText::ZoneName(slot.mapId)},
|
|
{"spawn_name", slot.spawnName},
|
|
{"location", choice.mapId},
|
|
{"location_name", GameText::ZoneName(choice.mapId)},
|
|
{"mode", HotPropertySlots::ModeName(choice.mode)},
|
|
{"property_id", nullptr},
|
|
{"picked", nullptr},
|
|
{"picked_unavailable", false},
|
|
{"picked_shown_elsewhere", false},
|
|
{"showing", showing[i].propertyId ? properties[*showing[i].propertyId] : nlohmann::json(nullptr)},
|
|
{"candidate_total", Candidates(choice.mapId, 1).total},
|
|
{"updated_at", it == chosen.end() ? 0 : it->second.updatedAt},
|
|
{"updated_by", it == chosen.end() ? "" : it->second.updatedBy},
|
|
};
|
|
if (choice.mode == eMode::PICKED) {
|
|
const auto& property = picked[i];
|
|
entry["property_id"] = std::to_string(choice.propertyId);
|
|
entry["picked"] = property;
|
|
entry["picked_unavailable"] = property.is_null();
|
|
entry["picked_shown_elsewhere"] = !property.is_null() && showing[i].pickFellBack;
|
|
}
|
|
list.push_back(std::move(entry));
|
|
}
|
|
|
|
nlohmann::json worlds = nlohmann::json::array();
|
|
for (const auto world : news.worlds) worlds.push_back({ {"map_id", world}, {"zone_name", GameText::ZoneName(world)} });
|
|
return {
|
|
{"full_auto", settings.fullAuto},
|
|
{"settings_updated_at", settings.updatedAt},
|
|
{"settings_updated_by", settings.updatedBy},
|
|
{"worlds", worlds},
|
|
{"slots", list},
|
|
};
|
|
}
|
|
}
|
|
|
|
void FeaturedProperties::RegisterRoutes() {
|
|
Route(eHTTPMethod::GET, "/api/featured_properties", Perm("feature_properties"),
|
|
"The news screen's \"Today's Top Properties\": full auto or per slot, the property worlds, and for each slot its location, what was chosen and what it shows",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
auto state = StateJson();
|
|
state["success"] = true;
|
|
JsonReply(reply, eHTTPStatusCode::OK, state);
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/featured_properties", Perm("feature_properties"),
|
|
"Switch \"Today's Top Properties\" between full auto (the top four approved public properties across every property world) and per slot. Body: {full_auto: bool}",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto body = ParseBody(context);
|
|
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
|
if (!body->contains("full_auto") || !(*body)["full_auto"].is_boolean()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "full_auto must be true or false");
|
|
|
|
IFeaturedProperties::FeaturedSettings settings;
|
|
settings.fullAuto = (*body)["full_auto"].get<bool>();
|
|
settings.updatedAt = static_cast<int64_t>(std::time(nullptr));
|
|
settings.updatedBy = context.authenticatedUser;
|
|
Database::Get()->SetFeaturedPropertiesSettings(settings);
|
|
Audit(context, "feature_property", std::string("Top properties: ") + (settings.fullAuto ? "full auto (the top properties across every property world)" : "per slot"));
|
|
BroadcastTableChanged("featured_properties");
|
|
JsonSuccess(reply, StateJson());
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/featured_properties/:template/candidates", Perm("feature_properties"),
|
|
"Approved public properties a \"Today's Top Properties\" slot can show, most reputation first (at most 25). Only the slot's own world (the news screen names it). Query: ?search= (name, description or owner)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto* slot = FindSlot(PathId<uint32_t>(context.path, 2));
|
|
if (!slot) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such slot");
|
|
const auto locationValue = QueryValue(context.queryString, "location");
|
|
const auto location = ParseLocation(locationValue.empty() ? nlohmann::json(nullptr) : nlohmann::json(locationValue), *slot);
|
|
if (!location) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "A slot can only show a property of its own world (the news screen names that world)");
|
|
|
|
const auto candidates = Candidates(*location, MAX_MATCHES, QueryValue(context.queryString, "search").substr(0, MAX_SEARCH));
|
|
nlohmann::json list = nlohmann::json::array();
|
|
for (const auto& candidate : candidates.entries) list.push_back(PropertyJson(candidate.info, candidate.ownerName));
|
|
JsonSuccess(reply, { {"total", candidates.total}, {"properties", list} });
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/featured_properties/:template", Perm("feature_properties"),
|
|
"Choose what a \"Today's Top Properties\" slot shows. Body: {mode: auto|picked|empty, property_id (for picked: an approved public property of the slot's own world that no other slot picked)}",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto templateId = PathId<uint32_t>(context.path, 2);
|
|
if (!templateId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid slot");
|
|
const auto body = ParseBody(context);
|
|
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
|
|
|
const auto* slot = FindSlot(templateId);
|
|
if (!slot) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such slot");
|
|
|
|
const auto mode = HotPropertySlots::ParseMode(body->value("mode", ""));
|
|
if (!mode) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "mode must be auto, picked or empty");
|
|
const auto location = ParseLocation(body->contains("location") ? (*body)["location"] : nlohmann::json(nullptr), *slot);
|
|
if (!location) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "A slot can only show a property of its own world (the news screen names that world)");
|
|
|
|
IFeaturedProperties::FeaturedSlot row;
|
|
row.templateId = slot->templateId;
|
|
row.mode = static_cast<uint8_t>(*mode);
|
|
row.zoneId = *location == slot->mapId ? 0 : *location; // 0 keeps following the slot's own world
|
|
row.updatedAt = static_cast<int64_t>(std::time(nullptr));
|
|
row.updatedBy = context.authenticatedUser;
|
|
|
|
std::string what = std::string(HotPropertySlots::ModeName(*mode));
|
|
AuditTarget target;
|
|
if (*mode == eMode::PICKED) {
|
|
const auto& idValue = (*body)["property_id"];
|
|
const auto propertyId = idValue.is_string() ? GeneralUtils::TryParse<LWOOBJID>(idValue.get<std::string>())
|
|
: idValue.is_number_integer() ? std::optional<LWOOBJID>(idValue.get<LWOOBJID>()) : std::nullopt;
|
|
if (!propertyId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "property_id is required to pick a property");
|
|
const auto picked = Featurable(*propertyId, *location);
|
|
if (!picked) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Only an approved public property in " + GameText::ZoneName(*location) + " can be picked there");
|
|
for (const auto& other : Database::Get()->GetFeaturedPropertySlots()) {
|
|
if (other.templateId == slot->templateId || HotPropertySlots::ModeFromInt(other.mode) != eMode::PICKED || other.propertyId != *propertyId) continue;
|
|
const auto* otherSlot = FindSlot(other.templateId);
|
|
if (!otherSlot) continue;
|
|
return JsonError(reply, eHTTPStatusCode::CONFLICT, "That property is already picked for the " + GameText::ZoneName(otherSlot->mapId) + " slot");
|
|
}
|
|
const auto& [info, ownerName] = *picked;
|
|
row.propertyId = *propertyId;
|
|
what = "property " + info.name + " (ID " + std::to_string(*propertyId) + ") by " + ownerName;
|
|
target = AuditTarget::Character(info.ownerId);
|
|
}
|
|
|
|
Database::Get()->SetFeaturedPropertySlot(row);
|
|
Audit(context, "feature_property", "Top properties slot " + GameText::ZoneName(slot->mapId) + " (template " + std::to_string(slot->templateId) + "): " + GameText::ZoneName(*location) + ", " + what, target);
|
|
BroadcastTableChanged("featured_properties", std::to_string(slot->templateId));
|
|
JsonSuccess(reply, StateJson());
|
|
});
|
|
}
|