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>
495 lines
26 KiB
C++
495 lines
26 KiB
C++
#include "LiveEventRoutes.h"
|
|
#include "GameText.h"
|
|
#include "LiveWorld.h"
|
|
#include "DashboardRoutes.h"
|
|
#include "ClientAssets.h"
|
|
#include "PlayerActions.h"
|
|
#include "ServerState.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <ctime>
|
|
#include <map>
|
|
#include <set>
|
|
|
|
#include "RouteUtils.h"
|
|
#include "WSRoutes.h"
|
|
#include "Alerts.h"
|
|
#include "CDClientDatabase.h"
|
|
#include "Database.h"
|
|
#include "Logger.h"
|
|
#include "LiveOpsRules.h"
|
|
#include "eHTTPMethod.h"
|
|
#include "eReplicaComponentType.h"
|
|
|
|
using namespace RouteUtils;
|
|
using LiveOpsRules::eEventType;
|
|
|
|
namespace {
|
|
using Event = ILiveOps::LiveEvent;
|
|
using eState = ILiveOps::eLiveEventState;
|
|
constexpr auto CHECK_INTERVAL = std::chrono::seconds(3);
|
|
constexpr size_t MAX_TITLE = 100;
|
|
constexpr size_t MAX_MESSAGE = 300;
|
|
|
|
std::chrono::steady_clock::time_point g_NextCheck{};
|
|
|
|
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
|
|
|
|
// Objects.type values each picker offers, so staff pick from what the client has rather than typing LOTs
|
|
const std::vector<std::string>& ObjectTypes(const std::string& kind) {
|
|
static const std::vector<std::string> treasure{ "Smashables", "Powerup", "Coin", "Environmental", "Structure", "Interactives", "Exhibits" };
|
|
static const std::vector<std::string> enemy{ "Enemies", "Enemy" };
|
|
static const std::vector<std::string> item{ "Loot" };
|
|
if (kind == "enemy") return enemy;
|
|
if (kind == "item") return item;
|
|
return treasure;
|
|
}
|
|
|
|
std::string ZoneList(const std::vector<uint32_t>& zones) {
|
|
if (zones.empty()) return "every world";
|
|
std::string text;
|
|
const auto& names = GameText::ZoneNames();
|
|
for (const auto zone : zones) {
|
|
const auto key = std::to_string(zone);
|
|
text += (text.empty() ? "" : ", ") + (names.contains(key) ? names[key].get<std::string>() : "zone " + key);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
nlohmann::json ZoneOptions() {
|
|
std::vector<std::pair<uint32_t, std::string>> zones;
|
|
for (const auto& [id, name] : GameText::ZoneNames().items()) {
|
|
if (const auto zone = GeneralUtils::TryParse<uint32_t>(id); zone && *zone > 0) zones.emplace_back(*zone, name.get<std::string>());
|
|
}
|
|
std::sort(zones.begin(), zones.end());
|
|
nlohmann::json options = nlohmann::json::array();
|
|
for (const auto& [id, name] : zones) options.push_back({ {"id", id}, {"name", name} });
|
|
return options;
|
|
}
|
|
|
|
std::string TypeLabel(eEventType type) {
|
|
switch (type) {
|
|
case eEventType::TREASURE_HUNT: return "Treasure hunt";
|
|
case eEventType::BONUS: return "Bonus";
|
|
case eEventType::INVASION: return "Invasion";
|
|
case eEventType::CELEBRATION: return "Celebration";
|
|
}
|
|
return "Event";
|
|
}
|
|
|
|
std::string Describe(const Event& event) {
|
|
return "#" + std::to_string(event.id) + " " + event.type + " \"" + event.title + "\" in " + ZoneList(event.zones) +
|
|
(event.instanceId >= 0 ? " (instance " + std::to_string(event.instanceId) + ")" : "");
|
|
}
|
|
|
|
// Does the CDClient have this object, drawn in the world (a render component), and of one of these types?
|
|
bool ObjectOfTypes(LOT lot, const std::vector<std::string>& types) {
|
|
auto stmt = CDClientDatabase::CreatePreppedStmt(
|
|
"SELECT o.type FROM Objects o WHERE o.id = ? AND EXISTS (SELECT 1 FROM ComponentsRegistry c WHERE c.id = o.id AND c.component_type = ?);");
|
|
stmt.bind(1, static_cast<int>(lot));
|
|
stmt.bind(2, static_cast<int>(eReplicaComponentType::RENDER));
|
|
auto result = stmt.execQuery();
|
|
if (result.eof()) return false;
|
|
const std::string type = result.getStringField(0, "");
|
|
return std::find(types.begin(), types.end(), type) != types.end();
|
|
}
|
|
|
|
bool EffectExists(int32_t effectId, const std::string& effectType) {
|
|
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT 1 FROM BehaviorEffect WHERE effectID = ? AND effectType = ? LIMIT 1;");
|
|
stmt.bind(1, effectId);
|
|
stmt.bind(2, effectType.c_str());
|
|
return !stmt.execQuery().eof();
|
|
}
|
|
|
|
float Multiplier(const nlohmann::json& body, const char* key) {
|
|
const auto& value = body.contains(key) ? body[key] : nlohmann::json(1.0);
|
|
return value.is_number() ? value.get<float>() : 0.0f;
|
|
}
|
|
|
|
template<typename T>
|
|
std::optional<T> Number(const nlohmann::json& body, const char* key, T fallback, T min, T max) {
|
|
if (!body.contains(key) || body[key].is_null()) return fallback;
|
|
if (!body[key].is_number()) return std::nullopt;
|
|
const auto value = body[key].get<double>();
|
|
if (value < static_cast<double>(min) || value > static_cast<double>(max)) return std::nullopt;
|
|
return static_cast<T>(value);
|
|
}
|
|
|
|
/**
|
|
* The event's settings from a request, checked and with only what the world reads. error says what is wrong.
|
|
*/
|
|
std::optional<nlohmann::json> ReadConfig(eEventType type, const nlohmann::json& body, std::string& error) {
|
|
nlohmann::json config = nlohmann::json::object();
|
|
const auto fail = [&error](const std::string& message) { error = message; return std::nullopt; };
|
|
switch (type) {
|
|
case eEventType::TREASURE_HUNT: {
|
|
const auto lot = Number<int32_t>(body, "lot", 0, 1, INT32_MAX);
|
|
if (!lot || !ObjectOfTypes(*lot, ObjectTypes("treasure"))) return fail("Pick the treasure from the list");
|
|
const auto count = Number<uint32_t>(body, "count", 10, 1, LiveOpsRules::MAX_TREASURES);
|
|
if (!count) return fail("Treasures: 1 to " + std::to_string(LiveOpsRules::MAX_TREASURES));
|
|
const auto coins = Number<int64_t>(body, "coins", 0, 0, 100000);
|
|
if (!coins) return fail("Coins per find: 0 to 100000");
|
|
const auto radius = Number<float>(body, "radius", 4.0f, 2.0f, 15.0f);
|
|
if (!radius) return fail("Pickup distance: 2 to 15");
|
|
const auto itemLot = Number<int32_t>(body, "itemLot", 0, 0, INT32_MAX);
|
|
if (!itemLot || (*itemLot > 0 && !ObjectOfTypes(*itemLot, ObjectTypes("item")))) return fail("Pick the item reward from the list");
|
|
const auto itemCount = Number<uint32_t>(body, "itemCount", 1, 1, 999);
|
|
if (!itemCount) return fail("Item count: 1 to 999");
|
|
config = { {"lot", *lot}, {"count", *count}, {"coins", *coins}, {"radius", *radius}, {"itemLot", *itemLot}, {"itemCount", *itemCount} };
|
|
if (const auto effect = Number<int32_t>(body, "foundEffectId", 0, 0, INT32_MAX); effect && *effect > 0) {
|
|
const auto effectType = body.value("foundEffectType", std::string{});
|
|
if (!EffectExists(*effect, effectType)) return fail("Pick the find effect from the list");
|
|
config["foundEffectId"] = *effect;
|
|
config["foundEffectType"] = effectType;
|
|
}
|
|
break;
|
|
}
|
|
case eEventType::BONUS: {
|
|
const LiveOpsRules::Multipliers multipliers{ Multiplier(body, "coins"), Multiplier(body, "uscore"), Multiplier(body, "lootChance") };
|
|
for (const auto value : { multipliers.coins, multipliers.uscore, multipliers.lootChance }) {
|
|
if (!(value >= 1.0f && value <= LiveOpsRules::MAX_MULTIPLIER)) return fail("Multipliers go from 1 to " + std::to_string(static_cast<int>(LiveOpsRules::MAX_MULTIPLIER)));
|
|
}
|
|
if (!multipliers.Any()) return fail("Raise at least one multiplier above 1");
|
|
config = { {"coins", multipliers.coins}, {"uscore", multipliers.uscore}, {"lootChance", multipliers.lootChance} };
|
|
break;
|
|
}
|
|
case eEventType::INVASION: {
|
|
nlohmann::json lots = nlohmann::json::array();
|
|
if (body.contains("lots") && body["lots"].is_array()) {
|
|
for (const auto& lot : body["lots"]) {
|
|
if (!lot.is_number_integer() || !ObjectOfTypes(lot.get<int32_t>(), ObjectTypes("enemy"))) return fail("Pick the enemies from the list");
|
|
if (std::find(lots.begin(), lots.end(), lot) == lots.end()) lots.push_back(lot);
|
|
}
|
|
}
|
|
if (lots.empty() || lots.size() > 5) return fail("Pick 1 to 5 kinds of enemy");
|
|
const auto waves = Number<uint32_t>(body, "waves", 3, 1, LiveOpsRules::MAX_WAVES);
|
|
const auto perWave = Number<uint32_t>(body, "perWave", 5, 1, LiveOpsRules::MAX_PER_WAVE);
|
|
const auto interval = Number<int64_t>(body, "interval", 90, 15, 3600);
|
|
const auto radius = Number<float>(body, "radius", 25.0f, 5.0f, 80.0f);
|
|
if (!waves || !perWave || !interval || !radius) return fail("Waves 1-20, enemies per wave 1-30, 15-3600 seconds apart, radius 5-80");
|
|
const auto center = body.value("center", std::string{ "spawn" });
|
|
if (center != "spawn" && center != "players") return fail("Attack the spawn point or the players");
|
|
config = { {"lots", lots}, {"waves", *waves}, {"perWave", *perWave}, {"interval", *interval}, {"radius", *radius}, {"center", center} };
|
|
break;
|
|
}
|
|
case eEventType::CELEBRATION: {
|
|
const auto effect = Number<int32_t>(body, "effectId", 0, 1, INT32_MAX);
|
|
const auto effectType = body.value("effectType", std::string{});
|
|
if (!effect || !EffectExists(*effect, effectType)) return fail("Pick the effect from the list");
|
|
const auto interval = Number<int64_t>(body, "interval", 10, 3, 600);
|
|
if (!interval) return fail("Every 3 to 600 seconds");
|
|
config = { {"effectId", *effect}, {"effectType", effectType}, {"interval", *interval} };
|
|
break;
|
|
}
|
|
}
|
|
return config;
|
|
}
|
|
|
|
// Summary of the instances' reports: {instances, players, found, treasures, kills, spawned, ...}
|
|
nlohmann::json Totals(const std::vector<ILiveOps::LiveEventInstance>& instances) {
|
|
nlohmann::json totals{ {"instances", 0}, {"open", 0} };
|
|
for (const auto& instance : instances) {
|
|
const auto status = nlohmann::json::parse(instance.status, nullptr, false);
|
|
if (!status.is_object()) continue;
|
|
totals["instances"] = totals["instances"].get<int>() + 1;
|
|
if (!status.value("closed", false)) totals["open"] = totals["open"].get<int>() + 1;
|
|
for (const auto& [key, value] : status.items()) {
|
|
if (!value.is_number_integer() || key == "players") continue;
|
|
totals[key] = totals.value(key, int64_t{ 0 }) + value.get<int64_t>();
|
|
}
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
nlohmann::json EventJson(const Event& event, const std::vector<ILiveOps::LiveEventInstance>& instances, bool detail) {
|
|
const auto type = LiveOpsRules::ParseType(event.type);
|
|
nlohmann::json list = nlohmann::json::array();
|
|
for (const auto& instance : instances) {
|
|
if (instance.eventId != event.id) continue;
|
|
auto status = nlohmann::json::parse(instance.status, nullptr, false);
|
|
list.push_back({ {"zone", instance.zoneId}, {"instance", instance.instanceId}, {"status", status.is_object() ? status : nlohmann::json::object()},
|
|
{"updatedAt", instance.updatedAt} });
|
|
}
|
|
std::vector<ILiveOps::LiveEventInstance> own;
|
|
for (const auto& instance : instances) if (instance.eventId == event.id) own.push_back(instance);
|
|
nlohmann::json scores = nlohmann::json::array();
|
|
if (detail) {
|
|
for (const auto& score : Database::Get()->GetLiveEventScores(event.id, 10)) {
|
|
scores.push_back({ {"characterId", std::to_string(score.characterId)}, {"name", score.name}, {"amount", score.amount} });
|
|
}
|
|
}
|
|
auto config = nlohmann::json::parse(event.config, nullptr, false);
|
|
return { {"id", event.id}, {"type", event.type}, {"typeLabel", type ? TypeLabel(*type) : event.type}, {"title", event.title}, {"message", event.message},
|
|
{"zones", event.zones}, {"zoneNames", ZoneList(event.zones)}, {"instanceId", event.instanceId}, {"config", config.is_object() ? config : nlohmann::json::object()},
|
|
{"startsAt", event.startsAt}, {"endsAt", event.endsAt}, {"state", event.state == eState::ACTIVE ? "running" : event.state == eState::ENDED ? "ended" : "cancelled"},
|
|
{"endedAt", event.endedAt}, {"endReason", event.endReason}, {"createdBy", event.createdBy}, {"endedBy", event.endedBy},
|
|
{"instances", list}, {"totals", Totals(own)}, {"scores", scores} };
|
|
}
|
|
|
|
// What happened, for the end announcement: "3 of 10 treasures found. Most found: Bob (2)."
|
|
std::string Summary(const Event& event) {
|
|
const auto type = LiveOpsRules::ParseType(event.type);
|
|
const auto totals = Totals(Database::Get()->GetLiveEventInstances({ event.id }));
|
|
const auto scores = Database::Get()->GetLiveEventScores(event.id, 3);
|
|
std::string text;
|
|
if (type == eEventType::TREASURE_HUNT) text = std::to_string(totals.value("found", 0)) + " of " + std::to_string(totals.value("treasures", 0)) + " treasures found.";
|
|
if (type == eEventType::INVASION) text = std::to_string(totals.value("kills", 0)) + " invaders smashed.";
|
|
if (!scores.empty() && !scores.front().name.empty()) {
|
|
text += (text.empty() ? "" : " ") + std::string(type == eEventType::INVASION ? "Top defenders: " : "Top finders: ");
|
|
for (size_t i = 0; i < scores.size(); i++) text += (i ? ", " : "") + scores[i].name + " (" + std::to_string(scores[i].amount) + ")";
|
|
text += ".";
|
|
}
|
|
return text;
|
|
}
|
|
|
|
void Finish(const Event& event, eState state, const HTTPContext& actor, const std::string& reason) {
|
|
if (!Database::Get()->EndLiveEvent(event.id, state, Now(), actor.authenticatedUser, reason)) return;
|
|
const auto summary = Summary(event);
|
|
// Treasure hunts and invasions are announced over by each world when it stops them ("The treasure hunt is
|
|
// over: 3 of 10 treasures were found here.", dGame/dUtilities/LiveEvents.cpp), with its own final count;
|
|
// announcing here as well said it twice
|
|
const auto type = LiveOpsRules::ParseType(event.type);
|
|
const bool worldsAnnounce = type == eEventType::TREASURE_HUNT || type == eEventType::INVASION;
|
|
if (!worldsAnnounce) {
|
|
LiveWorld::Announce(event.title.empty() ? "Event over" : event.title, "The event is over. " + (summary.empty() ? std::string("Thanks for playing!") : summary), event.zones);
|
|
}
|
|
Audit(actor, "end_live_event", Describe(event) + ": " + reason + (summary.empty() ? "" : " " + summary));
|
|
Alerts::Emit("server", "Live event ended", event.title + " (" + event.type + "): " + reason + ". " + summary, {}, "/live_events");
|
|
BroadcastTableChanged("live_events", std::to_string(event.id));
|
|
// Worlds end it on their own at its end time; an early end needs them told
|
|
LiveEventRoutes::ReloadWorlds();
|
|
}
|
|
|
|
// Worlds running a zone now, for the page: which zones would pick an event up straight away
|
|
nlohmann::json RunningWorlds();
|
|
|
|
/**
|
|
* What an event is from a request: {type, title, message, zones, instance, config}, checked the way the worlds read
|
|
* it. False and error when something is wrong.
|
|
*/
|
|
bool ReadEvent(const nlohmann::json& body, Event& event, std::string& error) {
|
|
const auto type = LiveOpsRules::ParseType(body.value("type", std::string{}));
|
|
if (!type) { error = "Pick a kind of live event"; return false; }
|
|
event.type = std::string(LiveOpsRules::TypeName(*type));
|
|
event.title = body.value("title", std::string{});
|
|
event.message = body.value("message", std::string{});
|
|
if (event.title.empty() || event.title.size() > MAX_TITLE) { error = "The title is needed (up to 100 characters)"; return false; }
|
|
if (event.message.size() > MAX_MESSAGE) { error = "The message is up to 300 characters"; return false; }
|
|
event.zones.clear();
|
|
if (body.contains("zones") && body["zones"].is_array()) {
|
|
const auto& known = GameText::ZoneNames();
|
|
for (const auto& zone : body["zones"]) {
|
|
if (!zone.is_number_unsigned() || zone.get<uint32_t>() == 0 || !known.contains(std::to_string(zone.get<uint32_t>()))) { error = "Pick zones from the list"; return false; }
|
|
if (std::find(event.zones.begin(), event.zones.end(), zone.get<uint32_t>()) == event.zones.end()) event.zones.push_back(zone.get<uint32_t>());
|
|
}
|
|
}
|
|
if (LiveOpsRules::NeedsZone(*type) && event.zones.empty()) { error = "Pick the zones to run it in"; return false; }
|
|
const auto instance = body.contains("instance") && body["instance"].is_number_integer() ? body["instance"].get<int64_t>() : -1;
|
|
if (instance < -1 || instance > INT32_MAX) { error = "Invalid instance"; return false; }
|
|
event.instanceId = static_cast<int32_t>(instance);
|
|
const auto config = ReadConfig(*type, body.value("config", nlohmann::json::object()), error);
|
|
if (!config) return false;
|
|
event.config = config->dump();
|
|
return true;
|
|
}
|
|
|
|
// Store, announce and tell the worlds; the message for staff
|
|
std::string Launch(Event& event, const HTTPContext& actor) {
|
|
const auto type = LiveOpsRules::ParseType(event.type);
|
|
const auto minutes = (event.endsAt - event.startsAt + 59) / 60;
|
|
event.createdBy = actor.authenticatedUser;
|
|
event.id = Database::Get()->InsertLiveEvent(event);
|
|
|
|
LiveEventRoutes::ReloadWorlds();
|
|
const auto label = type ? TypeLabel(*type) : event.type;
|
|
const bool announced = LiveWorld::Announce(event.title, event.message.empty() ? label + " for the next " + std::to_string(minutes) + " minutes!" : event.message, event.zones);
|
|
Audit(actor, "start_live_event", Describe(event) + " for " + std::to_string(minutes) + " minutes: " + event.config);
|
|
Alerts::Emit("server", "Live event started", event.title + " (" + event.type + ") in " + ZoneList(event.zones) + " for " + std::to_string(minutes) + " minutes", {}, "/live_events");
|
|
BroadcastTableChanged("live_events", std::to_string(event.id));
|
|
|
|
// Worlds of the zones running now pick it up at once; others when one starts
|
|
size_t running = 0;
|
|
for (const auto& world : RunningWorlds()) {
|
|
if (event.instanceId >= 0 && world["instance"].get<int64_t>() != event.instanceId) continue; // only that instance runs it
|
|
if (event.zones.empty() || std::find(event.zones.begin(), event.zones.end(), world["zone"].get<uint32_t>()) != event.zones.end()) running++;
|
|
}
|
|
return std::string(announced ? "Started" : "Started, but master isn't connected so it wasn't announced") +
|
|
(running == 0 ? ". No world of those zones is running yet; it starts in each one that opens before it ends." : " in " + std::to_string(running) + " running world(s).");
|
|
}
|
|
|
|
nlohmann::json RunningWorlds() {
|
|
nlohmann::json worlds = nlohmann::json::array();
|
|
std::lock_guard lock(ServerState::g_StatusMutex);
|
|
for (const auto& world : ServerState::g_WorldInstances) {
|
|
if (world.mapID == 0 || world.cloneID != 0) continue;
|
|
worlds.push_back({ {"zone", world.mapID}, {"instance", world.instanceID}, {"players", world.players} });
|
|
}
|
|
return worlds;
|
|
}
|
|
}
|
|
|
|
namespace LiveEventRoutes {
|
|
std::optional<nlohmann::json> CheckPart(const nlohmann::json& body, std::string& error) {
|
|
if (!body.is_object()) { error = "The live event is an object"; return std::nullopt; }
|
|
Event event;
|
|
if (!ReadEvent(body, event, error)) return std::nullopt;
|
|
return nlohmann::json{ {"type", event.type}, {"title", event.title}, {"message", event.message}, {"zones", event.zones}, {"instance", event.instanceId},
|
|
{"config", nlohmann::json::parse(event.config)} };
|
|
}
|
|
|
|
std::optional<uint64_t> StartPart(const nlohmann::json& body, int64_t endsAt, const HTTPContext& actor, std::string& error, std::string& message) {
|
|
Event event;
|
|
if (!ReadEvent(body, event, error)) return std::nullopt;
|
|
event.startsAt = Now();
|
|
event.endsAt = std::min(endsAt, event.startsAt + LiveOpsRules::MAX_DURATION);
|
|
if (event.endsAt - event.startsAt < LiveOpsRules::MIN_DURATION) { error = "Less than a minute is left of the event"; return std::nullopt; }
|
|
message = Launch(event, actor);
|
|
return event.id;
|
|
}
|
|
|
|
bool EndPart(uint64_t id, const HTTPContext& actor, const std::string& reason) {
|
|
const auto event = Database::Get()->GetLiveEvent(id);
|
|
if (!event || event->state != eState::ACTIVE) return false;
|
|
Finish(*event, eState::CANCELLED, actor, reason);
|
|
return true;
|
|
}
|
|
|
|
bool Running(uint64_t id) {
|
|
const auto event = Database::Get()->GetLiveEvent(id);
|
|
return event && event->state == eState::ACTIVE;
|
|
}
|
|
|
|
void ReloadWorlds() {
|
|
PlayerActionRequest request;
|
|
request.action = ePlayerAction::RELOAD_LIVE_OPS;
|
|
PlayerActions::Request(request, 0, [](const PlayerActionResult& result) {
|
|
return PlayerActions::Outcome{ true, "Reloaded in " + std::to_string(result.affected) + " world(s)" };
|
|
});
|
|
}
|
|
|
|
void Update() {
|
|
const auto steady = std::chrono::steady_clock::now();
|
|
if (steady < g_NextCheck) return;
|
|
g_NextCheck = steady + CHECK_INTERVAL;
|
|
try {
|
|
const auto now = Now();
|
|
for (const auto& event : Database::Get()->GetLiveEvents(true, 200)) {
|
|
if (now >= event.endsAt) Finish(event, eState::ENDED, SystemContext(), "Time is up");
|
|
}
|
|
} catch (const std::exception& ex) {
|
|
LOG("Live events check failed: %s", ex.what());
|
|
}
|
|
}
|
|
|
|
nlohmann::json PublicJson() {
|
|
nlohmann::json events = nlohmann::json::array();
|
|
const auto now = Now();
|
|
for (const auto& event : Database::Get()->GetLiveEvents(true, 20)) {
|
|
if (!LiveOpsRules::InWindow(event.startsAt, event.endsAt, now)) continue;
|
|
const auto type = LiveOpsRules::ParseType(event.type);
|
|
events.push_back({ {"title", event.title.empty() ? (type ? TypeLabel(*type) : event.type) : event.title}, {"type", event.type},
|
|
{"where", ZoneList(event.zones)}, {"ends_at", event.endsAt} });
|
|
}
|
|
return events;
|
|
}
|
|
|
|
void RegisterRoutes() {
|
|
Route(eHTTPMethod::GET, "/live_events", Perm("live_events_manage"), "Live events page",
|
|
[](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "live_events.jinja2", "live_events"); });
|
|
|
|
Route(eHTTPMethod::GET, "/api/live-events", Perm("live_events_manage"),
|
|
"Running and recent live events with each world instance's progress and the top players, the zones and the worlds running now",
|
|
[](HTTPReply& reply, const HTTPContext&) {
|
|
const auto events = Database::Get()->GetLiveEvents(false, 40);
|
|
std::vector<uint64_t> ids;
|
|
for (const auto& event : events) ids.push_back(event.id);
|
|
const auto instances = Database::Get()->GetLiveEventInstances(ids);
|
|
nlohmann::json rows = nlohmann::json::array();
|
|
for (const auto& event : events) rows.push_back(EventJson(event, instances, event.state == eState::ACTIVE || rows.size() < 10));
|
|
JsonSuccess(reply, { {"events", rows}, {"zones", ZoneOptions()}, {"worlds", RunningWorlds()}, {"now", Now()},
|
|
{"limits", { {"title", MAX_TITLE}, {"message", MAX_MESSAGE}, {"minMinutes", LiveOpsRules::MIN_DURATION / 60}, {"maxMinutes", LiveOpsRules::MAX_DURATION / 60},
|
|
{"treasures", LiveOpsRules::MAX_TREASURES}, {"waves", LiveOpsRules::MAX_WAVES}, {"perWave", LiveOpsRules::MAX_PER_WAVE},
|
|
{"multiplier", LiveOpsRules::MAX_MULTIPLIER} }} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/live-events/objects", Perm("live_events_manage"),
|
|
"Objects from the CDClient to pick for an event. Query: kind=treasure|enemy|item, q (name or LOT)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto kind = QueryValue(context.queryString, "kind");
|
|
const auto query = QueryValue(context.queryString, "q");
|
|
const auto& types = ObjectTypes(kind);
|
|
std::string placeholders;
|
|
for (size_t i = 0; i < types.size(); i++) placeholders += i ? ", ?" : "?";
|
|
// IN, not a correlated EXISTS: ComponentsRegistry has no index, and the EXISTS form took seconds with the
|
|
// bundled SQLite for the kinds with thousands of objects
|
|
auto stmt = CDClientDatabase::CreatePreppedStmt(
|
|
"SELECT o.id, o.name, o.displayName, o.type FROM Objects o WHERE o.type IN (" + placeholders + ") "
|
|
"AND o.id IN (SELECT c.id FROM ComponentsRegistry c WHERE c.component_type = ?) "
|
|
"AND (? = '' OR o.name LIKE '%' || ? || '%' OR o.displayName LIKE '%' || ? || '%' OR o.id = ?) ORDER BY o.name LIMIT 60;");
|
|
int index = 1;
|
|
for (const auto& type : types) stmt.bind(index++, type.c_str());
|
|
stmt.bind(index++, static_cast<int>(eReplicaComponentType::RENDER));
|
|
stmt.bind(index++, query.c_str());
|
|
stmt.bind(index++, query.c_str());
|
|
stmt.bind(index++, query.c_str());
|
|
stmt.bind(index++, GeneralUtils::TryParse<int>(query).value_or(-1));
|
|
auto result = stmt.execQuery();
|
|
nlohmann::json objects = nlohmann::json::array();
|
|
for (; !result.eof(); result.nextRow()) {
|
|
const std::string displayName = result.getStringField("displayName", "");
|
|
objects.push_back({ {"lot", result.getIntField("id")}, {"name", displayName.empty() ? result.getStringField("name", "") : displayName},
|
|
{"type", result.getStringField("type", "")} });
|
|
}
|
|
JsonSuccess(reply, { {"objects", objects}, {"types", types} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/live-events/effects", Perm("live_events_manage"),
|
|
"Effects from the CDClient's BehaviorEffect table to play in a celebration. Query: q (name, type or ID)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto query = QueryValue(context.queryString, "q");
|
|
auto stmt = CDClientDatabase::CreatePreppedStmt(
|
|
"SELECT effectID, effectType, MIN(effectName) AS effectName FROM BehaviorEffect WHERE effectType IS NOT NULL AND effectType != '' "
|
|
"AND (effectName LIKE '%' || ? || '%' OR effectType LIKE '%' || ? || '%' OR effectID = ?) GROUP BY effectID, effectType ORDER BY effectID LIMIT 60;");
|
|
stmt.bind(1, query.c_str());
|
|
stmt.bind(2, query.c_str());
|
|
stmt.bind(3, GeneralUtils::TryParse<int>(query).value_or(-1));
|
|
auto result = stmt.execQuery();
|
|
nlohmann::json effects = nlohmann::json::array();
|
|
for (; !result.eof(); result.nextRow()) {
|
|
effects.push_back({ {"id", result.getIntField("effectID")}, {"type", result.getStringField("effectType", "")}, {"name", result.getStringField("effectName", "")} });
|
|
}
|
|
JsonSuccess(reply, { {"effects", effects} });
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/live-events", Perm("live_events_manage"),
|
|
"Start a live event now. Body: {type: treasure_hunt|bonus|invasion|celebration, title, message, zones: [zone IDs], instance (-1: every instance), "
|
|
"minutes, config: {...per type}}",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto body = ParseBody(context);
|
|
if (!body || !body->is_object()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
|
Event event;
|
|
std::string error;
|
|
if (!ReadEvent(*body, event, error)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, error);
|
|
const auto minutes = body->value("minutes", int64_t{ 0 });
|
|
if (minutes * 60 < LiveOpsRules::MIN_DURATION || minutes * 60 > LiveOpsRules::MAX_DURATION) {
|
|
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "It can run from 1 minute to 7 days");
|
|
}
|
|
event.startsAt = Now();
|
|
event.endsAt = event.startsAt + minutes * 60;
|
|
const auto message = Launch(event, context);
|
|
JsonSuccess(reply, { {"id", event.id}, {"message", message} });
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/live-events/:id/end", Perm("live_events_manage"), "End a running live event now; worlds remove what it spawned",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto id = PathId<uint64_t>(context.path, 2);
|
|
const auto event = id ? Database::Get()->GetLiveEvent(*id) : std::nullopt;
|
|
if (!event) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Event not found");
|
|
if (event->state != eState::ACTIVE) return JsonError(reply, eHTTPStatusCode::CONFLICT, "It has already ended");
|
|
Finish(*event, eState::CANCELLED, context, "Ended early by " + context.authenticatedUser);
|
|
JsonSuccess(reply, { {"message", "Ended"} });
|
|
});
|
|
}
|
|
}
|