#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 #include #include #include #include #include #include #include #include #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(std::time(nullptr)); } std::filesystem::path VanityFolder() { return BinaryPathFinder::GetBinaryDir() / "vanity"; } struct Feature { std::string description; std::optional> version; // FeatureGating: unlocked from this client version std::map zones; // zone -> objects gated on it }; // FeatureGating, read once std::map 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> 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(result.getIntField(0, 0))); } } catch (const std::exception& ex) { LOG("Could not read ZoneTable: %s", ex.what()); } auto scenes = std::make_shared, 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> 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()] = zone[1].get(); } LOG("Events calendar: %zu features, from %zu scene files", g_Features.size(), count); BroadcastTableChanged("scheduled_events"); }); } const std::map& Features() { if (g_Scan == eScan::NOT_STARTED) StartFeatureScan(); return g_Features; } std::tuple ClientVersionSetting() { return { GeneralUtils::TryParse(Game::config->GetValue("version_major")).value_or(ClientVersion::major), GeneralUtils::TryParse(Game::config->GetValue("version_current")).value_or(ClientVersion::current), GeneralUtils::TryParse(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() : "Zone " + key; } std::string ZoneList(const std::vector& 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> zones; for (const auto& [id, name] : ZoneNames().items()) { if (const auto zone = GeneralUtils::TryParse(id); zone && *zone > 0) zones.emplace_back(*zone, name.get()); } 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& 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 ZonesOf(const std::string& feature) { const auto& features = Features(); const auto it = features.find(feature); return it == features.end() ? std::map{} : 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() : ""; } // ---------------------------------------------------------------- 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 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 Forbidden(const HTTPContext& context, const std::vector& a, const std::vector& 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(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 PartsOf(const Event& event) { std::string error; auto parts = EventParts::Parse(event.parts, error); if (!parts) LOG("Scheduled event %llu: %s", static_cast(event.id), error.c_str()); return parts.value_or(std::vector{}); } // When the stretch it is on in now ends (nullopt: not known, or not in the next years) std::optional 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> 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 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(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 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()); LiveWorld::Announce(Text(config, "title"), message, zones); } // The next repeat of an announcement part after `after`, while the event is on std::optional 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", int64_t{ 15 }) * 60, 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 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(event.id), ex.what()); } } if (vanity) RespawnVanity(actor); } // ---------------------------------------------------------------- reading an event from the page std::optional 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 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() + "\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(), 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 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(); 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()))) { 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 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() : 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> 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 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 FromBody(const nlohmann::json& body, Event& event, std::vector& 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() : 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()) > MAX_PRIORITY) return "Priority is a whole number from -1000 to 1000"; event.priority = body["priority"].get(); } if (body.contains("mode")) { const auto value = body["mode"].is_number_integer() ? body["mode"].get() : -1; const auto mode = value >= 0 && value <= UINT8_MAX ? magic_enum::enum_cast(static_cast(value)) : std::nullopt; if (!mode) return "Pick whether it is off, on by its schedule or always on"; event.mode = static_cast(*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().empty())) { event.schedule.clear(); } else { std::string error; const auto schedule = input.is_string() ? ScheduleRules::ParseSchedule(input.get(), 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() : event.startsAt; const auto endsAt = body.contains("endsAt") && body["endsAt"].is_number_integer() ? body["endsAt"].get() : 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& 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(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()) states.push_back({ {"value", static_cast(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()) { 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& events, int64_t now) { auto sorted = events; VanityEvents::SortForMerge(sorted); std::vector on; std::vector>> touched, switched; for (const auto& event : sorted) { std::set 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 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(); if (at >= from && at < to) occurrences.push_back({ {"kind", "restart"}, {"id", 0}, {"name", "Restart"}, {"start", at}, {"end", at}, {"link", "/#restart"} }); } } } std::optional IdFrom(const HTTPContext& context) { return PathId(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()}, {"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 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(), 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() : Now(); const auto count = std::clamp(body->contains("count") && (*body)["count"].is_number_integer() ? (*body)["count"].get() : 5, 1, 50); nlohmann::json windows = nlohmann::json::array(); for (const auto& window : ScheduleRules::Windows(*schedule, from, from + ScheduleRules::HORIZON_SECONDS, static_cast(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(QueryValue(context.queryString, "from")); const auto to = GeneralUtils::TryParse(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(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() : -1; const auto mode = value >= 0 && value <= UINT8_MAX ? magic_enum::enum_cast(static_cast(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(*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(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() : Now(); std::vector chosen; if (body.contains("ids") && body["ids"].is_array()) { std::set ids; for (const auto& id : body["ids"]) if (id.is_number_unsigned()) ids.insert(id.get()); 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 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{}); const auto files = [](const std::vector& 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 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 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; } }