Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of Skald v0.5.2
Skald.dll
Decompiled 3 days ago
The result has been truncated due to the large size, download it to view full contents!
using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Text; using System.Text.RegularExpressions; using System.Threading; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using Microsoft.CodeAnalysis; using Skald.Client; using Skald.Net; using Skald.Server; using Skald.Util; using Splatform; using TMPro; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")] [assembly: AssemblyMetadata("BuildTime", "2026-11-01 11:08")] [assembly: AssemblyCompany("Skald")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("0.5.2.0")] [assembly: AssemblyInformationalVersion("0.5.2")] [assembly: AssemblyProduct("Skald")] [assembly: AssemblyTitle("Skald")] [assembly: AssemblyVersion("0.5.2.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace Skald { internal static class SkaldConfig { public static ConfigEntry<bool> Enabled; public static ConfigEntry<bool> AchievementsEnabled; public static ConfigEntry<bool> AnnounceInChat; public static ConfigEntry<float> BannerSeconds; public static ConfigEntry<bool> BannersInChat; public static ConfigEntry<float> JoinDelaySeconds; public static ConfigEntry<float> FirstJoinDelaySeconds; public static ConfigEntry<string> AnnouncerName; public static ConfigEntry<bool> CountLifetimeStats; public static ConfigEntry<float> ReportIntervalSeconds; public static ConfigEntry<float> StartupGraceSeconds; public static ConfigEntry<bool> FirstToReach; public static ConfigEntry<bool> DeathAnnouncements; public static ConfigEntry<bool> DeathsToDiscord; public static ConfigEntry<bool> NemesisEnabled; public static ConfigEntry<int> NemesisThreshold; public static ConfigEntry<bool> StreaksEnabled; public static ConfigEntry<int> StreakEndMentionDays; public static ConfigEntry<bool> ChatCommands; public static ConfigEntry<bool> WhileYouWereAway; public static ConfigEntry<KeyboardShortcut> PanelKey; public static ConfigEntry<float> CatchupMinHours; public static ConfigEntry<bool> ReturnMessages; public static ConfigEntry<float> ReturnAfterDays; public static ConfigEntry<bool> BossSummaries; public static ConfigEntry<bool> WorldCensus; public static ConfigEntry<float> CensusIntervalHours; public static ConfigEntry<bool> RaidAnnouncements; public static ConfigEntry<bool> WatchdogEnabled; public static ConfigEntry<float> SampleIntervalSeconds; public static ConfigEntry<float> FrameWarnMs; public static ConfigEntry<float> FrameCriticalMs; public static ConfigEntry<int> SustainedSamples; public static ConfigEntry<int> MemoryWarnMb; public static ConfigEntry<int> MemoryCriticalMb; public static ConfigEntry<int> ObjectAlertCount; public static ConfigEntry<float> SaveWarnSeconds; public static ConfigEntry<float> AlertCooldownMinutes; public static ConfigEntry<string> DiscordWebhookUrl; public static ConfigEntry<bool> DiscordAnnounceAchievements; public static ConfigEntry<bool> RaidsToDiscord; public static ConfigEntry<bool> WeeklyRecap; public static ConfigEntry<DayOfWeek> RecapDay; public static ConfigEntry<int> RecapHour; public static ConfigEntry<bool> LiveStatus; public static ConfigEntry<int> LiveStatusIntervalSeconds; public static ConfigEntry<string> StatusWebhookUrl; public static ConfigEntry<string> StatusServerName; public static ConfigEntry<bool> DeathLog; public static ConfigEntry<int> DeathLogSize; public static ConfigEntry<string> DeathLogWebhookUrl; public static ConfigEntry<bool> WorldReport; public static ConfigEntry<string> WorldReportWebhookUrl; public static ConfigEntry<bool> Leaderboard; public static ConfigEntry<int> LeaderboardIntervalMinutes; public static ConfigEntry<string> LeaderboardWebhookUrl; public static void Bind(ConfigFile cfg) { //IL_007a: Unknown result type (might be due to invalid IL or missing references) //IL_0084: Expected O, but got Unknown //IL_00cd: Unknown result type (might be due to invalid IL or missing references) //IL_00d7: Expected O, but got Unknown //IL_0105: Unknown result type (might be due to invalid IL or missing references) //IL_010f: Expected O, but got Unknown //IL_0177: Unknown result type (might be due to invalid IL or missing references) //IL_0181: Expected O, but got Unknown //IL_022f: Unknown result type (might be due to invalid IL or missing references) //IL_0239: Expected O, but got Unknown //IL_02a4: Unknown result type (might be due to invalid IL or missing references) //IL_02fc: Unknown result type (might be due to invalid IL or missing references) //IL_0306: Expected O, but got Unknown //IL_034f: Unknown result type (might be due to invalid IL or missing references) //IL_0359: Expected O, but got Unknown //IL_03bd: Unknown result type (might be due to invalid IL or missing references) //IL_03c7: Expected O, but got Unknown //IL_042b: Unknown result type (might be due to invalid IL or missing references) //IL_0435: Expected O, but got Unknown //IL_0496: Unknown result type (might be due to invalid IL or missing references) //IL_04a0: Expected O, but got Unknown //IL_0546: Unknown result type (might be due to invalid IL or missing references) //IL_0550: Expected O, but got Unknown //IL_061e: Unknown result type (might be due to invalid IL or missing references) //IL_0628: Expected O, but got Unknown //IL_06a5: Unknown result type (might be due to invalid IL or missing references) //IL_06af: Expected O, but got Unknown //IL_074a: Unknown result type (might be due to invalid IL or missing references) //IL_0754: Expected O, but got Unknown //IL_0797: Unknown result type (might be due to invalid IL or missing references) //IL_07a1: Expected O, but got Unknown Enabled = cfg.Bind<bool>("General", "Enabled", true, "Master switch. False leaves the game completely unpatched."); AchievementsEnabled = cfg.Bind<bool>("Achievements", "Enabled", true, "Track and announce achievements. Server-side setting."); AnnounceInChat = cfg.Bind<bool>("Achievements", "AnnounceInChat", true, "Show Skald's public announcements in game (achievements, deaths, raids, streaks). Server-side setting. Players need the mod to see them."); BannerSeconds = cfg.Bind<float>("Banners", "Seconds", 4f, new ConfigDescription("How long Skald's centre-screen banners stay on screen, in seconds. 4 is the game's own. Server-side setting: players see what the server says.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(2f, 30f), Array.Empty<object>())); BannersInChat = cfg.Bind<bool>("Banners", "AlsoInChat", true, "Also write each banner into chat, which opens the chat window for a moment. Off: banners are banner-only, and an achievement's description goes under its title on screen instead. Server-side setting."); JoinDelaySeconds = cfg.Bind<float>("Announcements", "JoinDelaySeconds", 10f, new ConfigDescription("How long after a player connects their \"join\" announcements wait, so they arrive once the player can see the screen rather than during loading. An announcement's own \"delay\" overrides this. Server-side setting.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(0f, 1800f), Array.Empty<object>())); FirstJoinDelaySeconds = cfg.Bind<float>("Announcements", "FirstJoinDelaySeconds", 90f, new ConfigDescription("The same for \"firstjoin\": a new player sits through the intro and the Valkyrie's ride first, so a welcome sent at once is easily missed. An announcement's own \"delay\" overrides this. Server-side setting.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(0f, 1800f), Array.Empty<object>())); AnnouncerName = cfg.Bind<string>("Achievements", "AnnouncerName", "Skald", "Name shown in front of Skald's chat lines. Server-side setting."); CountLifetimeStats = cfg.Bind<bool>("Achievements", "CountLifetimeStats", false, "False (default): a player's stats are baselined when Skald first sees them, so achievements measure progress on THIS server only. True: count their whole character lifetime, which will instantly unlock a lot for anyone with an established character."); ReportIntervalSeconds = cfg.Bind<float>("Achievements", "ReportIntervalSeconds", 30f, new ConfigDescription("How often a client reports its stats to the server.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(5f, 300f), Array.Empty<object>())); StartupGraceSeconds = cfg.Bind<float>("Achievements", "StartupGraceSeconds", 30f, "Announcements are suppressed for this long after the server starts, so loading an existing world does not spam every already-earned milestone."); FirstToReach = cfg.Bind<bool>("Achievements", "FirstToReachBiomes", true, "Call out the first player on the server to reach each biome."); DeathAnnouncements = cfg.Bind<bool>("Deaths", "Enabled", true, "Announce deaths, with the cause and whatever did it. Read by both halves: a client with this off reports nothing."); DeathsToDiscord = cfg.Bind<bool>("Deaths", "AlsoPostToDiscord", false, "Also send death announcements to the webhook. Server-side setting."); NemesisEnabled = cfg.Bind<bool>("Deaths", "Nemesis", true, "When the same kind of creature (or the same player) keeps killing someone, it becomes their nemesis. Killing one afterwards settles it."); NemesisThreshold = cfg.Bind<int>("Deaths", "NemesisThreshold", 3, new ConfigDescription("Deaths to the same killer before it becomes a nemesis.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(2, 20), Array.Empty<object>())); StreaksEnabled = cfg.Bind<bool>("Deaths", "SurvivalStreaks", true, "Announce in-game days survived without dying, at 5, 10, 20, 30, 50, 75 and 100."); StreakEndMentionDays = cfg.Bind<int>("Deaths", "StreakEndMentionDays", 3, "When someone dies with a streak at least this long, the death message says so."); ChatCommands = cfg.Bind<bool>("Social", "ChatCommands", true, "Enable !skald, !deaths, !top and !recap. Read by both halves."); PanelKey = cfg.Bind<KeyboardShortcut>("Social", "PanelKey", new KeyboardShortcut((KeyCode)291, Array.Empty<KeyCode>()), "Key that opens Skald's panel from anywhere. Client-side setting."); WhileYouWereAway = cfg.Bind<bool>("Social", "WhileYouWereAway", true, "Privately tell an arriving player what they missed: bosses, what others earned, how many died, and whether their streak survived."); CatchupMinHours = cfg.Bind<float>("Social", "WhileYouWereAwayMinHours", 0.5f, new ConfigDescription("Hours away before that summary is worth sending.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(0.05f, 720f), Array.Empty<object>())); ReturnMessages = cfg.Bind<bool>("Social", "ReturnMessages", true, "Announce players coming back after a long absence."); ReturnAfterDays = cfg.Bind<float>("Social", "ReturnAfterDays", 3f, new ConfigDescription("Real days away that count as a long absence.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(0.5f, 365f), Array.Empty<object>())); BossSummaries = cfg.Bind<bool>("Events", "BossSummaries", true, "When a boss dies, report how long the fight took and who died in it."); WorldCensus = cfg.Bind<bool>("Events", "WorldCensus", true, "Periodically count what the world is made of, so growth can be blamed on something specific. The count is spread over many frames."); CensusIntervalHours = cfg.Bind<float>("Events", "CensusIntervalHours", 6f, new ConfigDescription("Hours between counts. A count only runs while someone is online.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(0.25f, 168f), Array.Empty<object>())); RaidAnnouncements = cfg.Bind<bool>("Events", "RaidAnnouncements", true, "Announce raids to everyone, naming whose base is under attack, and report the casualties when they end."); WatchdogEnabled = cfg.Bind<bool>("Watchdog", "Enabled", true, "Monitor server performance. Server-side setting."); SampleIntervalSeconds = cfg.Bind<float>("Watchdog", "SampleIntervalSeconds", 10f, new ConfigDescription("Seconds per metrics sample.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(1f, 120f), Array.Empty<object>())); FrameWarnMs = cfg.Bind<float>("Watchdog", "FrameWarnMs", 50f, "Average frame time over a sample above this is a warning. The dedicated server normally sits far below this."); FrameCriticalMs = cfg.Bind<float>("Watchdog", "FrameCriticalMs", 120f, "Average frame time over a sample above this is critical."); SustainedSamples = cfg.Bind<int>("Watchdog", "SustainedSamples", 3, new ConfigDescription("Consecutive bad samples before alerting. Stops a single hitch from paging you.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(1, 20), Array.Empty<object>())); MemoryWarnMb = cfg.Bind<int>("Watchdog", "MemoryWarnMb", 2800, "Process memory warning threshold. Tuned for a 4 GB box."); MemoryCriticalMb = cfg.Bind<int>("Watchdog", "MemoryCriticalMb", 3400, "Process memory critical threshold. Tuned for a 4 GB box."); ObjectAlertCount = cfg.Bind<int>("Watchdog", "ObjectAlertCount", 0, "Optional: alert once the world holds more than this many networked objects. 0 (default) never alerts. The count only ever goes up - every area explored adds its trees, rocks and bushes for good - so Skald tracks it and reports its growth rather than warning about it."); SaveWarnSeconds = cfg.Bind<float>("Watchdog", "SaveWarnSeconds", 5f, "Warn when a world save blocks for longer than this."); AlertCooldownMinutes = cfg.Bind<float>("Watchdog", "AlertCooldownMinutes", 15f, new ConfigDescription("Minimum gap between repeats of the same alert.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(0f, 1440f), Array.Empty<object>())); DiscordWebhookUrl = cfg.Bind<string>("Discord", "WebhookUrl", "", "Discord webhook URL. Empty (default) means nothing is ever sent anywhere. Paste your own webhook here to switch Discord on."); DiscordAnnounceAchievements = cfg.Bind<bool>("Discord", "AnnounceAchievements", false, "Also post achievement unlocks and boss summaries to the webhook."); RaidsToDiscord = cfg.Bind<bool>("Discord", "AnnounceRaids", false, "Also post raid start and end to the webhook."); WeeklyRecap = cfg.Bind<bool>("Discord", "WeeklyRecap", true, "Post a weekly summary to the webhook: hours, deaths, achievements, bosses, and server health with a capacity forecast."); RecapDay = cfg.Bind<DayOfWeek>("Discord", "RecapDay", DayOfWeek.Sunday, "Day of the week the recap posts, in the server's local time."); LiveStatus = cfg.Bind<bool>("Discord", "LiveStatus", true, "Keep one Discord message up to date with who is online and how the server is doing, edited in place rather than reposted. Pin it once. Needs a webhook."); LiveStatusIntervalSeconds = cfg.Bind<int>("Discord", "LiveStatusIntervalSeconds", 120, new ConfigDescription("Seconds between status updates.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(30, 3600), Array.Empty<object>())); StatusWebhookUrl = cfg.Bind<string>("Discord", "StatusWebhookUrl", "", "Optional separate webhook for the status message, e.g. for a #server-status channel. Empty uses WebhookUrl."); StatusServerName = cfg.Bind<string>("Discord", "StatusServerName", "", "Name shown on the status message. Empty uses the server's own name."); DeathLog = cfg.Bind<bool>("Discord", "DeathLog", false, "Keep one message up to date with the most recent deaths, instead of a post per death. Needs a webhook."); DeathLogSize = cfg.Bind<int>("Discord", "DeathLogSize", 10, new ConfigDescription("How many deaths the log shows.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(3, 25), Array.Empty<object>())); DeathLogWebhookUrl = cfg.Bind<string>("Discord", "DeathLogWebhookUrl", "", "Optional webhook of its own, e.g. a #deaths channel. Empty uses WebhookUrl."); WorldReport = cfg.Bind<bool>("Discord", "WorldReport", false, "Keep one message up to date with what the world is made of, its busiest areas and how fast it is growing. Redrawn after each census."); WorldReportWebhookUrl = cfg.Bind<string>("Discord", "WorldReportWebhookUrl", "", "Optional webhook of its own for the world report. Empty uses WebhookUrl."); Leaderboard = cfg.Bind<bool>("Discord", "Leaderboard", false, "Keep one message up to date with the standings: achievements, hours, deaths and streaks."); LeaderboardIntervalMinutes = cfg.Bind<int>("Discord", "LeaderboardIntervalMinutes", 15, new ConfigDescription("Minutes between leaderboard updates.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(1, 1440), Array.Empty<object>())); LeaderboardWebhookUrl = cfg.Bind<string>("Discord", "LeaderboardWebhookUrl", "", "Optional webhook of its own for the leaderboard. Empty uses WebhookUrl."); RecapHour = cfg.Bind<int>("Discord", "RecapHour", 18, new ConfigDescription("Hour (0-23) the recap posts, in the server's local time. If the server is down then, it posts when it next starts.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(0, 23), Array.Empty<object>())); } } [BepInPlugin("com.dks.skald", "Skald", "0.5.2")] [BepInProcess("valheim.exe")] [BepInProcess("valheim_server.exe")] public class SkaldPlugin : BaseUnityPlugin { public const string PluginGuid = "com.dks.skald"; public const string PluginName = "Skald"; public const string PluginVersion = "0.5.2"; internal static ManualLogSource Log; private Harmony _harmony; private void Awake() { //IL_0038: Unknown result type (might be due to invalid IL or missing references) //IL_0042: Expected O, but got Unknown Log = ((BaseUnityPlugin)this).Logger; SkaldConfig.Bind(((BaseUnityPlugin)this).Config); if (!SkaldConfig.Enabled.Value) { Log.LogInfo((object)"Skald is disabled in config; not patching."); return; } _harmony = new Harmony("com.dks.skald"); _harmony.PatchAll(Assembly.GetExecutingAssembly()); Log.LogInfo((object)("Skald 0.5.2 (built " + Built() + ") loaded from " + ((BaseUnityPlugin)this).Info.Location + ".")); WarnAboutOtherCopies(); } private void WarnAboutOtherCopies() { try { string fullPath = Path.GetFullPath(((BaseUnityPlugin)this).Info.Location); string[] files = Directory.GetFiles(Paths.PluginPath, "Skald.dll", SearchOption.AllDirectories); foreach (string text in files) { if (!string.Equals(Path.GetFullPath(text), fullPath, StringComparison.OrdinalIgnoreCase)) { Log.LogWarning((object)("ANOTHER COPY OF SKALD is in the plugins folder: " + text + ". Only one runs (this one, " + fullPath + "). Delete the one you don't want, or updates to it will seem to do nothing.")); } } } catch (Exception) { } } private string Built() { try { object[] customAttributes = typeof(SkaldPlugin).Assembly.GetCustomAttributes(typeof(AssemblyMetadataAttribute), inherit: false); for (int i = 0; i < customAttributes.Length; i++) { AssemblyMetadataAttribute assemblyMetadataAttribute = (AssemblyMetadataAttribute)customAttributes[i]; if (assemblyMetadataAttribute.Key == "BuildTime") { return assemblyMetadataAttribute.Value; } } return "unknown"; } catch (Exception) { return "unknown"; } } private void Update() { SkaldRuntime.Tick(Time.deltaTime); } private void OnDestroy() { SkaldRuntime.Teardown(); Harmony harmony = _harmony; if (harmony != null) { harmony.UnpatchSelf(); } } } internal static class SkaldRuntime { private static bool _started; public static bool IsServer { get; private set; } public static bool IsDedicated { get; private set; } public static bool HasLocalPlayer { get; private set; } public static void Tick(float dt) { ZNet instance = ZNet.instance; if ((Object)(object)instance == (Object)null) { if (_started) { Teardown(); } return; } if (!_started) { Startup(instance); } Protocol.Register(IsServer, HasLocalPlayer); if (IsServer) { ServerHub.Tick(dt); Watchdog.Tick(dt); } if (HasLocalPlayer) { ClientReporter.Tick(dt); PanelClient.Tick(dt); } } private static void Startup(ZNet net) { _started = true; IsServer = net.IsServer(); IsDedicated = net.IsDedicated(); HasLocalPlayer = !IsDedicated; if (IsServer) { ServerHub.Start(); Watchdog.Start(); } if (HasLocalPlayer) { ClientReporter.Start(); } SkaldPlugin.Log.LogInfo((object)("Skald active (server=" + IsServer + " dedicated=" + IsDedicated + " localPlayer=" + HasLocalPlayer + ").")); } public static void Teardown() { if (_started) { _started = false; if (IsServer) { ServerHub.Stop(); Watchdog.Stop(); } if (HasLocalPlayer) { ClientReporter.Stop(); PanelClient.Reset(); } Protocol.Reset(); IsServer = false; IsDedicated = false; HasLocalPlayer = false; } } } } namespace Skald.Util { internal sealed class JsonValue { public enum Kind { Null, Bool, Number, String, Array, Object } public Kind Type; public bool Bool; public double Number; public string String; public List<JsonValue> Array; public Dictionary<string, JsonValue> Object; public static JsonValue NewObject() { return new JsonValue { Type = Kind.Object, Object = new Dictionary<string, JsonValue>() }; } public static JsonValue NewArray() { return new JsonValue { Type = Kind.Array, Array = new List<JsonValue>() }; } public static JsonValue Of(string s) { return new JsonValue { Type = Kind.String, String = s }; } public static JsonValue Of(double d) { return new JsonValue { Type = Kind.Number, Number = d }; } public static JsonValue Of(bool b) { return new JsonValue { Type = Kind.Bool, Bool = b }; } public bool Has(string key) { if (Type == Kind.Object) { return Object.ContainsKey(key); } return false; } public JsonValue Get(string key) { if (Type != Kind.Object || !Object.TryGetValue(key, out var value)) { return null; } return value; } public string GetString(string key, string fallback = null) { JsonValue jsonValue = Get(key); if (jsonValue == null || jsonValue.Type != Kind.String) { return fallback; } return jsonValue.String; } public double GetNumber(string key, double fallback = 0.0) { JsonValue jsonValue = Get(key); if (jsonValue == null || jsonValue.Type != Kind.Number) { return fallback; } return jsonValue.Number; } public bool GetBool(string key, bool fallback = false) { JsonValue jsonValue = Get(key); if (jsonValue == null || jsonValue.Type != Kind.Bool) { return fallback; } return jsonValue.Bool; } public void Set(string key, JsonValue value) { Object[key] = value; } public void Add(JsonValue value) { Array.Add(value); } } internal sealed class JsonException : Exception { public JsonException(string message) : base(message) { } } internal static class Json { private static readonly Regex Position = new Regex("at position (\\d+)"); public static JsonValue Parse(string text) { try { int i = 0; JsonValue result = ParseValue(text, ref i); SkipWhitespace(text, ref i); if (i < text.Length) { throw new JsonException("Unexpected text after the end at position " + i + "."); } return result; } catch (JsonException ex) { throw new JsonException(Locate(ex.Message, text)); } } public static string Locate(string message, string text) { Match match = Position.Match(message ?? ""); if (!match.Success || !int.TryParse(match.Groups[1].Value, out var result) || text == null) { return message; } result = Math.Max(0, Math.Min(result, text.Length)); int num = 1; int num2 = 0; for (int i = 0; i < result; i++) { if (text[i] == '\n') { num++; num2 = i + 1; } } int num3 = text.IndexOf('\n', num2); if (num3 < 0) { num3 = text.Length; } string text2 = text.Substring(num2, num3 - num2).Trim(); if (text2.Length > 80) { text2 = text2.Substring(0, 77) + "..."; } string text3 = message.Substring(0, match.Index).TrimEnd() + " on line " + num + ", column " + (result - num2 + 1) + ": " + text2; if (message.StartsWith("Expected ','")) { text3 += ". A comma is probably missing at the end of the line before this one."; } else if (!text3.EndsWith(".")) { text3 += "."; } return text3; } public static string Write(JsonValue value, int indent = 0) { StringBuilder stringBuilder = new StringBuilder(); WriteValue(stringBuilder, value, indent); return stringBuilder.ToString(); } private static JsonValue ParseValue(string s, ref int i) { SkipWhitespace(s, ref i); if (i >= s.Length) { throw new JsonException("Unexpected end of file."); } switch (s[i]) { case '{': return ParseObject(s, ref i); case '[': return ParseArray(s, ref i); case '"': return JsonValue.Of(ParseString(s, ref i)); case 't': Expect(s, ref i, "true"); return JsonValue.Of(b: true); case 'f': Expect(s, ref i, "false"); return JsonValue.Of(b: false); case 'n': Expect(s, ref i, "null"); return new JsonValue(); default: return JsonValue.Of(ParseNumber(s, ref i)); } } private static JsonValue ParseObject(string s, ref int i) { JsonValue jsonValue = JsonValue.NewObject(); i++; SkipWhitespace(s, ref i); if (i < s.Length && s[i] == '}') { i++; return jsonValue; } while (true) { SkipWhitespace(s, ref i); if (i >= s.Length || s[i] != '"') { throw new JsonException("Expected a property name at position " + i + "."); } string text = ParseString(s, ref i); SkipWhitespace(s, ref i); if (i >= s.Length || s[i] != ':') { throw new JsonException("Expected ':' after '" + text + "'."); } i++; jsonValue.Object[text] = ParseValue(s, ref i); SkipWhitespace(s, ref i); if (i >= s.Length) { throw new JsonException("Unexpected end of file inside an object."); } if (s[i] != ',') { break; } i++; } if (s[i] == '}') { i++; return jsonValue; } throw new JsonException("Expected ',' or '}' at position " + i + "."); } private static JsonValue ParseArray(string s, ref int i) { JsonValue jsonValue = JsonValue.NewArray(); i++; SkipWhitespace(s, ref i); if (i < s.Length && s[i] == ']') { i++; return jsonValue; } while (true) { jsonValue.Array.Add(ParseValue(s, ref i)); SkipWhitespace(s, ref i); if (i >= s.Length) { throw new JsonException("Unexpected end of file inside an array."); } if (s[i] != ',') { break; } i++; } if (s[i] == ']') { i++; return jsonValue; } throw new JsonException("Expected ',' or ']' at position " + i + "."); } private static string ParseString(string s, ref int i) { i++; StringBuilder stringBuilder = new StringBuilder(); while (i < s.Length) { char c = s[i++]; switch (c) { case '"': return stringBuilder.ToString(); default: stringBuilder.Append(c); break; case '\\': { if (i >= s.Length) { throw new JsonException("Unterminated escape sequence."); } char c2 = s[i++]; switch (c2) { case '"': stringBuilder.Append('"'); break; case '\\': stringBuilder.Append('\\'); break; case '/': stringBuilder.Append('/'); break; case 'b': stringBuilder.Append('\b'); break; case 'f': stringBuilder.Append('\f'); break; case 'n': stringBuilder.Append('\n'); break; case 'r': stringBuilder.Append('\r'); break; case 't': stringBuilder.Append('\t'); break; case 'u': if (i + 4 > s.Length) { throw new JsonException("Truncated \\u escape."); } stringBuilder.Append((char)int.Parse(s.Substring(i, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture)); i += 4; break; default: throw new JsonException("Unknown escape '\\" + c2 + "'."); } break; } } } throw new JsonException("Unterminated string."); } private static double ParseNumber(string s, ref int i) { int num = i; if (i < s.Length && (s[i] == '-' || s[i] == '+')) { i++; } while (i < s.Length && (char.IsDigit(s[i]) || s[i] == '.' || s[i] == 'e' || s[i] == 'E' || ((s[i] == '-' || s[i] == '+') && (s[i - 1] == 'e' || s[i - 1] == 'E')))) { i++; } string text = s.Substring(num, i - num); if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) { throw new JsonException("'" + text + "' is not a number (position " + num + ")."); } return result; } private static void Expect(string s, ref int i, string word) { if (i + word.Length > s.Length || s.Substring(i, word.Length) != word) { throw new JsonException("Expected '" + word + "' at position " + i + "."); } i += word.Length; } private static void SkipWhitespace(string s, ref int i) { while (i < s.Length) { char c = s[i]; if (c == '/' && i + 1 < s.Length && s[i + 1] == '/') { while (i < s.Length && s[i] != '\n') { i++; } continue; } if (!char.IsWhiteSpace(c)) { break; } i++; } } private static void WriteValue(StringBuilder sb, JsonValue v, int indent) { if (v == null) { sb.Append("null"); return; } switch (v.Type) { case JsonValue.Kind.Null: sb.Append("null"); break; case JsonValue.Kind.Bool: sb.Append(v.Bool ? "true" : "false"); break; case JsonValue.Kind.Number: sb.Append(v.Number.ToString("0.############", CultureInfo.InvariantCulture)); break; case JsonValue.Kind.String: WriteString(sb, v.String); break; case JsonValue.Kind.Array: WriteArray(sb, v, indent); break; case JsonValue.Kind.Object: WriteObject(sb, v, indent); break; } } private static void WriteArray(StringBuilder sb, JsonValue v, int indent) { if (v.Array.Count == 0) { sb.Append("[]"); return; } sb.Append("[\n"); for (int i = 0; i < v.Array.Count; i++) { Indent(sb, indent + 1); WriteValue(sb, v.Array[i], indent + 1); if (i < v.Array.Count - 1) { sb.Append(','); } sb.Append('\n'); } Indent(sb, indent); sb.Append(']'); } private static void WriteObject(StringBuilder sb, JsonValue v, int indent) { if (v.Object.Count == 0) { sb.Append("{}"); return; } sb.Append("{ "); int num = 0; foreach (KeyValuePair<string, JsonValue> item in v.Object) { WriteString(sb, item.Key); sb.Append(": "); WriteValue(sb, item.Value, indent + 1); if (num < v.Object.Count - 1) { sb.Append(", "); } num++; } sb.Append(" }"); } private static void WriteString(StringBuilder sb, string s) { sb.Append('"'); string text = s ?? ""; foreach (char c in text) { switch (c) { case '"': sb.Append("\\\""); continue; case '\\': sb.Append("\\\\"); continue; case '\n': sb.Append("\\n"); continue; case '\r': sb.Append("\\r"); continue; case '\t': sb.Append("\\t"); continue; } if (c < ' ') { StringBuilder stringBuilder = sb.Append("\\u"); int num = c; stringBuilder.Append(num.ToString("x4")); } else { sb.Append(c); } } sb.Append('"'); } private static void Indent(StringBuilder sb, int depth) { sb.Append(' ', depth * 2); } } } namespace Skald.Server { internal enum AchievementScope { Player, World } internal sealed class AchievementDef { public string Id; public string Title; public string Description; public string Type; public string Stat; public double Target; public string Unit = ""; public double Scale = 1.0; public double Hours; public string Biome; public string Key; public string Creature; public double Percent; public bool Hidden; } internal sealed class Achievement { public string Id; public string Title; public string Description; public AchievementScope Scope; public bool Hidden; public Func<PlayerRecord, bool> PlayerTest; public Func<WorldRecord, bool> WorldTest; public Func<PlayerRecord, double> Current; public double Target; public double DisplayScale = 1.0; public string Unit = ""; public int BiomeId; public string BiomeName; } internal static class AchievementCatalog { private static List<Achievement> _all; public static List<Achievement> All { get { if (_all == null) { _all = CatalogFile.Compile(Defaults(), new List<string>()); } return _all; } } public static int PlayerCount { get { int num = 0; foreach (Achievement item in All) { if (item.Scope == AchievementScope.Player) { num++; } } return num; } } public static int WorldCount => All.Count - PlayerCount; public static bool IsOriginal(string id) { if (!id.StartsWith("explore_")) { return !id.StartsWith("hunt_"); } return false; } public static void Use(List<Achievement> achievements) { _all = achievements; } public static List<AchievementDef> Defaults() { return new List<AchievementDef> { Key("boss_eikthyr", "Antler Broken", "Eikthyr has fallen.", "defeated_eikthyr"), Key("boss_elder", "Root and Branch", "The Elder has fallen.", "defeated_gdking"), Key("boss_bonemass", "Rot Cleared", "Bonemass has fallen.", "defeated_bonemass"), Key("boss_moder", "Sky Torn", "Moder has fallen.", "defeated_dragon"), Key("boss_yagluth", "Ash of Kings", "Yagluth has fallen.", "defeated_goblinking"), Key("boss_queen", "Hive Silenced", "The Queen has fallen.", "defeated_queen"), Key("boss_fader", "Cinders", "Fader has fallen.", "defeated_fader"), Stat("kills_100", "Blooded", "100 creatures slain.", "EnemyKills", 100.0, "kills"), Explored("explore_2", "Wayfinder", "Explored 2% of the world.", 2.0), Explored("explore_5", "Cartographer", "Explored 5% of the world.", 5.0), Explored("explore_15", "Edge of the Known", "Explored 15% of the world.", 15.0), Explored("explore_25", "Map Maker", "Explored a quarter of the world.", 25.0), Explored("explore_50", "Half the World", "Explored half of the world.", 50.0), Hidden(Explored("explore_75", "The Long Road", "Explored three quarters of the world.", 75.0)), Hidden(Explored("explore_100", "Nothing Left Unseen", "Explored the whole world.", 99.5)), Kills("hunt_greydwarf", "Dwarf Tosser", "250 greydwarfs of any kind.", "greydwarf", 250.0), Kills("hunt_troll", "Troll Toppler", "25 trolls brought down.", "troll", 25.0), Kills("hunt_draugr", "Back to the Bog", "100 draugr returned to the mud.", "draugr", 100.0), Kills("hunt_wolf", "Pack Breaker", "50 wolves.", "wolf", 50.0), Kills("hunt_deathsquito", "Swatter", "25 deathsquitos. Two grams each.", "deathsquito", 25.0), Kills("hunt_seeker", "Bug Hunt", "100 seekers of any kind.", "seeker", 100.0), Kills("hunt_boar", "Pork Barrel", "100 boars.", "boar", 100.0), Kills("hunt_neck", "Neck and Neck", "50 necks.", "neck", 50.0), Kills("hunt_serpent", "Here Be Serpents", "5 sea serpents.", "serpent", 5.0), Kills("hunt_lox", "Lox and Load", "25 lox.", "lox", 25.0), Kills("hunt_gjall", "Popped", "10 gjall.", "gjall", 10.0), Hidden(Kills("hunt_abomination", "Uprooted", "10 abominations.", "abomination", 10.0)), Stat("kills_1000", "Scourge of the Tenth World", "1000 creatures slain.", "EnemyKills", 1000.0, "kills"), Stat("arrows_1000", "Fletcher", "1000 arrows loosed.", "ArrowsShot", 1000.0, "arrows"), Stat("deaths_10", "Familiar with the Ferryman", "Died 10 times.", "Deaths", 10.0, "deaths"), Stat("deaths_50", "Stubborn", "Died 50 times and kept going.", "Deaths", 50.0, "deaths"), Stat("fall_10", "Gravity Wins", "Fell to your death 10 times.", "DeathByFall", 10.0, "falls"), Stat("drown_5", "Not a Swimmer", "Drowned 5 times.", "DeathByDrowning", 5.0, "drownings"), Stat("trees_500", "Woodsman", "500 trees felled.", "TreeChops", 500.0, "trees"), Stat("trees_5000", "Deforestation Incident", "5000 trees felled.", "TreeChops", 5000.0, "trees"), Stat("mines_1000", "Delver", "1000 nodes mined.", "MineHits", 1000.0, "nodes"), Stat("builds_500", "Housebuilder", "500 pieces placed.", "Builds", 500.0, "pieces"), Stat("builds_5000", "Architect of the Realm", "5000 pieces placed.", "Builds", 5000.0, "pieces"), Stat("crafts_250", "Smith", "250 items crafted or upgraded.", "CraftsOrUpgrades", 250.0, "crafts"), Stat("walk_100k", "Long Road", "100 km travelled on foot.", "DistanceWalk", 100000.0, "km", 1000.0), Stat("sail_50k", "Seafarer", "50 km sailed.", "DistanceSail", 50000.0, "km", 1000.0), Stat("portals_100", "Frequent Flyer", "100 portal trips.", "PortalsUsed", 100.0, "trips"), Stat("tame_10", "Beastfriend", "10 creatures tamed.", "CreatureTamed", 10.0, "tamed"), Stat("food_500", "Well Fed", "500 meals eaten.", "FoodEaten", 500.0, "meals"), Stat("sleep_50", "Well Rested", "Slept through 50 nights.", "Sleep", 50.0, "nights"), Biome("biome_swamp", "Into the Mire", "Set foot in the Swamp.", "Swamp"), Biome("biome_mountain", "Above the Treeline", "Set foot on the Mountain.", "Mountain"), Biome("biome_plains", "Golden Fields", "Set foot in the Plains.", "Plains"), Biome("biome_mistlands", "Into the Mist", "Set foot in the Mistlands.", "Mistlands"), Biome("biome_ashlands", "Where It Burns", "Set foot in the Ashlands.", "AshLands"), Biome("biome_deepnorth", "Far North", "Set foot in the Deep North.", "DeepNorth"), Playtime("time_24h", "A Day in Valheim", "24 hours on this server.", 24.0), Playtime("time_100h", "Resident of the Tenth World", "100 hours on this server.", 100.0), Hidden(Stat("turret_death", "Friendly Fire", "Killed by your own turret.", "DeathByTurret", 1.0, "deaths")), Hidden(Stat("drawbridge_death", "Architecturally Unfortunate", "Crushed by a drawbridge.", "DeathByDrawBridge", 1.0, "deaths")), Hidden(Stat("edge_death", "Off the Map", "Sailed off the edge of the world.", "DeathByEdgeOfWorld", 1.0, "deaths")), Hidden(Stat("tree_death", "Timber", "Killed by a tree you were chopping.", "DeathByTree", 1.0, "deaths")), Hidden(Stat("petting", "Animal Person", "Petted a tamed animal 100 times.", "TamedPetting", 100.0, "pats")) }; } private static AchievementDef Stat(string id, string title, string desc, string stat, double target, string unit, double scale = 1.0) { return new AchievementDef { Id = id, Title = title, Description = desc, Type = "stat", Stat = stat, Target = target, Unit = unit, Scale = scale }; } private static AchievementDef Explored(string id, string title, string desc, double percent) { return new AchievementDef { Id = id, Title = title, Description = desc, Type = "explored", Percent = percent }; } private static AchievementDef Kills(string id, string title, string desc, string creature, double target) { return new AchievementDef { Id = id, Title = title, Description = desc, Type = "kills", Creature = creature, Target = target, Unit = "kills" }; } private static AchievementDef Playtime(string id, string title, string desc, double hours) { return new AchievementDef { Id = id, Title = title, Description = desc, Type = "playtime", Hours = hours }; } private static AchievementDef Biome(string id, string title, string desc, string biome) { return new AchievementDef { Id = id, Title = title, Description = desc, Type = "biome", Biome = biome }; } private static AchievementDef Key(string id, string title, string desc, string key) { return new AchievementDef { Id = id, Title = title, Description = desc, Type = "key", Key = key }; } private static AchievementDef Hidden(AchievementDef def) { def.Hidden = true; return def; } } internal static class AdminCommands { public static List<string> Handle(string arg, long sender, string playerName) { string[] array = (arg ?? "").Trim().Split(new char[1] { ' ' }, 2, StringSplitOptions.RemoveEmptyEntries); string text = ((array.Length != 0) ? array[0].ToLowerInvariant() : "list"); string text2 = ((array.Length > 1) ? array[1].Trim() : ""); switch (text) { case "list": return List(); case "add": return Add(text2); case "delete": case "remove": return Remove(text2); case "on": return Switch(text2, true); case "off": return Switch(text2, false); case "toggle": return Switch(text2, null); case "test": return Test(text2, sender, playerName); case "reload": return Reload(); case "help": return Help(); default: { List<string> list = new List<string>(); list.Add("No such thing as !announce " + text + "."); list.AddRange(Help()); return list; } } } public static List<string> Help() { return new List<string> { "!announce list - what is set up", "!announce add id=lore when=\"every 45m\" to=chat line=\"The gods are watching.\"", "!announce on <id> / off <id> - switch one without deleting it", "!announce test <id> - say it to yourself now", "!announce remove <id>, !announce reload - reread the file", "Triggers: every <time>, at HH:MM, dawn, dusk, newday, join, firstjoin,", " biome:<name> (walks in), firstbiome:<name> (first ever), death, boss, raid, achievement.", "To: chat, banner, discord for everyone. \"private\" or \"private, banner\" for just that player." }; } private static List<string> List() { List<Announcements.Entry> list = Announcements.All(); if (list.Count == 0) { return new List<string> { "No announcements yet. !announce add, or edit announcements.json." }; } List<string> list2 = new List<string> { list.Count + " announcement(s):" }; foreach (Announcements.Entry item in list) { list2.Add((item.Enabled ? "[on] " : "[off] ") + item.Id + " - " + item.When + " -> " + item.To + " (" + item.Lines.Count + " line" + ((item.Lines.Count == 1) ? "" : "s") + ")"); if (item.Lines.Count > 0) { list2.Add(" \"" + Shorten(item.Lines[0], 70) + "\""); } } return list2; } private static List<string> Add(string rest) { if (Unclosed(rest)) { return Fail("the command was cut off before its closing quote. Valheim's chat box only takes 127 characters: use the F10 Admin tab, or shorten it."); } List<KeyValuePair<string, string>> list = Fields(rest); string text = Value(list, "id"); if (string.IsNullOrEmpty(text)) { return Fail("give it an id, e.g. id=lore."); } string text2 = Value(list, "when"); if (string.IsNullOrEmpty(text2)) { return Fail("say when, e.g. when=\"every 45m\"."); } List<string> list2 = new List<string>(); foreach (KeyValuePair<string, string> item in list) { if (item.Key == "line" || item.Key == "lines") { list2.Add(item.Value); } } if (list2.Count == 0) { return Fail("give it something to say, e.g. line=\"...\"."); } string problem; Announcements.Entry entry = Announcements.Build(text, text2, Value(list, "to"), list2, out problem); if (entry == null) { return Fail(problem); } string text3 = Value(list, "requireplayers"); if (!string.IsNullOrEmpty(text3) && int.TryParse(text3, out var result)) { entry.RequirePlayers = result; } string text4 = Value(list, "seconds"); if (!string.IsNullOrEmpty(text4)) { entry.BannerSeconds = Announcements.Seconds(text4, 0.0); } string text5 = Value(list, "delay"); if (!string.IsNullOrEmpty(text5)) { entry.DelaySeconds = Math.Min(1800.0, Math.Max(0.0, Announcements.Seconds(text5, 0.0))); } string text6 = Value(list, "mingap"); if (!string.IsNullOrEmpty(text6)) { entry.MinGapSeconds = Announcements.Seconds(text6, 0.0); } string text7 = Announcements.Add(entry); if (text7 != null) { return Fail(text7); } List<string> list3 = new List<string>(); list3.Add("Added \"" + entry.Id + "\": " + entry.When + " -> " + entry.To + "."); list3.Add("Try it with !announce test " + entry.Id + "."); return list3; } private static List<string> Remove(string id) { id = id.Trim(); if (id.Length == 0) { return Fail("which one? !announce remove <id>."); } string text = Announcements.Remove(id); if (text == null) { return new List<string> { "Removed \"" + id + "\"." }; } return Fail(text); } private static List<string> Switch(string id, bool? on) { id = id.Trim(); if (id.Length == 0) { return Fail("which one? !announce on <id>."); } Announcements.Entry entry = Announcements.Find(id); if (entry == null) { return Fail("there is no announcement called \"" + id + "\"."); } if (on.HasValue && entry.Enabled == on.Value) { List<string> list = new List<string>(); list.Add("\"" + entry.Id + "\" is already " + (on.Value ? "on" : "off") + "."); return list; } bool nowOn; string text = Announcements.Toggle(entry.Id, out nowOn); if (text == null) { List<string> list = new List<string>(); list.Add("\"" + entry.Id + "\" is now " + (nowOn ? "on" : "off") + "."); return list; } return Fail(text); } private static List<string> Test(string id, long sender, string playerName) { id = id.Trim(); if (id.Length == 0) { return Fail("which one? !announce test <id>."); } Occasion occasion = new Occasion { Player = playerName, PeerUid = sender, Biome = "Meadows", Boss = "Eikthyr", Killer = "a troll", Detail = "1" }; if (!Announcements.Test(id, occasion)) { return Fail("there is no announcement called \"" + id + "\", or it has nothing to say."); } return new List<string>(); } private static List<string> Reload() { Announcements.Load(); return new List<string> { "Reread announcements.json: " + Announcements.Count + " loaded." }; } private static List<string> Fail(string why) { return new List<string> { "Not done: " + why }; } public static bool Unclosed(string text) { if (string.IsNullOrEmpty(text)) { return false; } char c = '\0'; for (int i = 0; i < text.Length; i++) { char c2 = text[i]; if (c == '\0') { if ((c2 == '"' || c2 == '\'') && i > 0 && text[i - 1] == '=') { c = c2; } } else if (c2 == '\\' && i + 1 < text.Length && text[i + 1] == c) { i++; } else if (c2 == c) { c = '\0'; } } return c != '\0'; } public static List<KeyValuePair<string, string>> Fields(string text) { List<KeyValuePair<string, string>> list = new List<KeyValuePair<string, string>>(); if (string.IsNullOrEmpty(text)) { return list; } int i = 0; while (i < text.Length) { for (; i < text.Length && char.IsWhiteSpace(text[i]); i++) { } if (i >= text.Length) { break; } int num = text.IndexOf('=', i); if (num < 0) { break; } string text2 = text.Substring(i, num - i).Trim().ToLowerInvariant(); i = num + 1; string value; if (i < text.Length && (text[i] == '"' || text[i] == '\'')) { char c = text[i++]; StringBuilder stringBuilder = new StringBuilder(); while (i < text.Length && text[i] != c) { if (text[i] == '\\' && i + 1 < text.Length && text[i + 1] == c) { i++; } stringBuilder.Append(text[i++]); } i++; value = stringBuilder.ToString(); } else { int j; for (j = i; j < text.Length && !char.IsWhiteSpace(text[j]); j++) { } value = text.Substring(i, j - i); i = j; } if (text2.Length > 0) { list.Add(new KeyValuePair<string, string>(text2, value)); } } return list; } private static string Value(List<KeyValuePair<string, string>> fields, string key) { foreach (KeyValuePair<string, string> field in fields) { if (field.Key == key) { return field.Value; } } return null; } private static string Shorten(string text, int limit) { if (string.IsNullOrEmpty(text) || text.Length <= limit) { return text ?? ""; } return text.Substring(0, limit - 3) + "..."; } public static string Page() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("Server clock: " + Announcements.ClockNow()); stringBuilder.AppendLine("<color=orange>Announcements</color>"); foreach (string item in List()) { stringBuilder.AppendLine(" " + item); } stringBuilder.AppendLine(); stringBuilder.AppendLine("<color=orange>Triggers</color>"); stringBuilder.AppendLine(" " + string.Join(" · ", Announcements.Triggers())); stringBuilder.AppendLine(); stringBuilder.AppendLine("<color=orange>Where they go</color>"); stringBuilder.AppendLine(" " + string.Join(" · ", Announcements.Destinations())); stringBuilder.AppendLine(); stringBuilder.AppendLine("<color=orange>From chat</color>"); foreach (string item2 in Help()) { stringBuilder.AppendLine(" " + item2); } return stringBuilder.ToString(); } public static string Time(double seconds) { if (seconds >= 3600.0) { return (seconds / 3600.0).ToString("0.#", CultureInfo.InvariantCulture) + "h"; } if (seconds >= 60.0) { return (seconds / 60.0).ToString("0.#", CultureInfo.InvariantCulture) + "m"; } return seconds.ToString("0", CultureInfo.InvariantCulture) + "s"; } } internal enum AdminListProblem { Listed, NeedsPrefix, Empty, NearMatch, OldFormat, Missing } internal static class AdminHelp { private static readonly Regex SteamId64 = new Regex("7656119\\d{10}"); private static readonly Regex OldSteamId = new Regex("^\\s*(STEAM_\\d:\\d:\\d+|\\[U:1:\\d+\\])\\s*$", RegexOptions.IgnoreCase); public static string SteamDigits(string id) { if (string.IsNullOrEmpty(id)) { return null; } Match match = SteamId64.Match(id); if (!match.Success) { return null; } return match.Value; } public static AdminListProblem Diagnose(string connectionId, IList<string> entries) { return Diagnose(connectionId, null, entries); } public static AdminListProblem Diagnose(string connectionId, string canonicalId, IList<string> entries) { List<string> list = new List<string>(); if (entries != null) { foreach (string entry in entries) { if (!string.IsNullOrEmpty(entry) && entry.Trim().Length > 0) { list.Add(entry); } } } if (list.Count == 0) { return AdminListProblem.Empty; } if (!string.IsNullOrEmpty(canonicalId)) { foreach (string item in list) { if (item.Trim() == canonicalId) { return AdminListProblem.Listed; } } } string text = SteamDigits(connectionId); if (text != null && !string.IsNullOrEmpty(canonicalId)) { foreach (string item2 in list) { if (item2.Trim() == text) { return AdminListProblem.NeedsPrefix; } } } if (text != null) { foreach (string item3 in list) { if (item3.Contains(text)) { return AdminListProblem.NearMatch; } } } foreach (string item4 in list) { if (OldSteamId.IsMatch(item4)) { return AdminListProblem.OldFormat; } } return AdminListProblem.Missing; } public static List<string> Explain(string connectionId, AdminListProblem problem) { return Explain(connectionId, null, problem); } public static List<string> Explain(string connectionId, string canonicalId, AdminListProblem problem) { string text = SteamDigits(connectionId); string text2 = ((!string.IsNullOrEmpty(canonicalId)) ? canonicalId : (text ?? connectionId)); List<string> list = new List<string> { "The server sees your connection as: " + connectionId }; switch (problem) { case AdminListProblem.Empty: list.Add("The adminlist.txt the server reads has no IDs in it. It uses the one in its save folder, next to worlds_local, not one in the install folder."); break; case AdminListProblem.Listed: list.Add("adminlist.txt already lists exactly that, so this should have worked. Restart the server so the list is read again, and check you are not connecting with a different account."); break; case AdminListProblem.NeedsPrefix: list.Add("Your ID is in adminlist.txt as the bare number, but this version of Valheim wants the prefixed form. Replace that line with exactly: " + text2); break; case AdminListProblem.NearMatch: list.Add("adminlist.txt does list you, but with other characters on the same line (spaces, quotes, a comment). Put " + text2 + " alone on its own line."); break; case AdminListProblem.OldFormat: list.Add("adminlist.txt has an old-style Steam ID (STEAM_0:... or [U:1:...]). Valheim needs the 17-digit one" + ((text != null) ? (": " + text) : "") + "."); break; default: list.Add("Add " + text2 + " on its own line to adminlist.txt in the server's save folder, next to worlds_local."); break; } list.Add("The server picks up changes to adminlist.txt straight away, no restart needed."); return list; } } internal struct Occasion { public string Player; public long PeerUid; public string Biome; public string Boss; public string Killer; public string Detail; public static Occasion For(string player, long peerUid) { return new Occasion { Player = player, PeerUid = peerUid }; } } internal static class Announcements { internal sealed class Entry { public string Id; public bool Enabled = true; public string When = ""; public string To = "chat"; public string Trigger; public double EverySeconds; public List<int> AtTimes = new List<int>(); public string Match; public List<string> Lines = new List<string>(); public bool Public; public bool Banner; public bool Private; public bool Discord; public int RequirePlayers = 1; public double MinGapSeconds; public double DelaySeconds = -1.0; public double BannerSeconds; public double NextDue; public long LastFiredAt = -1L; public double LastFired = double.NegativeInfinity; public readonly Dictionary<long, double> LastFiredFor = new Dictionary<long, double>(); } private sealed class Waiting { public string Id; public Occasion Occasion; public double Due; } private const double FloorSeconds = 30.0; private static readonly List<Entry> Entries = new List<Entry>(); private static readonly List<Waiting> Delayed = new List<Waiting>(); private static double _clock; private static int _lastGameDay = -1; private static bool _wasDay; private static bool _seenDaylight; public static int Count => Entries.Count; public static void Reset() { Entries.Clear(); Delayed.Clear(); _clock = 0.0; _lastGameDay = -1; _seenDaylight = false; } public static void Tick(float dt) { if (Entries.Count == 0) { return; } _clock += dt; if (Delayed.Count > 0) { FireDelayed(); } foreach (Entry entry in Entries) { if (entry.Enabled && !(entry.Trigger != "every") && !(_clock < entry.NextDue)) { entry.NextDue = _clock + entry.EverySeconds; Fire(entry, default(Occasion)); } } DateTime now = DateTime.Now; foreach (Entry entry2 in Entries) { if (!entry2.Enabled || entry2.Trigger != "at") { continue; } int num = now.Hour * 60 + now.Minute; if (entry2.AtTimes.Contains(num)) { long num2 = now.Date.Ticks / 600000000 + num; if (entry2.LastFiredAt != num2) { entry2.LastFiredAt = num2; Fire(entry2, default(Occasion)); } } } GameTime(); } private static void GameTime() { EnvMan instance = EnvMan.instance; if ((Object)(object)instance == (Object)null) { return; } int day; bool flag; try { day = instance.GetDay(); flag = EnvMan.IsDay(); } catch (Exception) { return; } if (_lastGameDay < 0) { _lastGameDay = day; _wasDay = flag; _seenDaylight = true; return; } if (day != _lastGameDay) { _lastGameDay = day; FireAll("newday", new Occasion { Detail = day.ToString(CultureInfo.InvariantCulture) }); } if (_seenDaylight && flag != _wasDay) { FireAll(flag ? "dawn" : "dusk", default(Occasion)); } _wasDay = flag; } public static void FireAll(string trigger, Occasion occasion, string match = null) { if (Entries.Count == 0) { return; } foreach (Entry entry in Entries) { if (entry.Enabled && !(entry.Trigger != trigger) && (entry.Match == null || string.Equals(entry.Match, match, StringComparison.OrdinalIgnoreCase))) { double num = DelayFor(entry); if (num <= 0.0) { Fire(entry, occasion); continue; } Delayed.Add(new Waiting { Id = entry.Id, Occasion = occasion, Due = _clock + num }); SkaldPlugin.Log.LogInfo((object)("Announcement \"" + entry.Id + "\" (" + entry.When + ") waits " + AdminCommands.Time(num) + (string.IsNullOrEmpty(occasion.Player) ? "" : (" for " + occasion.Player)) + ".")); } } } public static double DelayFor(Entry entry) { if (entry.DelaySeconds >= 0.0) { return entry.DelaySeconds; } string trigger = entry.Trigger; if (!(trigger == "join")) { if (trigger == "firstjoin") { return SkaldConfig.FirstJoinDelaySeconds.Value; } return 0.0; } return SkaldConfig.JoinDelaySeconds.Value; } private static void FireDelayed() { for (int i = 0; i < Delayed.Count; i++) { Waiting waiting = Delayed[i]; if (_clock < waiting.Due) { continue; } Delayed.RemoveAt(i--); Entry entry = Find(waiting.Id); if (entry != null && entry.Enabled) { if (!StillHere(waiting.Occasion.PeerUid)) { Held(entry, waiting.Occasion, "they left before it was due"); } else { Fire(entry, waiting.Occasion); } } } } private static bool StillHere(long peerUid) { if (peerUid == 0L) { return true; } try { ZNet instance = ZNet.instance; if ((Object)(object)instance == (Object)null) { return false; } return instance.GetPeer(peerUid) != null || SkaldRuntime.HasLocalPlayer; } catch (Exception) { return false; } } private static void Fire(Entry entry, Occasion occasion) { if (entry.Lines.Count == 0) { return; } int num = Online(); if (num < entry.RequirePlayers) { Held(entry, occasion, "needs " + entry.RequirePlayers + " online, " + num + " are"); return; } if (entry.Private && occasion.PeerUid == 0L) { Held(entry, occasion, "it is private, and a " + entry.When + " moment has no player to send it to"); return; } double num2 = Math.Max(entry.MinGapSeconds, 30.0); if (occasion.PeerUid != 0L) { if (entry.LastFiredFor.TryGetValue(occasion.PeerUid, out var value) && _clock - value < num2) { Held(entry, occasion, "it fired for them " + (int)(_clock - value) + "s ago"); return; } entry.LastFiredFor[occasion.PeerUid] = _clock; } else { if (_clock - entry.LastFired < num2) { Held(entry, occasion, "it fired " + (int)(_clock - entry.LastFired) + "s ago"); return; } entry.LastFired = _clock; } string text = Fill(Phrases.Pick("ann:" + entry.Id, entry.Lines), occasion); if (text.Length == 0) { return; } bool? inChat = ((entry.Banner && entry.Public) ? new bool?(true) : ((bool?)null)); if (entry.Private) { if (entry.Banner) { Announcer.PrivateBanner(occasion.PeerUid, text, entry.BannerSeconds, inChat); } else if (entry.Public || !entry.Discord) { Announcer.Private(occasion.PeerUid, text); } } else if (entry.Banner) { Announcer.Banner(text, null, entry.BannerSeconds, inChat); } else if (entry.Public) { Announcer.Public(text); } if (entry.Discord) { DiscordNotifier.Alert(AlertLevel.Info, "Skald", text); } SkaldPlugin.Log.LogInfo((object)("Announcement \"" + entry.Id + "\" (" + entry.When + ") said " + (string.IsNullOrEmpty(occasion.Player) ? "" : ("for " + occasion.Player + " ")) + "-> " + entry.To + ".")); } private static void Held(Entry entry, Occasion occasion, string why) { SkaldPlugin.Log.LogInfo((object)("Announcement \"" + entry.Id + "\" (" + entry.When + ") held back" + (string.IsNullOrEmpty(occasion.Player) ? "" : (" for " + occasion.Player)) + ": " + why + ".")); } public static bool Listening(string trigger) { foreach (Entry entry in Entries) { if (entry.Enabled && entry.Trigger == trigger) { return true; } } return false; } private static int Online() { try { return ((Object)(object)ZNet.instance != (Object)null) ? ZNet.instance.GetPeerConnections() : 0; } catch (Exception) { return 0; } } private static string Today() { try { EnvMan instance = EnvMan.instance; return ((Object)(object)instance != (Object)null) ? instance.GetDay().ToString(CultureInfo.InvariantCulture) : ""; } catch (Exception) { return ""; } } public static string Fill(string line, Occasion occasion) { if (string.IsNullOrEmpty(line)) { return ""; } return line.Replace("{player}", string.IsNullOrEmpty(occasion.Player) ? "Someone" : occasion.Player).Replace("{biome}", occasion.Biome ?? "").Replace("{boss}", occasion.Boss ?? "") .Replace("{killer}", string.IsNullOrEmpty(occasion.Killer) ? "something unseen" : occasion.Killer) .Replace("{detail}", occasion.Detail ?? "") .Replace("{day}", Today()) .Replace("{online}", Online().ToString(CultureInfo.InvariantCulture)); } public static List<Entry> All() { return new List<Entry>(Entries); } public static Entry Find(string id) { foreach (Entry entry in Entries) { if (string.Equals(entry.Id, id, StringComparison.OrdinalIgnoreCase)) { return entry; } } return null; } public static string Add(Entry entry) { if (entry == null) { return "nothing to add."; } if (string.IsNullOrEmpty(entry.Id)) { return "it needs an id."; } if (Find(entry.Id) != null) { return "there is already one called \"" + entry.Id + "\"."; } if (entry.Lines.Count == 0) { return "it needs at least one line to say."; } Entries.Add(entry); return Save(); } public static string Remove(string id) { Entry entry = Find(id); if (entry == null) { return "there is no announcement called \"" + id + "\"."; } Entries.Remove(entry); return Save(); } public static string Toggle(string id, out bool nowOn) { nowOn = false; Entry entry = Find(id); if (entry == null) { return "there is no announcement called \"" + id + "\"."; } entry.Enabled = !entry.Enabled; nowOn = entry.Enabled; return Save(); } public static bool Test(string id, Occasion occasion) { Entry entry = Find(id); if (entry == null || entry.Lines.Count == 0) { return false; } string text = Fill(Phrases.Pick("ann:" + entry.Id, entry.Lines), occasion); if (text.Length == 0) { return false; } if (entry.Banner) { Announcer.PrivateBanner(occasion.PeerUid, text, entry.BannerSeconds, entry.Public ? new bool?(true) : ((bool?)null)); } else { Announcer.Private(occasion.PeerUid, text); } return true; } public static string Save() { try { string text = PathFor(); Directory.CreateDirectory(Path.GetDirectoryName(text)); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append(Header()); JsonValue jsonValue = JsonValue.NewObject(); JsonValue jsonValue2 = JsonValue.NewArray(); foreach (Entry entry in Entries) { jsonValue2.Add(Write(entry)); } jsonValue.Set("announcements", jsonValue2); stringBuilder.Append(Json.Write(jsonValue)); string text2 = text + ".tmp"; File.WriteAllText(text2, stringBuilder.ToString()); if (File.Exists(text)) { File.Replace(text2, text, text + ".bak"); } else { File.Move(text2, text); } return null; } catch (Exception ex) { SkaldPlugin.Log.LogError((object)("Could not write announcements.json: " + ex.Message)); return "the file could not be written (" + ex.Message + ")."; } } private static JsonValue Write(Entry entry) { JsonValue jsonValue = JsonValue.NewObject(); jsonValue.Set("id", JsonValue.Of(entry.Id)); jsonValue.Set("enabled", JsonValue.Of(entry.Enabled)); jsonValue.Set("when", JsonValue.Of(entry.When)); jsonValue.Set("to", JsonValue.Of(entry.To)); JsonValue jsonValue2 = JsonValue.NewArray(); foreach (string line in entry.Lines) { jsonValue2.Add(JsonValue.Of(line)); } jsonValue.Set("lines", jsonValue2); if (entry.RequirePlayers != 1) { jsonValue.Set("requirePlayers", JsonValue.Of(entry.RequirePlayers)); } if (entry.MinGapSeconds > 0.0) { jsonValue.Set("minGap", JsonValue.Of(entry.MinGapSeconds + "s")); } if (entry.BannerSeconds > 0.0) { jsonValue.Set("seconds", JsonValue.Of(entry.BannerSeconds)); } if (entry.DelaySeconds >= 0.0) { jsonValue.Set("delay", JsonValue.Of(AdminCommands.Time(entry.DelaySeconds))); } return jsonValue; } public static Entry Build(string id, string when, string to, List<string> lines, out string problem) { problem = null; List<string> list = new List<string>(); Entry entry = new Entry { Id = id, When = (when ?? ""), To = (string.IsNullOrEmpty(to) ? "chat" : to) }; if (lines != null) { entry.Lines.AddRange(lines); } if (!ParseWhen(entry.When, entry, "\"" + id + "\"", list) || !ParseTo(entry.To, entry, "\"" + id + "\"", list) || !PrivateFits(entry, "\"" + id + "\"", list)) { problem = ((list.Count > 0) ? list[0] : "it could not be understood."); return null; } if (entry.Private) { entry.RequirePlayers = 0; } return entry; } public static string[] Triggers() { string[] array = new string[9] { "Meadows", "BlackForest", "Swamp", "Mountain", "Plains", "Mistlands", "AshLands", "DeepNorth", "Ocean" }; List<string> list = new List<string> { "every 30m", "every 1h", "at 03:00", "dawn", "dusk", "newday", "join", "firstjoin" }; string[] array2 = array; foreach (string text in array2) { list.Add("biome:" + text); } array2 = array; foreach (string text2 in array2) { list.Add("firstbiome:" + text2); } list.AddRange(new string[4] { "death", "boss", "raid", "achievement" }); return list.ToArray(); } public static string[] Destinations() { return new string[8] { "chat", "banner", "chat, banner", "private", "private, banner", "private, chat, banner", "discord", "chat, discord" }; } public static string PathFor() { return Path.Combine(Path.Combine(Paths.ConfigPath, "Skald"), "announcements.json"); } public static void Load() { Reset(); string text = PathFor(); List<string> list = new List<string>(); try { if (!File.Exists(text)) { Directory.CreateDirectory(Path.GetDirectoryName(text)); File.WriteAllText(text, DefaultText()); SkaldPlugin.Log.LogInfo((object)("Wrote an example " + text + ". Only the welcome for first-time players is on; the rest wait for \"enabled\": true.")); } List<Entry> list2 = Parse(File.ReadAllText(text), list); foreach (string item in list) { SkaldPlugin.Log.LogError((object)("announcements.json: " + item)); } Entries.AddRange(list2); SkaldPlugin.Log.LogInfo((object)("Loaded " + list2.Count + " announcement(s), " + list2.FindAll((Entry e) => e.Enabled).Count + " switched on, from " + text + ((list.Count > 0) ? (" (" + list.Count + " skipped, see above)") : "") + ".")); } catch (Exception ex) { SkaldPlugin.Log.LogError((object)("Could not read " + text + " (" + ex.Message + "); no custom announcements this session. The file has not been changed.")); } } public static List<Entry> Parse(string text, List<string> errors) { List<Entry> list = new List<Entry>(); JsonValue jsonValue = Json.Parse(text); JsonValue jsonValue2; if (jsonValue.Type == JsonValue.Kind.Array) { jsonValue2 = jsonValue; } else { if (jsonValue.Type != JsonValue.Kind.Object || !jsonValue.Has("announcements")) { throw new JsonException("Expected an object with an \"announcements\" list."); } jsonValue2 = jsonValue.Get("announcements"); } if (jsonValue2.Type != JsonValue.Kind.Array) { throw new JsonException("\"announcements\" must be a list."); } for (int i = 0; i < jsonValue2.Array.Count; i++) { JsonValue jsonValue3 = jsonValue2.Array[i]; string text2 = "entry " + (i + 1); if (jsonValue3.Type != JsonValue.Kind.Object) { errors.Add(text2 + " is not an object."); continue; } string text3 = jsonValue3.GetString("id", text2.Replace(" ", "")); text2 = "\"" + text3 + "\""; Entry entry = new Entry { Id = text3, Enabled = jsonValue3.GetBool("enabled", fallback: true), When = jsonValue3.GetString("when", ""), To = jsonValue3.GetString("to", "chat") }; if (!ParseWhen(entry.When, entry, text2, errors) || !ParseTo(entry.To, entry, text2, errors) || !PrivateFits(entry, text2, errors)) { continue; } if (jsonValue3.Has("lines")) { JsonValue jsonValue4 = jsonValue3.Get("lines"); if (jsonValue4.Type == JsonValue.Kind.Array) { foreach (JsonValue item in jsonValue4.Array) { if (item.Type == JsonValue.Kind.String && item.String.Trim().Length > 0) { entry.Lines.Add(item.String); } else { errors.Add(text2 + " has a line that is not text."); } } } else { errors.Add(text2 + ": \"lines\" should be a list in [ ]."); } } if (entry.Lines.Count == 0) { errors.Add(text2 + " has no lines to say; skipped."); continue; } if (jsonValue3.Has("requirePlayers")) { entry.RequirePlayers = (int)jsonValue3.GetNumber("requirePlayers", 1.0); } if (jsonValue3.Has("minGap")) { entry.MinGapSeconds = Seconds(jsonValue3.GetString("minGap", ""), 0.0); } if (jsonValue3.Has("delay")) { JsonValue jsonValue5 = jsonValue3.Get("delay"); entry.DelaySeconds = ((jsonValue5.Type == JsonValue.Kind.Number) ? jsonValue5.Number : Seconds(jsonValue5.String, -1.0)); if (entry.DelaySeconds > 1800.0) { entry.DelaySeconds = 1800.0; } } if (jsonValue3.Has("seconds")) { JsonValue jsonValue6 = jsonValue3.Get("seconds"); entry.BannerSeconds = ((jsonValue6.Type == JsonValue.Kind.Number) ? jsonValue6.Number : Seconds(jsonValue6.String, 0.0)); } if (entry.Private && !jsonValue3.Has("requirePlayers")) { entry.RequirePlayers = 0; } list.Add(entry); } return list; } private static bool ParseWhen(string when, Entry entry, string where, List<string> errors) { when = (when ?? "").Trim(); if (when.Length == 0) { errors.Add(where + " has no \"when\"; skipped."); return false; } string text = when.ToLowerInvariant(); if (text.StartsWith("every")) { double num = Seconds(text.Substring(5), 0.0); if (num <= 0.0) { errors.Add(where + ": \"" + when + "\" needs a time, e.g. \"every 45m\"."); return false; } entry.Trigger = "every"; entry.EverySeconds = Math.Max(num, 30.0); entry.NextDue = entry.EverySeconds; return true; } if (text.StartsWith("at ")) { List<int> list = new List<int>(); string[] array = text.Substring(3).Split(',', ' ', ';'); foreach (string text2 in array) { if (text2.Trim().Length != 0) { int num2 = ClockTime(text2.Trim()); if (num2 < 0) { errors.Add(where + ": \"" + text2.Trim() + "\" isn't a 24-hour time. Write it like 03:00, and separate several with commas: \"at 02:50, 08:50\"."); return false; } if (!list.Contains(num2)) { list.Add(num2); } } } if (list.Count == 0) { errors.Add(where + ": \"" + when + "\" needs a 24-hour time, e.g. \"at 03:00\"."); return false; } list.Sort(); entry.Trigger = "at"; entry.AtTimes = list; return true; } int num3 = text.IndexOf(':'); string text3 = ((num3 > 0) ? text.Substring(0, num3) : text); string text4 = ((num3 > 0) ? when.Substring(num3 + 1).Trim() : null); switch (text3) { case "join": case "raid": case "boss": case "dawn": case "dusk": case "biome": case "death": case "newday": case "firstjoin": case "achievement": case "firstbiome": entry.Trigger = text3; entry.Match = (string.IsNullOrEmpty(text4) ? null : text4); return true; default: errors.Add(where + ": don't know when \"" + when + "\" is. Use every/at/dawn/dusk/newday/join/firstjoin/biome:Swamp/firstbiome:Swamp/death/boss/raid/achievement."); return false; } } private static bool ParseTo(string to, Entry entry, string where, List<string> errors) { string[] array = (to ?? "").Split(',', '+', ' '); foreach (string text in array) { string text2 = text.Trim().ToLowerInvariant(); if (text2 == null || text2.Length != 0) { switch (text2) { case "chat": entry.Public = true; break; case "banner": entry.Banner = true; break; case "private": entry.Private = true; break; case "discord": entry.Discord = true; break; default: errors.Add(where + ": don't know where \"" + text.Trim() + "\" is. Use chat, banner, private or discord."); return false; } } } if (!entry.Public && !entry.Banner && !entry.Private && !entry.Discord) { entry.Public = true; } return true; } public static int ClockTime(string text) { string[] array = (text ?? "").Trim().Split(':'); if (array.Length != 2 || array[1].Length != 2 || !int.TryParse(array[0], out var result) || !int.TryParse(array[1], out var result2) || result < 0 || result > 23 || result2 < 0 || result2 > 59) { return -1; } return result * 60 + result2; } public static string ClockNow() { DateTime now = DateTime.Now; TimeSpan utcOffset = TimeZoneInfo.Local.GetUtcOffset(now); return now.ToString("HH:mm", CultureInfo.InvariantCulture) + " (UTC" + ((utcOffset < TimeSpan.Zero) ? "-" : "+") + utcOffset.ToString("hh\\:mm", CultureInfo.InvariantCulture) + ")"; } public static bool HasPlayer(string trigger) { switch (trigger) { case "join": case "firstjoin": case "biome": case "firstbiome": case "death": case "achievement": return true; default: return false; } } private static bool PrivateFits(Entry entry, string where, List<string> errors) { if (!entry.Private) { return true; } if (entry.Discord) { errors.Add(where + ": \"private\" can't go to Discord, which only posts to a channel for everyone. Use private on its own, or discord without it."); return false; } if (!HasPlayer(entry.Trigger)) { errors.Add(where + ": \"private\" needs a player it happened to, and \"" + entry.When + "\" has none. Private works with join, firstjoin, biome, firstbiome, death and achievement."); return false; } return true; } public static double Seconds(string text, double fallback) { text = (text ?? "").Trim().ToLowerInvariant(); if (text.Length == 0) { return fallback; } double num = 1.0; if (text.EndsWith("s")) { text = text.Substring(0, text.Length - 1); } else if (text.EndsWith("m")) { num = 60.0; text = text.Substring(0, text.Length - 1); } else if (text.EndsWith("h")) { num = 3600.0; text = text.Substring(0, text.Length - 1); } if (!double.TryParse(text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) { return fallback; } return result * num; } public static string Header() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("// Your own messages, on a timer or when something happens."); stringBuilder.AppendLine("// Full list of triggers, bosses, biomes and placeholders: GUIDE.txt, beside"); stringBuilder.AppendLine("// this file. Admins can also manage these in game: F10, then the Admin tab."); stringBuilder.AppendLine("// Every example below is switched off except \"welcome\"; set \"enabled\": true"); stringBuilder.AppendLine("// to use one, or false to silence the welcome."); stringBuilder.AppendLine("//"); stringBuilder.AppendLine("// \"when\":"); stringBuilder.AppendLine("// \"every 45m\" on a timer while the server runs (30s minimum)"); stringBuilder.AppendLine("// \"at 03:00\" once a day, server clock (several: \"at 02:50, 14:50\")"); stringBuilder.AppendLine("// \"dawn\" / \"dusk\" in-game sunrise and sunset"); stringBuilder.AppendLine("// \"newday\" when the in-game day rolls over"); stringBuilder.AppendLine("// \"join\" a player connects"); stringBuilder.AppendLine("// \"firstjoin\" a player's first time ever on this server"); stringBuilder.AppendLine("// \"biome:Swamp\" a player walks into a biome (not on spawning; once per"); stringBuilder.AppendLine("// 5 minutes each, so walking a border doesn't spam)"); stringBuilder.AppendLine("// \"firstbiome:Swamp\" a player's first time EVER in a biome"); stringBuilder.AppendLine("// (Meadows, BlackForest, Swamp, Mountain, Plains,"); stringBuilder.AppendLine("// Mistlands, AshLands, DeepNorth, Ocean)"); stringBuilder.AppendLine("// \"death\" any player dies"); stringBuilder.AppendLine("// \"boss\" a boss falls"); stringBuilder.AppendLine("// \"raid\" a raid begins"); stringBuilder.AppendLine("// \"achievement\" someone earns one"); stringBuilder.AppendLine("//"); stringBuilder.AppendLine("// \"to\": chat, banner (big text on screen), discord: for everyone."); stringBuilder.AppendLine("// Add private to make it only for the player it happened to:"); stringBuilder.AppendLine("// \"private\" is a line in their chat, \"private, banner\" their banner."); stringBuilder.AppendLine("// Combine the rest with commas: \"chat, discord\"."); stringBuilder.AppendLine("//"); stringBuilder.AppendLine("// \"lines\": one is picked each time, dealt so all are used before repeats."); stringBuilder.AppendLine("// \"requirePlayers\": how many must be online (0 = say it to an empty server)."); stringBuilder.AppendLine("// \"minGap\": least time between repeats, e.g. \"10m\"."); stringBuilder.AppendLine("// \"delay\": wait this long before saying it, e.g. \"2m\" (\"0s\" for at once). join and"); stringBuilder.AppendLine("// firstjoin wait by default; see [Announcements] in Skald's .cfg."); stringBuilder.AppendLine("// \"seconds\": how long a banner stays up (2 to 30); otherwise [Banners] Seconds."); stringBuilder.AppendLine("//"); stringBuilder.AppendLine("// Placeholders: {player} {biome} {boss} {killer} {day} {online}"); return stringBuilder.ToString(); } public static string DefaultText() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append(Header()); JsonValue jsonValue = JsonValue.NewObject(); JsonValue jsonValue2 = JsonValue.NewArray(); jsonValue2.Add(Example("lore", "every 45m", "chat", false, "The gods are watching. They are also bored, which is worse.", "Odin gave an eye for wisdom. You will give more, for less.", "Every raven that follows you is taking notes.", "The dead of Valheim do not rest. They wait, and they count.", "Somewhere a Valkyrie is deciding whether tonight was brave or merely loud.", "The Allfather hanged nine nights on the world tree for the runes. You complained about the walk.", "Thor killed giants. You have a hammer and a pile of wood. Begin.", "The old stories say the brave feast in Valhalla. The rest are chewed on in the dark.", "Fenrir is bound by a ribbon. Something here is bound by less.", "Loki's children are loose in the world. You have met two of them already.", "The Norns wove your thread long ago. They did not say it was long.", "The sea remembers every ship it has taken. It has room for more.")); jsonValue2.Add(Example("reach-meadows", "firstbiome:Meadows", "private", false, "Soft grass, soft light, soft creatures. Nothing here is trying very hard to kill you. Yet.")); jsonValue2.Add(Example("reach-blackforest", "firstbiome:BlackForest", "private", false, "The trees close in and the light goes green. Something small is already following you.", "Under these pines the ground is old, and so is what sleeps in it.")); jsonValue2.Add(Example("reach-swamp", "firstbiome:Swamp", "private", false, "The water here has a taste. Old iron, and older mistakes.", "Something in the mist has been waiting a long time for company.")); jsonValue2.Add(Example("reach-mountain", "firstbiome:Mountain", "private", false, "The cold up here killed men long before anything with teeth did.", "Above the snow line the wind sounds almost like speech. Do not answer it.")); jsonValue2.Add(Example("reach-plains", "firstbiome:Plains", "private", false, "Golden grass, open sky, and two grams of pure malice for every step you take.", "The stone towers out here were built by something that did not want neighbours.")); jsonValue2.Add(Example("reach-mistlands", "firstbiome:Mistlands", "private", false, "The mist takes your eyes first. Everything after that is a negotiation.", "Whatever built these spires stopped building a long time ago, and did not leave.")); jsonValue2.Add(Example("reach-ashlands", "firstbiome:AshLands", "private", false, "The ground here is still burning. It has been burning for longer than your people have had names.")); jsonValue2.Add(Example("reach-deepnorth", "firstbiome:DeepNorth", "private", false, "This far north the world thins out. Even the dark feels older.")); jsonValue2.Add(Example("reach-ocean", "firstbiome:Ocean", "private", false, "No land in sight. The sea remembers every ship it has taken, and is patient.")); jsonValue2.Add(Example("first-night", "dusk", "banner", false, "Night falls. Something is already awake.", "The light goes. Keep the fire and keep your back to it.")); jsonValue2.Add(Example("new-day", "newday", "banner", false, "Day {day}. Still here.", "Day {day} begins, and the world has not finished with you.")); jsonValue2.Add(Example("welcome", "firstjoin", "private", true, "Welcome, {player}. The skald will remember what you do here. Press F10 to see what it has written.", "A new name in the sagas: {player}. Try to make it a long entry.")); jsonValue2.Add(Example("restart-warning", "at 02:50", "chat, banner", false, "The realm sleeps at 3am. Find a bed and log off soon.")); jsonValue.Set("announcements", jsonValue2); stringBuilder.Append(Json.Write(jsonValue)); return stringBuilder.ToString(); } private static JsonValue Example(string id, string when, string to, bool enabled, params string[] lines) { JsonValue jsonValue = JsonValue.NewObject(); jsonValue.Set("id", JsonValue.Of(id)); jsonValue.Set("enabled", JsonValue.Of(enabled)); jsonValue.Set("when", JsonValue.Of(when)); jsonValue.Set("to", JsonValue.Of(to)); JsonValue jsonValue2 = JsonValue.NewArray(); foreach (string s in lines) { jsonValue2.Add(JsonValue.Of(s)); } jsonValue.Set("lines", jsonValue2); return jsonValue; } } internal static class Announcer { private static readonly HashSet<long> ModdedPeers = new HashSet<long>(); public static void MarkModded(long peerUid) { ModdedPeers.Add(peerUid); } public static void Retain(HashSet<long> connected) { ModdedPeers.RemoveWhere((long uid) => !connected.Contains(uid)); } public static void Reset() { ModdedPeers.Clear(); } public static void Public(string text) { SkaldPlugin.Log.LogInfo((object)("Announce: " + text)); if (SkaldConfig.AnnounceInChat.Value) { Broadcast(ShowStyle.Chat, text, null); } } public static void Banner(string banner, string detail, double seconds = 0.0, bool? inChat = null) { SkaldPlugin.Log.LogInfo((object)("Banner: " + banner + " | " + detail)); if (SkaldConfig.AnnounceInChat.Value) { Broadcast(ShowStyle.Banner, banner, detail, seconds, inChat); } } public static void PrivateBanner(long peerUid, string text, double seconds = 0.0, bool? inChat = null) { ZNet instance = ZNet.instance; if ((Object)(object)instance == (Object)null) { return; } if (instance.GetPeer(peerUid) == null) { if (SkaldRuntime.HasLocalPlayer) { ClientDisplay.Show(ShowStyle.Banner, Label(), text, null, BannerSeconds(seconds), BannersInChat(inChat)); } } else { Send(peerUid, ShowStyle.Banner, text, null, seconds, inChat); } } public static void Private(long peerUid, string text) { ZNet instance = ZNet.instance; if ((Object)(object)instance == (Object)null) { return; } if (instance.GetPeer(peerUid) == null) { if (SkaldRuntime.HasLocalPlayer) { ClientDisplay.Show(ShowStyle.Private, Label(), text, null); } } else { Send(peerUid, ShowStyle.Private, text, null); } } public static void PlayerUnlock(string playerName, Achievement achievement, bool firstToReach) { string text = (string.IsNullOrEmpty(playerName) ? "Someone" : playerName); string detail = (firstToReach ? (text + " is the first on the server to set foot in the " + achievement.BiomeName + ".") : achievement.Description); Banner(text + " earned \"" + achievement.Title + "\"", detail); } public static void WorldUnlock(Achievement achievement) { Banner(achievement.Title, achievement.Description); } private static void Broadcast(ShowStyle style, string primary, string secondary, double seconds = 0.0, bool? inChat = null) { foreach (long moddedPeer in ModdedPeers) { Send(moddedPeer, style, primary, secondary, seconds, inChat); } if (SkaldRuntime.HasLocalPlayer) { ClientDisplay.Show(style, Label(), primary, secondary, BannerSeconds(seconds), BannersInChat(inChat)); } } private static void Send(long peerUid, ShowStyle style, string primary, string secondary, double seconds = 0.0, bool? inChat = null) { //IL_000a: Unknown result type (might be due to invalid IL or missing references) //IL_0010: Expected O, but got Unknown ZRoutedRpc instance = ZRoutedRpc.instance; if (instance != null) { ZPackage val = new ZPackage(); val.Write(3); val.Write((int)style); val.Write(Label()); val.Write(primary ?? ""); val.Write(secondary ?? ""); val.Write(BannerSeconds(seconds)); val.Write(BannersInChat(inChat)); instance.InvokeRoutedRPC(peerUid, "Skald_Show", new object[1] { val }); } } private static float BannerSeconds(double own = 0.0) { if (!(own > 0.0)) { return SkaldConfig.BannerSeconds.Value; } return Math.Min(30f, Math.Max(2f, (float)own)); } private static bool BannersInChat(bool? asked = null) { return asked ?? SkaldConfig.BannersInChat.Value; } private static string Label() { return SafeText.Clean(SkaldConfig.AnnouncerName.Value, 24); } } internal static class SafeText { public static string Clean(string value, int maxLength) { if (string.IsNullOrEmpty(value)) { return ""; } StringBuilder stringBuilder = new StringBuilder(value.Length); foreach (char c in value) { if (!char.IsControl(c) && c != '<' && c != '>') { stringBuilder.Append(c); if (stringBuilder.Length >= maxLength) { break; } } } return stringBuilder.ToString().Trim(); } } internal static class Boards { private const double MinGapSeconds = 15.0; private static double _clock; private static bool _deathsDirty; private static bool _worldDirty; private static double _deathsSent; private static double _worldSent; private static double _leaderboardSent; public static void Reset() { _clock = 0.0; _deathsDirty = false; _worldDirty = false; _deathsSent = double.NegativeInfinity; _worldSent = double.NegativeInfinity; _leaderboardSent = double.NegativeInfinity; } public static void DeathsChanged() { _deathsDirty = true; } public static void WorldChanged() { _worldDirty = true; } public static void Tick(float dt) { if (!SkaldStore.Loaded) { return; } _clock += dt; if (_deathsDirty && SkaldConfig.DeathLog.Value && Ready(_deathsSent)) { _deathsDirty = false; _deathsSent = _clock; Send("deaths", SkaldConfig.DeathLogWebhookUrl.Value, DeathLog()); } if (_worldDirty && SkaldConfig.WorldReport.Value && Ready(_worldSent)) { _worldDirty = false; _worldSent = _clock; Send("world", SkaldConfig.WorldReportWebhookUrl.Value, WorldReport()); } if (SkaldConfig.Leaderboard.Value && WorldCensus.AnyoneOnline()) { double num = Math.Max(60, SkaldConfig.LeaderboardIntervalMinutes.Value * 60); if (_clock - _leaderboardSent >= num) { _leaderboardSent = _clock; Send("leaderboard", SkaldConfig.LeaderboardWebhookUrl.Value, Leaderboard()); } } } private static bool Ready(double last) { return _clock - last >= 15.0; } private static void Send(string name, string ownUrl, string json) { string text = (string.IsNullOrEmpty(ownUrl) ? SkaldConfig.DiscordWebhookUrl.Value : ownUrl); if (!string.IsNullOrEmpty(text) && text.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && json != null) { Pinned.Update(name, text, json); } } public static string DeathLog() { int num = Math.Max(3, Math.Min(25, SkaldConfig.DeathLogSize.Value)); List<DeathEvent> deaths = SkaldStore.World.Deaths; StringBuilder stringBuilder = new StringBuilder(); int num2 = 0; int num3 = deaths.Count - 1; while (num3 >= 0 && num2 < num) { DeathEvent deathEvent = deaths[num3]; PlayerRecord value2; string value = ((SkaldStore.Players.TryGetValue(deathEvent.PlayerId, out value2) && !string.IsNullOrEmpty(value2.Name)) ? value2.Name : "Someone"); string value3 = (string.IsNullOrEmpty(deathEvent.KillerName) ? DeathCauses.Name(deathEvent.HitType) : deathEvent.KillerName); stringBuilder.Append("**").Append(value).Append("** - ") .Append(value3) .Append(" <t:") .Append(deathEvent.Time) .Append(":R>") .AppendLine(); num3--; num2++; } if (num2 == 0) { stringBuilder.AppendLine("Nobody has died yet. Give it time."); } JsonValue jsonValue = Embed("Deaths", stringBuilder.ToString(), 13715803); jsonValue.Set("footer", Footer(SkaldStore.World.Deaths.Count + " recorded in total")); return Wrap(jsonValue); } public static string WorldReport() { CensusSnapshot latest = WorldCensus.Latest; if (latest == null) { return null; } JsonValue jsonValue = Embed("The world", "Counted <t:" + latest.Time + ":R>. " + WorldCensus.N(latest.Total) + " objects in all.", 5930394); JsonValue jsonValue2 = JsonValue.NewArray(); StringBuilder stringBuilder = new StringBuilder(); foreach (KeyValuePair<string, int> item in latest.Categories.OrderByDescending((KeyValuePair<string, int> k) => k.Value).Take(8)) { stringBuilder.Append(item.Key).Append(": ").Append(WorldCensus.N(item.Value)) .AppendLine(); } jsonValue2.Add(Field("What it's made of", stringBuilder.ToString(), inline: false)); if (latest.Hotspots.Count > 0) { StringBuilder stringBuilder2 = new StringBuilder(); for (int num = 0; num < latest.Hotspots.Count; num++) { stringBuilder2.Append(num + 1).Append(". ").Append(latest.Hotspots[num].Describe()) .AppendLine(); } jsonValue2.Add(Field("Busiest areas", stringBuilder2.ToString(), inline: false)); } if (HealthLog.Ready) { HealthSummary healthSummary = HealthLog.Summarize(Now() - 604800, SkaldConfig.ObjectAlertCount.Value, Watchdog.LastZdos, Watchdog.LastProcessMb); jsonValue2.Add(Field("Growth", Forecast.Growth(healthSummary.ZdoForecast), inline: true)); Dictionary<string, int> dictionary = HealthLog.CensusAt(Now() - 604800); if (dictionary != null && dictionary.TryGetValue("_total", out var value) && value > 0) { jsonValue2.Add(Field("This week", ((latest.Total - value >= 0) ? "+" : "") + WorldCensus.N(latest.Total - value), inline: true)); } } jsonValue.Set("fields", jsonValue2); jsonValue.Set("footer", Footer("Counted every " + SkaldConfig.CensusIntervalHours.Value.ToString("0.#", CultureInfo.InvariantCulture) + " h")); return Wrap(jsonValue); } public static string Leaderboard() { List<PlayerRecord> list = SkaldStore.Players.Values.Where((PlayerRecord p) => !string.IsNullOrEmpty(p.Name)).ToList(); if (list.Count == 0) { return null; } double dayLength = ServerHub.DayLength(); JsonValue jsonValue = Embed("Standings", "Updated <t:" + Now() + ":R>", 14722136); JsonValue jsonValue2 = JsonValue.NewArray(); jsonValue2.Add(Field("Achievements", Board(list.OrderByDescending((PlayerRecord p) => p.Unlocked.Count), (PlayerRecord p) => p.Unlocked.Count.ToString(CultureInfo.InvariantCulture)), inline: true)); jsonValue2.Add(Field("Hours played", Board(list.OrderByDescending((PlayerRecord p) => p.PlaytimeSeconds), (PlayerRecord p) => (p.PlaytimeSeconds / 3600.0).ToString("0.0", CultureInfo.InvariantCulture)), inline: true)); jsonValue2.Add(Field("Explored", Board(list.OrderByDescending((PlayerRecord p) => p.ExploredPercent), (PlayerRecord p) => p.ExploredPercent.ToString("0.#", CultureInfo.InvariantCulture) + "%"), inline: true)); jsonValue2.Add(Field("Kills", Board(list.OrderByDescending((PlayerRecord p) => p.KillsOf("*")), (PlayerRecord p) => p.KillsOf("*").ToString(CultureInfo.InvariantCulture)), inline: true)); jsonValue2.Add(Field("Deaths", Board(list.OrderByDescending((PlayerRecord p) => p.DeathCount), (PlayerRecord p) => p.DeathCount.ToString(CultureInfo.InvariantCulture)), inline: true)); jsonValue2.Add(Field("Longest streak", Board(list.OrderByDescending((PlayerRecord p) => p.BestStreakWorldSeconds), (PlayerRecord p) => Phrases.Plural(Streaks.Days(p.BestStreakWorldSeconds, dayLength), "day")), inline: true)); jsonValue2.Add(Field("Current streak", Board(list.OrderByDescending((PlayerRecord p) => p.StreakWorldSeconds), (PlayerRecord p) => Phrases.Plural(Streaks.Days(p.StreakWorldSeconds, dayLength), "day")), inline: true)); jsonValue.Set("fields", jsonValue2); jsonValue.Set("footer", Footer(list.Count + " player(s) tracked")); return Wrap(jsonValue); } private static string Board(IEnumerable<PlayerRecord> ordered, Func<PlayerRecord, string> value) { StringBuilder stringBuilder = new StringBuilder(); int num = 1; foreach (PlayerRecord item in ordered.Take(5)) { stringBuilder.Append((num == 1) ? "**" : "").Append(num).Append(". ") .Append(item.Name) .Append((num == 1) ? "**" : "") .Append(" - ") .Append(value(item)) .AppendLine(); num++; } if (stringBuilder.Length <= 0) { return "Nobody yet."; } return stringBuilder.ToString(); } private static long Now() { return DateTimeOffset.UtcNow.ToUnixTimeSeconds(); } private static JsonValue Embed(string title, string description, int colour) { JsonValue jsonValue = JsonValue.NewObject(); jsonValue.Set("title", JsonValue.Of(title)); jsonValue.Set("description", JsonValue.Of(Trim(description, 4000))); jsonValue.Set("color", JsonValue.Of(colour)); return jsonValue; } private static JsonValue Field(string name, string value, bool inline) { JsonValue jsonValue = JsonValue.NewObject(); jsonValue.Set("name", JsonValue.Of(name)); jsonValue.Set("value", JsonValue.Of(Trim(value, 1000))); jsonValue.Set("inline", JsonValue.Of(inline)); return jsonValue; } private static JsonValue Footer(string text) { JsonValue jsonValue = JsonValue.NewObject(); jsonValue.Set("text", JsonValue.Of("Skald 0.5.2 · " + text)); return jsonValue; } private static string Trim(string text, int limit) { if (string.IsNullOrEmpty(text)) { return "-"; } if (text.Length > limit) { return text.Substring(0, limit - 1) + "…"; } return text; } private static string Wrap(JsonValue embed) { JsonValue jsonValue = JsonValue.NewArray(); jsonValue.Add(embed); JsonValue jsonValue2 = JsonValue.NewObject(); jsonValue2.Set("parse", JsonValue.NewArray()); JsonValue jsonValue3 = JsonValue.NewObject(); jsonValue3.Set("embeds", jsonValue); jsonValue3.Set("allowed_mentions", jsonValue2); return Json.Write(jsonValue3); } } internal static class BossFights { private const float CasualtyRadius = 120f; private const double MaxFightSeconds = 7200.0; private const double UnknownFightWindow = 1800.0; private static readonly Dictionary<string, long> RecentlyReported = new Dictionary<string, long>(); public static void OnBossDown(long sender, ZPackage pkg) { //IL_006b: Unknown result type (might be due to invalid IL or missing references) //IL_0070: Unknown result type (might be due to invalid IL or missing references) //IL_00fe: Unknown result type (might be due to invalid IL or missing references) if (!SkaldConfig.BossSummaries.Value || !SkaldStore.Loaded || !Throttle.Allow(sender, "boss", 10f)) { return; } string text; string text2; double num; Vector3 pos; try { if (pkg.ReadInt() != 3) { return; } text = SafeText.Clean(pkg.ReadString(), 60); text2 = SafeText.Clean(pkg.ReadString(), 40); num = pkg.ReadDouble(); pos = new Vector3(pkg.ReadSingle(), pkg.ReadSingle(), pkg.ReadSingle()); } catch (Exception ex) { SkaldPlugin.Log.LogError((object)("Bad Skald boss report: " + ex.Message)); return; } long num2 = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (!RecentlyReported.TryGetValue(text, out var value) || num2 - value >= 60) { RecentlyReported[text] = num2; bool flag = num > 0.0 && num <= 7200.0; double num3 = (flag ? num : 1800.0); List<string> dead = ServerHub.DeathsNear(pos, 120f, num2 - (long)num3 - 15); string text3 = Describe(string.IsNullOrEmpty(text2) ? "The boss" : text2, flag ? num : (-1.0), dead); Announcer.Public(text3); Announcements.FireAll("boss", new Occasion { Boss = (string.IsNullOrEmpty(text2) ? "The boss" : text2) }, Guide.Short(text)); if (SkaldConfig.DiscordAnnounceAchievements.Value) { DiscordNotifier.Alert(AlertLevel.Info, "Boss down", text3); } } } public static string Describe(string boss, double seconds, List<string> dead) { string text = ((seconds > 0.0) ? (boss + " fell after a " + Duration(seconds) + " fight.") : (boss + " has fallen.")); if (dead.Count == 0) { return text + " No one died. Suspicious."; } return text + " Casualties: " + dead.Count + " (" + string.Join(", ", dead) + ")."; } private static string Duration(double seconds) { if (seconds < 90.0) { return Math.Max(1, (int)Math.Round(seconds)) + "-second"; } if (seconds < 5400.0) { return (int)Math.Round(s