mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
Every MASTER service packet is now an LUBitStream struct (docs/PacketArchitecture.md, PR 14) and the master server's switch is a dispatch map (PacketDispatcher), as are the master handlers of the world, chat and dashboard servers. - dNet/MasterPackets.h: RequestZoneTransfer, RequestZoneTransferResponse, ServerInfo, RequestSessionKey, SetSessionKey, SessionKeyResponse, NewSessionAlert, PlayerAdded / PlayerRemoved, CreatePrivateZone, RequestPrivateZone (passwords still cut to 50 characters when read), WorldReady, WorldReadyInfo (WORLD_READY to the dashboard), PrepZone, Shutdown, ShutdownResponse, WorldShutDown (SHUTDOWN_RESPONSE to the dashboard), ShutdownUniverse, AffirmTransferRequest/Response, RequestServerList, ServerListResponse, DashboardShutdown, ConfigReload, InstanceShutdown. The Send* functions are gone; MasterPackets::SendToMaster(msg) and SendTo(sysAddr, msg) send a struct. - The dashboard and instance migration structs (PlayerAction, DataChanged, Dashboard messages, MessageCapture, InstanceMigration) are LUBitStreams of the MASTER service now and moved to dNet/master/, included by MasterPackets.h. Their payloads are unchanged; master forwards them by re-serializing the struct instead of copying raw bytes. - InstanceManager, ZoneInstanceManager, MigrationCoordinator, dServer (server info, zone transfer response), auth (SET_SESSION_KEY), the world (session keys, player added and removed, world ready, shutdown response, affirmations, prep zone, shutdown universe) and the dashboard (server list, instance shutdown, config reload, announcements, player actions, message capture) send and read structs. - The login stamps are a `stamps` field of RequestZoneTransfer and RequestZoneTransferResponse (read leniently as before: a message without them reads as empty); master adds its stamps in the REQUEST_ZONE_TRANSFER handler and when it answers, as it did. - InstanceManager::GetInstanceBySysAddr takes a const address. Verified: tests/dGameTests/dNetTests/Legacy/MasterPacketsLegacy.h is a verbatim copy of the old writers and readers; MasterPacketsTests requires identical bytes for a grid of inputs, checks the old readers read what the structs write, round trips and truncation, hand written golden packets, that zone transfers without stamps still read, that the dashboard/migration structs write what "header + Serialize" wrote, and that the dispatcher drops truncated packets. No wire bytes changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1147 lines
60 KiB
C++
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} });
|
|
});
|
|
|
|
Route(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;
|
|
}
|
|
}
|