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 VikingStoryteller v0.1.0
plugins/VikingStoryteller.dll
Decompiled 17 hours agousing System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using BepInEx; using BepInEx.Configuration; using HarmonyLib; using Newtonsoft.Json; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: AssemblyVersion("0.0.0.0")] namespace VikingStoryteller; [BepInPlugin("JacobsValheim.VikingStoryteller", "Viking Storyteller", "0.1.0")] public class Plugin : BaseUnityPlugin { private class Connection { public string token; public float helloAt; public float lastHello = -999f; public float window; public int received; } private class Cached { public Entity entity; public float time; } public class Capture { public DeathReport report; public ZDO zdo; public long owner; } private const string Hello = "JVStory_Hello_v1"; private const string Accept = "JVStory_Accept_v1"; private const string Report = "JVStory_Death_v1"; public static Plugin Instance; private static readonly FieldInfo LastHit = AccessTools.Field(typeof(Character), "m_lastHit"); private readonly Dictionary<ZRpc, Connection> connections = new Dictionary<ZRpc, Connection>(); private readonly Dictionary<string, Cached> cache = new Dictionary<string, Cached>(); private readonly Queue<DeathReport> outgoing = new Queue<DeathReport>(); private readonly Queue<string> lines = new Queue<string>(); private readonly HashSet<string> emitted = new HashSet<string>(); private readonly Dictionary<string, Entity> roster = new Dictionary<string, Entity>(); private StoryModel model; private ConfigEntry<bool> send; private ConfigEntry<bool> collect; private Harmony harmony; private string world; private string token; private string directory; private ZRpc acceptedServer; private float nextTick; private float nextSave; private float nextFlush; private long accepted; private long rejected; private long dropped; private long failures; private bool hooksReady; private static readonly JsonSerializerSettings JsonSettings; private void Awake() { //IL_0064: Unknown result type (might be due to invalid IL or missing references) //IL_006e: Expected O, but got Unknown //IL_00c0: Unknown result type (might be due to invalid IL or missing references) //IL_00d5: Unknown result type (might be due to invalid IL or missing references) //IL_00e2: Expected O, but got Unknown //IL_00e2: Expected O, but got Unknown //IL_0122: Unknown result type (might be due to invalid IL or missing references) //IL_0130: Expected O, but got Unknown Instance = this; send = ((BaseUnityPlugin)this).Config.Bind<bool>("Reporting", "Send gameplay death reports", true, "Send owner-observed deaths to a server running this mod. No chat, inventory, credentials, or external service traffic."); collect = ((BaseUnityPlugin)this).Config.Bind<bool>("Server", "Collect gameplay death reports", true, "On a server, accept connected owners' reports and write local story files. Does not grant clients admin powers."); directory = Path.Combine(Paths.ConfigPath, "VikingStoryteller"); harmony = new Harmony("JacobsValheim.VikingStoryteller"); try { Type[] array = new Type[2] { typeof(Character), typeof(Player) }; foreach (Type type in array) { harmony.Patch((MethodBase)AccessTools.DeclaredMethod(type, "OnDeath", (Type[])null, (Type[])null), new HarmonyMethod(typeof(Plugin), "BeforeDeath", (Type[])null), new HarmonyMethod(typeof(Plugin), "AfterDeath", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); } harmony.Patch((MethodBase)AccessTools.Method(typeof(ZDOMan), "HandleDestroyedZDO", (Type[])null, (Type[])null), new HarmonyMethod(typeof(Plugin), "BeforeDestroyed", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); hooksReady = true; } catch (Exception ex) { harmony.UnpatchSelf(); ((BaseUnityPlugin)this).Logger.LogError((object)("Death observation disabled: " + ex.Message)); } ((BaseUnityPlugin)this).Logger.LogInfo((object)"Read-only gameplay reporter loaded. No admin commands, gameplay changes, automatic announcements, or external analytics."); } private void OnDestroy() { if ((Object)(object)Instance == (Object)(object)this) { Instance = null; } if (harmony != null) { harmony.UnpatchSelf(); } if (model != null) { Flush(); Save(); } } private static bool Server() { return (Object)(object)ZNet.instance != (Object)null && ZNet.instance.IsServer(); } private static string CurrentWorld() { return (WorldGenerator.instance == null || WorldGenerator.instance.m_world == null) ? null : WorldGenerator.instance.m_world.m_uid.ToString(); } private void Update() { if (Time.realtimeSinceStartup < nextTick) { return; } nextTick = Time.realtimeSinceStartup + 0.5f; try { Tick(); } catch (Exception ex) { failures++; if (failures <= 3) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Story observation failed: " + ex.Message)); } } } private void Tick() { //IL_03c5: Unknown result type (might be due to invalid IL or missing references) //IL_03ca: Unknown result type (might be due to invalid IL or missing references) //IL_03e9: Unknown result type (might be due to invalid IL or missing references) if ((Object)(object)ZNet.instance == (Object)null || ZDOMan.instance == null || (Object)(object)ZNetScene.instance == (Object)null) { return; } string text = CurrentWorld(); if (text == null) { return; } if (world != text) { if (model != null) { Flush(); Save(); } world = text; token = null; acceptedServer = null; connections.Clear(); outgoing.Clear(); cache.Clear(); roster.Clear(); emitted.Clear(); model = null; } bool flag = Server(); float now = Time.realtimeSinceStartup; if (flag && collect.Value && model == null) { Directory.CreateDirectory(directory); StoryState storyState = null; string text2 = Path.Combine(directory, world + "-state.json"); if (File.Exists(text2)) { try { storyState = JsonConvert.DeserializeObject<StoryState>(File.ReadAllText(text2), JsonSettings); if (storyState == null || storyState.deaths == null || storyState.deathIds == null || storyState.links == null) { throw new Exception("Invalid saved story state"); } } catch { File.Copy(text2, text2 + ".unreadable-" + Guid.NewGuid().ToString("N")); failures++; storyState = null; } } model = new StoryModel(world, storyState, WriteEvent); } List<ZNetPeer> peers = ZNet.instance.GetPeers(); foreach (ZNetPeer item in peers) { if (item.m_rpc == null) { continue; } if (!connections.TryGetValue(item.m_rpc, out var value)) { Connection connection = new Connection(); connection.helloAt = now + 5f; value = connection; connections[item.m_rpc] = value; if (flag) { item.m_rpc.Register<int>("JVStory_Hello_v1", (Action<ZRpc, int>)ReceiveHello); item.m_rpc.Register<string>("JVStory_Death_v1", (Action<ZRpc, string>)ReceiveReport); } else { item.m_rpc.Register<string>("JVStory_Accept_v1", (Action<ZRpc, string>)ReceiveAccept); } } if (!flag && send.Value && item.IsReady() && item == ZNet.instance.GetServerPeer() && acceptedServer != item.m_rpc && now >= value.helloAt) { if (value.received < 3) { item.m_rpc.Invoke("JVStory_Hello_v1", new object[1] { 1 }); value.received++; } value.helloAt = now + 15f; } if (!flag || !item.IsReady() || !(item.m_characterID != ZDOID.None)) { continue; } ZDO zDO = ZDOMan.instance.GetZDO(item.m_characterID); if (zDO != null) { Entity entity = FromZdo(zDO); if (entity != null) { entity.player = true; entity.account = item.m_socket.GetHostName(); entity.name = Clean(item.m_playerName); roster[entity.id] = entity; } } } ZRpc[] array = connections.Keys.Where((ZRpc rpc) => !peers.Any((ZNetPeer p) => p.m_rpc == rpc)).ToArray(); foreach (ZRpc key in array) { connections.Remove(key); } if (!flag && (acceptedServer == null || !peers.Any((ZNetPeer p) => p.m_rpc == acceptedServer))) { acceptedServer = null; token = null; outgoing.Clear(); } if (!flag && send.Value && acceptedServer != null && token != null) { for (int num2 = 0; num2 < 8; num2++) { if (outgoing.Count <= 0) { break; } DeathReport deathReport = outgoing.Dequeue(); deathReport.token = token; if (StoryModel.Valid(deathReport, world, ZNet.instance.GetTimeSeconds())) { acceptedServer.Invoke("JVStory_Death_v1", new object[1] { JsonConvert.SerializeObject((object)deathReport) }); } else { dropped++; } } } string[] array2 = (from p in cache where now - p.Value.time > 30f select p.Key).ToArray(); foreach (string key2 in array2) { cache.Remove(key2); } if (roster.Count > 4096) { array2 = roster.Keys.Take(roster.Count - 4096).ToArray(); foreach (string key2 in array2) { roster.Remove(key2); } } if (flag && model != null) { if (now >= nextFlush) { nextFlush = now + 2f; Flush(); } if (now >= nextSave) { nextSave = now + 15f; Save(); } } } private void ReceiveHello(ZRpc rpc, int version) { try { ZNetPeer val = Peer(rpc); if (Server() && collect.Value && model != null && version == 1 && val != null && connections.TryGetValue(rpc, out var value) && !(Time.realtimeSinceStartup - value.lastHello < 3f)) { value.lastHello = Time.realtimeSinceStartup; if (value.token == null) { value.token = Guid.NewGuid().ToString("N"); } rpc.Invoke("JVStory_Accept_v1", new object[1] { world + "|" + value.token }); } } catch { failures++; } } private void ReceiveAccept(ZRpc rpc, string value) { try { if (!Server() && send.Value && value != null && value.Length <= 80 && ZNet.instance.GetServerPeer() != null && ZNet.instance.GetServerPeer().m_rpc == rpc) { string[] array = value.Split(new char[1] { '|' }); if (array.Length == 2 && !(array[0] != world) && Guid.TryParse(array[1], out var _)) { acceptedServer = rpc; token = array[1]; ((BaseUnityPlugin)this).Logger.LogInfo((object)"Gameplay reporting accepted by the connected server."); } } } catch { failures++; } } private static ZNetPeer Peer(ZRpc rpc) { return ((Object)(object)ZNet.instance == (Object)null) ? null : ((IEnumerable<ZNetPeer>)ZNet.instance.GetPeers()).FirstOrDefault((Func<ZNetPeer, bool>)((ZNetPeer p) => p.IsReady() && p.m_rpc == rpc)); } private void ReceiveReport(ZRpc rpc, string json) { try { ZNetPeer val = Peer(rpc); if (!Server() || !collect.Value || model == null || val == null || !connections.TryGetValue(rpc, out var value) || value.token == null) { rejected++; return; } float realtimeSinceStartup = Time.realtimeSinceStartup; if (realtimeSinceStartup - value.window >= 10f) { value.window = realtimeSinceStartup; value.received = 0; } if (++value.received > 256 || json == null || json.Length > 4096) { rejected++; return; } DeathReport deathReport = JsonConvert.DeserializeObject<DeathReport>(json, JsonSettings); if (!StoryModel.Valid(deathReport, world, ZNet.instance.GetTimeSeconds()) || deathReport.token != value.token) { rejected++; } else { Record(deathReport, val.m_uid, val.m_socket.GetHostName(), "client_owner_report; native entity ownership checked; not anti-cheat proof"); } } catch { rejected++; } } private void Record(DeathReport report, long sender, string reporter, string evidence) { Entity victim = Lookup(report.victim); Entity attacker = Lookup(report.attacker); if (model.Record(report, sender, reporter, victim, attacker, evidence, DateTime.UtcNow)) { accepted++; } else { rejected++; } } public static string Clean(string text) { if (text == null) { return null; } text = text.Replace("\r", " ").Replace("\n", " ").Replace("<", "") .Replace(">", ""); return (text.Length <= 128) ? text : text.Substring(0, 128); } private Entity FromZdo(ZDO z) { //IL_0186: Unknown result type (might be due to invalid IL or missing references) //IL_0192: Unknown result type (might be due to invalid IL or missing references) GameObject prefab = ZNetScene.instance.GetPrefab(z.GetPrefab()); if ((Object)(object)prefab == (Object)null || (Object)(object)prefab.GetComponent<Character>() == (Object)null) { return null; } Entity entity = new Entity(); entity.id = ((object)Unsafe.As<ZDOID, ZDOID>(ref z.m_uid)/*cast due to .constrained prefix*/).ToString(); entity.prefab = ((Object)prefab).name; entity.owner = z.GetOwner(); entity.player = (Object)(object)prefab.GetComponent<Player>() != (Object)null; entity.level = z.GetInt("level", 1); Entity entity2 = entity; if (entity2.player && roster.TryGetValue(entity2.id, out var value)) { entity2.account = value.account; entity2.name = value.name; } if (entity2.player) { ZNetPeer val = ((IEnumerable<ZNetPeer>)ZNet.instance.GetPeers()).FirstOrDefault((Func<ZNetPeer, bool>)((ZNetPeer candidate) => candidate.m_characterID == z.m_uid)); if (val != null) { entity2.account = val.m_socket.GetHostName(); entity2.name = Clean(val.m_playerName); } else if ((Object)(object)Player.m_localPlayer != (Object)null && ((Character)Player.m_localPlayer).GetZDOID() == z.m_uid) { entity2.account = "local-host"; entity2.name = Clean(Player.m_localPlayer.GetPlayerName()); } } return entity2; } private Entity Lookup(string id) { //IL_0041: Unknown result type (might be due to invalid IL or missing references) if (!StoryModel.Id(id)) { return null; } string[] array = id.Split(new char[1] { ':' }); ZDO zDO = ZDOMan.instance.GetZDO(new ZDOID(long.Parse(array[0]), uint.Parse(array[1]))); if (zDO != null) { return FromZdo(zDO); } if (cache.TryGetValue(id, out var value) && Time.realtimeSinceStartup - value.time <= 30f) { return value.entity; } Entity value2; return roster.TryGetValue(id, out value2) ? value2 : null; } private static void BeforeDestroyed(ZDOID __0) { //IL_0038: Unknown result type (might be due to invalid IL or missing references) Plugin instance = Instance; if ((Object)(object)instance == (Object)null || !Server() || instance.model == null) { return; } try { ZDO zDO = ZDOMan.instance.GetZDO(__0); if (zDO == null) { return; } Entity entity = instance.FromZdo(zDO); if (entity != null) { if (instance.cache.Count >= 4096) { instance.dropped++; return; } instance.cache[entity.id] = new Cached { entity = entity, time = Time.realtimeSinceStartup }; } } catch { instance.failures++; } } private static void BeforeDeath(Character __instance, out Capture __state) { //IL_00e1: Unknown result type (might be due to invalid IL or missing references) //IL_00e6: Unknown result type (might be due to invalid IL or missing references) //IL_0154: Unknown result type (might be due to invalid IL or missing references) //IL_0159: Unknown result type (might be due to invalid IL or missing references) //IL_0185: Unknown result type (might be due to invalid IL or missing references) __state = null; Plugin instance = Instance; if ((Object)(object)instance == (Object)null || !instance.hooksReady || (Object)(object)ZNet.instance == (Object)null || instance.world == null || (!Server() && (!instance.send.Value || instance.token == null)) || (Server() && (!instance.collect.Value || instance.model == null))) { return; } try { ZNetView component = ((Component)__instance).GetComponent<ZNetView>(); if ((Object)(object)component == (Object)null || !component.IsOwner() || component.GetZDO() == null || __instance.GetHealth() > 0f) { return; } ZDO zDO = component.GetZDO(); object? value = LastHit.GetValue(__instance); HitData val = (HitData)((value is HitData) ? value : null); Vector3 position = ((Component)__instance).transform.position; __state = new Capture { zdo = zDO, owner = zDO.GetOwner(), report = new DeathReport { id = Guid.NewGuid().ToString(), world = instance.world, victim = ((object)Unsafe.As<ZDOID, ZDOID>(ref zDO.m_uid)/*cast due to .constrained prefix*/).ToString(), attacker = ((val == null || val.m_attacker == ZDOID.None) ? null : ((object)Unsafe.As<ZDOID, ZDOID>(ref val.m_attacker)/*cast due to .constrained prefix*/).ToString()), cause = ((val == null) ? "unknown" : ((object)val.m_hitType).ToString()), player = __instance.IsPlayer(), gameTime = ZNet.instance.GetTimeSeconds(), x = position.x, y = position.y, z = position.z } }; if (Server()) { Entity entity = instance.FromZdo(zDO); if (entity != null) { instance.cache[entity.id] = new Cached { entity = entity, time = Time.realtimeSinceStartup }; } } } catch { instance.failures++; __state = null; } } private static void AfterDeath(Capture __state, bool __runOriginal) { Plugin instance = Instance; if ((Object)(object)instance == (Object)null || __state == null || !__runOriginal) { return; } try { DeathReport report = __state.report; if ((!report.player || __state.zdo.GetBool(ZDOVars.s_dead, false)) && instance.emitted.Add(report.victim)) { if (instance.emitted.Count > 8192) { instance.emitted.Clear(); instance.emitted.Add(report.victim); } if (Server()) { instance.Record(report, __state.owner, "server", "server_owner_observation"); } else if (instance.outgoing.Count < 256) { instance.outgoing.Enqueue(report); } else { instance.dropped++; } } } catch { instance.failures++; } } private void WriteEvent(StoryEvent e) { if (lines.Count >= 2048) { dropped++; } else { lines.Enqueue(JsonConvert.SerializeObject((object)e)); } } private void Flush() { if (lines.Count == 0) { return; } try { string text = Path.Combine(directory, world + "-events.jsonl"); if (File.Exists(text) && new FileInfo(text).Length >= 20971520) { File.Move(text, Path.Combine(directory, world + "-events-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmssfff") + ".jsonl")); foreach (string item in (from x in Directory.GetFiles(directory, world + "-events-*.jsonl") orderby x descending select x).Skip(10)) { File.Delete(item); } } File.AppendAllText(text, string.Join(Environment.NewLine, lines.ToArray()) + Environment.NewLine); lines.Clear(); } catch { failures++; } } private void Atomic(string name, object data) { string text = Path.Combine(directory, name); string text2 = text + ".tmp"; File.WriteAllText(text2, JsonConvert.SerializeObject(data, (Formatting)1)); if (File.Exists(text)) { File.Replace(text2, text, null); } else { File.Move(text2, text); } } private void Save() { if (model == null) { return; } try { Atomic(world + "-state.json", model.state); Atomic("status.json", new { utc = DateTime.UtcNow.ToString("o"), world = world, version = "0.1.0", hooksReady = hooksReady, accepted = accepted, rejected = rejected, dropped = dropped, failures = failures, reportingConnections = connections.Values.Count((Connection c) => c.token != null), queuedRecords = lines.Count, limitations = "Owner-reported deaths are not anti-cheat proof. Missing reporters, unknown prefabs, unresolved attackers, lag, mods replacing death handling and telemetry limits can leave gaps. No automatic announcements or roasts." }); } catch { failures++; } } static Plugin() { //IL_0019: Unknown result type (might be due to invalid IL or missing references) //IL_001f: Expected O, but got Unknown JsonSerializerSettings val = new JsonSerializerSettings(); val.TypeNameHandling = (TypeNameHandling)0; val.MaxDepth = 8; JsonSettings = val; } } public class DeathReport { public int protocol = 1; public string token; public string id; public string world; public string victim; public string attacker; public string cause; public bool player; public double gameTime; public float x; public float y; public float z; } public class Entity { public string id; public string prefab; public string account; public string name; public long owner; public bool player; public int level; } public class StoryEvent { public string id; public string world; public string kind; public string observedUtc; public string evidence; public string reporter; public string sourceEvent; public string relatedEvent; public string beneficiary; public double gameTime; public string cause; public Entity victim; public Entity attacker; public float x; public float y; public float z; } public class StoryState { public string world; public Dictionary<string, string> deathIds = new Dictionary<string, string>(); public List<StoryEvent> deaths = new List<StoryEvent>(); public HashSet<string> links = new HashSet<string>(); } public class StoryModel { public StoryState state; private readonly Action<StoryEvent> output; public StoryModel(string world, StoryState saved, Action<StoryEvent> output) { state = ((saved != null && saved.world == world) ? saved : new StoryState { world = world }); this.output = output; } public static bool Id(string value) { if (string.IsNullOrEmpty(value) || value.Length > 40) { return false; } string[] array = value.Split(new char[1] { ':' }); long result; uint result2; return array.Length == 2 && long.TryParse(array[0], out result) && uint.TryParse(array[1], out result2) && result != 0 && result2 != 0; } public static bool Valid(DeathReport report, string world, double now) { Guid result; return report != null && report.protocol == 1 && report.world == world && Guid.TryParse(report.id, out result) && Id(report.victim) && (report.attacker == null || Id(report.attacker)) && report.cause != null && report.cause.Length <= 64 && !double.IsNaN(report.gameTime) && !double.IsInfinity(report.gameTime) && Math.Abs(now - report.gameTime) <= 120.0 && !float.IsNaN(report.x + report.y + report.z) && !float.IsInfinity(report.x + report.y + report.z) && Math.Abs(report.x) <= 12000f && Math.Abs(report.z) <= 12000f && report.y >= -200f && report.y <= 10000f; } public static bool Authorized(DeathReport report, long sender, Entity victim) { return victim != null && victim.id == report.victim && victim.owner == sender && victim.player == report.player && (!report.player || !string.IsNullOrEmpty(victim.account)); } public bool Record(DeathReport report, long sender, string reporter, Entity victim, Entity attacker, string evidence, DateTime now) { if (!Authorized(report, sender, victim) || state.deathIds.ContainsKey(report.victim)) { return false; } if (attacker != null && attacker.id != report.attacker) { attacker = null; } StoryEvent e = new StoryEvent { id = report.id, world = state.world, kind = (report.player ? "player_death" : "creature_death"), observedUtc = now.ToString("o"), gameTime = report.gameTime, victim = victim, attacker = attacker, cause = report.cause, reporter = reporter, evidence = evidence, x = report.x, y = report.y, z = report.z }; state.deathIds[report.victim] = e.id; state.deaths.Add(e); output(e); StoryEvent[] array = state.deaths.Where((StoryEvent d) => d.kind == "player_death" && d.attacker != null && !d.attacker.player && ((e.kind == "player_death") ? (d.id == e.id) : (d.attacker.id == e.victim.id))).ToArray(); foreach (StoryEvent playerDeath in array) { List<StoryEvent> deaths = state.deaths; Func<StoryEvent, bool> predicate = (StoryEvent d) => d.kind == "creature_death" && (e.kind != "creature_death" || d.id == e.id) && d.victim.id == playerDeath.attacker.id && d.attacker != null && d.attacker.player && !string.IsNullOrEmpty(d.attacker.account) && d.gameTime >= playerDeath.gameTime; StoryEvent[] array2 = deaths.Where(predicate).ToArray(); foreach (StoryEvent storyEvent in array2) { string item = playerDeath.id + "/" + storyEvent.id; if (state.links.Add(item)) { output(new StoryEvent { id = Guid.NewGuid().ToString(), world = state.world, kind = ((storyEvent.attacker.account == playerDeath.victim.account) ? "revenge" : "avenged_by_teammate"), observedUtc = now.ToString("o"), gameTime = storyEvent.gameTime, victim = storyEvent.victim, attacker = storyEvent.attacker, beneficiary = playerDeath.victim.account, sourceEvent = storyEvent.id, relatedEvent = playerDeath.id, evidence = "matching_world_and_entity_ids_from_death_reports; client reports are not anti-cheat proof" }); } } } if (state.deaths.Count > 2000) { state.deaths.RemoveRange(0, state.deaths.Count - 2000); HashSet<string> retained = new HashSet<string>(state.deaths.Select((StoryEvent d) => d.id)); state.links.RemoveWhere((string k) => !retained.Contains(k.Split(new char[1] { '/' })[0]) || !retained.Contains(k.Split(new char[1] { '/' })[1])); } if (state.deathIds.Count > 8192) { HashSet<string> keep = new HashSet<string>(state.deaths.Select((StoryEvent d) => d.victim.id)); string[] array3 = state.deathIds.Keys.Where((string k) => !keep.Contains(k)).Take(state.deathIds.Count - 8192).ToArray(); foreach (string key in array3) { state.deathIds.Remove(key); } } return true; } }