diff --git a/ui/JevOverlay/.gitignore b/ui/JevOverlay/.gitignore new file mode 100644 index 0000000..3b7b156 --- /dev/null +++ b/ui/JevOverlay/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +# Local absolute path to the decision log. +JevOverlay.config.json diff --git a/ui/JevOverlay/Core/DecisionHistory.cs b/ui/JevOverlay/Core/DecisionHistory.cs new file mode 100644 index 0000000..824e167 --- /dev/null +++ b/ui/JevOverlay/Core/DecisionHistory.cs @@ -0,0 +1,120 @@ +using System.Text.Json; + +namespace JevOverlay.Core; + +public readonly record struct DecisionKey(string Session, int Step); + +public sealed class DecisionEntry(DecisionKey key, JsonElement record) +{ + private static readonly JsonSerializerOptions Pretty = new() { WriteIndented = true }; + public DecisionKey Key { get; } = key; + public JsonElement Record { get; } = record; + public string Action => Text(Record, "action"); + public string Source => Text(Record, "source", "unknown"); + public string Status { get; internal set; } = "Proposal only; execution is not recorded."; + public string? SessionEnd { get; internal set; } + public string Title => $"#{Key.Step} · {Source.ToUpperInvariant()} · {Text(Record, "state_type")}"; + + public string Summary() + { + string latency = ""; + var jev = Field(Record, "jev"); + if (Field(jev, "latency_s") is { ValueKind: JsonValueKind.Number } seconds && + seconds.TryGetDouble(out double number) && double.IsFinite(number)) + latency = $" · {number * 1000:0} ms"; + return Limit($"{Title}\nSession: {Key.Session} · {Text(Record, "ts")}\n" + + $"Proposed: {Action}\nParameters: {Field(Record, "params")}\n" + + $"{Status}\n" + (SessionEnd is null ? "" : $"{SessionEnd}\n") + + $"\nReason: {Text(Record, "reason", "Not recorded")}\n" + + $"Recorded confidence: {Field(Record, "confidence")?.ToString() ?? "—"}{latency}\n" + + (string.IsNullOrEmpty(Text(Record, "error")) ? "" : $"Error: {Text(Record, "error")}\n") + + "Confidence does not prove correctness."); + } + + public string Evidence() + { + var jev = Field(Record, "jev"); + if (jev is null || jev.Value.ValueKind == JsonValueKind.Null) + return "No Jev response recorded for this decision.\nThe source field identifies the policy path."; + return Limit("Recorded Jev response (not private reasoning).\n" + + "The recorded answer gate may differ from the policy's final gate.\n\n" + + JsonSerializer.Serialize(jev.Value, Pretty)); + } + + internal static string Limit(string value) => value.Length <= 16000 ? value : value[..16000] + "\n[Display truncated]"; + internal static JsonElement? Field(JsonElement? obj, string name) => + obj is { ValueKind: JsonValueKind.Object } element && element.TryGetProperty(name, out var value) + ? value : null; + internal static string Text(JsonElement? obj, string name, string fallback = "") => + Field(obj, name) is { ValueKind: JsonValueKind.String } value ? value.GetString()! : fallback; +} + +/// Joins recorded events by session and step, never by wall-clock time. +public sealed class DecisionHistory +{ + public const int Capacity = 100; + private readonly List entries = []; + public IReadOnlyList Entries => entries; + public int SkippedLines { get; private set; } + public string? Notice { get; private set; } + + public void Apply(TailBatch batch) + { + if (batch.Reset) + { + entries.Clear(); + Notice = null; + SkippedLines = 0; + } + SkippedLines += batch.SkippedLines; + foreach (string line in batch.Lines) + { + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + using var document = JsonDocument.Parse(line); + if (!Consume(document.RootElement)) SkippedLines++; + } + catch (JsonException) { SkippedLines++; } + } + } + + private bool Consume(JsonElement root) + { + string session = DecisionEntry.Text(root, "session"); + string kind = DecisionEntry.Text(root, "event"); + if (session.Length == 0 || kind.Length == 0) return false; + if (kind == "session_end") + { + string message = $"Session ended: {DecisionEntry.Text(root, "reason", "unspecified")}"; + foreach (var entry in entries.Where(e => e.Key.Session == session)) entry.SessionEnd = message; + Notice = $"{session}: {message}"; + return true; + } + if (kind is not ("decide" or "action_error" or "action_rejected" or "model_error" or + "fallback_error" or "policy_error" or "no_decision")) + return true; // Other runner events do not describe a decision or its status. + if (DecisionEntry.Field(root, "step") is not { ValueKind: JsonValueKind.Number } stepValue || + !stepValue.TryGetInt32(out int step) || step < 0) return false; + var key = new DecisionKey(session, step); + if (kind == "decide") + { + if (DecisionEntry.Text(root, "action").Length == 0) return false; + entries.RemoveAll(entry => entry.Key == key); + entries.Insert(0, new DecisionEntry(key, root.Clone())); + if (entries.Count > Capacity) entries.RemoveAt(entries.Count - 1); + Notice = null; + } + else if (kind is "action_error" or "action_rejected") + { + var entry = entries.Find(entry => entry.Key == key); + string action = DecisionEntry.Text(root, "action"); + if (entry is not null && (action.Length == 0 || action == entry.Action)) + entry.Status = kind == "action_error" + ? $"Action request error; completion unknown: {DecisionEntry.Text(root, "error")}" + : $"Action request rejected: {DecisionEntry.Text(root, "message")}"; + } + else Notice = DecisionEntry.Limit($"{session} #{step} · {kind}: {DecisionEntry.Text(root, "error")}"); + return true; + } +} diff --git a/ui/JevOverlay/Core/LogTail.cs b/ui/JevOverlay/Core/LogTail.cs new file mode 100644 index 0000000..20f4b30 --- /dev/null +++ b/ui/JevOverlay/Core/LogTail.cs @@ -0,0 +1,93 @@ +using System.Text; + +namespace JevOverlay.Core; + +public sealed record TailBatch(IReadOnlyList Lines, bool Reset = false, + string? Error = null, bool CatchingUp = false, int SkippedLines = 0); + +/// Single-worker, bounded JSONL reader. It never writes to the source. +public sealed class LogTail(string path) +{ + public const int MaxReadBytes = 256 * 1024; + public const int MaxLineBytes = 128 * 1024; + private static readonly UTF8Encoding Utf8 = new(false, true); + private readonly MemoryStream pending = new(); + private long offset; + private byte[] prefix = []; + private byte[] checkpoint = []; + private bool initialized; + private bool discardLine; + + public TailBatch Poll() + { + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + long length = stream.Length; + bool reset = !initialized || length < offset || + !Matches(stream, 0, prefix) || + !Matches(stream, offset - checkpoint.Length, checkpoint); + if (reset) + { + // Start near the end of an existing log, not at the start of a long run. + offset = Math.Max(0, length - MaxReadBytes); + pending.SetLength(0); + discardLine = offset > 0; + prefix = ReadAt(stream, 0, (int)Math.Min(64, length)); + initialized = true; + } + + byte[] bytes = ReadAt(stream, offset, (int)Math.Min(MaxReadBytes, length - offset)); + offset += bytes.Length; + checkpoint = ReadAt(stream, Math.Max(0, offset - 64), (int)Math.Min(64, offset)); + List lines = []; + int skipped = 0; + foreach (byte value in bytes) + { + if (value == (byte)'\n') + { + if (!discardLine && pending.Length > 0) + { + try { lines.Add(Utf8.GetString(pending.GetBuffer(), 0, (int)pending.Length)); } + catch (DecoderFallbackException) { skipped++; } + } + discardLine = false; + pending.SetLength(0); + } + else if (!discardLine) + { + if (pending.Length == MaxLineBytes) + { + pending.SetLength(0); + discardLine = true; + skipped++; + } + else pending.WriteByte(value); + } + } + return new(lines, reset, CatchingUp: offset < length, SkippedLines: skipped); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new([], Error: "Log unavailable. Check log_path and file permissions."); + } + } + + private static bool Matches(FileStream stream, long position, byte[] expected) => + expected.Length == 0 || ReadAt(stream, position, expected.Length).AsSpan().SequenceEqual(expected); + + private static byte[] ReadAt(FileStream stream, long position, int count) + { + stream.Position = position; + byte[] buffer = new byte[count]; + int read = 0; + while (read < count) + { + int next = stream.Read(buffer, read, count - read); + if (next == 0) break; + read += next; + } + return read == count ? buffer : buffer[..read]; + } +} diff --git a/ui/JevOverlay/Core/OverlayConfig.cs b/ui/JevOverlay/Core/OverlayConfig.cs new file mode 100644 index 0000000..7e4e692 --- /dev/null +++ b/ui/JevOverlay/Core/OverlayConfig.cs @@ -0,0 +1,21 @@ +using System.Text.Json; + +namespace JevOverlay.Core; + +public static class OverlayConfig +{ + public const string FileName = "JevOverlay.config.json"; + + public static string LoadLogPath(string modDirectory) + { + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(modDirectory, FileName))); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("log_path", out var value) || + value.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(value.GetString()) || + !Path.IsPathFullyQualified(value.GetString()!)) + throw new FormatException("log_path must be an absolute file path."); + return Path.GetFullPath(value.GetString()!); + } +} diff --git a/ui/JevOverlay/JevOverlay.config.example.json b/ui/JevOverlay/JevOverlay.config.example.json new file mode 100644 index 0000000..4500868 --- /dev/null +++ b/ui/JevOverlay/JevOverlay.config.example.json @@ -0,0 +1,3 @@ +{ + "log_path": "/absolute/path/to/sts2-bot/capture/decisions.jsonl" +} diff --git a/ui/JevOverlay/JevOverlay.csproj b/ui/JevOverlay/JevOverlay.csproj new file mode 100644 index 0000000..4d5aa72 --- /dev/null +++ b/ui/JevOverlay/JevOverlay.csproj @@ -0,0 +1,27 @@ + + + net9.0 + JevOverlay + enable + enable + false + + + + + + + $(STS2GameDataDir)/sts2.dll + false + + + $(STS2GameDataDir)/GodotSharp.dll + false + + + + + + + diff --git a/ui/JevOverlay/JevOverlay.json b/ui/JevOverlay/JevOverlay.json new file mode 100644 index 0000000..113ff0a --- /dev/null +++ b/ui/JevOverlay/JevOverlay.json @@ -0,0 +1,10 @@ +{ + "id": "JevOverlay", + "name": "Jev Decision Log", + "author": "sts2-bot contributors", + "description": "Read-only in-game viewer for the bot's decision log.", + "version": "0.1.0", + "has_pck": false, + "has_dll": true, + "affects_gameplay": false +} diff --git a/ui/JevOverlay/NuGet.Config b/ui/JevOverlay/NuGet.Config new file mode 100644 index 0000000..ddd33ab --- /dev/null +++ b/ui/JevOverlay/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/JevOverlay/UI/OverlayView.cs b/ui/JevOverlay/UI/OverlayView.cs new file mode 100644 index 0000000..4ab931a --- /dev/null +++ b/ui/JevOverlay/UI/OverlayView.cs @@ -0,0 +1,184 @@ +using Godot; +using JevOverlay.Core; + +namespace JevOverlay.UI; + +/// Godot controls only. This view cannot read files or call the game API. +public sealed class OverlayView +{ + private readonly CanvasLayer layer = new() { Name = "JevDecisionOverlay", Layer = 100 }; + private readonly PanelContainer panel = new() { MouseFilter = Control.MouseFilterEnum.Stop }; + private readonly VBoxContainer body = new(); + private readonly Label status = new() { AutowrapMode = TextServer.AutowrapMode.WordSmart }; + private readonly RichTextLabel summary = TextArea(); + private readonly RichTextLabel evidence = TextArea(); + private readonly ItemList history = new() + { + CustomMinimumSize = new(0, 110), + FocusMode = Control.FocusModeEnum.None, + SizeFlagsVertical = Control.SizeFlags.ExpandFill, + }; + private readonly Button collapse = Button("−", "Collapse panel (F8)"); + private readonly Button details = Button("Show questions and answers"); + private readonly Button follow = Button("Follow latest"); + private IReadOnlyList entries = []; + private DecisionKey? selected; + private bool following = true; + private bool collapsed; + private bool expanded; + + public OverlayView(SceneTree tree) + { + var root = new Control { MouseFilter = Control.MouseFilterEnum.Ignore }; + layer.AddChild(root); + tree.Root.AddChild(layer); + root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); + root.AddChild(panel); + panel.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.TopRight); + var style = new StyleBoxFlat + { + BgColor = new Color(0.055f, 0.065f, 0.085f, 0.96f), + BorderColor = new Color(0.3f, 0.4f, 0.5f), + BorderWidthLeft = 1, BorderWidthRight = 1, BorderWidthTop = 1, BorderWidthBottom = 1, + CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8, + CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8, + ContentMarginLeft = 12, ContentMarginRight = 12, + ContentMarginTop = 10, ContentMarginBottom = 10, + }; + panel.AddThemeStyleboxOverride("panel", style); + panel.Theme = new Theme { DefaultFontSize = 16 }; + panel.AddThemeColorOverride("font_color", new Color(0.92f, 0.94f, 0.96f)); + var layout = new VBoxContainer(); + layout.AddThemeConstantOverride("separation", 8); + panel.AddChild(layout); + var header = new HBoxContainer(); + layout.AddChild(header); + header.AddChild(new Label + { + Text = "Jev decisions · read only", + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + MouseFilter = Control.MouseFilterEnum.Ignore, + }); + header.AddChild(collapse); + collapse.Pressed += ToggleCollapsed; + layout.AddChild(body); + body.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + body.AddThemeConstantOverride("separation", 8); + body.AddChild(status); + body.AddChild(summary); + body.AddChild(details); + body.AddChild(evidence); + evidence.Visible = false; + details.Pressed += () => + { + expanded = !expanded; + evidence.Visible = expanded; + details.Text = expanded ? "Hide questions and answers" : "Show questions and answers"; + }; + body.AddChild(new Label { Text = "Recent decisions · select to inspect" }); + body.AddChild(history); + body.AddChild(follow); + follow.ToggleMode = true; + follow.ButtonPressed = true; + follow.Toggled += enabled => + { + following = enabled; + if (enabled) SelectLatest(); + }; + history.ItemSelected += index => + { + if (index < 0 || index >= entries.Count) return; + following = false; + follow.SetPressedNoSignal(false); + selected = entries[(int)index].Key; + RenderSelection(); + }; + summary.Text = "Waiting for decision records.\nF8 collapses or expands this panel."; + } + + public void ToggleCollapsed() + { + collapsed = !collapsed; + body.Visible = !collapsed; + collapse.Text = collapsed ? "+" : "−"; + collapse.TooltipText = collapsed ? "Expand panel (F8)" : "Collapse panel (F8)"; + } + + public void Resize(Vector2 viewport) + { + panel.OffsetLeft = -Math.Min(440, Math.Max(280, viewport.X - 32)); + panel.OffsetRight = -16; + panel.OffsetTop = 64; + panel.OffsetBottom = collapsed ? 112 : Math.Min(viewport.Y - 16, expanded ? 764 : 584); + } + + public void SetStatus(string text, string? tooltip = null) + { + status.Text = text; + status.TooltipText = tooltip ?? text; + } + + public void Render(DecisionHistory model) + { + // Copy the list; selection must refer to the same ordering as the visible rows. + entries = model.Entries.ToArray(); + history.Clear(); + foreach (var entry in entries) + { + int index = history.AddItem($"#{entry.Key.Step} {entry.Source.ToUpperInvariant()} {entry.Action}"); + history.SetItemTooltip(index, $"Session: {entry.Key.Session}\n{entry.Status}"); + history.SetItemCustomFgColor(index, entry.Source switch + { + "jev" => new Color(0.55f, 0.8f, 1f), + "fallback" => new Color(1f, 0.75f, 0.4f), + _ => new Color(0.8f, 0.85f, 0.85f), + }); + } + if (following) SelectLatest(); + else RenderSelection(); + } + + private void SelectLatest() + { + selected = entries.Count == 0 ? null : entries[0].Key; + RenderSelection(); + if (entries.Count > 0) history.EnsureCurrentIsVisible(); + } + + private void RenderSelection() + { + int index = -1; + for (int i = 0; i < entries.Count; i++) + if (entries[i].Key == selected) { index = i; break; } + if (index < 0) + { + summary.Text = selected is null ? "Waiting for decision records." + : "This decision is no longer in the recent history. Select Follow latest."; + evidence.Text = "No decision selected."; + } + else + { + history.Select(index); + summary.Text = entries[index].Summary(); + evidence.Text = entries[index].Evidence(); + } + } + + public void Dispose() => layer.QueueFree(); + + private static Button Button(string text, string tooltip = "") => new() + { + Text = text, TooltipText = tooltip, FocusMode = Control.FocusModeEnum.None, + }; + + private static RichTextLabel TextArea() => new() + { + BbcodeEnabled = false, + SelectionEnabled = true, + ScrollActive = true, + CustomMinimumSize = new(0, 90), + SizeFlagsVertical = Control.SizeFlags.ExpandFill, + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + FocusMode = Control.FocusModeEnum.None, + }; +}