// PanelBloodMoonAgent — writes a tiny status JSON the panel scheduler // reads to decide whether to delay a restart through a blood-moon horde. // // Status file path: /game-saves/.panel-bloodmoon-status.json // (lives next to .panel-telnet-password — same volume the entrypoint // uses for panel-owned state) // // Shape: // { // "schema": 1, // "written_at_unix": 1763500000, // "in_game_day": 14, // "in_game_hour": 22, // "in_game_minute": 3, // "bloodmoon_freq": 7, // "bloodmoon_range": 0, // "day_night_length": 60, // "next_horde_day": 14, // "active": true, // "ends_at_unix": 1763500900, // best-effort wall-clock end (0 = unknown) // "source": "ai_director" // or "math_fallback" // } // // We bind IModApi + Mod at compile time so the 7DTD loader finds our entry // point through its normal reflection probe. EVERY other game type // (GamePrefs, World, AIDirector, AIDirectorBloodMoonComponent, GameStats, // GameUtils) goes through string-keyed reflection — the refugebotserver // Agent's 2026-04-29 outage taught us that a hard reference to a missing // EnumGamePrefs member makes Mono refuse to load the whole assembly. // Type-name lookup keeps us version-tolerant. using System; using System.IO; using System.Reflection; using System.Threading; namespace Panel.BloodMoonAgent { // IModApi is in Assembly-CSharp. The 7DTD ModManager scans loaded mod // assemblies for public, non-abstract types implementing this interface // and instantiates the first one it finds, then calls InitMod(Mod). public class BloodMoonAgent : IModApi { public void InitMod(Mod _modInstance) { try { if (_instance != null) return; // idempotent — Mod manager only calls once, but be safe _instance = this; Start(); TryLog("InitMod completed; status file: " + OutputPath); } catch (Exception ex) { TryLog("InitMod failed: " + ex.Message); } } private static BloodMoonAgent? _instance; // Reflection handles — looked up once, cached. Null means "not in // this game build" and we silently skip that data source. private Type? _enumPrefsT; private Type? _gamePrefsT; private Type? _gameManagerT; private Type? _gameUtilsT; private Type? _gameStatsT; private Type? _enumGameStatsT; private Thread? _worker; private volatile bool _stopRequested; private const string OutputPath = "/game-saves/.panel-bloodmoon-status.json"; private const string OutputPathTmp = "/game-saves/.panel-bloodmoon-status.json.tmp"; private const int TickSeconds = 5; private void Start() { _enumPrefsT = ResolveType("EnumGamePrefs"); _gamePrefsT = ResolveType("GamePrefs"); _gameManagerT = ResolveType("GameManager"); _gameUtilsT = ResolveType("GameUtils"); _gameStatsT = ResolveType("GameStats"); _enumGameStatsT = ResolveType("EnumGameStats"); _worker = new Thread(WorkerLoop) { IsBackground = true, Name = "PanelBloodMoonAgent" }; _worker.Start(); } private static Type? ResolveType(string name) { try { var t = Type.GetType(name + ", Assembly-CSharp"); if (t != null) return t; t = Type.GetType(name); if (t != null) return t; // Fallback: scan loaded assemblies. Some 7DTD builds put types // in unexpected assemblies after Unity merges. foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { try { var hit = asm.GetType(name); if (hit != null) return hit; } catch { } } } catch { } return null; } private void WorkerLoop() { // Wait briefly so GameManager.Instance.World has populated. World // is null during early boot; writing "active=false" too early is // fine (panel falls back to "treat unknown as not-bloodmoon" = // restart proceeds normally) but we'd rather get real data ASAP. while (!_stopRequested) { try { var status = Snapshot(); WriteStatus(status); } catch (Exception ex) { TryLog("tick failed: " + ex.Message); } for (int i = 0; i < TickSeconds * 10 && !_stopRequested; i++) { Thread.Sleep(100); } } } // ── Snapshot ─────────────────────────────────────────────────────── private Status Snapshot() { var s = new Status { Schema = 1, WrittenAtUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), Source = "math_fallback", }; // Game prefs (string-keyed, safe on missing members) s.BloodMoonFreq = PrefInt("BloodMoonFrequency", 7); s.BloodMoonRange = PrefInt("BloodMoonRange", 0); s.DayNightLength = PrefInt("DayNightLength", 60); // World time → day/hour/minute via GameUtils.WorldTimeToDays/Hours ulong worldTime = 0; object? world = GetWorld(); if (world != null) { worldTime = (ulong)(GetFieldValue(world, "worldTime") ?? 0UL); } if (worldTime > 0 && _gameUtilsT != null) { s.InGameDay = InvokeStatic(_gameUtilsT, "WorldTimeToDays", worldTime); s.InGameHour = InvokeStatic(_gameUtilsT, "WorldTimeToHours", worldTime); s.InGameMinute = (int)((worldTime / 60UL) % 60UL); } // Authoritative: ask the AIDirector's BloodMoonComponent. // Path: GameManager.Instance.World.aiDirector.BloodMoonComponent.BloodMoonActive // GameStats.GetInt(EnumGameStats.BloodMoonDay) → next horde day bool gotAuthoritative = false; try { if (world != null) { var aiDirector = GetFieldOrProp(world, "aiDirector"); if (aiDirector != null) { var bmComponent = GetFieldOrProp(aiDirector, "BloodMoonComponent"); if (bmComponent != null) { var activeObj = GetFieldOrProp(bmComponent, "BloodMoonActive"); if (activeObj is bool active) { s.Active = active; gotAuthoritative = true; s.Source = "ai_director"; } } } } } catch (Exception ex) { TryLog("authoritative bloodmoon lookup failed: " + ex.Message); } // Next horde day via GameStats.GetInt(EnumGameStats.BloodMoonDay) try { if (_gameStatsT != null && _enumGameStatsT != null && Enum.IsDefined(_enumGameStatsT, "BloodMoonDay")) { var key = Enum.Parse(_enumGameStatsT, "BloodMoonDay"); var m = _gameStatsT.GetMethod("GetInt", new[] { _enumGameStatsT }); if (m != null) { var v = m.Invoke(null, new[] { key }); s.NextHordeDay = Convert.ToInt32(v ?? 0); } } } catch (Exception ex) { TryLog("GameStats.GetInt(BloodMoonDay) failed: " + ex.Message); } // Math fallback for Active if AIDirector wasn't available. // Window is in-game 22:00 (bloodmoon day) → 04:00 (next day). // This is wrong when BloodMoonRange > 0 — the authoritative path // is the correct answer in that case. We treat math as best-effort. if (!gotAuthoritative && s.BloodMoonFreq > 0 && s.InGameDay > 0) { int day = s.InGameDay; int freq = s.BloodMoonFreq; bool todayIsBM = (day % freq == 0); bool yesterdayWasBM = (day > 1) && ((day - 1) % freq == 0); if (todayIsBM && s.InGameHour >= 22) s.Active = true; else if (yesterdayWasBM && s.InGameHour < 4) s.Active = true; // Also derive NextHordeDay from math if we didn't get it from GameStats if (s.NextHordeDay == 0) { int rem = freq - (day % freq); s.NextHordeDay = day + ((rem == 0) ? freq : rem); } } // Compute EndsAtUnix when active: time until in-game 04:00 of the // morning after the bloodmoon day, scaled by DayNightLength. // DayNightLength = real minutes per in-game 24h day. if (s.Active && s.DayNightLength > 0 && worldTime > 0) { int gameMinutesLeft; if (s.InGameHour >= 22) { // 22:00 → 28:00 (04:00 next day) = up to 6h = 360 game-minutes gameMinutesLeft = ((28 - s.InGameHour) * 60) - s.InGameMinute; } else if (s.InGameHour < 4) { gameMinutesLeft = ((4 - s.InGameHour) * 60) - s.InGameMinute; } else { // Mid-day bloodmoon? Only possible if AIDirector says so // outside the normal window. Conservative: assume 360 game-min. gameMinutesLeft = 360; } if (gameMinutesLeft < 0) gameMinutesLeft = 0; // 1440 game-minutes per in-game day → real seconds per game-min: // realSecPerGameMin = (DayNightLength * 60) / 1440 = DayNightLength / 24 double realSecondsLeft = (double)gameMinutesLeft * s.DayNightLength / 24.0; s.EndsAtUnix = s.WrittenAtUnix + (long)Math.Ceiling(realSecondsLeft); } return s; } // ── GamePrefs helpers (mirrors refugebotserver's pattern) ────────── private int PrefInt(string name, int fallback) { try { if (_enumPrefsT == null || _gamePrefsT == null) return fallback; if (!Enum.IsDefined(_enumPrefsT, name)) return fallback; var key = Enum.Parse(_enumPrefsT, name); var m = _gamePrefsT.GetMethod("GetInt", new[] { _enumPrefsT }); if (m == null) return fallback; return Convert.ToInt32(m.Invoke(null, new[] { key }) ?? fallback); } catch { return fallback; } } // ── Reflection helpers ───────────────────────────────────────────── private object? GetWorld() { try { if (_gameManagerT == null) return null; // GameManager.Instance is a static FIELD (not a property) in // current 7DTD builds. Try field first, then property as a // safety net for older/newer builds. object? instance = null; var instF = _gameManagerT.GetField("Instance", BindingFlags.Public | BindingFlags.Static); if (instF != null) instance = instF.GetValue(null); if (instance == null) { var instP = _gameManagerT.GetProperty("Instance", BindingFlags.Public | BindingFlags.Static); if (instP != null) instance = instP.GetValue(null); } if (instance == null) return null; return GetFieldOrProp(instance, "World"); } catch { return null; } } private static object? GetFieldOrProp(object target, string name) { if (target == null) return null; var t = target.GetType(); var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); if (f != null) return f.GetValue(target); var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); if (p != null) return p.GetValue(target); return null; } private static object? GetFieldValue(object target, string name) { return GetFieldOrProp(target, name); } private static T InvokeStatic(Type t, string methodName, params object[] args) { try { var argTypes = new Type[args.Length]; for (int i = 0; i < args.Length; i++) argTypes[i] = args[i].GetType(); var m = t.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static, null, argTypes, null); if (m == null) return default!; var v = m.Invoke(null, args); return v == null ? default! : (T)Convert.ChangeType(v, typeof(T)); } catch { return default!; } } // ── Output ───────────────────────────────────────────────────────── private void WriteStatus(Status s) { string json = s.ToJson(); try { // Atomic write: write to .tmp, then rename. Reader sees either // the old file or the new one, never a torn write. File.WriteAllText(OutputPathTmp, json); // File.Replace fails on Mono if destination doesn't exist; use // a plain Move+Delete fallback. if (File.Exists(OutputPath)) { File.Delete(OutputPath); } File.Move(OutputPathTmp, OutputPath); } catch (Exception ex) { TryLog("write status failed: " + ex.Message); } } // ── Logging ──────────────────────────────────────────────────────── // 7DTD's Log.Out is in Assembly-CSharp. We do best-effort reflection // so logs surface in the game console; fall back to stderr otherwise. private static Type? _logT; private static MethodInfo? _logOutM; private static bool _logResolved; private static void TryLog(string msg) { try { if (!_logResolved) { _logT = Type.GetType("Log, Assembly-CSharp") ?? Type.GetType("Log"); if (_logT != null) { _logOutM = _logT.GetMethod("Out", new[] { typeof(string) }); } _logResolved = true; } var line = "[PanelBloodMoonAgent] " + msg; if (_logOutM != null) { _logOutM.Invoke(null, new object[] { line }); } else { Console.Error.WriteLine(line); } } catch { } } // ── Status DTO ───────────────────────────────────────────────────── // Hand-rolled JSON — keeps the mod dependency-free (no Newtonsoft // load, no risk of clashing with 7DTD's bundled copy). private class Status { public int Schema; public long WrittenAtUnix; public int InGameDay; public int InGameHour; public int InGameMinute; public int BloodMoonFreq; public int BloodMoonRange; public int DayNightLength; public int NextHordeDay; public bool Active; public long EndsAtUnix; public string Source = ""; public string ToJson() { return "{" + "\"schema\":" + Schema + ",\"written_at_unix\":" + WrittenAtUnix + ",\"in_game_day\":" + InGameDay + ",\"in_game_hour\":" + InGameHour + ",\"in_game_minute\":" + InGameMinute + ",\"bloodmoon_freq\":" + BloodMoonFreq + ",\"bloodmoon_range\":" + BloodMoonRange + ",\"day_night_length\":" + DayNightLength + ",\"next_horde_day\":" + NextHordeDay + ",\"active\":" + (Active ? "true" : "false") + ",\"ends_at_unix\":" + EndsAtUnix + ",\"source\":\"" + JsonEscape(Source) + "\"" + "}"; } private static string JsonEscape(string s) { if (string.IsNullOrEmpty(s)) return ""; return s.Replace("\\", "\\\\").Replace("\"", "\\\""); } } } }