panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
// 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<int>(_gameUtilsT, "WorldTimeToDays", worldTime);
|
||||
s.InGameHour = InvokeStatic<int>(_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<T>(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("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user