sts2-bot/ui/JevOverlay/Mod.cs
0xrsydn 130273570b feat(ui): integrate read-only Jev decision overlay
Initialize the Godot panel, poll the decision log in the background, and preserve inspection state during updates. Use a .conf file to keep local settings separate from mod manifests.
2026-09-23 13:40:02 +07:00

105 lines
4 KiB
C#

using System.Diagnostics;
using Godot;
using JevOverlay.Core;
using JevOverlay.UI;
using MegaCrit.Sts2.Core.Modding;
namespace JevOverlay;
[ModInitializer("Initialize")]
public static class Mod
{
private static SceneTree? tree;
private static OverlayView? view;
private static LogTail? reader;
private static readonly DecisionHistory History = new();
private static Task<TailBatch>? poll;
private static double nextPoll;
private static double? lastRows;
private static bool keyDown;
private static string? configError;
private static TailBatch? lastBatch;
public static void Initialize()
{
if (tree is not null) return;
try
{
string directory = Path.GetDirectoryName(typeof(Mod).Assembly.Location)!;
try { reader = new LogTail(OverlayConfig.LoadLogPath(directory)); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or
System.Text.Json.JsonException or FormatException or ArgumentException)
{
configError = $"Configure log_path in {OverlayConfig.FileName}, then restart the game.";
GD.PrintErr($"[JevOverlay] Configuration unavailable or invalid ({ex.GetType().Name}).");
}
tree = (SceneTree)Engine.GetMainLoop();
tree.ProcessFrame += OnFrame;
tree.Root.TreeExiting += Shutdown;
GD.Print("[JevOverlay] Read-only decision viewer initialized. F8 toggles the panel.");
}
catch (Exception ex)
{
GD.PrintErr($"[JevOverlay] Could not initialize ({ex.GetType().Name}).");
}
}
private static void OnFrame()
{
try
{
view ??= new OverlayView(tree!);
bool pressed = Input.IsPhysicalKeyPressed(Key.F8);
if (pressed && !keyDown) view.ToggleCollapsed();
keyDown = pressed;
view.Resize(tree!.Root.GetVisibleRect().Size);
if (configError is not null)
{
view.SetStatus(configError);
return;
}
double now = (double)Stopwatch.GetTimestamp() / Stopwatch.Frequency;
if (poll is { IsCompleted: true })
{
// Only file I/O runs on the worker. All model and UI updates run here.
lastBatch = poll.GetAwaiter().GetResult();
poll = null;
History.Apply(lastBatch);
if (lastBatch.Lines.Count > 0) lastRows = now;
if (lastBatch.Reset || lastBatch.Lines.Count > 0) view.Render(History);
UpdateStatus(now);
}
if (poll is null && now >= nextPoll)
{
nextPoll = now + 0.5;
poll = Task.Run(reader!.Poll);
UpdateStatus(now);
}
}
catch (Exception ex)
{
// A broken viewer must not interrupt play or produce one error per frame.
GD.PrintErr($"[JevOverlay] Viewer disabled ({ex.GetType().Name}).");
if (tree is not null) tree.ProcessFrame -= OnFrame;
view?.Dispose();
view = null;
}
}
private static void UpdateStatus(double now)
{
string text = lastBatch?.Error ?? (lastBatch?.CatchingUp == true ? "Reading pending log rows…"
: lastRows is null ? "Waiting for complete log rows."
: $"Read-only log · no new rows for {Math.Max(0, now - lastRows.Value):0}s");
if (History.SkippedLines > 0) text += $" · skipped {History.SkippedLines}";
if (History.Notice is { } notice)
text += "\n" + (notice.Length > 110 ? notice[..110] + "…" : notice);
view!.SetStatus(text, text + "\nFile activity does not prove that the bot or game is running.");
}
private static void Shutdown()
{
if (tree is not null) tree.ProcessFrame -= OnFrame;
// The scene tree frees the controls. A pending bounded file read can finish independently.
}
}