diff --git a/EventNotifier/scripting/EventNotifier.sp b/EventNotifier/scripting/EventNotifier.sp new file mode 100644 index 0000000..37cef6f --- /dev/null +++ b/EventNotifier/scripting/EventNotifier.sp @@ -0,0 +1,392 @@ +#include +#include + +#define EVENTNOTIFIER_CORE +#include + +#pragma semicolon 1 +#pragma newdecls required + +#define PLUGIN_VERSION "1.0" + +ConVar g_cvUrl; +ConVar g_cvServer; +ConVar g_cvInterval; + +ArrayList g_Events; +Handle g_hRefreshTimer; +bool g_bFetching; +bool g_bEverFetched; + +GlobalForward g_fwdOnEventsUpdated; + +public Plugin myinfo = +{ + name = "EventNotifier", + author = "marisakirisame.net", + description = "Fetches upcoming Unloze events and exposes them to other plugins", + version = PLUGIN_VERSION, + url = "https://marisakirisame.net" +}; + +public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max) +{ + CreateNative("EventNotifier_GetEventCount", Native_GetEventCount); + CreateNative("EventNotifier_HasFetched", Native_HasFetched); + CreateNative("EventNotifier_FindEventByNumber", Native_FindEventByNumber); + CreateNative("EventNotifier_GetEvent", Native_GetEvent); + CreateNative("EventNotifier_Refresh", Native_Refresh); + RegPluginLibrary("EventNotifier"); + return APLRes_Success; +} + +public void OnPluginStart() +{ + g_Events = new ArrayList(sizeof(EventInfo)); + g_fwdOnEventsUpdated = new GlobalForward("EventNotifier_OnEventsUpdated", ET_Ignore); + + g_cvUrl = CreateConVar("sm_event_url", "https://marisakirisame.net/unloze-events.json", + "Base URL of the Unloze events API (query parameters are appended automatically)"); + g_cvServer = CreateConVar("sm_event_server", "ze", + "Value of the 'server' query parameter sent to the events API"); + g_cvInterval = CreateConVar("sm_event_refresh_interval", "1800", + "How often to re-fetch the event list, in seconds", _, true, 30.0); + + g_cvUrl.AddChangeHook(OnSourceChanged); + g_cvServer.AddChangeHook(OnSourceChanged); + g_cvInterval.AddChangeHook(OnIntervalChanged); + + AutoExecConfig(true, "EventNotifier"); + + RegAdminCmd("sm_event_refresh", Command_EventRefresh, ADMFLAG_GENERIC, "Re-fetches the event list now"); +} + +public void OnConfigsExecuted() +{ + RestartRefreshTimer(); + FetchEvents(); +} + +public void OnSourceChanged(ConVar convar, const char[] oldValue, const char[] newValue) +{ + FetchEvents(); +} + +public void OnIntervalChanged(ConVar convar, const char[] oldValue, const char[] newValue) +{ + RestartRefreshTimer(); +} + +void RestartRefreshTimer() +{ + delete g_hRefreshTimer; + g_hRefreshTimer = CreateTimer(g_cvInterval.FloatValue, Timer_Refresh, _, TIMER_REPEAT); +} + +public Action Timer_Refresh(Handle timer) +{ + FetchEvents(); + return Plugin_Continue; +} + +// --------------------------------------------------------------------------- +// Natives +// --------------------------------------------------------------------------- + +public any Native_GetEventCount(Handle plugin, int numParams) +{ + return g_Events.Length; +} + +public any Native_HasFetched(Handle plugin, int numParams) +{ + return g_bEverFetched; +} + +public any Native_FindEventByNumber(Handle plugin, int numParams) +{ + return FindEventByNumber(GetNativeCell(1)); +} + +public any Native_GetEvent(Handle plugin, int numParams) +{ + int index = GetNativeCell(1); + int size = GetNativeCell(3); + + if (size < sizeof(EventInfo)) + return ThrowNativeError(SP_ERROR_NATIVE, "EventInfo buffer too small (%d < %d), recompile against the current EventNotifier.inc", size, sizeof(EventInfo)); + + if (index < 0 || index >= g_Events.Length) + return false; + + EventInfo ev; + g_Events.GetArray(index, ev); + SetNativeArray(2, ev, sizeof(EventInfo)); + return true; +} + +public any Native_Refresh(Handle plugin, int numParams) +{ + FetchEvents(); + return 0; +} + +// --------------------------------------------------------------------------- +// Fetching +// --------------------------------------------------------------------------- + +void FetchEvents() +{ + if (g_bFetching) + return; + + char url[256]; + g_cvUrl.GetString(url, sizeof(url)); + if (!url[0]) + { + LogError("sm_event_url is empty, cannot fetch events"); + return; + } + + char server[64]; + g_cvServer.GetString(server, sizeof(server)); + + HTTPRequest request = new HTTPRequest(url); + request.AppendQueryParam("server", "%s", server); + request.AppendQueryParam("future", "1"); + request.SetHeader("Accept", "application/json"); + request.Timeout = 20; + + g_bFetching = true; + request.Get(OnEventsFetched); +} + +public void OnEventsFetched(HTTPResponse response, any value, const char[] error) +{ + g_bFetching = false; + + if (error[0]) + { + LogError("Event fetch failed: %s", error); + return; + } + + if (response.Status != HTTPStatus_OK) + { + LogError("Event fetch failed: HTTP %d", response.Status); + return; + } + + JSON data = response.Data; + if (data == null) + { + LogError("Event fetch failed: response was not valid JSON"); + return; + } + + JSONObject root = view_as(data); + if (!root.HasKey("events") || root.IsNull("events")) + { + LogError("Event fetch failed: response has no 'events' array"); + return; + } + + JSONArray events = view_as(root.Get("events")); + ArrayList parsed = new ArrayList(sizeof(EventInfo)); + + int count = events.Length; + for (int i = 0; i < count; i++) + { + if (events.IsNull(i)) + continue; + + JSONObject obj = view_as(events.Get(i)); + EventInfo ev; + ParseEvent(obj, ev); + delete obj; + + parsed.PushArray(ev); + } + delete events; + + // Soonest first so "the next N events" means something regardless of API order. + parsed.SortCustom(SortEventsByStart); + + int previous = g_Events.Length; + delete g_Events; + g_Events = parsed; + + if (!g_bEverFetched || previous != g_Events.Length) + LogMessage("Fetched %d upcoming event(s)", g_Events.Length); + g_bEverFetched = true; + + Call_StartForward(g_fwdOnEventsUpdated); + Call_Finish(); +} + +// Ascending by start time, with unknown (0) start times last. +int SortEventsByStart(int index1, int index2, Handle array, Handle hndl) +{ + ArrayList list = view_as(array); + int a = list.Get(index1, EventInfo::start); + int b = list.Get(index2, EventInfo::start); + + if (a == b) + return 0; + if (a == 0) + return 1; + if (b == 0) + return -1; + return (a < b) ? -1 : 1; +} + +void ParseEvent(JSONObject obj, EventInfo ev) +{ + char buf[64]; + + ev.number = (obj.HasKey("eventNumber") && !obj.IsNull("eventNumber")) ? obj.GetInt("eventNumber") : 0; + + GetJsonString(obj, "name", ev.name, sizeof(ev.name)); + if (!ev.name[0]) + GetJsonString(obj, "title", ev.name, sizeof(ev.name)); + + GetJsonString(obj, "server", ev.server, sizeof(ev.server)); + GetJsonString(obj, "rewards", ev.rewards, sizeof(ev.rewards)); + GetJsonString(obj, "leader", ev.leader, sizeof(ev.leader)); + GetJsonString(obj, "kickers", ev.kickers, sizeof(ev.kickers)); + + ev.start = 0; + if (GetJsonString(obj, "start", buf, sizeof(buf))) + ev.start = ParseIso8601(buf); + + ev.end = 0; + if (GetJsonString(obj, "end", buf, sizeof(buf))) + ev.end = ParseIso8601(buf); + + ev.maps[0] = '\0'; + if (obj.HasKey("maps") && !obj.IsNull("maps")) + { + JSONArray maps = view_as(obj.Get("maps")); + int len = maps.Length; + for (int i = 0; i < len; i++) + { + char map[128]; + if (!maps.GetString(i, map, sizeof(map)) || !map[0]) + continue; + + if (ev.maps[0]) + StrCat(ev.maps, sizeof(ev.maps), ", "); + StrCat(ev.maps, sizeof(ev.maps), map); + } + delete maps; + } +} + +bool GetJsonString(JSONObject obj, const char[] key, char[] buffer, int maxlen) +{ + buffer[0] = '\0'; + if (!obj.HasKey(key) || obj.IsNull(key)) + return false; + return obj.GetString(key, buffer, maxlen) && buffer[0] != '\0'; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +public Action Command_EventRefresh(int client, int args) +{ + if (g_bFetching) + { + ReplyToCommand(client, "[Events] A fetch is already in progress."); + return Plugin_Handled; + } + + FetchEvents(); + ReplyToCommand(client, "[Events] Refreshing the event list."); + return Plugin_Handled; +} + +int FindEventByNumber(int number) +{ + int count = g_Events.Length; + for (int i = 0; i < count; i++) + { + if (g_Events.Get(i, EventInfo::number) == number) + return i; + } + return -1; +} + +// --------------------------------------------------------------------------- +// Time helpers +// --------------------------------------------------------------------------- + +// Parses dates into a UTC unix timestamp. +// Returns 0 when the string cannot be parsed. +int ParseIso8601(const char[] str) +{ + int year, month, day, hour, minute, second; + if (!ReadFixedInt(str, 0, 4, year) || str[4] != '-' + || !ReadFixedInt(str, 5, 2, month) || str[7] != '-' + || !ReadFixedInt(str, 8, 2, day)) + return 0; + + int pos = 10; + if (str[pos] == 'T' || str[pos] == ' ') + { + if (!ReadFixedInt(str, 11, 2, hour) || str[13] != ':' || !ReadFixedInt(str, 14, 2, minute)) + return 0; + pos = 16; + if (str[pos] == ':') + { + if (!ReadFixedInt(str, 17, 2, second)) + return 0; + pos = 19; + } + if (str[pos] == '.') + { + pos++; + while (IsCharNumeric(str[pos])) + pos++; + } + } + + int offset = 0; + if (str[pos] == '+' || str[pos] == '-') + { + int sign = (str[pos] == '-') ? -1 : 1; + int offHour, offMinute; + if (!ReadFixedInt(str, pos + 1, 2, offHour)) + return 0; + int minPos = (str[pos + 3] == ':') ? pos + 4 : pos + 3; + ReadFixedInt(str, minPos, 2, offMinute); + offset = sign * (offHour * 3600 + offMinute * 60); + } + + int days = DaysFromCivil(year, month, day); + return days * 86400 + hour * 3600 + minute * 60 + second - offset; +} + +bool ReadFixedInt(const char[] str, int pos, int digits, int &out) +{ + out = 0; + for (int i = 0; i < digits; i++) + { + if (!IsCharNumeric(str[pos + i])) + return false; + out = out * 10 + (str[pos + i] - '0'); + } + return true; +} + +// Days since 1970-01-01 for a proper date. +int DaysFromCivil(int y, int m, int d) +{ + y -= (m <= 2) ? 1 : 0; + int era = ((y >= 0) ? y : y - 399) / 400; + int yoe = y - era * 400; + int doy = (153 * (m + ((m > 2) ? -3 : 9)) + 2) / 5 + d - 1; + int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + doe - 719468; +} diff --git a/EventNotifier/scripting/EventNotifierAnnouncements.sp b/EventNotifier/scripting/EventNotifierAnnouncements.sp new file mode 100644 index 0000000..c7e10eb --- /dev/null +++ b/EventNotifier/scripting/EventNotifierAnnouncements.sp @@ -0,0 +1,181 @@ +#include +#include +#include + +#pragma semicolon 1 +#pragma newdecls required + +#define PLUGIN_VERSION "1.0" + +// Shortest automatic announcement interval, in seconds. Values below this are +// raised to it; 0 or below disables the timer entirely. +#define MIN_ANNOUNCE_INTERVAL 60.0 + +// Visible characters kept from the event name in chat. +#define ANNOUNCE_NAME_MAX 64 +// Visible characters allowed for the map list before " +N more" is appended. +#define ANNOUNCE_MAPS_BUDGET 120 + +ConVar g_cvInterval; +ConVar g_cvCount; + +Handle g_hAnnounceTimer; +int g_iRotation; + +public Plugin myinfo = +{ + name = "EventNotifier Announcements", + author = "marisakirisame.net", + description = "Periodically announces upcoming Unloze events in chat", + version = PLUGIN_VERSION, + url = "https://marisakirisame.net" +}; + +public void OnPluginStart() +{ + g_cvInterval = CreateConVar("sm_event_announce_interval", "120", + "Seconds between event announcements in chat, -1 to disable automatic announcements (sm_event_announce still works)", _, true, -1.0); + g_cvCount = CreateConVar("sm_event_announce_count", "3", + "Rotate announcements through the next N upcoming events, one per announcement", _, true, 1.0, true, 20.0); + + // CreateConVar keeps the old bounds when the cvar already exists (plugin reload), + // so apply the lower bound explicitly. + g_cvInterval.SetBounds(ConVarBound_Lower, true, -1.0); + g_cvInterval.AddChangeHook(OnIntervalChanged); + + AutoExecConfig(true, "EventNotifierAnnouncements"); + + RegAdminCmd("sm_event_announce", Command_Announce, ADMFLAG_GENERIC, "Posts the next event announcement in chat now"); +} + +public void OnConfigsExecuted() +{ + RestartAnnounceTimer(); +} + +public void OnIntervalChanged(ConVar convar, const char[] oldValue, const char[] newValue) +{ + RestartAnnounceTimer(); +} + +void RestartAnnounceTimer() +{ + delete g_hAnnounceTimer; + + float interval = g_cvInterval.FloatValue; + if (interval <= 0.0) + return; + + if (interval < MIN_ANNOUNCE_INTERVAL) + interval = MIN_ANNOUNCE_INTERVAL; + + g_hAnnounceTimer = CreateTimer(interval, Timer_Announce, _, TIMER_REPEAT); +} + +public Action Timer_Announce(Handle timer) +{ + PostAnnouncement(); + return Plugin_Continue; +} + +public Action Command_Announce(int client, int args) +{ + if (PostAnnouncement()) + ReplyToCommand(client, "[Events] Announcement posted."); + else + ReplyToCommand(client, "[Events] No upcoming events to announce."); + return Plugin_Handled; +} + +// --------------------------------------------------------------------------- +// Announcement +// --------------------------------------------------------------------------- + +// Posts one event from the rotation window. Returns false when there is nothing to post. +bool PostAnnouncement() +{ + int count = EventNotifier_GetEventCount(); + if (count <= 0) + return false; + + int window = g_cvCount.IntValue; + if (window > count) + window = count; + + int index = g_iRotation % window; + g_iRotation = (index + 1) % window; + + EventInfo ev; + if (!EventNotifier_GetEvent(index, ev)) + return false; + + char name[ANNOUNCE_NAME_MAX + 1]; + strcopy(name, sizeof(name), ev.name); + if (!name[0]) + strcopy(name, sizeof(name), "Unnamed event"); + + int now = GetTime(); + char span[32]; + if (ev.start <= 0) + { + CPrintToChatAll("{darkorange}[Events] {yellow}#%d: {lightgreen}%s {grey}date TBA", ev.number, name); + } + else if (now < ev.start) + { + EventNotifier_FormatDuration(ev.start - now, span, sizeof(span)); + CPrintToChatAll("{darkorange}[Events] {yellow}#%d: {lightgreen}%s {lime}starts in %s", ev.number, name, span); + } + else + { + EventNotifier_FormatDuration(now - ev.start, span, sizeof(span)); + CPrintToChatAll("{darkorange}[Events] {yellow}#%d: {lightgreen}%s {orchid}started %s ago", ev.number, name, span); + } + + if (ev.maps[0]) + { + char mapsLine[ANNOUNCE_MAPS_BUDGET + 132]; + int more = BuildMapsLine(ev.maps, mapsLine, sizeof(mapsLine), ANNOUNCE_MAPS_BUDGET); + if (more > 0) + CPrintToChatAll("{darkorange}[Events] {white}Maps: {lightgreen}%s {grey}+%d more", mapsLine, more); + else + CPrintToChatAll("{darkorange}[Events] {white}Maps: {lightgreen}%s", mapsLine); + } + + CPrintToChatAll("{darkorange}[Events] {white}Type {lime}!event %d {white}for details and the full map list.", ev.number); + return true; +} + +// Copies ", "-separated items from maps into out while the visible length stays +// within budget. Always emits at least the first item. Returns how many items +// were left out (0 when everything fit). +int BuildMapsLine(const char[] maps, char[] out, int maxlen, int budget) +{ + // Count from the raw string so "+N more" is right even past the explode limit. + int total = 1; + for (int i = 0; maps[i]; i++) + { + if (maps[i] == ',' && maps[i + 1] == ' ') + total++; + } + + char parts[32][128]; + int count = ExplodeString(maps, ", ", parts, sizeof(parts), sizeof(parts[])); + + out[0] = '\0'; + int used = 0; + int shown = 0; + for (int i = 0; i < count; i++) + { + int add = strlen(parts[i]) + ((shown > 0) ? 2 : 0); + if (shown > 0 && used + add > budget) + break; + + if (shown > 0) + StrCat(out, maxlen, ", "); + StrCat(out, maxlen, parts[i]); + used += add; + shown++; + } + + return total - shown; +} diff --git a/EventNotifier/scripting/EventNotifierMenu.sp b/EventNotifier/scripting/EventNotifierMenu.sp new file mode 100644 index 0000000..5a554e4 --- /dev/null +++ b/EventNotifier/scripting/EventNotifierMenu.sp @@ -0,0 +1,225 @@ +#include +#include + +#pragma semicolon 1 +#pragma newdecls required + +#define PLUGIN_VERSION "1.1" +#define CHAT_PREFIX "\x04[Events]\x01" + +#define PANEL_LINE_WIDTH 60 + +#define KEY_BACK 8 +#define KEY_EXIT 10 + +public Plugin myinfo = +{ + name = "EventNotifier Menu", + author = "marisakirisame.net", + description = "Shows upcoming Unloze events in-game via sm_event", + version = PLUGIN_VERSION, + url = "https://marisakirisame.net" +}; + +public void OnPluginStart() +{ + RegConsoleCmd("sm_event", Command_EventMenu, "Shows upcoming Unloze events. Usage: sm_event [event number]"); + RegConsoleCmd("sm_events", Command_EventMenu, "Shows upcoming Unloze events. Usage: sm_events [event number]"); +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +public Action Command_EventMenu(int client, int args) +{ + if (client <= 0 || !IsClientInGame(client)) + { + ReplyToCommand(client, "This command can only be used in-game."); + return Plugin_Handled; + } + + if (EventNotifier_GetEventCount() == 0) + { + if (EventNotifier_HasFetched()) + PrintToChat(client, "%s There are no upcoming events.", CHAT_PREFIX); + else + PrintToChat(client, "%s The event list is not available right now, try again later.", CHAT_PREFIX); + return Plugin_Handled; + } + + if (args >= 1) + { + char arg[16]; + GetCmdArg(1, arg, sizeof(arg)); + int number = StringToInt(arg); + int index = EventNotifier_FindEventByNumber(number); + if (index == -1) + { + PrintToChat(client, "%s Event #%d is not in the upcoming list.", CHAT_PREFIX, number); + return Plugin_Handled; + } + ShowEventDetails(client, index); + return Plugin_Handled; + } + + ShowEventList(client); + return Plugin_Handled; +} + +// --------------------------------------------------------------------------- +// Menus +// --------------------------------------------------------------------------- + +void ShowEventList(int client) +{ + Menu menu = new Menu(EventList_Handler); + menu.SetTitle("Upcoming Unloze events"); + + int count = EventNotifier_GetEventCount(); + for (int i = 0; i < count; i++) + { + EventInfo ev; + if (!EventNotifier_GetEvent(i, ev)) + continue; + + char info[16], display[EVENT_STR_LEN + 16]; + IntToString(ev.number, info, sizeof(info)); + Format(display, sizeof(display), "%d: %s", ev.number, ev.name); + menu.AddItem(info, display); + } + + menu.ExitButton = true; + menu.Display(client, MENU_TIME_FOREVER); +} + +public int EventList_Handler(Menu menu, MenuAction action, int param1, int param2) +{ + switch (action) + { + case MenuAction_Select: + { + char info[16]; + menu.GetItem(param2, info, sizeof(info)); + + int index = EventNotifier_FindEventByNumber(StringToInt(info)); + if (index == -1) + { + PrintToChat(param1, "%s That event is no longer in the upcoming list.", CHAT_PREFIX); + ShowEventList(param1); + } + else + { + ShowEventDetails(param1, index); + } + } + case MenuAction_End: + { + delete menu; + } + } + return 0; +} + +void ShowEventDetails(int client, int index) +{ + EventInfo ev; + if (!EventNotifier_GetEvent(index, ev)) + { + PrintToChat(client, "%s That event is no longer in the upcoming list.", CHAT_PREFIX); + return; + } + + char line[EVENT_STR_LEN + 32]; + Panel panel = new Panel(); + + Format(line, sizeof(line), "Event #%d: %s", ev.number, ev.name); + panel.SetTitle(line); + panel.DrawText(" "); + + if (ev.start > 0) + { + char when[64]; + FormatTime(when, sizeof(when), "%a %d %b %Y, %H:%M %Z", ev.start); + Format(line, sizeof(line), "Date: %s", when); + panel.DrawText(line); + + EventNotifier_FormatRelativeTime(ev.start, ev.end, line, sizeof(line)); + panel.DrawText(line); + } + else + { + panel.DrawText("Date: unknown"); + } + + Format(line, sizeof(line), "Server: %s", ev.server[0] ? ev.server : "unknown"); + panel.DrawText(line); + + if (ev.maps[0]) + DrawWrappedList(panel, "Maps: ", ev.maps); + else + panel.DrawText("Maps: none listed"); + + Format(line, sizeof(line), "Rewards: %s", ev.rewards[0] ? ev.rewards : "none"); + panel.DrawText(line); + Format(line, sizeof(line), "Leader: %s", ev.leader[0] ? ev.leader : "none"); + panel.DrawText(line); + Format(line, sizeof(line), "Kickers: %s", ev.kickers[0] ? ev.kickers : "none"); + panel.DrawText(line); + + panel.DrawText(" "); + panel.CurrentKey = KEY_BACK; + panel.DrawItem("Back"); + panel.CurrentKey = KEY_EXIT; + panel.DrawItem("Exit"); + + panel.Send(client, EventDetails_Handler, MENU_TIME_FOREVER); + delete panel; +} + +public int EventDetails_Handler(Menu menu, MenuAction action, int param1, int param2) +{ + if (action == MenuAction_Select && param2 == KEY_BACK) + { + if (IsClientInGame(param1)) + Command_EventMenu(param1, 0); + } + return 0; +} + +// Draws "prefix a, b, c" and continues onto new indented lines when a line +// would exceed PANEL_LINE_WIDTH characters, splitting only at the commas. +void DrawWrappedList(Panel panel, const char[] prefix, const char[] list) +{ + char parts[32][128]; + int count = ExplodeString(list, ", ", parts, sizeof(parts), sizeof(parts[])); + + char line[PANEL_LINE_WIDTH + 160]; + strcopy(line, sizeof(line), prefix); + bool lineHasItems = false; + + for (int i = 0; i < count; i++) + { + char item[132]; + strcopy(item, sizeof(item), parts[i]); + if (i < count - 1) + StrCat(item, sizeof(item), ","); + + if (lineHasItems && strlen(line) + 1 + strlen(item) > PANEL_LINE_WIDTH) + { + panel.DrawText(line); + strcopy(line, sizeof(line), " "); + lineHasItems = false; + } + else if (lineHasItems) + { + StrCat(line, sizeof(line), " "); + } + + StrCat(line, sizeof(line), item); + lineHasItems = true; + } + + if (lineHasItems) + panel.DrawText(line); +} diff --git a/EventNotifier/scripting/include/EventNotifier.inc b/EventNotifier/scripting/include/EventNotifier.inc new file mode 100644 index 0000000..5546e3d --- /dev/null +++ b/EventNotifier/scripting/include/EventNotifier.inc @@ -0,0 +1,142 @@ +#if defined _EventNotifier_included + #endinput +#endif + +#define _EventNotifier_included + +#define EVENT_STR_LEN 128 +#define EVENT_MAPS_LEN 1024 + +enum struct EventInfo +{ + int number; + int start; // unix timestamp (UTC), 0 if unknown + int end; // unix timestamp (UTC), 0 if unknown + char name[EVENT_STR_LEN]; + char server[EVENT_STR_LEN]; + char rewards[EVENT_STR_LEN]; + char leader[EVENT_STR_LEN]; + char kickers[EVENT_STR_LEN]; + char maps[EVENT_MAPS_LEN]; // ", "-joined list +} + +#if !defined EVENTNOTIFIER_CORE + +/** + * Number of events in the last successful fetch (0 before the first fetch). + * + * @return Event count. + */ +native int EventNotifier_GetEventCount(); + +/** + * Whether at least one fetch has succeeded since the plugin loaded. + * + * @return True once the event list has been fetched. + */ +native bool EventNotifier_HasFetched(); + +/** + * Finds an event by its event number. + * + * @param number Event number. + * @return Index into the event list, or -1 if not found. + */ +native int EventNotifier_FindEventByNumber(int number); + +/** + * Copies an event into the caller's EventInfo. + * + * @param index Index into the event list. + * @param ev EventInfo to fill. + * @param size Size of ev, must be sizeof(EventInfo). + * @return True on success, false if index is out of range. + * @error Buffer smaller than sizeof(EventInfo). + */ +native bool EventNotifier_GetEvent(int index, any[] ev, int size = sizeof(EventInfo)); + +/** + * Starts a fetch of the event list now. No-op if a fetch is already running. + */ +native void EventNotifier_Refresh(); + +/** + * Called after every successful fetch, once the event list has been replaced. + */ +forward void EventNotifier_OnEventsUpdated(); + +#endif // EVENTNOTIFIER_CORE + +// --------------------------------------------------------------------------- +// Display helpers +// --------------------------------------------------------------------------- + +/** + * Formats a duration in seconds as "1d 2h 3m", "2h 3m" or "3m". + */ +stock void EventNotifier_FormatDuration(int seconds, char[] buffer, int maxlen) +{ + int days = seconds / 86400; + int hours = (seconds % 86400) / 3600; + int minutes = (seconds % 3600) / 60; + + if (days > 0) + Format(buffer, maxlen, "%dd %dh %dm", days, hours, minutes); + else if (hours > 0) + Format(buffer, maxlen, "%dh %dm", hours, minutes); + else + Format(buffer, maxlen, "%dm", minutes); +} + +/** + * Formats an event's status relative to now as a "Status: ..." line. + */ +stock void EventNotifier_FormatRelativeTime(int start, int end, char[] buffer, int maxlen) +{ + int now = GetTime(); + + if (end > 0 && now >= start && now < end) + { + strcopy(buffer, maxlen, "Status: In progress now!"); + return; + } + + char span[32]; + if (now >= start) + { + EventNotifier_FormatDuration(now - start, span, sizeof(span)); + Format(buffer, maxlen, "Status: Started %s ago", span); + } + else + { + EventNotifier_FormatDuration(start - now, span, sizeof(span)); + Format(buffer, maxlen, "Status: Starts in %s", span); + } +} + +#if !defined EVENTNOTIFIER_CORE + +public SharedPlugin __pl_EventNotifier = +{ + name = "EventNotifier", + file = "EventNotifier.smx", + + #if defined REQUIRE_PLUGIN + required = 1 + #else + required = 0 + #endif +}; + +#if !defined REQUIRE_PLUGIN + public void __pl_EventNotifier_SetNTVOptional() + { + MarkNativeAsOptional("EventNotifier_GetEventCount"); + MarkNativeAsOptional("EventNotifier_HasFetched"); + MarkNativeAsOptional("EventNotifier_FindEventByNumber"); + MarkNativeAsOptional("EventNotifier_GetEvent"); + MarkNativeAsOptional("EventNotifier_Refresh"); + } +#endif + +#endif // EVENTNOTIFIER_CORE