panel public release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:13:02 -07:00
commit 8a94ffd58f
2165 changed files with 301493 additions and 0 deletions
@@ -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("\"", "\\\"");
}
}
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xml>
<Name value="PanelBloodMoonAgent" />
<DisplayName value="Panel Blood-Moon Agent" />
<Description value="Writes a small JSON status file the panel scheduler reads to delay restarts during blood moons." />
<Author value="panel" />
<Version value="0.1.0" />
<Website value="" />
</xml>
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>Panel.BloodMoonAgent</RootNamespace>
<AssemblyName>PanelBloodMoonAgent</AssemblyName>
<OutputType>Library</OutputType>
<!--
We reference Assembly-CSharp.dll ONLY to bind IModApi + Mod at compile
time so the 7DTD loader's reflection probe finds our entry point. All
game DATA access (GamePrefs, World, AIDirector, etc.) goes through
string-keyed reflection (see BloodMoonAgent.cs) to dodge the
EnumGamePrefs landmine where a missing enum member stops Mono from
loading the whole assembly. The two-type binding is stable across 7DTD
versions — IModApi has had the same shape since A19.
Private=false means we don't ship Assembly-CSharp.dll alongside our DLL;
it's resolved at runtime from the game's own Managed folder.
Override LocalAssemblyCSharp on the command line to point at a different
game install:
dotnet build -p:LocalAssemblyCSharp=D:\path\to\Assembly-CSharp.dll
-->
<LocalAssemblyCSharp Condition="'$(LocalAssemblyCSharp)' == ''">C:\7dtdserver\7DaysToDieServer_Data\Managed\Assembly-CSharp.dll</LocalAssemblyCSharp>
<NoWarn>$(NoWarn);CS8632</NoWarn>
</PropertyGroup>
<ItemGroup>
<Reference Include="Assembly-CSharp">
<HintPath>$(LocalAssemblyCSharp)</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>