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.4.0
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.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: AssemblyCompany("Skald")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("0.4.0.0")] [assembly: AssemblyInformationalVersion("0.4.0")] [assembly: AssemblyProduct("Skald")] [assembly: AssemblyTitle("Skald")] [assembly: AssemblyVersion("0.4.0.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<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<bool> PanelIcon; public static ConfigEntry<bool> PanelButtonExtendPanel; public static ConfigEntry<float> PanelButtonOffsetX; public static ConfigEntry<float> PanelButtonOffsetY; 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 void Bind(ConfigFile cfg) { //IL_00b4: Unknown result type (might be due to invalid IL or missing references) //IL_00be: Expected O, but got Unknown //IL_016c: Unknown result type (might be due to invalid IL or missing references) //IL_0176: Expected O, but got Unknown //IL_01e1: Unknown result type (might be due to invalid IL or missing references) //IL_02ad: Unknown result type (might be due to invalid IL or missing references) //IL_02b7: Expected O, but got Unknown //IL_0300: Unknown result type (might be due to invalid IL or missing references) //IL_030a: Expected O, but got Unknown //IL_036e: Unknown result type (might be due to invalid IL or missing references) //IL_0378: Expected O, but got Unknown //IL_03dc: Unknown result type (might be due to invalid IL or missing references) //IL_03e6: Expected O, but got Unknown //IL_0447: Unknown result type (might be due to invalid IL or missing references) //IL_0451: Expected O, but got Unknown //IL_04f7: Unknown result type (might be due to invalid IL or missing references) //IL_0501: Expected O, but got Unknown //IL_05cf: Unknown result type (might be due to invalid IL or missing references) //IL_05d9: Expected O, but got Unknown //IL_063b: Unknown result type (might be due to invalid IL or missing references) //IL_0645: 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."); 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."); PanelIcon = cfg.Bind<bool>("Social", "PanelIcon", false, "Experimental: add a Skald rune to the row of icons on the inventory screen. Off by default - that row is prefab data and mods rearrange it, so the rune tends to land badly. Use the key above instead. Client-side setting."); PanelButtonExtendPanel = cfg.Bind<bool>("Social", "PanelButtonExtendPanel", true, "Widen the panel behind the row of inventory icons to make room, and put Skald first. False puts it after the last icon instead, which may overhang the panel. Client-side setting."); PanelButtonOffsetX = cfg.Bind<float>("Social", "PanelButtonOffsetX", 0f, "Nudge the Skald button sideways, in pixels, if another mod's layout leaves it somewhere awkward. Client-side setting."); PanelButtonOffsetY = cfg.Bind<float>("Social", "PanelButtonOffsetY", 0f, "Nudge the Skald button up or down, in pixels. 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.", (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."); 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.4.0")] [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.4.0"; 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.4.0 loaded."); } 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 { public static JsonValue Parse(string text) { 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; } 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 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 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"), 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 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 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 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) { SkaldPlugin.Log.LogInfo((object)("Banner: " + banner + " | " + detail)); if (SkaldConfig.AnnounceInChat.Value) { Broadcast(ShowStyle.Banner, banner, detail); } } 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) { foreach (long moddedPeer in ModdedPeers) { Send(moddedPeer, style, primary, secondary); } if (SkaldRuntime.HasLocalPlayer) { ClientDisplay.Show(style, Label(), primary, secondary); } } private static void Send(long peerUid, ShowStyle style, string primary, string secondary) { //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 ?? ""); instance.InvokeRoutedRPC(peerUid, "Skald_Show", new object[1] { val }); } } 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 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 key; string text; double num; Vector3 pos; try { if (pkg.ReadInt() != 3) { return; } key = SafeText.Clean(pkg.ReadString(), 60); text = 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(key, out var value) || num2 - value >= 60) { RecentlyReported[key] = 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 text2 = Describe(string.IsNullOrEmpty(text) ? "The boss" : text, flag ? num : (-1.0), dead); Announcer.Public(text2); if (SkaldConfig.DiscordAnnounceAchievements.Value) { DiscordNotifier.Alert(AlertLevel.Info, "Boss down", text2); } } } 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(seconds / 60.0) + "-minute"; } return (int)Math.Round(seconds / 3600.0) + "-hour"; } } internal static class CatalogFile { private const string Header = "// Skald achievements. Edit freely; the server reads this at startup.\n//\n// Every entry needs: id, title, description, type.\n// \"type\": \"stat\" - stat (a PlayerStatType name), target, unit, optional scale\n// scale divides the numbers shown, e.g. 1000 for metres -> km\n// \"type\": \"playtime\" - hours on this server\n// \"type\": \"biome\" - biome: Meadows, BlackForest, Swamp, Mountain, Plains,\n// Mistlands, AshLands, DeepNorth, Ocean\n// \"type\": \"key\" - key: a world flag, e.g. defeated_bonemass (server-wide)\n//\n// Optional on any entry: \"hidden\": true, to keep it off the list until earned.\n//\n// Stat achievements count progress made on THIS server, from the moment Skald\n// first saw the character. Delete this file to get the defaults back.\n"; public static string PathFor() { return Path.Combine(Path.Combine(Paths.ConfigPath, "Skald"), "achievements.json"); } public static List<Achievement> Load() { string text = PathFor(); List<string> list = new List<string>(); try { if (!File.Exists(text)) { WriteDefaults(text); SkaldPlugin.Log.LogInfo((object)("Wrote the default achievements to " + text + ". Edit it to add your own.")); return Compile(AchievementCatalog.Defaults(), list); } List<Achievement> list2 = Compile(ParseFile(File.ReadAllText(text), list), list); foreach (string item in list) { SkaldPlugin.Log.LogError((object)("achievements.json: " + item)); } if (list2.Count == 0) { SkaldPlugin.Log.LogError((object)"achievements.json produced no usable achievements; using the built-in catalog for this session. The file has not been changed."); return Compile(AchievementCatalog.Defaults(), new List<string>()); } SkaldPlugin.Log.LogInfo((object)("Loaded " + list2.Count + " achievements from " + text + ((list.Count > 0) ? (" (" + list.Count + " skipped, see above)") : ""))); return list2; } catch (Exception ex) { SkaldPlugin.Log.LogError((object)("Could not read " + text + " (" + ex.Message + "); using the built-in catalog for this session. The file has not been changed.")); return Compile(AchievementCatalog.Defaults(), new List<string>()); } } public static List<AchievementDef> ParseFile(string text, List<string> errors) { JsonValue jsonValue = Json.Parse(text); JsonValue jsonValue2; if (jsonValue.Type == JsonValue.Kind.Array) { jsonValue2 = jsonValue; } else { if (!jsonValue.Has("achievements")) { throw new JsonException("Expected an object with an \"achievements\" array."); } jsonValue2 = jsonValue.Get("achievements"); } if (jsonValue2.Type != JsonValue.Kind.Array) { throw new JsonException("\"achievements\" must be an array."); } List<AchievementDef> list = new List<AchievementDef>(); for (int i = 0; i < jsonValue2.Array.Count; i++) { JsonValue jsonValue3 = jsonValue2.Array[i]; if (jsonValue3.Type != JsonValue.Kind.Object) { errors.Add("entry " + (i + 1) + " is not an object."); continue; } list.Add(new AchievementDef { Id = jsonValue3.GetString("id"), Title = jsonValue3.GetString("title"), Description = jsonValue3.GetString("description", ""), Type = (jsonValue3.GetString("type") ?? "").ToLowerInvariant(), Stat = jsonValue3.GetString("stat"), Target = jsonValue3.GetNumber("target"), Unit = jsonValue3.GetString("unit", ""), Scale = jsonValue3.GetNumber("scale", 1.0), Hours = jsonValue3.GetNumber("hours"), Biome = jsonValue3.GetString("biome"), Key = jsonValue3.GetString("key"), Hidden = jsonValue3.GetBool("hidden") }); } return list; } public static List<Achievement> Compile(List<AchievementDef> defs, List<string> errors) { //IL_014a: Unknown result type (might be due to invalid IL or missing references) //IL_032f: Unknown result type (might be due to invalid IL or missing references) //IL_0375: Unknown result type (might be due to invalid IL or missing references) //IL_037c: Expected I4, but got Unknown //IL_03a0: Unknown result type (might be due to invalid IL or missing references) List<Achievement> list = new List<Achievement>(); HashSet<string> hashSet = new HashSet<string>(); foreach (AchievementDef def in defs) { if (string.IsNullOrEmpty(def.Id)) { errors.Add("an entry has no id; skipped."); continue; } if (!hashSet.Add(def.Id)) { errors.Add("'" + def.Id + "' appears more than once; the later one is skipped."); continue; } Achievement achievement = new Achievement { Id = def.Id, Title = (string.IsNullOrEmpty(def.Title) ? def.Id : def.Title), Description = (def.Description ?? ""), Hidden = def.Hidden, Scope = AchievementScope.Player }; switch (def.Type) { case "stat": { if (string.IsNullOrEmpty(def.Stat) || !Enum.TryParse<PlayerStatType>(def.Stat, ignoreCase: true, out PlayerStatType stat) || !Enum.IsDefined(typeof(PlayerStatType), stat)) { errors.Add("'" + def.Id + "' has an unknown stat '" + def.Stat + "'; skipped."); continue; } if (def.Target <= 0.0) { errors.Add("'" + def.Id + "' needs a target above zero; skipped."); continue; } double target = def.Target; achievement.PlayerTest = (PlayerRecord rec) => (double)rec.Progress(stat) >= target; achievement.Current = (PlayerRecord rec) => rec.Progress(stat); achievement.Target = target; achievement.Unit = def.Unit ?? ""; achievement.DisplayScale = ((def.Scale > 0.0) ? def.Scale : 1.0); break; } case "playtime": { if (def.Hours <= 0.0) { errors.Add("'" + def.Id + "' needs hours above zero; skipped."); continue; } double seconds = def.Hours * 3600.0; achievement.PlayerTest = (PlayerRecord rec) => rec.PlaytimeSeconds >= seconds; achievement.Current = (PlayerRecord rec) => rec.PlaytimeSeconds; achievement.Target = seconds; achievement.Unit = "h"; achievement.DisplayScale = 3600.0; break; } case "biome": { if (string.IsNullOrEmpty(def.Biome) || !Enum.TryParse<Biome>(def.Biome, ignoreCase: true, out Biome result) || (int)result == 0) { errors.Add("'" + def.Id + "' has an unknown biome '" + def.Biome + "'; skipped."); continue; } int biomeId = (int)result; achievement.PlayerTest = (PlayerRecord rec) => rec.Biomes.Contains(biomeId); achievement.BiomeId = biomeId; achievement.BiomeName = Pretty(result); break; } case "key": { if (string.IsNullOrEmpty(def.Key)) { errors.Add("'" + def.Id + "' needs a key; skipped."); continue; } string key = def.Key; achievement.Scope = AchievementScope.World; achievement.WorldTest = (WorldRecord world) => world.GlobalKeys.Contains(key); break; } default: errors.Add("'" + def.Id + "' has unknown type '" + def.Type + "'; expected stat, playtime, biome or key. Skipped."); continue; } list.Add(achievement); } return list; } private unsafe static string Pretty(Biome biome) { //IL_0000: Unknown result type (might be due to invalid IL or missing references) //IL_0002: Invalid comparison between Unknown and I4 //IL_000e: Unknown result type (might be due to invalid IL or missing references) //IL_0011: Invalid comparison between Unknown and I4 //IL_0004: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Invalid comparison between Unknown and I4 //IL_0013: Unknown result type (might be due to invalid IL or missing references) //IL_0016: Invalid comparison between Unknown and I4 //IL_0008: Unknown result type (might be due to invalid IL or missing references) //IL_000a: Invalid comparison between Unknown and I4 if ((int)biome <= 8) { if ((int)biome == 4) { return "Mountains"; } if ((int)biome == 8) { return "Black Forest"; } } else { if ((int)biome == 32) { return "Ashlands"; } if ((int)biome == 64) { return "Deep North"; } } return ((object)(*(Biome*)(&biome))/*cast due to .constrained prefix*/).ToString(); } public static void WriteDefaults(string path) { string directoryName = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName)) { Directory.CreateDirectory(directoryName); } File.WriteAllText(path, Serialize(AchievementCatalog.Defaults())); } public static string Serialize(List<AchievementDef> defs) { JsonValue jsonValue = JsonValue.NewArray(); foreach (AchievementDef def in defs) { JsonValue jsonValue2 = JsonValue.NewObject(); jsonValue2.Set("id", JsonValue.Of(def.Id)); jsonValue2.Set("title", JsonValue.Of(def.Title)); jsonValue2.Set("description", JsonValue.Of(def.Description ?? "")); jsonValue2.Set("type", JsonValue.Of(def.Type)); switch (def.Type) { case "stat": jsonValue2.Set("stat", JsonValue.Of(def.Stat)); jsonValue2.Set("target", JsonValue.Of(def.Target)); if (!string.IsNullOrEmpty(def.Unit)) { jsonValue2.Set("unit", JsonValue.Of(def.Unit)); } if (def.Scale != 1.0) { jsonValue2.Set("scale", JsonValue.Of(def.Scale)); } break; case "playtime": jsonValue2.Set("hours", JsonValue.Of(def.Hours)); break; case "biome": jsonValue2.Set("biome", JsonValue.Of(def.Biome)); break; case "key": jsonValue2.Set("key", JsonValue.Of(def.Key)); break; } if (def.Hidden) { jsonValue2.Set("hidden", JsonValue.Of(b: true)); } jsonValue.Add(jsonValue2); } JsonValue jsonValue3 = JsonValue.NewObject(); jsonValue3.Set("achievements", jsonValue); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("// Skald achievements. Edit freely; the server reads this at startup.\n//\n// Every entry needs: id, title, description, type.\n// \"type\": \"stat\" - stat (a PlayerStatType name), target, unit, optional scale\n// scale divides the numbers shown, e.g. 1000 for metres -> km\n// \"type\": \"playtime\" - hours on this server\n// \"type\": \"biome\" - biome: Meadows, BlackForest, Swamp, Mountain, Plains,\n// Mistlands, AshLands, DeepNorth, Ocean\n// \"type\": \"key\" - key: a world flag, e.g. defeated_bonemass (server-wide)\n//\n// Optional on any entry: \"hidden\": true, to keep it off the list until earned.\n//\n// Stat achievements count progress made on THIS server, from the moment Skald\n// first saw the character. Delete this file to get the defaults back.\n"); stringBuilder.Append(Json.Write(jsonValue3)); stringBuilder.Append('\n'); return stringBuilder.ToString(); } } internal static class Catchup { private const int MaxNamedAchievements = 4; public static List<string> Build(PlayerRecord rec, WorldRecord world, ICollection<PlayerRecord> players, long since, long now, double dayLength) { List<string> list = new List<string>(); List<string> list2 = new List<string>(); Dictionary<string, Achievement> titles = new Dictionary<string, Achievement>(); foreach (Achievement item in AchievementCatalog.All) { titles[item.Id] = item; } List<string> list3 = (from kv in world.UnlockTimes where kv.Value >= since && titles.ContainsKey(kv.Key) orderby kv.Value select titles[kv.Key].Description.Replace(" has fallen.", "")).ToList(); if (list3.Count > 0) { list2.Add(((list3.Count == 1) ? "Boss down: " : "Bosses down: ") + string.Join(", ", list3) + "."); } List<string> list4 = new List<string>(); foreach (PlayerRecord player in players) { if (player.PlayerId == rec.PlayerId) { continue; } foreach (KeyValuePair<string, long> item2 in player.UnlockTimes.OrderBy((KeyValuePair<string, long> k) => k.Value)) { if (item2.Value >= since && titles.ContainsKey(item2.Key) && titles[item2.Key].Scope == AchievementScope.Player) { list4.Add(player.Name + " earned " + titles[item2.Key].Title); } } } if (list4.Count > 0) { List<string> list5 = list4.Take(4).ToList(); string text = string.Join(", ", list5); if (list4.Count > list5.Count) { text = text + " and " + (list4.Count - list5.Count) + " more"; } list2.Add(text + "."); } List<DeathEvent> list6 = world.Deaths.Where((DeathEvent d) => d.Time >= since).ToList(); if (list6.Count > 0) { IGrouping<string, DeathEvent> grouping = (from d in list6 group d by (!string.IsNullOrEmpty(d.KillerName)) ? d.KillerName : DeathCauses.Name(d.HitType) into g orderby g.Count() descending select g).First(); list2.Add(Phrases.Plural(list6.Count, "death") + " while you were gone" + ((grouping.Count() > 1) ? (" (" + grouping.Key + " took " + grouping.Count() + ")") : "") + "."); } int num = Streaks.Days(rec.StreakWorldSeconds, dayLength); if (num >= 1) { list2.Add("Your " + Phrases.Plural(num, "day") + " without dying still stands."); } if (list2.Count == 0) { return list; } list.Add("While you were away (" + Away(now - since) + "):"); list.AddRange(list2); return list; } public static string Away(long seconds) { if (seconds < 3600) { return Phrases.Plural((int)Math.Max(1L, seconds / 60), "minute"); } if (seconds < 86400) { return Phrases.Plural((int)Math.Round((double)seconds / 3600.0), "hour"); } return Phrases.Plural((int)Math.Round((double)seconds / 86400.0), "day"); } } internal static class Commands { public static void OnCommand(long sender, ZPackage pkg) { if (!SkaldConfig.ChatCommands.Value || !Throttle.Allow(sender, "command", 1f)) { return; } string text; try { if (pkg.ReadInt() != 3) { return; } text = SafeText.Clean(pkg.ReadString(), 120); } catch (Exception ex) { SkaldPlugin.Log.LogError((object)("Bad Skald command: " + ex.Message)); return; } if (!SkaldStore.Loaded) { Announcer.Private(sender, "Skald is still waking up. Try again in a moment."); return; } PlayerRecord self = ServerHub.RecordForSender(sender); bool flag = ServerHub.IsAdminSender(sender); List<string> list; try { list = Handle(text, self, flag, ServerHub.DayLength(), ServerHub.RecapPreview, ServerHub.WorldReport); } catch (Exception ex2) { SkaldPlugin.Log.LogError((object)("Skald command '" + text + "' failed: " + ex2)); list = new List<string> { "Something went wrong answering that. It has been logged." }; } if (!flag && text.TrimStart().StartsWith("!recap", StringComparison.OrdinalIgnoreCase)) { list.AddRange(ServerHub.AdminDiagnosis(sender)); } foreach (string item in list) { Announcer.Private(sender, item); } } public static List<string> Handle(string text, PlayerRecord self, bool isAdmin, double dayLength, Func<List<string>> recapPreview, Func<List<string>> worldReport = null) { string[] array = (text ?? "").Trim().Split(new char[1] { ' ' }, 2, StringSplitOptions.RemoveEmptyEntries); string text2 = ((array.Length != 0) ? array[0].TrimStart('!').ToLowerInvariant() : ""); string text3 = ((array.Length > 1) ? array[1].Trim() : ""); if (text2 == "skald" && string.Equals(text3, "help", StringComparison.OrdinalIgnoreCase)) { return Help(); } switch (text2) { case "skald": { PlayerRecord playerRecord = Target(self, text3); if (playerRecord != null) { return Summary(playerRecord, dayLength); } return NotFound(text3); } case "deaths": { PlayerRecord playerRecord2 = Target(self, text3); if (playerRecord2 != null) { return Deaths(playerRecord2); } return NotFound(text3); } case "top": return Top(SkaldStore.Players.Values, dayLength); case "world": if (worldReport == null) { return new List<string> { "The world census is not available." }; } return worldReport(); case "recap": if (!isAdmin) { return new List<string> { "Only server admins can use !recap." }; } return recapPreview(); default: return Help(); } } public static List<string> Help() { return new List<string> { "!skald [name] - achievements, playtime, streak and what's next", "!deaths [name] - how, and to what", "!top - leaderboards", "!world - what the world is made of, and what grew this week", "!recap - post this week's recap so far to Discord (admins)" }; } public static List<string> Summary(PlayerRecord rec, double dayLength) { List<Achievement> list = AchievementCatalog.All.Where((Achievement a) => a.Scope == AchievementScope.Player).ToList(); int num = list.Count((Achievement a) => rec.Unlocked.Contains(a.Id)); int count = SkaldStore.World.Unlocked.Count; int num2 = AchievementCatalog.All.Count - list.Count; List<string> list2 = new List<string>(); list2.Add(rec.Name + " - " + num + "/" + list.Count + " achievements · " + Hours(rec.PlaytimeSeconds) + " played · " + Phrases.Plural(rec.DeathCount, "death") + " · server bosses " + count + "/" + num2); List<string> list3 = list2; int n = Streaks.Days(rec.StreakWorldSeconds, dayLength); int num3 = Streaks.Days(rec.BestStreakWorldSeconds, dayLength); string text = "Streak: " + Phrases.Plural(n, "day") + " (best " + num3 + ")"; if (rec.Nemeses.Count > 0) { text = text + " · Nemesis: " + string.Join(", ", rec.Nemeses.Select((string k) => KillerName(rec, k))); } list3.Add(text); List<string> list4 = (from x in (from a in list where !rec.Unlocked.Contains(a.Id) && a.Current != null && a.Target > 0.0 select new { a = a, ratio = a.Current(rec) / a.Target } into x orderby x.ratio descending select x).Take(3) select x.a.Title + " " + Amount(x.a, x.a.Current(rec)) + "/" + Amount(x.a, x.a.Target) + ((x.a.Unit.Length > 0) ? (" " + x.a.Unit) : "")).ToList(); if (list4.Count > 0) { list3.Add("Next up: " + string.Join(" · ", list4)); } return list3; } public static List<string> Deaths(PlayerRecord rec) { if (rec.DeathCount == 0) { return new List<string> { rec.Name + " has not died on this server. Yet." }; } List<string> list = new List<string> { rec.Name + " has died " + Phrases.Plural(rec.DeathCount, "time") + "." }; List<string> list2 = (from kv in rec.DeathsByCause.OrderByDescending((KeyValuePair<int, int> kv) => kv.Value).Take(4) select DeathCauses.Name(kv.Key) + " " + kv.Value).ToList(); if (list2.Count > 0) { list.Add("Causes: " + string.Join(" · ", list2)); } List<string> list3 = (from kv in rec.KillerDeaths.OrderByDescending((KeyValuePair<string, int> kv) => kv.Value).Take(4) select KillerName(rec, kv.Key) + " " + kv.Value).ToList(); if (list3.Count > 0) { list.Add("Killers: " + string.Join(" · ", list3)); } list.Add((rec.Nemeses.Count == 0) ? "No active grudges." : ("Nemesis: " + string.Join(", ", rec.Nemeses.Select(delegate(string k) { rec.NemesisCount.TryGetValue(k, out var value); return KillerName(rec, k) + " (" + value + " since the last reckoning)"; })))); return list; } public static List<string> Top(IEnumerable<PlayerRecord> all, double dayLength) { List<PlayerRecord> list = all.Where((PlayerRecord p) => !string.IsNullOrEmpty(p.Name)).ToList(); if (list.Count == 0) { return new List<string> { "Nobody has been tracked yet." }; } int playerAchievements = AchievementCatalog.PlayerCount; return new List<string> { "Most achievements: " + Board(list, (PlayerRecord p) => p.Unlocked.Count, (int n) => n + "/" + playerAchievements), "Most time played: " + Board(list, (PlayerRecord p) => p.PlaytimeSeconds, Hours), "Most deaths: " + Board(list, (PlayerRecord p) => p.DeathCount, (int n) => n.ToString()), "Longest streak: " + Board(list, (PlayerRecord p) => p.BestStreakWorldSeconds, (double s) => Phrases.Plural(Streaks.Days(s, dayLength), "day")) }; } private static string Board<T>(List<PlayerRecord> players, Func<PlayerRecord, T> key, Func<T, string> show) { return string.Join(" · ", from p in players.OrderByDescending(key).Take(3) select p.Name + " " + show(key(p))); } private static PlayerRecord Target(PlayerRecord self, string arg) { if (!string.IsNullOrEmpty(arg)) { return SkaldStore.FindByName(arg); } return self; } private static List<string> NotFound(string arg) { return new List<string> { string.IsNullOrEmpty(arg) ? "Skald has not seen your character yet. Give it a moment after spawning." : ("No one called '" + arg + "' has been seen here.") }; } private static string KillerName(PlayerRecord rec, string key) { if (rec.KillerNames.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value)) { return value; } if (!key.StartsWith("player:")) { return key; } return key.Substring(7); } private static string Hours(double seconds) { return (seconds / 3600.0).ToString("0.0", CultureInfo.InvariantCulture) + " h"; } private static string Amount(Achievement a, double raw) { double d = raw / a.DisplayScale; if (!(a.DisplayScale > 1.0)) { return Math.Floor(d).ToString("0", CultureInfo.InvariantCulture); } return d.ToString("0.#", CultureInfo.InvariantCulture); } } internal static class DeathMessages { private static readonly string[] Fallback = new string[10] { "{player} died in a way the sagas decline to record.", "{player} stopped living, abruptly and without ceremony.", "Something unspeakable happened to {player}. The details are missing.", "{player} was returned to the soil by unclear means.", "{player} has been erased from the working day.", "{player} died in a manner the skalds refuse to describe.", "{player} stopped, for reasons unrecorded.", "Something got {player}. Nobody knows what.", "{player} has left the world by an unclear exit.", "{player} is dead. The details did not survive either." }; private static readonly Dictionary<HitType, string[]> Catalog = new Dictionary<HitType, string[]> { { (HitType)1, new string[40] { "{player} was opened from collarbone to hip by {killer}.", "{killer} beat {player} into a shape no helmet was designed for.", "{player} was murdered by {killer}, thoroughly and with evident enthusiasm.", "{killer} removed {player} from the inside out and left the packaging.", "{player} was folded backwards by {killer} until something important gave way.", "{killer} introduced {player} to their own skeleton.", "{player} was chewed, considered, and discarded by {killer}.", "{killer} rearranged {player} into an abstract piece.", "{player} was hollowed out by {killer} with unsettling precision.", "{killer} pulped {player} into something the crows will argue over.", "{player} came apart in {killer}'s grip like wet bread.", "{killer} struck {player} hard enough to redistribute them across the biome.", "{player} was disassembled by {killer} in the wrong order.", "{killer} made a long, wet, conclusive point about {player}.", "{player} was unmade by {killer} and is now mostly a smell.", "{killer} took {player} apart with the patience of a butcher.", "{player} was reduced by {killer} to a cautionary tale and a stain.", "{killer} hit {player} so hard the armour kept the shape and the rest did not.", "{player} was emptied out by {killer} in about three seconds.", "{killer} broke {player} into pieces that no longer fit together.", "{player} went down under {killer} and did not come back up.", "{killer} turned {player} inside out and left the result for the ravens.", "{player} was ended by {killer} mid-sentence.", "{killer} put {player} through something that will require a mop.", "{player} was flattened, opened and finished by {killer}, in that order.", "{killer} made {player} a lesson in what not to approach.", "{player} was separated from several important parts by {killer}.", "{killer} closed the distance, and {player} closed permanently.", "{player} was struck down by {killer} and left to cool.", "{killer} spent four seconds on {player}. It was enough.", "{player} became something {killer} had to wipe off.", "{killer} hit {player} once. That was the whole fight.", "{player} was dismantled by {killer} with no particular urgency.", "{killer} ground {player} into the moss.", "{player} was carved up by {killer} and arranged badly.", "{killer} left {player} in a shape the healers would refuse.", "{player} met {killer} and lost every argument at once.", "{killer} turned {player} into an open question.", "{player} was punched through by {killer}, and kept going briefly.", "{killer} ended {player} with the sound of something wet giving way." } }, { (HitType)2, new string[16] { "{killer} murdered {player}. In cold blood. On purpose.", "{player} was cut down by {killer}, who will have to explain that later.", "{killer} put {player} in the ground and kept the axe.", "{player} learned that {killer} was never really a friend.", "{killer} split {player} open and called it a misunderstanding.", "{player} was betrayed, perforated, and left steaming by {killer}.", "{killer} ended {player} with the confidence of someone who planned it.", "{player} died of {killer}, at close range.", "{killer} killed {player}. The longhouse will hear about this.", "{player} was put down by {killer}. A friend, allegedly.", "{killer} buried an axe in {player} and called it a discussion.", "{player} died to {killer}. The word for that is murder.", "{killer} took {player} apart, and took the loot too.", "{player} trusted {killer}. That was the mistake.", "{killer} opened {player} up and looked genuinely pleased about it.", "{player} was killed by {killer}. Not a monster. A friend." } }, { (HitType)3, new string[18] { "{player} fell a very long way and arrived all at once.", "{player} discovered the ground travelling upward at speed.", "{player} hit the earth hard enough to become part of it.", "{player} was flattened into a thin, regrettable layer.", "{player} came down wrong and then kept being wrong.", "Gravity took an interest in {player} and won decisively.", "{player} landed in several places at once.", "{player} broke every bone worth naming, then a few extra.", "{player} stepped off something high and learned about it.", "{player} arrived at the bottom in worse condition than expected.", "{player} tested the drop personally. The results were conclusive.", "{player} fell, and the landing did all the work.", "{player} went down the fast way and stopped hard.", "{player} misjudged one step and paid for all of them.", "{player} discovered that the ground does not negotiate.", "{player} fell far enough to have time to think about it.", "{player} hit the rocks and became a geological feature.", "{player} took the shortest way down and the longest way back." } }, { (HitType)4, new string[12] { "{player} filled up with cold water and stopped arguing.", "{player} breathed the sea and the sea accepted.", "{player} sank quietly, which was the loudest part.", "The water went into {player} and never came out.", "{player} drowned, slowly, with plenty of time to think about it.", "{player} is now part of the lake.", "{player} swam out too far and stopped swimming back.", "{player} went down with the armour still on.", "{player} traded air for water at an unfavourable rate.", "{player} tried to out-swim a bad decision and lost.", "{player} disappeared under the surface without much noise.", "{player} learned that stamina runs out before the sea does." } }, { (HitType)5, new string[12] { "{player} cooked inside their own armour.", "{player} burned down to the interesting parts.", "{player} went up like dry kindling and stayed up.", "{player} was rendered, in the culinary sense.", "Fire ate {player} from the skin inward.", "{player} is now a smell and a warning.", "{player} caught fire and stayed caught.", "{player} burned until there was nothing left worth naming.", "{player} became a torch, briefly and brightly.", "{player} was cooked through, and then some.", "{player} stood too close to something hot and hungry.", "{player} went out in flames, and then just went out." } }, { (HitType)6, new string[12] { "{player} froze solid with their eyes still open.", "The cold got into {player} and turned everything to glass.", "{player} stiffened, cracked, and stopped.", "{player} was preserved at the exact moment of realising.", "Frost hollowed {player} out and left the shell standing.", "{player} went blue, then quiet.", "{player} stopped shivering, which was the bad sign.", "{player} was taken by the cold, quietly and completely.", "{player} froze to the mountain and stayed there.", "{player} forgot a cape, and the mountain remembered.", "{player} got cold, then colder, then permanent.", "The frost put {player} away for good." } }, { (HitType)7, new string[12] { "{player} rotted from the middle outward.", "{player} was liquefied by something that got in through a scratch.", "Poison walked through {player} and closed the door behind it.", "{player} turned an unhelpful colour and then a worse one.", "{player} died sweating, twitching, and deeply regretting the swamp.", "Something venomous had a slow and thorough opinion about {player}.", "{player} died green and unhappy.", "{player} was hollowed out by something with a taste for it.", "{player} went stiff, and then went still.", "{player} was poisoned thoroughly, by something small.", "{player} spent the last few minutes regretting the swamp.", "Venom finished what a bad idea started for {player}." } }, { (HitType)9, new string[8] { "{player} suffocated in their own hearth smoke.", "{player} was kippered inside their own house.", "{player} choked to death somewhere that should have been safe.", "The smoke found {player} and would not leave.", "{player} built a fire indoors and paid the standard price.", "{player} was smoked out of a house {player} built.", "{player} choked on hearth smoke like an amateur.", "{player} died of poor ventilation. A truly Viking end." } }, { (HitType)8, new string[8] { "{player} was swallowed by the water without fuss.", "The sea took {player} and did not negotiate.", "{player} went under and the surface closed over neatly.", "{player} is somewhere down there, still.", "{player} went into the water, and the water kept the difference.", "{player} was taken quietly, without a splash worth mentioning.", "{player} sank out of the story.", "The water closed over {player} and moved on." } }, { (HitType)10, new string[8] { "{player} sailed off the edge of everything.", "{player} found where the world stops, and kept going.", "{player} was deleted by geography.", "{player} fell out of the map and out of the story.", "{player} kept sailing until there was nothing left to sail on.", "{player} tested the edge of the world. The world won.", "{player} went past the last wave and did not come back.", "{player} fell off the end of everything." } }, { (HitType)11, new string[10] { "{player} was struck hard enough to change shape permanently.", "Something enormous met {player} and {player} lost.", "{player} was compressed into a smaller, wetter version.", "{player} took the full force and distributed it poorly.", "{player} was hit so hard the sound arrived afterwards.", "{player} was hit by something with mass and no opinion.", "{player} stopped suddenly against something that did not.", "{player} was compacted.", "{player} took an impact that redistributed the important parts.", "Something big met {player}. Only one of them walked away." } }, { (HitType)12, new string[8] { "{player} was run down by their own cart.", "{player} was crushed by a wagon they packed themselves.", "{player} lost an argument with a heavy wooden box on wheels.", "The cart went over {player} twice, to be certain.", "{player} was killed by cargo.", "{player} was flattened by a cart, which is a choice.", "{player} lost a wrestling match with a very large wheelbarrow.", "{player} died hauling, which is at least honest work." } }, { (HitType)13, new string[12] { "{player} was crushed by the very tree they were chopping.", "A tree fell on {player} with what felt like intent.", "{player} was driven into the forest floor by several tons of pine.", "{player} chopped, looked up, and ran out of time.", "The forest took {player} back in one motion.", "{player} was pressed flat beneath honest timber.", "{player} chopped once too often.", "{player} was buried by the forest, using a single tree.", "{player} was struck down by lumber in transit.", "{player} learned which way a tree falls. Once.", "{player} was pressed into the ground by a birch with a grudge.", "{player} died beneath honest work." } }, { (HitType)14, new string[10] { "{player} did this to themselves. Entirely.", "{player} was killed by {player}, which raises questions.", "{player} achieved self-destruction with real commitment.", "{player} took themselves apart and could not finish reassembly.", "{player} needed no help at all, in the end.", "{player} was killed by {player}. No help required.", "{player} managed it alone, which is both impressive and sad.", "{player} did something unwise and then finished the job.", "{player} required no assistance whatsoever.", "{player} is survived by the decision that caused this." } }, { (HitType)15, new string[10] { "{player} was flattened by their own architecture.", "The building fell on {player}, which the building had been threatening for a while.", "{player} died of poor structural planning.", "{player} was buried under a roof they personally installed.", "{player} learned about load-bearing walls the expensive way.", "{player} was crushed by a build that failed inspection.", "{player} died under a roof {player} was proud of.", "{player} learned about structural integrity from underneath.", "{player} was flattened by an unsupported ambition.", "The house came down. {player} did not get out." } }, { (HitType)16, new string[8] { "{player} was shot dead by their own defences.", "The turret did not recognise {player} and did not ask.", "{player} was punched through by friendly fire, mechanically applied.", "{player} was perforated by something they built to protect them.", "{player} walked into a firing line of {player}'s own making.", "{player} was shot by a machine that was only following orders.", "{player} died to friendly fire, mechanically administered.", "{player} built it, loaded it, and then stood in front of it." } }, { (HitType)17, new string[8] { "{player} was crushed between hull and hard place.", "{player} was ground into the seabed by their own longship.", "The boat came down on {player} with the full weight of the voyage.", "{player} was mangled by several tons of buoyant timber.", "{player} was crushed by a boat. On land. Somehow.", "{player} died beneath a vessel {player} was quite fond of.", "{player} lost an argument with several tons of karve.", "{player} was pinned by a hull that had other plans." } }, { (HitType)18, new string[8] { "{player} was skewered from above by the ceiling.", "A spike of ancient stone went through {player} lengthwise.", "{player} was pinned to the cave floor like a specimen.", "The cave dropped a stone spear on {player} and said nothing.", "{player} was skewered by geology.", "{player} died to a rock with excellent aim.", "{player} was pinned in place by the cave itself.", "{player} looked up too late." } }, { (HitType)19, new string[8] { "{player} was launched, and landed as several separate deliveries.", "{player} caught a boulder with their entire body.", "{player} was reduced to pulp by long-range artillery.", "Something heavy arrived from far away, and {player} was under it.", "{player} received a boulder, airmail.", "{player} was demolished at range.", "{player} was hit by artillery and scattered accordingly.", "{player} was on the receiving end of very good aim." } }, { (HitType)20, new string[8] { "{player} was cooked alive in falling ash.", "The burning sky came down on {player}.", "{player} was seared through by cinders and stopped moving.", "{player} caught fire from above and could not put it out.", "{player} burned under a sky that was also burning.", "{player} was set alight from above.", "{player} died in falling fire.", "The ash took {player} while it was still hot." } }, { (HitType)21, new string[8] { "{player} boiled away in the burning sea.", "{player} went into water that was not water, and did not come back.", "The scalding tide dissolved {player} entirely.", "{player} was rendered down by the Ashlands ocean.", "{player} swam in the wrong sea.", "{player} was boiled by water with opinions.", "{player} went in, and came out as steam.", "{player} was dissolved by the burning tide." } }, { (HitType)22, new string[8] { "{player} sank into molten rock, briefly and brightly.", "{player} went into the lava and became a light.", "{player} was consumed by liquid stone in under a second.", "The ground opened, glowed, and finished {player}.", "{player} stepped into the glow and became part of it.", "{player} was swallowed by molten stone.", "{player} burned all the way through in one moment.", "{player} is now geology." } }, { (HitType)23, new string[8] { "{player} was fed into the machine and processed.", "{player} went in whole and did not come out that way.", "The incinerator did not distinguish {player} from refuse.", "{player} was industrially reduced to nothing.", "{player} was processed as waste.", "{player} climbed in. That was the last decision.", "{player} was turned to ash on an industrial schedule.", "The machine did not ask {player} any questions." } }, { (HitType)24, new string[8] { "{player} was crushed by their own drawbridge.", "{player} was folded into the gate they built.", "The bridge came down, and {player} was in the way.", "{player} was pressed flat by several tons of hinged wood.", "{player} was folded shut.", "{player} died to their own gate, which is embarrassing.", "{player} was caught by a bridge with bad timing.", "{player} was flattened by a door the size of a wall." } } }; private static readonly Dictionary<string, string[]> Signature = new Dictionary<string, string[]> { { "deathsquito", new string[4] { "A Deathsquito killed {player}. Two grams of pure malice.", "{player} was assassinated by an insect with a name like a warning.", "{killer} arrived at speed and left {player} face down in the Plains.", "{player} heard the buzzing. It was already too late." } }, { "troll", new string[4] { "{killer} clubbed {player} into the treeline.", "{player} was swatted by {killer} like a bad thought.", "{killer} hit {player} once, and the trees kept the echo.", "{player} was introduced to the ground by {killer}. Repeatedly." } }, { "greydwarf", new string[4] { "{player} was pulled down by {killer} and friends.", "{killer} threw a rock. {player} stopped a rock.", "{player} was mobbed to death by the forest's least impressive residents.", "{killer} finished {player} with a stick and enthusiasm." } }, { "serpent", new string[4] { "{killer} took {player} off the deck and into the deep.", "{player} met {killer} at sea and lost the boat and the argument.", "{killer} ended {player}'s sailing career in one bite.", "{player} was dragged under by {killer}, karve and all." } }, { "wolf", new string[4] { "{killer} ran {player} down in the snow.", "{player} was eaten on a mountainside by {killer}.", "{killer} and the pack left very little of {player}.", "{player} was brought down by teeth and altitude." } }, { "goblin", new string[4] { "{killer} beat {player} to death with something spiky and homemade.", "{player} was overrun by {killer} and a great deal of shouting.", "{killer} put a torch through {player} and celebrated.", "{player} was taken apart by {killer} in the tall grass." } }, { "seeker", new string[4] { "{killer} pinned {player} in the mist and did the rest quietly.", "{player} never saw {killer} coming. Nobody does.", "{killer} clicked once, and {player} stopped existing.", "{player} was taken by {killer} in the fog, thoroughly." } }, { "gjall", new string[4] { "{killer} bombed {player} from above.", "{player} was cooked in the mist by {killer}.", "{killer} dropped something horrible on {player}.", "{player} looked up at {killer}, and that was the last look." } }, { "neck", new string[4] { "{player} was killed by {killer}. A Neck. Let that sit.", "{killer} finished {player}, which should not be possible.", "{player} died to the least threatening thing in the Tenth World.", "{killer} will be telling this story for generations." } }, { "boar", new string[4] { "{player} was gored by {killer}. Breakfast won.", "{killer} killed {player}. In fairness, it was provoked.", "{player} lost to livestock.", "{killer} put {player} down in the meadows, humiliatingly." } }, { "skeleton", new string[4] { "{killer} put rusted iron through {player}.", "{player} was killed by something that was already dead.", "{killer} rattled up out of the dark and finished {player}.", "{player} lost to a pile of bones with ambition." } }, { "leech", new string[4] { "{killer} drained {player} in the swamp water.", "{player} was emptied by {killer}, quietly, from below.", "{killer} attached to {player}, and that was that.", "{player} died to a worm with a drinking problem." } } }; public static int CategoryCount => Catalog.Count; public static string Pick(HitType hitType, string player, string killer, string killerKey = null) { //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_001e: Unknown result type (might be due to invalid IL or missing references) //IL_0021: Expected I4, but got Unknown //IL_0030: Unknown result type (might be due to invalid IL or missing references) if (!Catalog.TryGetValue(hitType, out var value) || value.Length == 0) { value = Fallback; } string text = "hit:" + (int)hitType; string[] lines = value; KeyValuePair<string, string[]> keyValuePair = SignatureFor(hitType, killerKey); if (keyValuePair.Value != null) { List<string> list = new List<string>(value.Length + keyValuePair.Value.Length); list.AddRange(value); list.AddRange(keyValuePair.Value); lines = list.ToArray(); text = text + ":" + keyValuePair.Key; } string text2 = Phrases.Pick(text, lines).Replace("{player}", player); if (text2.Contains("{killer}")) { string newValue = (string.IsNullOrEmpty(killer) ? "something unseen" : killer); text2 = text2.Replace("{killer}", newValue); } return text2; } private static KeyValuePair<string, string[]> SignatureFor(HitType hitType, string killerKey) { //IL_0000: Unknown result type (might be due to invalid IL or missing references) //IL_0002: Invalid comparison between Unknown and I4 if ((int)hitType != 1 || string.IsNullOrEmpty(killerKey)) { return new KeyValuePair<string, string[]>(null, null); } string text = killerKey.ToLowerInvariant(); foreach (KeyValuePair<string, string[]> item in Signature) { if (text.Contains(item.Key)) { return item; } } return new KeyValuePair<string, string[]>(null, null); } public static List<string> AllLines() { List<string> list = new List<string>(Fallback); foreach (string[] value in Catalog.Values) { list.AddRange(value); } foreach (string[] value2 in Signature.Values) { list.AddRange(value2); } return list; } } internal enum AlertLevel { Info, Warning, Critical, Recovery } internal static class DiscordNotifier { private const int MaxQueued = 50; private const int PaceMs = 2000; private static readonly Queue<string> Pending = new Queue<string>(); private static readonly object Gate = new object(); private static bool _draining; private static bool _warnedAboutFailure; public static bool Configured { get { string value = SkaldConfig.DiscordWebhookUrl.Value; if (!string.IsNullOrEmpty(value)) { return value.StartsWith("https://", StringComparison.OrdinalIgnoreCase); } return false; } } public static void Alert(AlertLevel level, string title, string message) { if (Configured) { Enqueue(BuildPayload(level, title, message)); } } public static void Achievement(string playerName, Achievement achievement) { if (Configured && SkaldConfig.DiscordAnnounceAchievements.Value) { string title = (string.IsNullOrEmpty(playerName) ? achievement.Title : (playerName + " earned \"" + achievement.Title + "\"")); Enqueue(BuildPayload(AlertLevel.Info, title, achievement.Description)); } } private static void Enqueue(string json) { lock (Gate) { if (Pending.Count >= 50) { return; } Pending.Enqueue(json); if (_draining) { return; } _draining = true; } ThreadPool.QueueUserWorkItem(Drain); } private static void Drain(object _) { while (true) { string json; lock (Gate) { if (Pending.Count == 0) { _draining = false; break; } json = Pending.Dequeue(); } Post(json); Thread.Sleep(2000); } } private static void Post(string json) { string value = SkaldConfig.DiscordWebhookUrl.Value; if (string.IsNullOrEmpty(value)) { return; } try { try { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; } catch (NotSupportedException) { } HttpWebRequest httpWebRequest = (HttpWebRequest)WebRequest.Create(value); httpWebRequest.Method = "POST"; httpWebRequest.ContentType = "application/json"; httpWebRequest.UserAgent = "Skald/0.4.0"; httpWebRequest.Timeout = 10000; byte[] bytes = Encoding.UTF8.GetBytes(json); httpWebRequest.ContentLength = bytes.Length; using (Stream stream = httpWebRequest.GetRequestStream()) { stream.Write(bytes, 0, bytes.Length); } using (HttpWebResponse httpWebResponse = (HttpWebResponse)httpWebRequest.GetResponse()) { int statusCode = (int)httpWebResponse.StatusCode; if (statusCode >= 300) { SkaldPlugin.Log.LogWarning((object)("Discord webhook returned HTTP " + statusCode + ".")); } } _warnedAboutFailure = false; } catch (WebException ex2) { if (_warnedAboutFailure) { return; } _warnedAboutFailure = true; string text = ex2.Message; if (ex2.Response is HttpWebResponse httpWebResponse2) { text = "HTTP " + (int)httpWebResponse2.StatusCode; try { using StreamReader streamReader = new StreamReader(httpWebResponse2.GetResponseStream()); text = text + " " + streamReader.ReadToEnd(); } catch (Exception) { } } SkaldPlugin.Log.LogWarning((object)("Discord webhook post failed (" + Redact(text) + "). Check the URL in the config; further failures will stay quiet until one succeeds.")); } catch (Exception ex4) { if (!_warnedAboutFailure) { _warnedAboutFailure = true; SkaldPlugin.Log.LogWarning((object)("Discord webhook post failed: " + Redact(ex4.Message))); } } } public static int Http(string method, string url, string json, int timeoutMs, out string body) { body = ""; try { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; } catch (NotSupportedException) { } HttpWebRequest httpWebRequest = (HttpWebRequest)WebRequest.Create(url); httpWebRequest.Method = method; httpWebRequest.ContentType = "application/json"; httpWebRequest.UserAgent = "Skald/0.4.0"; httpWebRequest.Timeout = timeoutMs; httpWebRequest.ReadWriteTimeout = timeoutMs; byte[] bytes = Encoding.UTF8.GetBytes(json); httpWebRequest.ContentLength = bytes.Length; using (Stream stream = httpWebRequest.GetRequestStream()) { stream.Write(bytes, 0, bytes.Length); } HttpWebResponse httpWebResponse; try { httpWebResponse = (HttpWebResponse)httpWebRequest.GetResponse(); } catch (WebException ex2) { httpWebResponse = ex2.Response as HttpWebResponse; if (httpWebResponse == null) { throw; } } using (httpWebResponse) { try { using StreamReader streamReader = new StreamReader(httpWebResponse.GetResponseStream()); body = streamReader.ReadToEnd(); } catch (Exception) { } return (int)httpWebResponse.StatusCode; } } public static string Redact(string message) { if (string.IsNullOrEmpty(message)) { return message; } string[] array = new string[2] { SkaldConfig.DiscordWebhookUrl.Value, SkaldConfig.StatusWebhookUrl.Value }; foreach (string text in array) { if (!string.IsNullOrEmpty(text) && text.Length > 12) { message = message.Replace(text, "<your webhook>"); } } return message; } private static string BuildPayload(AlertLevel level, string title, string message) { int value = level switch { AlertLevel.Critical => 13715803, AlertLevel.Warning => 14722136, AlertLevel.Recovery => 5999706, _ => 5930394, }; StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("{\"embeds\":[{"); stringBuilder.Append("\"title\":\"").Append(Escape(title)).Append("\","); stringBuilder.Append("\"description\":\"").Append(Escape(message)).Append("\","); stringBuilder.Append("\"color\":").Append(value); stringBuilder.Append("}],\"allowed_mentions\":{\"parse\":[]}}"); return stringBuilder.ToString(); } private static string Escape(string s) { if (string.IsNullOrEmpty(s)) { return ""; } StringBuilder stringBuilder = new StringBuilder(s.Length + 16); foreach (char c in s) { switch (c) { case '"': stringBuilder.Append("\\\""); continue; case '\\': stringBuilder.Append("\\\\"); continue; case '\n': stringBuilder.Append("\\n"); continue; case '\r': stringBuilder.Append("\\r"); continue; case '\t': stringBuilder.Append("\\t"); continue; } if (c < ' ') { StringBuilder stringBuilder2 = stringBuilder.Append("\\u"); int num = c; stringBuilder2.Append(num.ToString("x4")); } else { stringBuilder.Append(c); } } return stringBuilder.ToString(); } } internal struct ForecastResult { public bool Enough; public double Latest; public double SlopePerDay; public double SpanDays; public double DaysUntil; } internal static class Forecast { public const int MinPoints = 12; public const double MinSpanDays = 2.0; public static ForecastResult Linear(IList<KeyValuePair<long, double>> points, double threshold) { ForecastResult result = new ForecastResult { DaysUntil = double.PositiveInfinity }; if (points == null || points.Count == 0) { return result; } long key = points[0].Key; long key2 = points[points.Count - 1].Key; result.Latest = points[points.Count - 1].Value; result.SpanDays = (double)(key2 - key) / 86400.0; if (points.Count < 12 || result.SpanDays < 2.0) { return result; } double num = points.Count; double num2 = 0.0; double num3 = 0.0; double num4 = 0.0; double num5 = 0.0; foreach (KeyValuePair<long, double> point in points) { double num6 = (double)(point.Key - key) / 86400.0; double value = point.Value; num2 += num6; num3 += value; num4 += num6 * num6; num5 += num6 * value; } double num7 = num * num4 - num2 * num2; if (Math.Abs(num7) < 1E-09) { return result; } result.Enough = true; result.SlopePerDay = (num * num5 - num2 * num3) / num7; if (result.Latest >= threshold) { result.DaysUntil = 0.0; } else if (result.SlopePerDay > 1E-06) { result.DaysUntil = (threshold - result.Latest) / result.SlopePerDay; } return result; } public static string Growth(ForecastResult f) { if (!f.Enough) { return "not enough history yet (needs about 2 days of samples)"; } if (f.SlopePerDay < 1.0) { return "holding steady"; } return "about " + Math.Round(f.SlopePerDay).ToString("N0", CultureInfo.InvariantCulture) + " new objects a day"; } public static string Describe(ForecastResult f) { if (!f.Enough) { return "not enough history yet (needs about 2 days of samples)"; } if (f.DaysUntil <= 0.0) { return "already past it"; } if (double.IsInfinity(f.DaysUntil)) { return "not rising, so no limit in sight"; } if (f.DaysUntil < 14.0) { return "about " + Phrases.Plural((int)Math.Round(f.DaysUntil), "day"); } if (f.DaysUntil < 182.0) { return "about " + Phrases.Plural((int)Math.Round(f.DaysUntil / 7.0), "week"); } return "more than 6 months"; } } [HarmonyPatch(typeof(ZoneSystem), "GlobalKeyAdd")] internal static class GlobalKeyAddPatch { private static void Postfix(string keyStr) { if (SkaldRuntime.IsServer) { ServerHub.OnGlobalKey(keyStr); } } } internal sealed class HealthSample { public long Time; public int Zdos; public long ProcessMb; public float FrameMs; public int Peers; } internal sealed class CensusLine { public long Time; public string Category; public int Count; } internal sealed class HealthSummary { public long PeakMb; public float WorstFrameMs; public double SlowestSaveSeconds; public int ZdosNow; public int ZdosAtStart; public bool HaveZdoHistory; public ForecastResult ZdoForecast; } internal static class HealthLog { public const double WriteIntervalSeconds = 900.0; private const int KeepDays = 35; private static readonly List<HealthSample> Samples = new List<HealthSample>(); private static readonly List<KeyValuePair<long, double>> Saves = new List<KeyValuePair<long, double>>(); private static readonly List<CensusLine> Census = new List<CensusLine>(); private static string _path; private static double _sinceWrite; private static long _peakMb; private static float _worstFrame; public static bool Ready => _path != null; public static void TryInit() { if (_path != null || !SkaldStore.Loaded) { return; } _path = SkaldStore.SiblingPath(".skald-health.tsv"); if (_path == null) { return; } Samples.Clear(); Saves.Clear(); Census.Clear(); _sinceWrite = 0.0; try { if (File.Exists(_path)) { Load(File.ReadAllLines(_path)); } } catch (Exception ex) { SkaldPlugin.Log.LogWarning((object)("Could not read health log (" + ex.Message + "); starting a new one.")); } } public static void Reset() { _path = null; Samples.Clear(); Saves.Clear(); Census.Clear(); } public static void RecordCensus(CensusSnapshot snapshot) { TryInit(); if (_path == null || snapshot == null) { return; } StringBuilder stringBuilder = new StringBuilder(); Add(snapshot.Time, "_total", snapshot.Total, stringBuilder); foreach (KeyValuePair<string, int> category in snapshot.Categories) { Add(snapshot.Time, category.Key, category.Value, stringBuilder); } try { File.AppendAllText(_path, stringBuilder.ToString()); } catch (Exception ex) { SkaldPlugin.Log.LogWarning((object)("Could not append the census to the health log: " + ex.Message)); } } private static void Add(long time, string category, int count, StringBuilder sb) { Census.Add(new CensusLine { Time = time, Category = category, Count = count }); sb.Append("c\t").Append(time).Append('\t') .Append(category.Replace('\t', ' ')) .Append('\t') .Append(count) .Append(Environment.NewLine); } public static Dictionary<string, int> CensusAt(long since) { long num = long.MaxValue; foreach (CensusLine censu in Census) { if (censu.Time >= since && censu.Time < num) { num = censu.Time; } } if (num == long.MaxValue) { return null; } Dictionary<string, int> dictionary = new Dictionary<string, int>(); foreach (CensusLine censu2 in Census) { if (censu2.Time == num) { dictionary[censu2.Category] = censu2.Count; } } return dictionary; } public static void Record(double elapsedSeconds, int zdos, long processMb, float frameMs, int peers) { if (processMb > _peakMb) { _peakMb = processMb; } if (frameMs > _worstFrame) { _worstFrame = frameMs; } _sinceWrite += elapsedSeconds; if (!(_sinceWrite < 900.0)) { TryInit(); if (_path != null) { HealthSample healthSample = new HealthSample { Time = Now(), Zdos = zdos, ProcessMb = _peakMb, FrameMs = _worstFrame, Peers = peers }; Samples.Add(healthSample); Append("h\t" + healthSample.Time + "\t" + zdos + "\t" + _peakMb + "\t" + _worstFrame.ToString("0.0", CultureInfo.InvariantCulture) + "\t" + peers); _sinceWrite = 0.0; _peakMb = 0L; _worstFrame = 0f; } } } public static void RecordSave(double seconds) { TryInit(); if (_path != null) { long key = Now(); Saves.Add(new KeyValuePair<long, double>(key, seconds)); Append("s\t" + key + "\t" + seconds.ToString("0.00", CultureInfo.InvariantCulture)); } } public static HealthSummary Summarize(long sinceUnix, int zdoThreshold, int zdosNow, long currentMb) { HealthSummary healthSummary = new HealthSummary { ZdosNow = zdosNow, PeakMb = currentMb }; foreach (HealthSample sample in Samples) { if (sample.Time >= sinceUnix) { if (sample.ProcessMb > healthSummary.PeakMb) { healthSummary.PeakMb = sample.ProcessMb; } if (sample.FrameMs > healthSummary.WorstFrameMs) { healthSummary.WorstFrameM