Files
DarkflameServer/dDashboardServer/routes/EventsCalendar.cpp
Aaron Kimbrell 3f2a9cc5b0 feat(dashboard): scoped API keys with rate limits and quotas
Bearer keys (dlk_...) are checked per request against their owner's
current account (ban, lock, demotion, sign out everywhere stop or narrow
them at once) and their scope: permission routes need the permission in
scope, read-only keys only read, level-only routes need an all-permission
key, and session-only paths (sign-in, password, 2FA, key management)
are never reachable with a key. Per-key rate limit and daily quota with
429 and X-RateLimit/X-Quota/Retry-After headers; usage is written in
batches every minute. WebSocket subscriptions honour the scope too.

Routes to list, make, rotate and revoke keys; staff with
api_keys_manage can see and revoke others' keys under the rank rules.
POST /api/auth/token now makes an all-permission key. Audit entries for
create/rotate/revoke/denied, and actions done with a key are attributed
to "user (key name)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:31:03 -05:00

1147 lines
60 KiB
C++

#include "EventsCalendar.h"
#include "EventSchedule.h"
#include "AnnouncementSchedule.h"
#include "LevelGating.h"
#include "ZonePaths.h"
#include "ClientAssets.h"
#include "DashboardRoutes.h"
#include "SettingsRoutes.h"
#include "LiveEventRoutes.h"
#include "LiveWorld.h"
#include "LiveOpsRules.h"
#include "PlayerActions.h"
#include "master/PlayerAction.h"
#include "ServerState.h"
#include "GameLabels.h"
#include <algorithm>
#include <chrono>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <map>
#include <memory>
#include <set>
#include <tuple>
#include "RouteUtils.h"
#include "WSRoutes.h"
#include "Alerts.h"
#include "Background.h"
#include "BinaryPathFinder.h"
#include "CDClientDatabase.h"
#include "ClientVersion.h"
#include "Database.h"
#include "EventParts.h"
#include "Game.h"
#include "Logger.h"
#include "ScheduleRules.h"
#include "VanityEvents.h"
#include "dConfig.h"
#include "eHTTPMethod.h"
using namespace RouteUtils;
namespace {
using eEventState = IServerOperations::eEventState;
using Event = IServerOperations::ScheduledEvent;
using EventParts::Part;
using EventParts::eKind;
using EventParts::eStep;
using ScheduleRules::eMode;
constexpr auto CHECK_INTERVAL = std::chrono::seconds(5);
constexpr size_t MAX_NAME_LENGTH = 100;
constexpr size_t MAX_NOTE_LENGTH = 500;
constexpr size_t MAX_PARTS = 20;
constexpr size_t MAX_REMOVALS = 200;
constexpr size_t MAX_MESSAGE_LENGTH = 1000;
constexpr int32_t MAX_PRIORITY = 1000;
constexpr int64_t MAX_RESTART_MINUTES = 1440;
// The longest stretch the calendar asks about at once
constexpr int64_t MAX_RANGE_SECONDS = 400 * 86400LL;
constexpr const char* VANITY_ROOT = "root.xml";
std::chrono::steady_clock::time_point g_NextCheck{};
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
std::filesystem::path VanityFolder() { return BinaryPathFinder::GetBinaryDir() / "vanity"; }
struct Feature {
std::string description;
std::optional<std::tuple<int32_t, int32_t, int32_t>> version; // FeatureGating: unlocked from this client version
std::map<uint32_t, uint32_t> zones; // zone -> objects gated on it
};
// FeatureGating, read once
std::map<std::string, Feature> g_Features;
enum class eScan { NOT_STARTED, RUNNING, DONE };
eScan g_Scan = eScan::NOT_STARTED;
/**
* Find which zones have objects gated on which features: the scene files are read here (the client path is a
* setting) and parsed on a worker thread, since that takes seconds. Their zones join g_Features when it finishes.
*/
void StartFeatureScan() {
g_Scan = eScan::RUNNING;
try {
auto result = CDClientDatabase::ExecuteQuery("SELECT featureName, major, current, minor, description FROM FeatureGating;");
for (; !result.eof(); result.nextRow()) {
const std::string name = result.getStringField(0, "");
if (name.empty()) continue;
auto& feature = g_Features[name];
feature.version = std::tuple{ result.getIntField(1, -1), result.getIntField(2, -1), result.getIntField(3, -1) };
feature.description = result.getStringField(4, "");
}
} catch (const std::exception& ex) {
LOG("Could not read FeatureGating: %s", ex.what());
}
// Several zone IDs can share a .luz
std::map<std::string, std::vector<uint32_t>> zonesByFile;
try {
auto result = CDClientDatabase::ExecuteQuery("SELECT zoneID, zoneName FROM ZoneTable;");
for (; !result.eof(); result.nextRow()) {
const std::string luz = result.getStringField(1, "");
if (!luz.empty()) zonesByFile[luz].push_back(static_cast<uint32_t>(result.getIntField(0, 0)));
}
} catch (const std::exception& ex) {
LOG("Could not read ZoneTable: %s", ex.what());
}
auto scenes = std::make_shared<std::vector<std::pair<std::vector<uint32_t>, std::string>>>(); // (zones, .lvl bytes)
for (const auto& [luzPath, zoneIds] : zonesByFile) {
const auto luz = ClientAssets::ReadResFile("maps/" + luzPath);
if (!luz) continue;
const auto folder = luzPath.substr(0, luzPath.find_last_of('/') + 1);
for (const auto& scene : ZonePaths::ReadSceneFiles(*luz)) {
if (auto lvl = ClientAssets::ReadResFile("maps/" + folder + scene)) scenes->emplace_back(zoneIds, std::move(*lvl));
}
}
Background::Run("events_feature_scan", [scenes](GameDatabase&) -> nlohmann::json {
// feature -> zone -> objects
std::map<std::string, std::map<uint32_t, uint32_t>> found;
for (const auto& [zoneIds, lvl] : *scenes) {
for (const auto& [name, objects] : LevelGating::ReadGatedFeatures(lvl)) {
for (const auto zone : zoneIds) found[name][zone] += objects;
}
}
nlohmann::json result = nlohmann::json::object();
for (const auto& [name, zones] : found) {
auto& list = result[name] = nlohmann::json::array();
for (const auto& [zone, objects] : zones) list.push_back({ zone, objects });
}
return result;
}, [count = scenes->size()](nlohmann::json result, const std::string& error) {
g_Scan = eScan::DONE;
if (!error.empty()) {
LOG("Events calendar: reading the scene files failed: %s", error.c_str());
return;
}
for (const auto& [name, zones] : result.items()) {
for (const auto& zone : zones) g_Features[name].zones[zone[0].get<uint32_t>()] = zone[1].get<uint32_t>();
}
LOG("Events calendar: %zu features, from %zu scene files", g_Features.size(), count);
BroadcastTableChanged("scheduled_events");
});
}
const std::map<std::string, Feature>& Features() {
if (g_Scan == eScan::NOT_STARTED) StartFeatureScan();
return g_Features;
}
std::tuple<int32_t, int32_t, int32_t> ClientVersionSetting() {
return { GeneralUtils::TryParse<int32_t>(Game::config->GetValue("version_major")).value_or(ClientVersion::major),
GeneralUtils::TryParse<int32_t>(Game::config->GetValue("version_current")).value_or(ClientVersion::current),
GeneralUtils::TryParse<int32_t>(Game::config->GetValue("version_minor")).value_or(ClientVersion::minor) };
}
std::string ZoneName(uint32_t zone) {
const auto& names = ZoneNames();
const auto key = std::to_string(zone);
return names.contains(key) ? names[key].get<std::string>() : "Zone " + key;
}
std::string ZoneList(const std::vector<uint32_t>& zones) {
if (zones.empty()) return "every world";
std::string text;
for (const auto zone : zones) text += (text.empty() ? "" : ", ") + ZoneName(zone);
return text;
}
nlohmann::json ZoneOptions() {
std::vector<std::pair<uint32_t, std::string>> zones;
for (const auto& [id, name] : 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;
}
// Running public worlds of the zones with objects gated on a feature: these keep what they loaded until restarted
nlohmann::json RunningWorlds(const std::map<uint32_t, uint32_t>& zones) {
nlohmann::json worlds = nlohmann::json::array();
std::lock_guard lock(ServerState::g_StatusMutex);
for (const auto& world : ServerState::g_WorldInstances) {
if (!zones.contains(world.mapID)) continue;
worlds.push_back({ {"zone", world.mapID}, {"zoneName", ZoneName(world.mapID)}, {"instance", world.instanceID}, {"clone", world.cloneID}, {"players", world.players} });
}
return worlds;
}
nlohmann::json FeatureJson(const std::string& name, const Feature& feature) {
nlohmann::json zones = nlohmann::json::array();
for (const auto& [zone, objects] : feature.zones) zones.push_back({ {"id", zone}, {"name", ZoneName(zone)}, {"objects", objects} });
nlohmann::json json{ {"name", name}, {"description", feature.description}, {"zones", zones}, {"version", nullptr}, {"unlocked", false} };
if (feature.version) {
const auto& [major, current, minor] = *feature.version;
json["version"] = std::to_string(major) + "." + std::to_string(current) + "." + std::to_string(minor);
// As CDFeatureGatingTable::FeatureUnlocked: already on for this client version, so an event changes nothing in the levels
json["unlocked"] = ClientVersionSetting() >= *feature.version;
}
return json;
}
std::map<uint32_t, uint32_t> ZonesOf(const std::string& feature) {
const auto& features = Features();
const auto it = features.find(feature);
return it == features.end() ? std::map<uint32_t, uint32_t>{} : it->second.zones;
}
std::string RestartAdvice(const std::string& feature) {
const auto zones = ZonesOf(feature);
if (zones.empty()) return "No zone has objects gated on it; players see client-side changes the next time they log in.";
std::string names;
for (const auto& [zone, objects] : zones) names += (names.empty() ? "" : ", ") + ZoneName(zone);
return "Worlds of " + names + " that are already running keep what they loaded until they are restarted; new logins get the change straight away.";
}
// Features staff may pick: the known ones, plus whatever an event_N setting already holds
bool KnownFeature(const std::string& feature) {
if (Features().contains(feature)) return true;
return EventSchedule::SlotHolding(Database::Get()->GetServerConfig({}), feature) != 0;
}
std::string Text(const nlohmann::json& json, const char* key) {
return json.contains(key) && json[key].is_string() ? json[key].get<std::string>() : "";
}
// ---------------------------------------------------------------- permissions
// What a part needs: the permission of the page that does the same thing by hand
const char* PermissionFor(eKind kind) {
switch (kind) {
case eKind::FEATURE: return "events_manage";
case eKind::VANITY: return "vanity_manage";
case eKind::LIVE_EVENT: return "live_events_manage";
case eKind::ANNOUNCEMENT: return "announcements_schedule";
case eKind::RESTART: return "server_restart";
}
return "events_manage";
}
// Anyone who may add some kind of part may see the events
bool CanView(const HTTPContext& context) {
return std::ranges::any_of(magic_enum::enum_values<eKind>(), [&](eKind kind) { return Can(context, PermissionFor(kind)); });
}
// The first kind of part in these lists the user may not add, change or remove, if any
std::optional<eKind> Forbidden(const HTTPContext& context, const std::vector<Part>& a, const std::vector<Part>& b = {}) {
for (const auto* list : { &a, &b }) {
for (const auto& part : *list) if (!Can(context, PermissionFor(part.kind))) return part.kind;
}
return std::nullopt;
}
std::string ForbiddenMessage(eKind kind) {
return "You may not change events with a " + EventParts::KindName(kind) + " part (needs " + PermissionFor(kind) + ")";
}
// ---------------------------------------------------------------- events
eMode ModeOf(const Event& event) { return static_cast<eMode>(event.mode); }
bool Once(const Event& event) { return event.schedule.empty(); }
bool IsOn(const Event& event, int64_t now) { return ScheduleRules::IsOn(ModeOf(event), event.schedule, event.startsAt, event.endsAt, now); }
std::vector<Part> PartsOf(const Event& event) {
std::string error;
auto parts = EventParts::Parse(event.parts, error);
if (!parts) LOG("Scheduled event %llu: %s", static_cast<unsigned long long>(event.id), error.c_str());
return parts.value_or(std::vector<Part>{});
}
// When the stretch it is on in now ends (nullopt: not known, or not in the next years)
std::optional<int64_t> CurrentEnd(const Event& event, int64_t now) {
if (ModeOf(event) != eMode::SCHEDULED) return std::nullopt;
if (Once(event)) return event.endsAt;
std::string error;
const auto schedule = ScheduleRules::ParseSchedule(event.schedule, error);
return schedule ? ScheduleRules::NextChange(*schedule, now) : std::nullopt;
}
// The next start and end of its schedule after now (for the list)
std::pair<std::optional<int64_t>, std::optional<int64_t>> NextTimes(const Event& event, int64_t now) {
if (Once(event)) {
if (now < event.startsAt) return { event.startsAt, event.endsAt };
if (now < event.endsAt) return { std::nullopt, event.endsAt };
return {};
}
std::string error;
const auto schedule = ScheduleRules::ParseSchedule(event.schedule, error);
if (!schedule) return {};
const bool on = ScheduleRules::Active(*schedule, now);
const auto first = ScheduleRules::NextChange(*schedule, now);
const auto second = first ? ScheduleRules::NextChange(*schedule, *first) : std::nullopt;
return on ? std::pair{ second, first } : std::pair{ first, second };
}
std::string Describe(const Event& event) {
std::string parts;
for (const auto& part : PartsOf(event)) parts += (parts.empty() ? "" : ", ") + EventParts::KindName(part.kind);
return "#" + std::to_string(event.id) + " " + event.name + " (" + GameLabels::Name(ModeOf(event)) + (parts.empty() ? "" : "; " + parts) + ")";
}
void Save(Event& event, const std::string& by) {
event.updatedAt = Now();
event.updatedBy = by;
Database::Get()->UpdateScheduledEvent(event);
BroadcastTableChanged("scheduled_events", std::to_string(event.id));
}
std::optional<Event> FindEvent(uint64_t id) {
for (auto& event : Database::Get()->GetScheduledEvents()) {
if (event.id == id) return std::move(event);
}
return std::nullopt;
}
// ---------------------------------------------------------------- parts: starting and ending
struct Run {
const HTTPContext& actor;
Event& event;
int64_t now;
bool vanityChanged{}; // a vanity part started or ended: the worlds respawn their vanity NPCs
};
// Put the feature in a free slot. false: still waiting (the reason is in status)
bool StartFeature(Part& part, Run& run) {
const auto feature = Text(part.config, "feature");
const auto rows = Database::Get()->GetServerConfig({});
// Already in a slot (a setting, or another event): on without taking another one
if (const auto holding = EventSchedule::SlotHolding(rows, feature)) {
part.state = { {"shared", true}, {"slot", holding} };
part.status = "Already on in " + EventSchedule::SlotSetting(holding);
return true;
}
const auto slot = EventSchedule::FreeSlot(EventSchedule::BusySlots(rows));
if (!slot) {
const std::string status = "Waiting: event_1 to event_8 all have a value. Clear one on the Settings page or end another event.";
if (part.status != status) {
part.status = status;
Alerts::Emit("server", "Event waiting for a slot", run.event.name + " should have switched " + feature + " on, but every event setting is in use.", {}, "/events");
}
return false;
}
const auto* previous = EventSchedule::SlotRow(rows, *slot);
const nlohmann::json change{ {"file", EventSchedule::FILE}, {"name", EventSchedule::SlotSetting(*slot)}, {"value", feature}, {"webWins", true} };
if (const auto error = SaveSetting(run.actor, change)) {
part.status = "Couldn't set " + EventSchedule::SlotSetting(*slot) + ": " + *error;
return false;
}
part.state = { {"slot", *slot}, {"previousWebWins", previous && previous->webWins},
{"previousValue", previous && previous->webValue ? nlohmann::json(*previous->webValue) : nlohmann::json(nullptr)} };
part.status = "On in " + EventSchedule::SlotSetting(*slot);
Audit(run.actor, "start_event", run.event.name + ": " + feature + " in " + EventSchedule::SlotSetting(*slot));
Alerts::Emit("server", "Event started", run.event.name + ": " + feature + " is on (" + EventSchedule::SlotSetting(*slot) + "). " + RestartAdvice(feature), {}, "/events");
return true;
}
// Put the slot back as it was, unless someone changed it by hand in the meantime. false: try again
bool EndFeature(Part& part, Run& run) {
const auto feature = Text(part.config, "feature");
const auto slot = static_cast<uint8_t>(part.state.value("slot", 0));
if (part.state.value("shared", false) || slot == 0) {
part.status = "Off";
return true;
}
const auto name = EventSchedule::SlotSetting(slot);
const auto rows = Database::Get()->GetServerConfig({ EventSchedule::FILE });
const auto* row = EventSchedule::SlotRow(rows, slot);
std::string status = "Off";
if (row && row->webValue == feature) {
const auto& previous = part.state.contains("previousValue") ? part.state["previousValue"] : nlohmann::json(nullptr);
const nlohmann::json change{ {"file", EventSchedule::FILE}, {"name", name}, {"value", previous.is_string() ? previous : nlohmann::json(nullptr)},
{"webWins", part.state.value("previousWebWins", false)} };
if (const auto error = SaveSetting(run.actor, change)) {
part.status = "Couldn't put " + name + " back: " + *error;
return false;
}
} else {
status += "; " + name + " had been changed by hand, so it was left as it was";
}
part.status = status;
Audit(run.actor, "end_event", run.event.name + ": " + feature + " (" + name + ")");
Alerts::Emit("server", "Event ended", run.event.name + ": " + feature + " is off. " + RestartAdvice(feature), {}, "/events");
return true;
}
bool StartLiveEvent(Part& part, Run& run) {
const auto end = CurrentEnd(run.event, run.now).value_or(run.now + LiveOpsRules::MAX_DURATION);
std::string error, message;
const auto id = LiveEventRoutes::StartPart(part.config, end, run.actor, error, message);
if (!id) {
part.status = "Couldn't start: " + error;
return false;
}
part.state = { {"id", *id} };
part.status = message;
return true;
}
bool EndLiveEvent(Part& part, Run& run) {
const auto id = part.state.value("id", uint64_t{ 0 });
part.status = id && LiveEventRoutes::EndPart(id, run.actor, run.event.name + " ended") ? "Ended with the event" : "Had already ended";
return true;
}
void Announce(const nlohmann::json& config, const std::string& message) {
std::vector<uint32_t> zones;
if (config.contains("zones") && config["zones"].is_array()) for (const auto& zone : config["zones"]) if (zone.is_number_unsigned()) zones.push_back(zone.get<uint32_t>());
LiveWorld::Announce(Text(config, "title"), message, zones);
}
// The next repeat of an announcement part after `after`, while the event is on
std::optional<int64_t> NextRepeat(const Part& part, const Run& run, int64_t after) {
const auto repeat = Text(part.config, "repeat");
if (repeat.empty()) return std::nullopt;
const auto schedule = Cron::Parse(repeat);
if (!schedule) return std::nullopt;
return AnnouncementSchedule::Next(*schedule, 0, CurrentEnd(run.event, run.now).value_or(0), after);
}
bool StartAnnouncement(Part& part, Run& run) {
const bool atStart = part.config.value("atStart", true);
if (atStart) Announce(part.config, Text(part.config, "message"));
const auto next = NextRepeat(part, run, run.now);
part.state = next ? nlohmann::json{ {"nextAt", *next} } : nlohmann::json::object();
part.status = atStart ? "Said when it started" : "On";
return true;
}
void ContinueAnnouncement(Part& part, Run& run) {
const auto at = part.state.value("nextAt", int64_t{ 0 });
// A repeat added while the event was on: the first one from now
if (at == 0 && !Text(part.config, "repeat").empty() && !part.state.contains("lastAt")) {
if (const auto next = NextRepeat(part, run, run.now)) part.state["nextAt"] = *next;
return;
}
if (at == 0 || run.now < at) return;
Announce(part.config, Text(part.config, "message"));
const auto next = NextRepeat(part, run, run.now);
const auto sent = part.state.value("sent", 0) + 1;
part.state = { {"sent", sent}, {"lastAt", run.now} };
if (next) part.state["nextAt"] = *next;
part.status = "Repeated " + std::to_string(sent) + (sent == 1 ? " time" : " times");
}
bool EndAnnouncement(Part& part, Run&) {
const auto message = Text(part.config, "endMessage");
if (!message.empty()) Announce(part.config, message);
part.state = nlohmann::json::object();
part.status = message.empty() ? "Off" : "Said when it ended";
return true;
}
// A restart when the event starts or ends; one already scheduled is left alone (the server restarts anyway)
bool Restart(Part& part, Run& run, const std::string& when) {
if (Text(part.config, "when") != when) {
if (when == "start") part.status = "Restarts when the event ends";
return true;
}
const auto error = LiveWorld::ScheduleRestart(part.config.value("minutes", 15), Text(part.config, "reason"), run.actor, false);
part.status = error ? "Not scheduled: " + *error : "Restart scheduled when it " + std::string(when == "start" ? "started" : "ended");
return true;
}
// Start or end one part; false when it has to be tried again
bool Step(Part& part, eStep step, Run& run) {
switch (part.kind) {
case eKind::FEATURE:
return step == eStep::START ? StartFeature(part, run) : EndFeature(part, run);
case eKind::VANITY:
run.vanityChanged = true;
part.status = step == eStep::START ? "On" : "Off";
return true;
case eKind::LIVE_EVENT:
return step == eStep::START ? StartLiveEvent(part, run) : EndLiveEvent(part, run);
case eKind::ANNOUNCEMENT:
return step == eStep::START ? StartAnnouncement(part, run) : EndAnnouncement(part, run);
case eKind::RESTART:
return Restart(part, run, step == eStep::START ? "start" : "end");
}
return true;
}
// A part taken out of an event (or changed) while it was on: undo what it switched on, without what it does when
// its event ends (no end announcement, no restart)
void Retire(Part& part, Run& run) {
switch (part.kind) {
case eKind::FEATURE: EndFeature(part, run); break;
case eKind::VANITY: run.vanityChanged = true; break;
case eKind::LIVE_EVENT: EndLiveEvent(part, run); break;
case eKind::ANNOUNCEMENT:
case eKind::RESTART: break;
}
}
void RespawnVanity(const HTTPContext& actor) {
PlayerActionRequest request;
request.action = ePlayerAction::RELOAD_VANITY;
PlayerActions::Request(request, actor.accountId, [](const PlayerActionResult& result) {
return PlayerActions::Outcome{ true, result.affected ? "Vanity NPCs respawned in " + std::to_string(result.affected) + " world(s)" : "No world with vanity NPCs is running" };
});
}
/**
* Bring one event's parts in line with whether it is on: start what should be on, end what shouldn't, carry on
* repeating announcements. `retired`: parts taken out of the event that are still on, ended here. Saves the event
* if anything changed; true when a vanity part started or ended.
*/
bool SyncEvent(Event& event, const HTTPContext& actor, int64_t now, std::vector<Part> retired = {}) {
auto parts = PartsOf(event);
const auto before = EventParts::Write(parts);
const auto stateBefore = event.state;
Run run{ actor, event, now };
for (auto& part : retired) Retire(part, run);
const bool on = IsOn(event, now);
for (auto& part : parts) {
const auto step = EventParts::StepFor(part, on);
if (step == eStep::CONTINUE && part.kind == eKind::ANNOUNCEMENT) ContinueAnnouncement(part, run);
if (step != eStep::START && step != eStep::END) continue;
try {
if (Step(part, step, run)) part.applied = step == eStep::START;
} catch (const std::exception& ex) {
part.status = "Failed: " + std::string(ex.what());
LOG("Scheduled event %s: %s part failed: %s", event.name.c_str(), EventParts::KindName(part.kind).c_str(), ex.what());
}
}
const bool applied = std::ranges::any_of(parts, [](const Part& part) { return part.applied; });
event.state = EventSchedule::NextState(event.state, Once(event), event.endsAt, on, applied, ModeOf(event) == eMode::OFF, now);
if (event.state != stateBefore && (event.state == eEventState::ACTIVE || stateBefore == eEventState::ACTIVE)) {
const bool started = event.state == eEventState::ACTIVE;
event.status = started ? "Switched on" : "Switched off";
Audit(actor, started ? "event_on" : "event_off", Describe(event));
if (!parts.empty() || !retired.empty()) {
Alerts::Emit("server", started ? "Scheduled event started" : "Scheduled event ended", event.name + (started ? " is on." : " is off."), {}, "/events");
}
} else if (event.state == eEventState::MISSED && stateBefore != eEventState::MISSED) {
event.status = "The dashboard wasn't running for any of it";
}
event.parts = EventParts::Write(parts);
if (event.parts != before || event.state != stateBefore || !retired.empty()) Save(event, actor.authenticatedUser);
return run.vanityChanged;
}
// Every event, now: the main loop, and straight after staff change one
void UpdateAll(const HTTPContext& actor) {
const auto now = Now();
bool vanity = false;
for (auto& event : Database::Get()->GetScheduledEvents()) {
try {
vanity = SyncEvent(event, actor, now) || vanity;
} catch (const std::exception& ex) {
LOG("Scheduled event %llu failed: %s", static_cast<unsigned long long>(event.id), ex.what());
}
}
if (vanity) RespawnVanity(actor);
}
// ---------------------------------------------------------------- reading an event from the page
std::optional<nlohmann::json> CheckFeature(const nlohmann::json& config, std::string& error) {
const auto feature = Text(config, "feature");
if (const auto problem = EventSchedule::ValidateFeature(feature)) { error = *problem; return std::nullopt; }
if (!KnownFeature(feature)) { error = "Pick a feature from the list"; return std::nullopt; }
return nlohmann::json{ {"feature", feature} };
}
std::optional<nlohmann::json> CheckVanity(const nlohmann::json& config, std::string& error) {
auto file = Text(config, "file");
if (!file.empty() && !file.ends_with(".xml")) file += ".xml";
if (!file.empty() && (!VanityEvents::ValidFileName(file) || file == VANITY_ROOT)) { error = "The overlay file is a vanity file name (letters, digits, - and _)"; return std::nullopt; }
std::string removals;
if (config.contains("removals") && config["removals"].is_array()) {
for (const auto& name : config["removals"]) if (name.is_string()) removals += name.get<std::string>() + "\n";
} else {
removals = Text(config, "removals");
}
const auto names = VanityEvents::SplitNames(removals);
if (names.size() > MAX_REMOVALS) { error = "Take out at most 200 NPCs"; return std::nullopt; }
for (const auto& name : names) if (name.size() > MAX_NAME_LENGTH) { error = "NPC names are up to 100 characters"; return std::nullopt; }
VanityEvents::FileSwitches switches;
if (config.contains("fileSwitches")) {
const auto& input = config["fileSwitches"];
const auto parsed = input.is_string() ? VanityEvents::ParseFileSwitches(input.get<std::string>(), error) : VanityEvents::ParseFileSwitches(input, error);
if (!parsed) { error = "File switches: " + error; return std::nullopt; }
switches = *parsed;
}
if (switches.contains(VANITY_ROOT)) { error = "File switches: root.xml is always loaded"; return std::nullopt; }
if (!file.empty() && switches.contains(file)) { error = "File switches: " + file + " is the overlay file, laid on while the event is on; don't switch it too"; return std::nullopt; }
if (file.empty() && names.empty() && switches.empty()) { error = "Switch a vanity file on or off, pick an overlay file or list NPCs to take out"; return std::nullopt; }
return nlohmann::json{ {"file", file}, {"removals", names}, {"fileSwitches", VanityEvents::ToJson(switches)} };
}
std::optional<nlohmann::json> CheckAnnouncement(const nlohmann::json& config, std::string& error) {
const auto title = Text(config, "title"), message = Text(config, "message"), endMessage = Text(config, "endMessage"), repeat = Text(config, "repeat");
const bool atStart = !config.contains("atStart") || !config["atStart"].is_boolean() || config["atStart"].get<bool>();
if (title.size() > 100) { error = "The title can be at most 100 characters"; return std::nullopt; }
if (message.size() > MAX_MESSAGE_LENGTH || endMessage.size() > MAX_MESSAGE_LENGTH) { error = "Messages are up to 1000 characters"; return std::nullopt; }
if ((atStart || !repeat.empty()) && message.empty()) { error = "Write the message"; return std::nullopt; }
if (!atStart && repeat.empty() && endMessage.empty()) { error = "Say it when the event starts, repeat it, or write a message for the end"; return std::nullopt; }
if (!repeat.empty()) {
if (const auto problem = AnnouncementSchedule::Validate(title, message, repeat, 0, 0, {}, Now())) { error = "Repeat: " + *problem; return std::nullopt; }
}
nlohmann::json zones = nlohmann::json::array();
if (config.contains("zones") && config["zones"].is_array()) {
const auto& known = ZoneNames();
for (const auto& zone : config["zones"]) {
if (!zone.is_number_unsigned() || !known.contains(std::to_string(zone.get<uint32_t>()))) { error = "Pick zones from the list"; return std::nullopt; }
if (std::find(zones.begin(), zones.end(), zone) == zones.end()) zones.push_back(zone);
}
}
return nlohmann::json{ {"title", title}, {"message", message}, {"zones", zones}, {"atStart", atStart}, {"repeat", repeat}, {"endMessage", endMessage} };
}
std::optional<nlohmann::json> CheckRestart(const nlohmann::json& config, std::string& error) {
const auto when = Text(config, "when");
if (when != "start" && when != "end") { error = "Restart when the event starts or when it ends"; return std::nullopt; }
const auto minutes = config.contains("minutes") && config["minutes"].is_number_integer() ? config["minutes"].get<int64_t>() : 0;
if (minutes < 1 || minutes > MAX_RESTART_MINUTES) { error = "Warn players 1 minute to 24 hours before the restart"; return std::nullopt; }
const auto reason = Text(config, "reason");
if (reason.size() > 300) { error = "The reason can be at most 300 characters"; return std::nullopt; }
return nlohmann::json{ {"when", when}, {"minutes", minutes}, {"reason", reason} };
}
// The parts from the page, each checked as its own page would; the error names the part
std::optional<std::vector<Part>> ReadParts(const nlohmann::json& input, std::string& error) {
if (!input.is_array()) { error = "Send parts: [{kind, config}]"; return std::nullopt; }
if (input.size() > MAX_PARTS) { error = "An event has at most 20 parts"; return std::nullopt; }
auto parts = EventParts::Parse(input, error);
if (!parts) return std::nullopt;
for (size_t i = 0; i < parts->size(); i++) {
auto& part = (*parts)[i];
std::optional<nlohmann::json> config;
std::string problem;
switch (part.kind) {
case eKind::FEATURE: config = CheckFeature(part.config, problem); break;
case eKind::VANITY: config = CheckVanity(part.config, problem); break;
case eKind::LIVE_EVENT: config = LiveEventRoutes::CheckPart(part.config, problem); break;
case eKind::ANNOUNCEMENT: config = CheckAnnouncement(part.config, problem); break;
case eKind::RESTART: config = CheckRestart(part.config, problem); break;
}
if (!config) { error = "Part " + std::to_string(i + 1) + " (" + EventParts::KindName(part.kind) + "): " + problem; return std::nullopt; }
part.config = std::move(*config);
}
return parts;
}
// Fill an event from the page's body (every field it has); the error names what is wrong
std::optional<std::string> FromBody(const nlohmann::json& body, Event& event, std::vector<Part>& parts, int64_t now) {
const auto text = [&body](const char* key, const std::string& fallback) {
return body.contains(key) && body[key].is_string() ? body[key].get<std::string>() : fallback;
};
event.name = text("name", event.name);
event.name.erase(0, event.name.find_first_not_of(" \t"));
event.name.erase(event.name.find_last_not_of(" \t") + 1);
if (event.name.empty() || event.name.size() > MAX_NAME_LENGTH) return "Give it a name of up to 100 characters";
event.note = text("note", event.note);
if (event.note.size() > MAX_NOTE_LENGTH) return "The note can be at most 500 characters";
if (body.contains("priority")) {
if (!body["priority"].is_number_integer() || std::abs(body["priority"].get<int64_t>()) > MAX_PRIORITY) return "Priority is a whole number from -1000 to 1000";
event.priority = body["priority"].get<int32_t>();
}
if (body.contains("mode")) {
const auto value = body["mode"].is_number_integer() ? body["mode"].get<int64_t>() : -1;
const auto mode = value >= 0 && value <= UINT8_MAX ? magic_enum::enum_cast<eMode>(static_cast<uint8_t>(value)) : std::nullopt;
if (!mode) return "Pick whether it is off, on by its schedule or always on";
event.mode = static_cast<uint8_t>(*mode);
}
// Recurring rules, or null / "" for once between startsAt and endsAt
if (body.contains("schedule")) {
const auto& input = body["schedule"];
if (input.is_null() || (input.is_string() && input.get<std::string>().empty())) {
event.schedule.clear();
} else {
std::string error;
const auto schedule = input.is_string() ? ScheduleRules::ParseSchedule(input.get<std::string>(), error) : ScheduleRules::ParseSchedule(input, error);
if (!schedule) return "Schedule: " + error;
event.schedule = ScheduleRules::ToJson(*schedule).dump();
}
}
if (Once(event)) {
const auto startsAt = body.contains("startsAt") && body["startsAt"].is_number_integer() ? body["startsAt"].get<int64_t>() : event.startsAt;
const auto endsAt = body.contains("endsAt") && body["endsAt"].is_number_integer() ? body["endsAt"].get<int64_t>() : event.endsAt;
// Times are only checked when they change, so an event that is over can still get a new note
if (startsAt != event.startsAt || endsAt != event.endsAt || event.id == 0) {
if (const auto error = EventSchedule::ValidateOnce(startsAt, endsAt, now)) return *error;
}
event.startsAt = startsAt;
event.endsAt = endsAt;
} else {
event.startsAt = event.endsAt = 0;
}
if (body.contains("parts")) {
std::string error;
auto read = ReadParts(body["parts"], error);
if (!read) return error;
parts = std::move(*read);
}
if (parts.empty()) return "Add at least one part: what the event switches on";
return std::nullopt;
}
// An empty vanity file for each vanity part's overlay file that doesn't exist yet, to fill in the NPC editor. The
// files it made
std::string CreateOverlays(const std::vector<Part>& parts) {
std::string made;
for (const auto& part : parts) {
const auto file = part.kind == eKind::VANITY ? Text(part.config, "file") : "";
std::error_code ec;
if (file.empty() || std::filesystem::exists(VanityFolder() / file, ec)) continue;
std::ofstream out(VanityFolder() / file, std::ios::binary);
out << VanityXml::Write({});
if (out) made += (made.empty() ? "" : ", ") + file;
}
return made;
}
// ---------------------------------------------------------------- JSON for the page
nlohmann::json PartJson(const Part& part) {
auto json = EventParts::ToJson(part);
json["permission"] = PermissionFor(part.kind);
if (part.kind == eKind::FEATURE) json["affected"] = RunningWorlds(ZonesOf(Text(part.config, "feature")));
if (part.kind == eKind::LIVE_EVENT && part.state.contains("id")) json["running"] = LiveEventRoutes::Running(part.state.value("id", uint64_t{ 0 }));
return json;
}
nlohmann::json EventJson(const Event& event, int64_t now) {
nlohmann::json parts = nlohmann::json::array();
std::string partsError;
if (const auto list = EventParts::Parse(event.parts, partsError)) for (const auto& part : *list) parts.push_back(PartJson(part));
const auto [nextStart, nextEnd] = NextTimes(event, now);
nlohmann::json json{ {"id", event.id}, {"name", event.name}, {"note", event.note}, {"mode", event.mode}, {"modeName", GameLabels::Name(ModeOf(event))},
{"once", Once(event)}, {"startsAt", event.startsAt}, {"endsAt", event.endsAt}, {"schedule", nullptr}, {"scheduleError", ""},
{"priority", event.priority}, {"parts", parts}, {"partsError", partsError}, {"on", IsOn(event, now)},
{"state", static_cast<uint8_t>(event.state)}, {"stateName", GameLabels::Name(event.state)}, {"status", event.status},
{"nextStart", nextStart ? nlohmann::json(*nextStart) : nlohmann::json(nullptr)}, {"nextEnd", nextEnd ? nlohmann::json(*nextEnd) : nlohmann::json(nullptr)},
{"createdAt", event.createdAt}, {"createdBy", event.createdBy}, {"updatedAt", event.updatedAt}, {"updatedBy", event.updatedBy} };
if (!Once(event)) {
std::string error;
if (const auto schedule = ScheduleRules::ParseSchedule(event.schedule, error)) json["schedule"] = ScheduleRules::ToJson(*schedule);
else json["scheduleError"] = error;
}
return json;
}
// [{value, name}] for every event state
nlohmann::json States() {
nlohmann::json states = nlohmann::json::array();
for (const auto state : magic_enum::enum_values<eEventState>()) states.push_back({ {"value", static_cast<uint8_t>(state)}, {"name", GameLabels::Name(state)} });
return states;
}
// Every kind of part, whether this user may add it
nlohmann::json Kinds(const HTTPContext& context) {
nlohmann::json kinds = nlohmann::json::array();
for (const auto kind : magic_enum::enum_values<eKind>()) {
kinds.push_back({ {"name", EventParts::KindName(kind)}, {"permission", PermissionFor(kind)}, {"allowed", Can(context, PermissionFor(kind))} });
}
return kinds;
}
// What vanity changes the events that are on now make, and which events change the same NPCs or files
nlohmann::json VanityJson(const std::vector<Event>& events, int64_t now) {
auto sorted = events;
VanityEvents::SortForMerge(sorted);
std::vector<VanityEvents::Changes> on;
std::vector<std::pair<uint64_t, std::set<std::string>>> touched, switched;
for (const auto& event : sorted) {
std::set<std::string> npcs, files;
for (const auto& change : EventParts::VanityChanges(event.name, event.parts)) {
if (IsOn(event, now)) on.push_back(change);
for (const auto& name : VanityEvents::SplitNames(change.removals)) npcs.insert(name);
if (!change.file.empty()) {
for (const auto& object : VanityEvents::LoadFiles(VanityFolder(), change.file).objects) if (!object.name.empty()) npcs.insert(object.name);
}
std::string error;
for (const auto& [file, switchOn] : VanityEvents::ParseFileSwitches(change.fileSwitches, error).value_or(VanityEvents::FileSwitches{})) files.insert(file);
}
if (npcs.empty() && files.empty()) continue;
touched.emplace_back(event.id, std::move(npcs));
switched.emplace_back(event.id, std::move(files));
}
// Pairs that change the same NPCs or switch the same files; whichever is laid on last (see SortForMerge) wins when both are on
nlohmann::json overlaps = nlohmann::json::array();
for (size_t a = 0; a < touched.size(); a++) {
for (size_t b = a + 1; b < touched.size(); b++) {
std::vector<std::string> npcs, files;
std::set_intersection(touched[a].second.begin(), touched[a].second.end(), touched[b].second.begin(), touched[b].second.end(), std::back_inserter(npcs));
std::set_intersection(switched[a].second.begin(), switched[a].second.end(), switched[b].second.begin(), switched[b].second.end(), std::back_inserter(files));
if (!npcs.empty() || !files.empty()) overlaps.push_back({ {"a", touched[a].first}, {"b", touched[b].first}, {"winner", touched[b].first}, {"npcs", npcs}, {"files", files} });
}
}
const auto world = VanityEvents::LoadWorld(VanityFolder(), VANITY_ROOT, on);
nlohmann::json conflicts = nlohmann::json::array(), fileConflicts = nlohmann::json::array();
for (const auto& conflict : world.conflicts) conflicts.push_back({ {"npc", conflict.npc}, {"events", conflict.events}, {"winner", conflict.events.back()} });
for (const auto& conflict : world.fileConflicts) {
nlohmann::json switches = nlohmann::json::array();
for (const auto& [name, switchOn] : conflict.switches) switches.push_back({ {"event", name}, {"on", switchOn} });
fileConflicts.push_back({ {"file", conflict.file}, {"switches", switches}, {"winner", conflict.switches.back().first}, {"on", conflict.switches.back().second} });
}
return { {"conflicts", conflicts}, {"fileConflicts", fileConflicts}, {"overlaps", overlaps} };
}
// The standalone things that also happen at set times, for the calendar: live events and the scheduled restart
void OtherOccurrences(nlohmann::json& occurrences, int64_t from, int64_t to, const HTTPContext& context) {
if (Can(context, "live_events_manage")) {
for (const auto& live : Database::Get()->GetLiveEvents(false, 100)) {
if (live.endsAt <= from || live.startsAt >= to) continue;
occurrences.push_back({ {"kind", "live_event"}, {"id", live.id}, {"name", live.title}, {"start", live.startsAt}, {"end", live.endedAt ? std::min(live.endedAt, live.endsAt) : live.endsAt},
{"link", "/live_events"} });
}
}
if (Can(context, "announcements_schedule")) {
const auto now = Now();
for (const auto& row : Database::Get()->GetScheduledAnnouncements()) {
if (!row.enabled) continue;
const auto schedule = Cron::Parse(row.schedule);
if (!schedule) continue;
// Every send in the stretch, from now on (a few per day at most are shown)
auto at = AnnouncementSchedule::Next(*schedule, row.startsAt, row.endsAt, std::max(from, now) - 1);
for (int count = 0; at && *at < to && count < 100; count++) {
occurrences.push_back({ {"kind", "announcement"}, {"id", row.id}, {"name", row.title.empty() ? row.message.substr(0, 40) : row.title}, {"start", *at}, {"end", *at},
{"link", "/announcements"} });
at = AnnouncementSchedule::Next(*schedule, row.startsAt, row.endsAt, *at);
}
}
}
if (Can(context, "server_restart")) {
const auto restart = LiveWorld::RestartStatus(false);
if (restart.contains("at") && restart["at"].is_number_integer()) {
const auto at = restart["at"].get<int64_t>();
if (at >= from && at < to) occurrences.push_back({ {"kind", "restart"}, {"id", 0}, {"name", "Restart"}, {"start", at}, {"end", at}, {"link", "/#restart"} });
}
}
}
std::optional<uint64_t> IdFrom(const HTTPContext& context) { return PathId<uint64_t>(context.path, 2); }
}
namespace EventsCalendar {
void Update() {
const auto steady = std::chrono::steady_clock::now();
if (steady < g_NextCheck) return;
g_NextCheck = steady + CHECK_INTERVAL;
if (g_Scan == eScan::NOT_STARTED) StartFeatureScan();
try {
UpdateAll(SystemContext());
} catch (const std::exception& ex) {
LOG_DEBUG("Could not update the scheduled events: %s", ex.what());
}
}
void RegisterRoutes() {
Route(eHTTPMethod::GET, "/events", 0, "Events page: scheduled events and the calendar (needs one of the permissions a part needs)",
[](HTTPReply& reply, const HTTPContext& context) {
if (!CanView(context)) return RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You may not see the scheduled events");
RenderPage(reply, context, "events.jinja2", "events");
});
// Vanity events are scheduled events now; old links still work
Route(eHTTPMethod::GET, "/vanity_events", 0, "The Events page (vanity events are scheduled events with a vanity part)",
[](HTTPReply& reply, const HTTPContext&) {
reply.status = eHTTPStatusCode::FOUND;
reply.location = "/events";
reply.message = "";
});
Route(eHTTPMethod::GET, "/api/events", 0,
"Every scheduled event with its parts (and what each did), whether it is on and its next start and end; the kinds of part and whether you may "
"add each; every feature a feature part can switch on (with the zones it is in), what is in event_1..event_8 now, the zones, and the vanity "
"conflicts between the events that are on",
[](HTTPReply& reply, const HTTPContext& context) {
if (!CanView(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may not see the scheduled events");
const auto rows = Database::Get()->GetServerConfig({});
const auto events = Database::Get()->GetScheduledEvents();
const auto now = Now();
nlohmann::json list = nlohmann::json::array();
for (const auto& event : events) list.push_back(EventJson(event, now));
nlohmann::json features = nlohmann::json::array();
for (const auto& [name, feature] : Features()) features.push_back(FeatureJson(name, feature));
// What each slot holds for the world servers: their worldconfig.ini, then sharedconfig.ini (see ConfigSync)
nlohmann::json slots = nlohmann::json::array();
const auto busy = EventSchedule::BusySlots(rows);
for (uint8_t slot = 1; slot <= EventSchedule::SLOTS; slot++) {
const auto name = EventSchedule::SlotSetting(slot);
nlohmann::json values = nlohmann::json::array();
for (const auto& row : rows) {
if (row.name != name) continue;
if (row.fileValue && !row.fileValue->empty()) values.push_back({ {"file", row.file}, {"source", row.fileSource}, {"value", *row.fileValue} });
if (row.webValue && !row.webValue->empty()) values.push_back({ {"file", row.file}, {"source", "web"}, {"value", *row.webValue}, {"webWins", row.webWins} });
}
// The event whose feature part holds it
nlohmann::json owner = nullptr;
for (const auto& event : events) {
for (const auto& part : PartsOf(event)) {
if (part.kind == eKind::FEATURE && part.applied && !part.state.value("shared", false) && part.state.value("slot", 0) == slot) owner = event.id;
}
}
slots.push_back({ {"slot", slot}, {"setting", name}, {"busy", busy[slot]}, {"values", values}, {"event", owner} });
}
const auto [major, current, minor] = ClientVersionSetting();
JsonSuccess(reply, { {"events", list}, {"features", features}, {"scanning", g_Scan != eScan::DONE}, {"slots", slots}, {"now", now},
{"clientVersion", std::to_string(major) + "." + std::to_string(current) + "." + std::to_string(minor)},
{"states", States()}, {"modes", GameLabels::List<eMode>()}, {"kinds", Kinds(context)}, {"zones", ZoneOptions()},
{"vanity", Can(context, "vanity_manage") ? VanityJson(events, now) : nlohmann::json(nullptr)} });
});
Route(eHTTPMethod::POST, "/api/events", 0,
"Create a scheduled event. Body: {name, note, mode (0 off, 1 by its schedule, 2 always on), schedule (recurring rules, or null for once), "
"startsAt, endsAt (unix, for once), priority, parts: [{kind: feature|vanity|live_event|announcement|restart, config}]}. Each part needs the "
"permission of its own page",
[](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::vector<Part> parts;
if (const auto error = FromBody(*body, event, parts, Now())) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, *error);
if (const auto kind = Forbidden(context, parts)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, ForbiddenMessage(*kind));
event.parts = EventParts::Write(parts);
const auto made = CreateOverlays(parts);
event.createdAt = event.updatedAt = Now();
event.createdBy = event.updatedBy = context.authenticatedUser;
event.id = Database::Get()->InsertScheduledEvent(event);
Audit(context, "schedule_event", Describe(event));
BroadcastTableChanged("scheduled_events", std::to_string(event.id));
UpdateAll(context); // start it now if it's on
JsonSuccess(reply, { {"message", made.empty() ? "Event scheduled" : "Event scheduled, with an empty " + made + " to put its NPCs in"}, {"id", event.id} });
});
ReadRoute(eHTTPMethod::POST, "/api/events/check", 0,
"Check a schedule and list when it is on. Body: {schedule (recurring rules), from (unix, default now), count (default 5)}. Returns {valid, error, schedule, on, windows: [{start, end}]}",
[](HTTPReply& reply, const HTTPContext& context) {
if (!CanView(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may not see the scheduled events");
const auto body = ParseBody(context);
if (!body || !body->is_object() || !body->contains("schedule")) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Send {schedule}");
std::string error;
const auto& input = (*body)["schedule"];
const auto schedule = input.is_string() ? ScheduleRules::ParseSchedule(input.get<std::string>(), error) : ScheduleRules::ParseSchedule(input, error);
if (!schedule) return JsonSuccess(reply, { {"valid", false}, {"error", error} });
const auto from = body->contains("from") && (*body)["from"].is_number_integer() ? (*body)["from"].get<int64_t>() : Now();
const auto count = std::clamp<int64_t>(body->contains("count") && (*body)["count"].is_number_integer() ? (*body)["count"].get<int64_t>() : 5, 1, 50);
nlohmann::json windows = nlohmann::json::array();
for (const auto& window : ScheduleRules::Windows(*schedule, from, from + ScheduleRules::HORIZON_SECONDS, static_cast<size_t>(count))) {
// The first may have started before `from`: say when it really did, if that's within a year
auto start = window.start;
if (start == from && ScheduleRules::Active(*schedule, from)) {
for (const auto& earlier : ScheduleRules::Windows(*schedule, from - 366 * 86400LL, from + 1)) if (earlier.end > from) start = earlier.start;
}
windows.push_back({ {"start", start}, {"end", window.end} });
}
JsonSuccess(reply, { {"valid", true}, {"schedule", ScheduleRules::ToJson(*schedule)}, {"on", ScheduleRules::Active(*schedule, from)}, {"windows", windows} });
});
Route(eHTTPMethod::GET, "/api/events/occurrences", 0,
"When each scheduled event is on between ?from and ?to (unix, at most 400 days apart), for the calendar, with the standalone live events, "
"repeating announcements and the scheduled restart you may see: {occurrences: [{kind, id, name, start, end, mode, link}]}",
[](HTTPReply& reply, const HTTPContext& context) {
if (!CanView(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may not see the scheduled events");
const auto from = GeneralUtils::TryParse<int64_t>(QueryValue(context.queryString, "from"));
const auto to = GeneralUtils::TryParse<int64_t>(QueryValue(context.queryString, "to"));
if (!from || !to || *to <= *from || *to - *from > MAX_RANGE_SECONDS) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Send ?from and ?to, at most 400 days apart");
nlohmann::json occurrences = nlohmann::json::array();
for (const auto& event : Database::Get()->GetScheduledEvents()) {
for (const auto& window : ScheduleRules::WindowsOf(ModeOf(event), event.schedule, event.startsAt, event.endsAt, *from, *to, 200)) {
occurrences.push_back({ {"kind", "event"}, {"id", event.id}, {"name", event.name}, {"start", window.start}, {"end", window.end}, {"mode", event.mode},
{"state", static_cast<uint8_t>(event.state)} });
}
}
OtherOccurrences(occurrences, *from, *to, context);
JsonSuccess(reply, { {"occurrences", occurrences} });
});
Route(eHTTPMethod::POST, "/api/events/:id", 0,
"Change a scheduled event. Body as when creating it (fields left out stay). Parts that stay the same carry on; changed or removed parts that "
"are on are ended first. Needs the permission of every kind of part it has, before and after",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = IdFrom(context);
const auto body = ParseBody(context);
if (!body || !body->is_object()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
auto event = id ? FindEvent(*id) : std::nullopt;
if (!event) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Event not found");
const auto before = PartsOf(*event);
auto parts = before;
if (const auto error = FromBody(*body, *event, parts, Now())) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, *error);
if (const auto kind = Forbidden(context, before, parts)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, ForbiddenMessage(*kind));
// A cancelled event switched back on waits for its times again
if (event->state == eEventState::CANCELLED && ModeOf(*event) != eMode::OFF) event->state = eEventState::SCHEDULED;
const auto made = CreateOverlays(parts);
auto reconciled = EventParts::Reconcile(before, std::move(parts));
event->parts = EventParts::Write(reconciled.parts);
Save(*event, context.authenticatedUser);
Audit(context, "update_event", Describe(*event));
if (SyncEvent(*event, context, Now(), std::move(reconciled.retired))) RespawnVanity(context);
UpdateAll(context);
JsonSuccess(reply, { {"message", made.empty() ? "Saved" : "Saved, with an empty " + made + " to put its NPCs in"} });
});
Route(eHTTPMethod::POST, "/api/events/:id/mode", 0,
"Switch a scheduled event off, to its schedule or always on. Body: {mode}. Needs the permission of every kind of part it has",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = IdFrom(context);
const auto body = ParseBody(context);
auto event = id ? FindEvent(*id) : std::nullopt;
if (!event) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Event not found");
if (const auto kind = Forbidden(context, PartsOf(*event))) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, ForbiddenMessage(*kind));
const auto value = body && body->contains("mode") && (*body)["mode"].is_number_integer() ? (*body)["mode"].get<int64_t>() : -1;
const auto mode = value >= 0 && value <= UINT8_MAX ? magic_enum::enum_cast<eMode>(static_cast<uint8_t>(value)) : std::nullopt;
if (!mode) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick whether it is off, on by its schedule or always on");
event->mode = static_cast<uint8_t>(*mode);
if (event->state == eEventState::CANCELLED && *mode != eMode::OFF) event->state = eEventState::SCHEDULED;
Save(*event, context.authenticatedUser);
Audit(context, "update_event", Describe(*event));
if (SyncEvent(*event, context, Now())) RespawnVanity(context);
JsonSuccess(reply, { {"message", "Now " + GameLabels::Name(*mode)} });
});
Route(eHTTPMethod::POST, "/api/events/:id/cancel", 0,
"Cancel a scheduled event: it is switched off and whatever it switched on is ended now. Needs the permission of every kind of part it has",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = IdFrom(context);
auto event = id ? FindEvent(*id) : std::nullopt;
if (!event) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Event not found");
if (const auto kind = Forbidden(context, PartsOf(*event))) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, ForbiddenMessage(*kind));
event->mode = static_cast<uint8_t>(eMode::OFF);
const bool vanity = SyncEvent(*event, context, Now());
event->state = eEventState::CANCELLED;
event->status = "Cancelled by " + context.authenticatedUser;
Save(*event, context.authenticatedUser);
Audit(context, "cancel_event", Describe(*event));
Alerts::Emit("server", "Event cancelled", event->name + " was cancelled.", {}, "/events");
if (vanity) RespawnVanity(context);
JsonSuccess(reply, { {"message", "Event cancelled"} });
});
Route(eHTTPMethod::POST, "/api/events/:id/delete", 0,
"Delete a scheduled event; whatever it switched on is undone first (without its end announcement or restart). Needs the permission of every kind of part it has",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = IdFrom(context);
auto event = id ? FindEvent(*id) : std::nullopt;
if (!event) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Event not found");
if (const auto kind = Forbidden(context, PartsOf(*event))) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, ForbiddenMessage(*kind));
// Undone as if its parts were taken out: no end announcement, no restart
Run run{ context, *event, Now() };
std::string problems;
for (auto& part : PartsOf(*event)) {
if (!part.applied) continue;
Retire(part, run);
if (part.status.starts_with("Couldn't")) problems += " " + part.status + ".";
}
const bool vanity = run.vanityChanged;
Database::Get()->DeleteScheduledEvent(event->id);
Audit(context, "delete_event", Describe(*event));
BroadcastTableChanged("scheduled_events", std::to_string(event->id));
if (vanity) RespawnVanity(context);
JsonSuccess(reply, { {"message", "Deleted." + problems} });
});
}
nlohmann::json VanityPreview(const nlohmann::json& body) {
const auto events = Database::Get()->GetScheduledEvents();
const auto at = body.contains("at") && body["at"].is_number_integer() ? body["at"].get<int64_t>() : Now();
std::vector<Event> chosen;
if (body.contains("ids") && body["ids"].is_array()) {
std::set<uint64_t> ids;
for (const auto& id : body["ids"]) if (id.is_number_unsigned()) ids.insert(id.get<uint64_t>());
for (const auto& event : events) if (ids.contains(event.id)) chosen.push_back(event);
} else {
for (const auto& event : events) if (IsOn(event, at)) chosen.push_back(event);
}
VanityEvents::SortForMerge(chosen);
std::vector<VanityEvents::Changes> changes;
nlohmann::json order = nlohmann::json::array();
for (const auto& event : chosen) {
auto list = EventParts::VanityChanges(event.name, event.parts);
if (list.empty()) continue;
nlohmann::json parts = nlohmann::json::array();
for (const auto& change : list) {
std::string error;
parts.push_back({ {"file", change.file}, {"removals", VanityEvents::SplitNames(change.removals)},
{"fileSwitches", VanityEvents::ToJson(VanityEvents::ParseFileSwitches(change.fileSwitches, error).value_or(VanityEvents::FileSwitches{}))} });
changes.push_back(change);
}
order.push_back({ {"id", event.id}, {"name", event.name}, {"priority", event.priority}, {"parts", parts} });
}
const auto world = VanityEvents::LoadWorld(VanityFolder(), VANITY_ROOT, changes);
const auto alone = VanityEvents::LoadWorld(VanityFolder(), VANITY_ROOT, std::vector<VanityEvents::Changes>{});
const auto files = [](const std::vector<VanityEvents::FileLoad>& list) {
nlohmann::json json = nlohmann::json::array();
for (const auto& file : list) json.push_back({ {"name", file.name}, {"includedBy", file.includedBy}, {"enabled", file.enabled}, {"switchedBy", file.switchedBy}, {"loaded", file.loaded} });
return json;
};
VanityXml::Document document;
document.objects = world.objects;
nlohmann::json conflicts = nlohmann::json::array(), fileConflicts = nlohmann::json::array();
for (const auto& conflict : world.conflicts) conflicts.push_back({ {"npc", conflict.npc}, {"events", conflict.events}, {"winner", conflict.events.back()} });
for (const auto& conflict : world.fileConflicts) {
nlohmann::json switches = nlohmann::json::array();
for (const auto& [name, switchOn] : conflict.switches) switches.push_back({ {"event", name}, {"on", switchOn} });
fileConflicts.push_back({ {"file", conflict.file}, {"switches", switches}, {"winner", conflict.switches.back().first}, {"on", conflict.switches.back().second} });
}
return { {"at", at}, {"events", order}, {"xml", VanityXml::Write(document)}, {"npcs", world.objects.size()}, {"fileNpcs", world.fileNpcs}, {"baseNpcs", alone.objects.size()},
{"files", files(world.files)}, {"baseFiles", files(alone.files)}, {"conflicts", conflicts}, {"fileConflicts", fileConflicts}, {"warnings", world.warnings} };
}
std::vector<VanityEvents::Changes> VanityChangesOn(int64_t at) {
auto events = Database::Get()->GetScheduledEvents();
std::erase_if(events, [at](const Event& event) { return !IsOn(event, at); });
VanityEvents::SortForMerge(events);
std::vector<VanityEvents::Changes> changes;
for (const auto& event : events) {
for (auto& change : EventParts::VanityChanges(event.name, event.parts)) changes.push_back(std::move(change));
}
return changes;
}
nlohmann::json VanityUses(int64_t now) {
nlohmann::json uses{ {"files", nlohmann::json::object()}, {"removes", nlohmann::json::object()}, {"events", nlohmann::json::array()} };
auto events = Database::Get()->GetScheduledEvents();
VanityEvents::SortForMerge(events);
for (const auto& event : events) {
const bool on = IsOn(event, now);
const auto ref = [&](const std::string& use) { return nlohmann::json{ {"id", event.id}, {"name", event.name}, {"on", on}, {"use", use} }; };
const auto changes = EventParts::VanityChanges(event.name, event.parts);
if (!changes.empty()) {
const auto [nextStart, nextEnd] = NextTimes(event, now);
uses["events"].push_back({ {"id", event.id}, {"name", event.name}, {"on", on}, {"priority", event.priority}, {"modeName", GameLabels::Name(ModeOf(event))},
{"nextStart", nextStart ? nlohmann::json(*nextStart) : nlohmann::json(nullptr)}, {"nextEnd", nextEnd ? nlohmann::json(*nextEnd) : nlohmann::json(nullptr)} });
}
for (const auto& change : changes) {
if (!change.file.empty()) uses["files"][change.file].push_back(ref("overlay"));
std::string error;
for (const auto& [file, switchOn] : VanityEvents::ParseFileSwitches(change.fileSwitches, error).value_or(VanityEvents::FileSwitches{})) {
uses["files"][file].push_back(ref(switchOn ? "on" : "off"));
}
for (const auto& name : VanityEvents::SplitNames(change.removals)) uses["removes"][name].push_back(ref("remove"));
}
}
return uses;
}
}