From 9ae7f7ffa17ecfabffbaa708812996a571db0fb6 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 14 Jul 2026 17:59:37 -0500 Subject: [PATCH] Perfecting the dossier hud --- .cursor/skills/idle-spectator-e2e/SKILL.md | 27 +- .../skills/idle-spectator-e2e/reference.md | 14 + IdleSpectator/AgentHarness.cs | 88 ++- IdleSpectator/Chronicle.cs | 24 + IdleSpectator/DossierAvatar.cs | 14 +- IdleSpectator/HarnessScenarios.cs | 7 +- IdleSpectator/Main.cs | 3 + IdleSpectator/UnitDossier.cs | 112 ++- IdleSpectator/WatchCaption.cs | 725 ++++++++++++++---- IdleSpectator/mod.json | 4 +- .../crop-dossier-hud.cpython-314.pyc | Bin 0 -> 14963 bytes scripts/crop-dossier-hud.py | 112 +++ scripts/harness-run.sh | 9 + 13 files changed, 971 insertions(+), 168 deletions(-) create mode 100644 scripts/__pycache__/crop-dossier-hud.cpython-314.pyc create mode 100755 scripts/crop-dossier-hud.py diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md index fd726b5..1dba1b8 100644 --- a/.cursor/skills/idle-spectator-e2e/SKILL.md +++ b/.cursor/skills/idle-spectator-e2e/SKILL.md @@ -42,6 +42,31 @@ After any feature or bug fix in `IdleSpectator/`: 6. Iterate until **all repeats PASS**. Do not stop after one flaky pass. 7. Report: scenario name(s), PASS/FAIL counts, and any new asserts. +### HUD / visual changes - screenshot gate (required) + +For dossier, Chronicle HUD, captions, icons, layout, or any on-screen chrome: + +1. Run a scenario that takes a screenshot (e.g. `chronicle_smoke`). + The harness writes: + - `.harness/hud-….png` (full frame) + - `.harness/hud-…-dossier.png` (**exact dossier UI rect** via `ReadPixels` - use this) +2. Wait ~1s after the run so the end-of-frame crop finishes, then: + ```bash + ./scripts/crop-dossier-hud.py --strict IdleSpectator/.harness/hud-*-dossier.png + ``` +3. **Open the `*-dossier.png` with the Read tool.** Never judge layout from the raw 2880p frame. +4. If the player pastes an in-game shot, treat that as ground truth. +5. Checklist - fail the change if any are wrong: + - Nametag row visible: species icon, `Name (species)`, level icon **and number**, task, sex + - No large empty void beside the avatar when History is empty (traits or history should fill it) + - History lines appear beside the avatar only when the subject has History + - Traits packed (no huge empty gaps); prefer full labels when space allows + - No clipped/overlapping chips; no header buried under the portrait frame +6. Fix and re-screenshot until the dossier crop matches the intended layout. +7. Report the `*-dossier.png` path(s) and what you verified. + +Harness asserts catch logic; Unity dossier crops catch layout. Both are required for UI work. + For tiny doc-only edits, harness is optional. For anything that touches camera, spectator, tips, settings, discovery, chronicle, or input exit, harness is required. @@ -118,7 +143,7 @@ Use `fast_timing` / `director_run` instead. - `set_setting` (`expect=enabled|show_watch_reasons|show_dossier_caption|chronicle_enabled`) - `discovery_reset`, `discovery_note`, `discovery_mark`, `discovery_drain` - `chronicle_force`, `chronicle_open`, `chronicle_jump` -- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `chronicle_count`, `chronicle_latest_contains` +- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `dossier_traits_ok`, `caption_layout_ok`, `dossier_history_contains`, `chronicle_count`, `chronicle_latest_contains` - `snapshot`, `reset_counters`, `scenario` ## Process safety diff --git a/.cursor/skills/idle-spectator-e2e/reference.md b/.cursor/skills/idle-spectator-e2e/reference.md index 9ab09e5..a94673e 100644 --- a/.cursor/skills/idle-spectator-e2e/reference.md +++ b/.cursor/skills/idle-spectator-e2e/reference.md @@ -58,6 +58,20 @@ Use harness `fast_timing` + `director_run` / `age_current` for scheduling tests. - `caption_layout_ok` - dossier panel size/layout bounds (catches off-panel text) - `screenshot` harness action writes `.harness/.png` via Unity ScreenCapture +## Screenshots + +Harness `screenshot` writes: + +- `IdleSpectator/.harness/.png` - full frame +- `IdleSpectator/.harness/-dossier.png` - **exact dossier rect** (`ReadPixels`) + +For HUD work: + +1. Prefer reading `*-dossier.png` (wait ~1s after the run). +2. Validate with `./scripts/crop-dossier-hud.py --strict IdleSpectator/.harness/hud-*-dossier.png` +3. Do not judge layout from raw 2880p frames. +4. Player-pasted shots override harness crops. + ## Extending scenarios Edit `IdleSpectator/HarnessScenarios.cs`: diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index b6c328c..d303977 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -1121,10 +1122,44 @@ public static class AgentHarness { pass = WatchCaption.LastLayoutOk && WatchCaption.LastPanelSize.y > 20f - && WatchCaption.LastPanelSize.y < 120f - && WatchCaption.LastPanelSize.x <= 280f; + && WatchCaption.LastPanelSize.y < 140f + && WatchCaption.LastPanelSize.x <= 300f; detail = - $"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}'"; + $"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}' hist='{(WatchCaption.LastHistoryPreview ?? "").Replace("\n", " ")}'"; + break; + } + case "dossier_traits_ok": + { + UnitDossier dossier = WatchCaption.Current; + string preview = WatchCaption.LastTraitsPreview ?? ""; + int count = dossier != null ? dossier.TopTraits.Count : 0; + bool noEllipsis = preview.IndexOf("...", System.StringComparison.Ordinal) < 0; + bool bounded = count >= 0 && count <= 3; + bool labelsMatch = true; + if (dossier != null) + { + for (int i = 0; i < dossier.TopTraits.Count; i++) + { + string name = dossier.TopTraits[i].Name ?? ""; + if (name.IndexOf("...", System.StringComparison.Ordinal) >= 0) + { + labelsMatch = false; + break; + } + } + } + + pass = bounded && noEllipsis && labelsMatch; + detail = $"traits={count} preview='{preview}' layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}"; + break; + } + case "dossier_history_contains": + { + string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; + string hist = WatchCaption.LastHistoryPreview ?? ""; + pass = !string.IsNullOrEmpty(needle) + && hist.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0; + detail = $"hist='{hist}' needle='{needle}'"; break; } case "chronicle_world_latest_contains": @@ -1820,8 +1855,17 @@ public static class AgentHarness // Absolute path required so Unity does not write under the game cwd. ScreenCapture.CaptureScreenshot(path); LastScreenshotPath = path; + + string cropPath = Path.Combine( + dir, + Path.GetFileNameWithoutExtension(name) + "-dossier.png"); + if (ModClass.Instance != null) + { + ModClass.Instance.StartCoroutine(CaptureDossierCropEndOfFrame(cropPath)); + } + LogService.LogInfo( - $"[IdleSpectator][HARNESS] screenshot queued path={path} layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}"); + $"[IdleSpectator][HARNESS] screenshot queued path={path} crop={cropPath} layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}"); return true; } catch (Exception ex) @@ -1832,6 +1876,42 @@ public static class AgentHarness } } + private static IEnumerator CaptureDossierCropEndOfFrame(string cropPath) + { + yield return new WaitForEndOfFrame(); + try + { + if (!WatchCaption.TryGetScreenPixelRect(out Rect rect)) + { + LogService.LogInfo("[IdleSpectator][HARNESS] dossier crop skipped: no visible dossier rect"); + yield break; + } + + int x = Mathf.Clamp(Mathf.FloorToInt(rect.xMin), 0, Screen.width - 1); + int y = Mathf.Clamp(Mathf.FloorToInt(rect.yMin), 0, Screen.height - 1); + int w = Mathf.Clamp(Mathf.CeilToInt(rect.width), 1, Screen.width - x); + int h = Mathf.Clamp(Mathf.CeilToInt(rect.height), 1, Screen.height - y); + if (w < 16 || h < 16) + { + LogService.LogInfo($"[IdleSpectator][HARNESS] dossier crop skipped: tiny rect {w}x{h}"); + yield break; + } + + Texture2D tex = new Texture2D(w, h, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(x, y, w, h), 0, 0); + tex.Apply(); + byte[] png = tex.EncodeToPNG(); + UnityEngine.Object.Destroy(tex); + File.WriteAllBytes(cropPath, png); + LogService.LogInfo( + $"[IdleSpectator][HARNESS] dossier crop written path={cropPath} rect=({x},{y},{w},{h}) screen={Screen.width}x{Screen.height}"); + } + catch (Exception ex) + { + LogService.LogInfo("[IdleSpectator][HARNESS] dossier crop failed: " + ex.Message); + } + } + private static string HarnessDir() { if (string.IsNullOrEmpty(ModClass.ModFolder)) diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index c0011b8..b22785a 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -118,6 +118,30 @@ public static class Chronicle } } + /// Newest-first slice for compact dossier HUD. + public static IReadOnlyList LatestForSubject(long subjectId, int max) + { + if (max <= 0) + { + return System.Array.Empty(); + } + + IReadOnlyList all = SnapshotForSubject(subjectId); + if (all.Count == 0) + { + return all; + } + + int take = Mathf.Min(max, all.Count); + ChronicleEntry[] newest = new ChronicleEntry[take]; + for (int i = 0; i < take; i++) + { + newest[i] = all[all.Count - 1 - i]; + } + + return newest; + } + public static IReadOnlyList SnapshotWorld() { lock (WorldEntries) diff --git a/IdleSpectator/DossierAvatar.cs b/IdleSpectator/DossierAvatar.cs index 1f05c10..9a732a5 100644 --- a/IdleSpectator/DossierAvatar.cs +++ b/IdleSpectator/DossierAvatar.cs @@ -21,6 +21,18 @@ public static class DossierAvatar public static bool UsingVanilla => _usingVanilla; + public static void ResetHost() + { + _template = null; + _instance = null; + _host = null; + _fallbackBg = null; + _fallbackSprite = null; + _usingVanilla = false; + _loggedMode = false; + _shownActorId = -1; + } + public static void EnsureHost(Transform parent, float size) { if (_host != null) @@ -28,7 +40,7 @@ public static class DossierAvatar return; } - GameObject hostGo = new GameObject("DossierAvatarHost", typeof(RectTransform)); + GameObject hostGo = new GameObject("DossierAvatarHost", typeof(RectTransform), typeof(RectMask2D)); hostGo.transform.SetParent(parent, false); _host = hostGo.GetComponent(); _host.anchorMin = new Vector2(0f, 1f); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 0b63002..c84d7e9 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -407,13 +407,17 @@ internal static class HarnessScenarios Step("ch11", "assert", expect: "dossier_contains", value: "auto"), Step("ch12", "assert", expect: "caption_contains", value: "auto"), Step("ch12b", "assert", expect: "caption_layout_ok"), - Step("ch12c", "assert", expect: "caption_contains", value: "age"), + Step("ch12c", "assert", expect: "dossier_traits_ok"), + Step("ch12d", "screenshot", value: "hud-dossier-empty-hist.png"), + Step("ch12e", "wait", wait: 0.55f), // History inject Step("ch13", "chronicle_force", label: "Killed"), Step("ch14", "assert", expect: "chronicle_count", value: "1", label: "min"), Step("ch15", "assert", expect: "chronicle_latest_contains", value: "Killed"), Step("ch15b", "assert", expect: "chronicle_latest_contains", value: "auto"), + Step("ch15c", "wait", wait: 0.2f), + Step("ch15d", "assert", expect: "dossier_history_contains", value: "Killed"), // Death-cause wording samples (victim POV) Step("ch16a", "chronicle_death_sample", value: "Age"), @@ -437,6 +441,7 @@ internal static class HarnessScenarios Step("ch18a2", "wait", wait: 0.6f), Step("ch18b", "chronicle_tab", value: "history"), Step("ch18c", "assert", expect: "caption_layout_ok"), + Step("ch18c2", "assert", expect: "dossier_traits_ok"), Step("ch18d", "assert", expect: "chronicle_tab", value: "history"), Step("ch18e", "chronicle_tab", value: "world"), Step("ch18f", "assert", expect: "chronicle_tab", value: "world"), diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs index 4c1952e..42d98e4 100644 --- a/IdleSpectator/Main.cs +++ b/IdleSpectator/Main.cs @@ -19,6 +19,8 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable /// Absolute path to this mod's folder (parent of mod.json). public static string ModFolder { get; private set; } + public static ModClass Instance { get; private set; } + public ModDeclare GetDeclaration() { return _declare; @@ -46,6 +48,7 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable public void OnLoad(ModDeclare pModDecl, GameObject pGameObject) { + Instance = this; _declare = pModDecl; _gameObject = pGameObject; ModFolder = pModDecl.FolderPath; diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index a70141c..0e87cb7 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Text; @@ -438,6 +439,8 @@ public sealed class UnitDossier return; } + // Pick the most interesting traits: rarity first, then non-default / dramatic. + List ranked = new List(); foreach (ActorTrait trait in actor.getTraits()) { if (trait == null || string.IsNullOrEmpty(trait.id)) @@ -445,16 +448,20 @@ public sealed class UnitDossier continue; } + ranked.Add(trait); + } + + ranked.Sort((a, b) => TraitInterestScore(b, actor).CompareTo(TraitInterestScore(a, actor))); + + for (int i = 0; i < ranked.Count && d.TopTraits.Count < 3; i++) + { + ActorTrait trait = ranked[i]; d.TopTraits.Add(new TraitChip { Id = trait.id, Name = TraitDisplayName(trait), Trait = trait }); - if (d.TopTraits.Count >= 3) - { - break; - } } } catch @@ -463,6 +470,103 @@ public sealed class UnitDossier } } + /// + /// Spectator-facing interest: Legendary/Epic over Normal, non-default over species baseline, + /// Positive/Negative over bland Other. Ties break on rarer birth rates. + /// + private static int TraitInterestScore(ActorTrait trait, Actor actor) + { + if (trait == null) + { + return int.MinValue; + } + + int score = 0; + try + { + score += (int)trait.rarity * 100; + } + catch + { + // older builds without rarity + } + + try + { + if (trait.type == TraitType.Negative) + { + score += 25; + } + else if (trait.type == TraitType.Positive) + { + score += 15; + } + } + catch + { + // ignore + } + + if (!IsDefaultTraitForActor(trait, actor)) + { + score += 50; + } + else + { + score -= 40; + } + + try + { + // Low birth rate = unusual to spawn with; more interesting on a watch card. + if (trait.rate_birth > 0 && trait.rate_birth <= 5) + { + score += 20; + } + else if (trait.rate_birth >= 40) + { + score -= 15; + } + } + catch + { + // ignore + } + + string id = trait.id ?? ""; + if (id.IndexOf("miracle", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("immortal", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("zombie", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("blessed", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("cursed", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("evil", StringComparison.OrdinalIgnoreCase) >= 0) + { + score += 35; + } + + return score; + } + + private static bool IsDefaultTraitForActor(ActorTrait trait, Actor actor) + { + try + { + if (trait == null || actor == null || actor.asset == null + || trait.default_for_actor_assets == null) + { + return false; + } + + return trait.default_for_actor_assets.Contains(actor.asset); + } + catch + { + return false; + } + } + private static string TraitDisplayName(ActorTrait trait) { try diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 7fc31c7..61c3f10 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; using UnityEngine.UI; @@ -5,30 +6,36 @@ using UnityEngine.UI; namespace IdleSpectator; /// -/// Persistent compact dossier (Option A / vanilla strip): -/// header species+name+sex, live portrait + level/task, full-width trait strip, reason. +/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, packed traits, reason. /// public static class WatchCaption { - private const float PanelWidth = 220f; + private const float PanelWidthMax = 340f; private const float SpeciesSize = 16f; private const float SexSize = 14f; private const float LiveMax = 44f; - private const float ChipIcon = 14f; + private const float ChipIcon = 12f; private const float TraitIcon = 12f; + private const float HistoryIcon = 10f; + private const float HistoryColW = 118f; private const float PadX = 4f; private const float PadTop = 4f; private const float HeaderH = 18f; private const float BodyH = 44f; - private const float TraitsH = 16f; + private const float TraitsH = 14f; private const float ReasonH = 12f; + private const float HistoryLineH = 13f; private const float Gap = 2f; + private const int HistoryMax = 3; + + private static float _panelWidth = LiveMax + PadX * 2f; private static readonly Color NameColor = new Color(0.95f, 0.93f, 0.88f, 1f); private static readonly Color StatValueColor = new Color(1f, 0.62f, 0.28f, 1f); private static readonly Color TaskColor = new Color(0.26f, 1f, 0.26f, 1f); private static readonly Color ReasonColor = new Color(0.95f, 0.72f, 0.38f, 1f); private static readonly Color TraitNameColor = new Color(0.78f, 0.8f, 0.84f, 1f); + private static readonly Color HistoryTextColor = new Color(0.82f, 0.84f, 0.86f, 1f); private static GameObject _root; private static RectTransform _rootRt; @@ -36,16 +43,20 @@ public static class WatchCaption private static Image _sexIcon; private static Text _nameText; private static Text _reasonText; - private static Text _metaText; private static Image _levelIcon; private static Text _levelValue; private static Image _taskIcon; - private static GameObject _metaCol; + private static Text _taskText; private static readonly TraitSlot[] _traitSlots = new TraitSlot[3]; private static GameObject _traitsRow; + private static GameObject _historyCol; + private static readonly HistorySlot[] _historySlots = new HistorySlot[HistoryMax]; + private static int _lastHistoryCount = -1; + private static long _lastHistorySubjectId; + private static UnitDossier _current; private static Actor _boundActor; private static bool _visible; @@ -57,6 +68,13 @@ public static class WatchCaption public Text Label; } + private sealed class HistorySlot + { + public GameObject Root; + public Image Icon; + public Text Label; + } + public static UnitDossier Current => _current; public static string LastHeadline { get; private set; } = ""; @@ -65,10 +83,45 @@ public static class WatchCaption public static string LastCaptionText { get; private set; } = ""; + /// Harness: newest mini-history line currently shown (if any). + public static string LastHistoryPreview { get; private set; } = ""; + + /// Harness: comma-joined top trait labels currently shown. + public static string LastTraitsPreview { get; private set; } = ""; + public static Vector2 LastPanelSize { get; private set; } public static bool LastLayoutOk { get; private set; } + /// Screen-pixel rect of the dossier card (Unity bottom-left origin), if visible. + public static bool TryGetScreenPixelRect(out Rect pixelRect) + { + pixelRect = default; + if (_rootRt == null || _root == null || !_root.activeInHierarchy) + { + return false; + } + + Vector3[] corners = new Vector3[4]; + _rootRt.GetWorldCorners(corners); + float xMin = float.MaxValue; + float yMin = float.MaxValue; + float xMax = float.MinValue; + float yMax = float.MinValue; + for (int i = 0; i < 4; i++) + { + Vector2 sp = RectTransformUtility.WorldToScreenPoint(null, corners[i]); + xMin = Mathf.Min(xMin, sp.x); + yMin = Mathf.Min(yMin, sp.y); + xMax = Mathf.Max(xMax, sp.x); + yMax = Mathf.Max(yMax, sp.y); + } + + float pad = 4f; + pixelRect = Rect.MinMaxRect(xMin - pad, yMin - pad, xMax + pad, yMax + pad); + return pixelRect.width > 8f && pixelRect.height > 8f; + } + public static void Clear() { _current = null; @@ -76,8 +129,12 @@ public static class WatchCaption LastHeadline = ""; LastDetail = ""; LastCaptionText = ""; + LastHistoryPreview = ""; + LastTraitsPreview = ""; LastPanelSize = Vector2.zero; LastLayoutOk = false; + _lastHistoryCount = -1; + _lastHistorySubjectId = 0; ApplyVisual(null, null); SetVisible(false); } @@ -95,6 +152,7 @@ public static class WatchCaption LastHeadline = CameraDirector.FormatWatchTip(interest); LastDetail = ""; LastCaptionText = LastHeadline; + LastHistoryPreview = ""; _current = null; _boundActor = null; EnsureBuilt(); @@ -104,7 +162,7 @@ public static class WatchCaption _nameText.text = LastHeadline; } - Relayout(false, 0, false, false); + Relayout(false, 0, false, false, 0); SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active); } @@ -151,13 +209,14 @@ public static class WatchCaption else if (_nameText != null) { _nameText.text = LastHeadline; - Relayout(false, 0, false, false); + Relayout(false, 0, false, false, 0); } SetVisible(true); } RefreshLivePortrait(); + RefreshHistoryIfChanged(); } private static void RefreshLivePortrait() @@ -186,8 +245,62 @@ public static class WatchCaption return; } - // Tile floor can change as the unit moves; keep portrait in sync. DossierAvatar.Show(actor); + BringHeaderFront(); + } + + private static void BringHeaderFront() + { + // Draw nametag above vanilla avatar chrome (which can paint outside its host). + void Front(Component c) + { + if (c != null) + { + c.transform.SetAsLastSibling(); + } + } + + Front(_speciesIcon); + Front(_nameText); + Front(_levelIcon); + Front(_levelValue); + Front(_taskIcon); + Front(_taskText); + Front(_sexIcon); + } + + private static void RefreshHistoryIfChanged() + { + if (!_visible || _current == null) + { + return; + } + + long id = _current.UnitId; + int count = Chronicle.HistoryCountFor(id); + if (id == _lastHistorySubjectId && count == _lastHistoryCount) + { + return; + } + + int shown = FillHistory(id); + // Re-run layout only when history presence flips or line count changes. + bool hasBody = _boundActor != null; + bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; + bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; + int traits = 0; + if (_traitsRow != null && _traitsRow.activeSelf) + { + for (int i = 0; i < _traitSlots.Length; i++) + { + if (_traitSlots[i] != null && _traitSlots[i].Root != null && _traitSlots[i].Root.activeSelf) + { + traits++; + } + } + } + + Relayout(hasBody, traits, hasTask, hasReason, shown); } private static void ApplyVisual(Actor actor, UnitDossier dossier) @@ -235,19 +348,23 @@ public static class WatchCaption bool hasBody = dossier != null && actor != null; DossierAvatar.SetActive(hasBody); - if (_metaCol != null) + + bool hasLevel = hasBody; + if (_levelIcon != null) { - _metaCol.SetActive(hasBody); + HudIcons.Apply(_levelIcon, hasLevel ? HudIcons.Level() : null); + _levelIcon.gameObject.SetActive(hasLevel); + } + + if (_levelValue != null) + { + _levelValue.text = hasLevel ? dossier.Level.ToString() : ""; + _levelValue.gameObject.SetActive(hasLevel); } if (hasBody) { DossierAvatar.Show(actor); - HudIcons.Apply(_levelIcon, HudIcons.Level()); - if (_levelValue != null) - { - _levelValue.text = dossier.Level.ToString(); - } } else { @@ -261,18 +378,26 @@ public static class WatchCaption _taskIcon.gameObject.SetActive(hasTask); } - if (_metaText != null) + if (_taskText != null) { - _metaText.text = hasTask ? dossier.TaskText : ""; - _metaText.gameObject.SetActive(hasTask); + string task = hasTask ? dossier.TaskText : ""; + if (task.Length > 14) + { + task = task.Substring(0, 13) + "..."; + } + + _taskText.text = task; + _taskText.gameObject.SetActive(hasTask); } int traitCount = 0; + LastTraitsPreview = ""; if (_traitsRow != null) { if (dossier != null && dossier.TopTraits.Count > 0) { _traitsRow.SetActive(true); + System.Text.StringBuilder traitPreview = new System.Text.StringBuilder(); for (int i = 0; i < _traitSlots.Length; i++) { TraitSlot slot = _traitSlots[i]; @@ -286,17 +411,18 @@ public static class WatchCaption UnitDossier.TraitChip chip = dossier.TopTraits[i]; slot.Root.SetActive(true); HudIcons.Apply(slot.Icon, HudIcons.FromTrait(chip.Trait)); + string name = chip.Name ?? ""; if (slot.Label != null) { - string label = chip.Name ?? ""; - if (label.Length > 11) - { - label = label.Substring(0, 10) + "..."; - } - - slot.Label.text = label; + slot.Label.text = name; } + if (traitPreview.Length > 0) + { + traitPreview.Append(", "); + } + + traitPreview.Append(name); traitCount++; } else @@ -304,6 +430,8 @@ public static class WatchCaption slot.Root.SetActive(false); } } + + LastTraitsPreview = traitPreview.ToString(); } else { @@ -311,6 +439,20 @@ public static class WatchCaption } } + int historyCount = 0; + if (dossier != null) + { + historyCount = FillHistory(dossier.UnitId); + } + else + { + LastHistoryPreview = ""; + if (_historyCol != null) + { + _historyCol.SetActive(false); + } + } + bool hasReason = dossier != null && !string.IsNullOrEmpty(dossier.ReasonLine); if (_reasonText != null) { @@ -318,24 +460,104 @@ public static class WatchCaption _reasonText.gameObject.SetActive(hasReason); } - Relayout(hasBody, traitCount, hasTask, hasReason); + Relayout(hasBody, traitCount, hasTask, hasReason, historyCount); } - private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason) + private static int FillHistory(long unitId) + { + IReadOnlyList entries = Chronicle.LatestForSubject(unitId, HistoryMax); + _lastHistorySubjectId = unitId; + _lastHistoryCount = Chronicle.HistoryCountFor(unitId); + + int shown = 0; + LastHistoryPreview = ""; + if (_historyCol == null) + { + return 0; + } + + if (entries == null || entries.Count == 0) + { + _historyCol.SetActive(false); + return 0; + } + + _historyCol.SetActive(true); + for (int i = 0; i < _historySlots.Length; i++) + { + HistorySlot slot = _historySlots[i]; + if (slot == null || slot.Root == null) + { + continue; + } + + if (i < entries.Count && entries[i] != null) + { + ChronicleEntry e = entries[i]; + slot.Root.SetActive(true); + HudIcons.Apply(slot.Icon, HudIcons.ForChronicleKind(e.Kind)); + string line = e.Line ?? ""; + if (line.Length > 28) + { + line = line.Substring(0, 27) + "..."; + } + + if (slot.Label != null) + { + slot.Label.text = line; + } + + if (shown == 0) + { + LastHistoryPreview = e.Line ?? ""; + } + + shown++; + } + else + { + slot.Root.SetActive(false); + } + } + + return shown; + } + + private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason, int historyCount) { float y = PadTop; - PlaceHeader(y); + float headerW = PlaceHeader(y, hasTask); y += HeaderH + Gap; + float bodyW = 0f; + float traitsW = 0f; + bool traitsBeside = hasBody && historyCount <= 0 && traitCount > 0; + if (hasBody) { - PlaceBody(y); - y += BodyH + Gap; + PlaceBody(y, historyCount); + if (historyCount > 0) + { + bodyW = LiveMax + 4f + HistoryColW; + y += BodyH + Gap; + } + else if (traitsBeside) + { + // Fill the body-right void with a vertical trait stack beside the portrait. + traitsW = PlaceTraitsBesideAvatar(y, traitCount); + bodyW = LiveMax + 4f + traitsW; + y += BodyH + Gap; + } + else + { + bodyW = LiveMax; + y += BodyH + Gap; + } } - if (traitCount > 0) + if (traitCount > 0 && !traitsBeside) { - PlaceTraits(y, traitCount); + traitsW = PlaceTraits(y, traitCount); y += TraitsH + Gap; } @@ -345,116 +567,194 @@ public static class WatchCaption y += ReasonH + Gap; } + _panelWidth = Mathf.Clamp( + PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, LiveMax), + LiveMax + PadX * 2f, + PanelWidthMax); + float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap); if (_rootRt != null) { - _rootRt.sizeDelta = new Vector2(PanelWidth, height); + _rootRt.sizeDelta = new Vector2(_panelWidth, height); LastPanelSize = _rootRt.sizeDelta; } - LastLayoutOk = ProbeLayoutInside(height); + BringHeaderFront(); + + LastLayoutOk = ProbeLayoutInside(height) && headerW > SpeciesSize; if (!LastLayoutOk) { LogService.LogInfo( - $"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} task={hasTask} reason={hasReason}"); + $"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} hist={historyCount} headerW={headerW}"); } } - private static void PlaceHeader(float yFromTop) + /// Left-pack nametag on the root: [species] Name [lv]n [task] [sex]. + private static float PlaceHeader(float yFromTop, bool hasTask) { + float x = PadX; + if (_speciesIcon != null) { - RectTransform rt = _speciesIcon.GetComponent(); - rt.anchorMin = new Vector2(0f, 1f); - rt.anchorMax = new Vector2(0f, 1f); - rt.pivot = new Vector2(0f, 1f); - rt.anchoredPosition = new Vector2(PadX, -yFromTop); - rt.sizeDelta = new Vector2(SpeciesSize, SpeciesSize); - } - - if (_sexIcon != null) - { - RectTransform rt = _sexIcon.GetComponent(); - rt.anchorMin = new Vector2(1f, 1f); - rt.anchorMax = new Vector2(1f, 1f); - rt.pivot = new Vector2(1f, 1f); - rt.anchoredPosition = new Vector2(-PadX, -yFromTop - 1f); - rt.sizeDelta = new Vector2(SexSize, SexSize); + PlaceLeftChip(_speciesIcon.rectTransform, x, yFromTop + 1f, SpeciesSize, SpeciesSize); + x += SpeciesSize + 3f; } if (_nameText != null) { - RectTransform rt = _nameText.GetComponent(); - rt.anchorMin = new Vector2(0f, 1f); - rt.anchorMax = new Vector2(1f, 1f); - rt.pivot = new Vector2(0.5f, 1f); - float right = (_sexIcon != null && _sexIcon.gameObject.activeSelf) ? (PadX + SexSize + 2f) : PadX; - rt.offsetMin = new Vector2(PadX + SpeciesSize + 4f, -(yFromTop + HeaderH)); - rt.offsetMax = new Vector2(-right, -yFromTop); + float nameW = Mathf.Clamp(MeasureTextWidth(_nameText, 72f), 28f, 120f); + PlaceLeftChip(_nameText.rectTransform, x, yFromTop, nameW, HeaderH); + x += nameW + 5f; } + + if (_levelIcon != null && _levelIcon.gameObject.activeSelf) + { + PlaceLeftChip(_levelIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon); + x += ChipIcon + 1f; + } + + if (_levelValue != null && _levelValue.gameObject.activeSelf) + { + float lvW = Mathf.Clamp(MeasureTextWidth(_levelValue, 10f), 8f, 24f); + PlaceLeftChip(_levelValue.rectTransform, x, yFromTop, lvW, HeaderH); + x += lvW + 4f; + } + + if (hasTask && _taskIcon != null && _taskText != null) + { + _taskIcon.gameObject.SetActive(true); + _taskText.gameObject.SetActive(true); + PlaceLeftChip(_taskIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon); + x += ChipIcon + 1f; + float taskW = Mathf.Clamp(MeasureTextWidth(_taskText, 36f), 20f, 72f); + PlaceLeftChip(_taskText.rectTransform, x, yFromTop, taskW, HeaderH); + x += taskW + 4f; + } + else + { + if (_taskText != null) + { + _taskText.gameObject.SetActive(false); + } + + if (_taskIcon != null) + { + _taskIcon.gameObject.SetActive(false); + } + } + + if (_sexIcon != null && _sexIcon.gameObject.activeSelf) + { + PlaceLeftChip(_sexIcon.rectTransform, x, yFromTop + 2f, SexSize, SexSize); + x += SexSize; + } + + BringHeaderFront(); + return Mathf.Max(0f, x - PadX); } - private static void PlaceBody(float yFromTop) + private static void PlaceLeftChip(RectTransform rt, float x, float yFromTop, float width, float height) + { + if (rt == null) + { + return; + } + + rt.anchorMin = new Vector2(0f, 1f); + rt.anchorMax = new Vector2(0f, 1f); + rt.pivot = new Vector2(0f, 1f); + rt.anchoredPosition = new Vector2(x, -yFromTop); + rt.sizeDelta = new Vector2(width, height); + } + + private static float MeasureTextWidth(Text text, float fallback) + { + if (text == null || string.IsNullOrEmpty(text.text)) + { + return fallback; + } + + try + { + float w = text.preferredWidth; + if (w > 1f) + { + return w + 2f; + } + } + catch + { + // fall through + } + + return fallback; + } + + private static void PlaceBody(float yFromTop, int historyCount) { DossierAvatar.Place(PadX, yFromTop, LiveMax); - if (_metaCol != null) + if (_historyCol == null) { - RectTransform col = _metaCol.GetComponent(); - col.anchorMin = new Vector2(0f, 1f); - col.anchorMax = new Vector2(1f, 1f); - col.pivot = new Vector2(0.5f, 1f); - col.offsetMin = new Vector2(PadX + LiveMax + 4f, -(yFromTop + BodyH)); - col.offsetMax = new Vector2(-PadX, -yFromTop); + return; } - // Level row at top of meta col, task under it. - if (_levelIcon != null) + if (historyCount <= 0) { - RectTransform irt = _levelIcon.GetComponent(); - irt.anchorMin = new Vector2(0f, 1f); - irt.anchorMax = new Vector2(0f, 1f); - irt.pivot = new Vector2(0f, 1f); - irt.anchoredPosition = new Vector2(0f, -2f); - irt.sizeDelta = new Vector2(ChipIcon, ChipIcon); + _historyCol.SetActive(false); + return; } - if (_levelValue != null) - { - RectTransform vrt = _levelValue.GetComponent(); - vrt.anchorMin = new Vector2(0f, 1f); - vrt.anchorMax = new Vector2(1f, 1f); - vrt.pivot = new Vector2(0.5f, 1f); - vrt.offsetMin = new Vector2(ChipIcon + 3f, -18f); - vrt.offsetMax = new Vector2(0f, -2f); - } + _historyCol.SetActive(true); + RectTransform col = _historyCol.GetComponent(); + col.anchorMin = new Vector2(0f, 1f); + col.anchorMax = new Vector2(0f, 1f); + col.pivot = new Vector2(0f, 1f); + col.anchoredPosition = new Vector2(PadX + LiveMax + 4f, -yFromTop); + col.sizeDelta = new Vector2(HistoryColW, BodyH); - if (_taskIcon != null) + for (int i = 0; i < _historySlots.Length; i++) { - RectTransform irt = _taskIcon.GetComponent(); - irt.anchorMin = new Vector2(0f, 1f); - irt.anchorMax = new Vector2(0f, 1f); - irt.pivot = new Vector2(0f, 1f); - irt.anchoredPosition = new Vector2(0f, -22f); - irt.sizeDelta = new Vector2(ChipIcon, ChipIcon); - } + HistorySlot slot = _historySlots[i]; + if (slot == null || slot.Root == null || !slot.Root.activeSelf) + { + continue; + } - if (_metaText != null) - { - RectTransform trt = _metaText.GetComponent(); - trt.anchorMin = new Vector2(0f, 1f); - trt.anchorMax = new Vector2(1f, 1f); - trt.pivot = new Vector2(0.5f, 1f); - trt.offsetMin = new Vector2(ChipIcon + 3f, -38f); - trt.offsetMax = new Vector2(0f, -22f); + RectTransform rt = slot.Root.GetComponent(); + rt.anchorMin = new Vector2(0f, 1f); + rt.anchorMax = new Vector2(1f, 1f); + rt.pivot = new Vector2(0.5f, 1f); + float top = i * HistoryLineH; + rt.offsetMin = new Vector2(0f, -(top + HistoryLineH)); + rt.offsetMax = new Vector2(0f, -top); + + if (slot.Icon != null) + { + RectTransform irt = slot.Icon.GetComponent(); + irt.anchorMin = new Vector2(0f, 0.5f); + irt.anchorMax = new Vector2(0f, 0.5f); + irt.pivot = new Vector2(0f, 0.5f); + irt.anchoredPosition = Vector2.zero; + irt.sizeDelta = new Vector2(HistoryIcon, HistoryIcon); + } + + if (slot.Label != null) + { + RectTransform lrt = slot.Label.GetComponent(); + lrt.anchorMin = new Vector2(0f, 0f); + lrt.anchorMax = new Vector2(1f, 1f); + lrt.offsetMin = new Vector2(HistoryIcon + 2f, 0f); + lrt.offsetMax = Vector2.zero; + } } } - private static void PlaceTraits(float yFromTop, int traitCount) + private static float PlaceTraits(float yFromTop, int traitCount) { if (_traitsRow == null) { - return; + return 0f; } RectTransform row = _traitsRow.GetComponent(); @@ -464,8 +764,7 @@ public static class WatchCaption row.offsetMin = new Vector2(PadX, -(yFromTop + TraitsH)); row.offsetMax = new Vector2(-PadX, -yFromTop); - float innerW = PanelWidth - PadX * 2f; - float slotW = innerW / Mathf.Max(1, traitCount); + float x = 0f; for (int i = 0; i < _traitSlots.Length; i++) { TraitSlot slot = _traitSlots[i]; @@ -474,31 +773,93 @@ public static class WatchCaption continue; } - RectTransform rt = slot.Root.GetComponent(); - rt.anchorMin = new Vector2(0f, 0f); - rt.anchorMax = new Vector2(0f, 1f); - rt.pivot = new Vector2(0f, 0.5f); - rt.anchoredPosition = new Vector2(i * slotW, 0f); - rt.sizeDelta = new Vector2(slotW - 2f, TraitsH); + float labelW = slot.Label != null + ? Mathf.Max(MeasureTextWidth(slot.Label, 28f), 18f) + : 28f; + float slotW = TraitIcon + 2f + labelW; + PlaceTraitSlot(slot, x, 0f, slotW, TraitsH); + x += slotW + 3f; + } - if (slot.Icon != null) + return x > 0f ? x - 3f : 0f; + } + + /// Vertical trait stack in the body-right slot when History is empty. + private static float PlaceTraitsBesideAvatar(float yFromTop, int traitCount) + { + if (_traitsRow == null) + { + return 0f; + } + + float colW = 0f; + for (int i = 0; i < _traitSlots.Length; i++) + { + TraitSlot slot = _traitSlots[i]; + if (slot == null || slot.Root == null || !slot.Root.activeSelf || slot.Label == null) { - RectTransform irt = slot.Icon.GetComponent(); - irt.anchorMin = new Vector2(0f, 0.5f); - irt.anchorMax = new Vector2(0f, 0.5f); - irt.pivot = new Vector2(0f, 0.5f); - irt.anchoredPosition = Vector2.zero; - irt.sizeDelta = new Vector2(TraitIcon, TraitIcon); + continue; } - if (slot.Label != null) + float labelW = Mathf.Max(MeasureTextWidth(slot.Label, 36f), 24f); + colW = Mathf.Max(colW, TraitIcon + 2f + labelW); + } + + if (colW < 1f) + { + colW = 56f; + } + + RectTransform row = _traitsRow.GetComponent(); + row.anchorMin = new Vector2(0f, 1f); + row.anchorMax = new Vector2(0f, 1f); + row.pivot = new Vector2(0f, 1f); + row.anchoredPosition = new Vector2(PadX + LiveMax + 4f, -yFromTop); + row.sizeDelta = new Vector2(colW, BodyH); + + float line = Mathf.Min(HistoryLineH, BodyH / Mathf.Max(1, traitCount)); + int shown = 0; + for (int i = 0; i < _traitSlots.Length; i++) + { + TraitSlot slot = _traitSlots[i]; + if (slot == null || slot.Root == null || !slot.Root.activeSelf) { - RectTransform lrt = slot.Label.GetComponent(); - lrt.anchorMin = new Vector2(0f, 0f); - lrt.anchorMax = new Vector2(1f, 1f); - lrt.offsetMin = new Vector2(TraitIcon + 2f, 0f); - lrt.offsetMax = Vector2.zero; + continue; } + + PlaceTraitSlot(slot, 0f, -(shown * line), colW, line); + shown++; + } + + return colW; + } + + private static void PlaceTraitSlot(TraitSlot slot, float x, float y, float slotW, float slotH) + { + RectTransform rt = slot.Root.GetComponent(); + rt.anchorMin = new Vector2(0f, 1f); + rt.anchorMax = new Vector2(0f, 1f); + rt.pivot = new Vector2(0f, 1f); + rt.anchoredPosition = new Vector2(x, y); + rt.sizeDelta = new Vector2(slotW, slotH); + + if (slot.Icon != null) + { + RectTransform irt = slot.Icon.GetComponent(); + irt.anchorMin = new Vector2(0f, 0.5f); + irt.anchorMax = new Vector2(0f, 0.5f); + irt.pivot = new Vector2(0f, 0.5f); + irt.anchoredPosition = Vector2.zero; + irt.sizeDelta = new Vector2(TraitIcon, TraitIcon); + } + + if (slot.Label != null) + { + RectTransform lrt = slot.Label.GetComponent(); + lrt.anchorMin = new Vector2(0f, 0f); + lrt.anchorMax = new Vector2(1f, 1f); + lrt.offsetMin = new Vector2(TraitIcon + 2f, 0f); + lrt.offsetMax = Vector2.zero; } } @@ -513,7 +874,9 @@ public static class WatchCaption && (_reasonText == null || !_reasonText.gameObject.activeSelf || LineInside(_reasonText.GetComponent(), panelHeight)) && (_traitsRow == null || !_traitsRow.activeSelf - || LineInside(_traitsRow.GetComponent(), panelHeight)); + || LineInside(_traitsRow.GetComponent(), panelHeight)) + && (_historyCol == null || !_historyCol.activeSelf + || LineInside(_historyCol.GetComponent(), panelHeight)); } private static bool LineInside(RectTransform line, float panelHeight) @@ -533,6 +896,13 @@ public static class WatchCaption return y >= 0f && y + h <= panelHeight + 0.5f; } + if (line.anchorMin.y == line.anchorMax.y && line.anchorMin.x == line.anchorMax.x) + { + float y = -line.anchoredPosition.y; + float h = line.sizeDelta.y > 0.1f ? line.sizeDelta.y : HeaderH; + return y >= 0f && y + h <= panelHeight + 0.5f; + } + return top >= 0f && bottom <= panelHeight + 0.5f && top < bottom; } @@ -550,6 +920,33 @@ public static class WatchCaption private static void EnsureBuilt() { + if (_root != null && (_nameText == null || _nameText.transform.parent != _root.transform)) + { + // Stale HUD from an older build (nested nametag row) - rebuild. + try + { + Object.Destroy(_root); + } + catch + { + // ignore + } + + _root = null; + _rootRt = null; + _speciesIcon = null; + _sexIcon = null; + _nameText = null; + _reasonText = null; + _levelIcon = null; + _levelValue = null; + _taskIcon = null; + _taskText = null; + _traitsRow = null; + _historyCol = null; + DossierAvatar.ResetHost(); + } + if (_root != null) { return; @@ -568,7 +965,7 @@ public static class WatchCaption _rootRt.anchorMin = new Vector2(0f, 1f); _rootRt.anchorMax = new Vector2(0f, 1f); _rootRt.pivot = new Vector2(0f, 1f); - _rootRt.sizeDelta = new Vector2(PanelWidth, HeaderH + PadTop * 2f); + _rootRt.sizeDelta = new Vector2(LiveMax + PadX * 2f, HeaderH + PadTop * 2f); _rootRt.anchoredPosition = new Vector2(12f, -12f); Image bg = _root.GetComponent(); @@ -579,33 +976,25 @@ public static class WatchCaption group.blocksRaycasts = false; group.interactable = false; - _speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize); - _sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize); - - _nameText = HudCanvas.MakeText(_root.transform, "Name", "", 11); - _nameText.fontStyle = FontStyle.Bold; - _nameText.color = NameColor; - _nameText.alignment = TextAnchor.MiddleLeft; - _nameText.horizontalOverflow = HorizontalWrapMode.Overflow; - + // Avatar/history/traits first so nametag chips can SetAsLastSibling on top. DossierAvatar.EnsureHost(_root.transform, LiveMax); - _metaCol = new GameObject("MetaCol", typeof(RectTransform)); - _metaCol.transform.SetParent(_root.transform, false); - _levelIcon = HudCanvas.MakeIcon(_metaCol.transform, "LevelIcon", ChipIcon); - _levelValue = HudCanvas.MakeText(_metaCol.transform, "LevelValue", "", 10); - _levelValue.color = StatValueColor; - _levelValue.fontStyle = FontStyle.Bold; - _levelValue.alignment = TextAnchor.MiddleLeft; - _levelValue.horizontalOverflow = HorizontalWrapMode.Overflow; - _levelValue.resizeTextMinSize = 8; - _levelValue.resizeTextMaxSize = 10; - - _taskIcon = HudCanvas.MakeIcon(_metaCol.transform, "TaskIcon", ChipIcon); - _metaText = HudCanvas.MakeText(_metaCol.transform, "Task", "", 9); - _metaText.color = TaskColor; - _metaText.alignment = TextAnchor.MiddleLeft; - _metaText.horizontalOverflow = HorizontalWrapMode.Overflow; + _historyCol = new GameObject("HistoryCol", typeof(RectTransform)); + _historyCol.transform.SetParent(_root.transform, false); + for (int i = 0; i < _historySlots.Length; i++) + { + GameObject slotGo = new GameObject("Hist" + i, typeof(RectTransform)); + slotGo.transform.SetParent(_historyCol.transform, false); + Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", HistoryIcon); + Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8); + label.color = HistoryTextColor; + label.alignment = TextAnchor.MiddleLeft; + label.horizontalOverflow = HorizontalWrapMode.Overflow; + label.resizeTextMinSize = 6; + label.resizeTextMaxSize = 8; + _historySlots[i] = new HistorySlot { Root = slotGo, Icon = icon, Label = label }; + slotGo.SetActive(false); + } _traitsRow = new GameObject("TraitsRow", typeof(RectTransform)); _traitsRow.transform.SetParent(_root.transform, false); @@ -628,14 +1017,40 @@ public static class WatchCaption _reasonText.color = ReasonColor; _reasonText.alignment = TextAnchor.MiddleLeft; + _speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize); + _sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize); + + _nameText = HudCanvas.MakeText(_root.transform, "Name", "", 11); + _nameText.fontStyle = FontStyle.Bold; + _nameText.color = NameColor; + _nameText.alignment = TextAnchor.MiddleLeft; + _nameText.horizontalOverflow = HorizontalWrapMode.Overflow; + + _levelIcon = HudCanvas.MakeIcon(_root.transform, "LevelIcon", ChipIcon); + _levelValue = HudCanvas.MakeText(_root.transform, "LevelValue", "", 9); + _levelValue.color = StatValueColor; + _levelValue.fontStyle = FontStyle.Bold; + _levelValue.alignment = TextAnchor.MiddleLeft; + _levelValue.horizontalOverflow = HorizontalWrapMode.Overflow; + + _taskIcon = HudCanvas.MakeIcon(_root.transform, "TaskIcon", ChipIcon); + _taskText = HudCanvas.MakeText(_root.transform, "Task", "", 8); + _taskText.color = TaskColor; + _taskText.alignment = TextAnchor.MiddleLeft; + _taskText.horizontalOverflow = HorizontalWrapMode.Overflow; + DossierAvatar.SetActive(false); - _metaCol.SetActive(false); + _historyCol.SetActive(false); _traitsRow.SetActive(false); _reasonText.gameObject.SetActive(false); - Relayout(false, 0, false, false); + _levelIcon.gameObject.SetActive(false); + _levelValue.gameObject.SetActive(false); + _taskIcon.gameObject.SetActive(false); + _taskText.gameObject.SetActive(false); + Relayout(false, 0, false, false, 0); _root.SetActive(false); _visible = false; - LogService.LogInfo("[IdleSpectator] Dossier HUD ready (vanilla strip + avatar frame)"); + LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag chips + mini history)"); } private static void PlaceLine(RectTransform lineRt, float yFromTop, float height, float left) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 8863c78..7be1ce8 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.9.10", - "description": "AFK Idle Spectator (I) + Chronicle (F9). Vanilla-strip dossier with unit avatar frame.", + "version": "0.9.20", + "description": "AFK Idle Spectator (I) + Chronicle (F9). Full trait names; rarity-ranked; E2E trait assert.", "GUID": "com.dazed.idlespectator" } diff --git a/scripts/__pycache__/crop-dossier-hud.cpython-314.pyc b/scripts/__pycache__/crop-dossier-hud.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..528635652bf77aa68ec1f84a099af8fa1b5051b6 GIT binary patch literal 14963 zcmb_@Yj7J!df*H&cmu?f1YZ(Ck)jBR50RAhupSgiks>9NqK4E^_ENw>1V}<6fzb>o zKD1t8_cj+uvO?GrBRFftaMq5wiInA5%4f#qOO)H&DyPm{2O7i@J7Kcg+Wq6Y{GiTq z?QX78$=3r6Fn}moPHk7sOi%aMJzszQ_1E8{XIFtvjX~P@n7S}jjbXn+FG3j`4NW2H{mINdfC@<-e2BiFx2oU^J7LdVH+9U5)1Qcl)Wk3aGM31^#6VOy( zq|Azuaw|4r2xv(K?otl`aA7xyV9^1H!@p)L*#*Q!^kB=Sq2O8AsE~vK+`I!lKyMG4S(t#<) z!`fVo2igZbK3ntt{kvzH_V3<((8hRNZm(~Q|2E^Aw)OWNw~c$<0FJOpf4@OJIPPU_ z(~LI&6^Gb>XYxqBP)9@S+U6OX&jTM|19qFs2e{G!uixjIn3%PVy1Wyt%{$6>=mVq! zt}*p_1mrsJzwF_QX+INST;70f!tcMx+B}nVU={{)hINg3UR2v`4UI5BFCAbTM}Sbm zr|ZY3+zs?>F3lPn(llv=+WL`k#_#ivOn98^r2nF)0g(O5?&|B=0OK7AJgeWGd{1_q z)3|&-KTH;kmhFRgqTdx5ho`J(5^x_ubDf@V3~{IwUcazGCWuj(L$d$7z$H*tj-AGV zAAloBDM>UUzL0$`0WePLVwgF#k8bd}A?KRqG@eOwkjbZ+LxyI$O@|ob8*o zKEKcN0}RlRkNTNOSAa!0Y~#8c$NiI@Mz?Fu<8JI{{1<@F*v4tZDd+t&^^<-#+vo)b zL^;p1NX}`U&h)@NPA8*<-mP%46>tk;zrh?hUh{~*M|N)T&zQX@RqzFXU_4z=1|=0- zKm*wq_Y@fB!dzuRYyj)$t8#DC07c{eGFmbWxm2QXholZsDxFAtPx_6PW}ybk9MY#i z5e*nE8^h<nYh26qyBz7$`$7 zETtzk;rH3k~e&U{%~=7N}tz5POX@FnGu=zel4h9O1~L}D1!i7 zft~aK9Sb&v(r2M41ERsnD=9jDH9Sz!NEPt13iJ)yM49qNkwag~^emdu7TQc%D6@F1 zbRkvfC`L0k1`-a&S?HMAe_PnMP9BCel99$FVZ|tu;R#)bE<&YetW%N(Y)aC#AMmtN zR^VlkEQR-{(%WI#|1o@@=bV$3K*g`owoh^rxEk>!T?}6>h9$u#w~gn9jD!%a^rAF~ z=m9IeSePT>*^3Vr(k0oIC+eYC)HI!i9!h|AN-=HW-6H1FJ!wh`U!Y4UAyLz(rfVm|Z(}h~%{qTkXv5I@>HOkJ z4PGU5+V|q;NbL9Z<>&;64lPJmYKF`rt% zkGLk-BMlj#6VQf*b&h=wsKgAFZ(fR2hMVqK-mrY{((hOhnb>y~n+)%O^r zNpMn7#O6w#s@pM0$>3z86Mk19H8`9>yM9W+<44A8V_ePC$0SzW22ooy)ktfrkQpDL zTg+Uyfu2i+{40Z&NY3maB>0hi2j=OJBnaFy4J2Sp%$}ikF@Mvtp{AU7ISD$G!!{_( z>^!_oalFFIQTR|f>{hU2sVId*TbEgzATLlWd0s2`CasjYtw2ANiB|Jpp%+!3sw~?l=bw!wR7#=&%w;vv`z(MoUSWBsp-nW9w#c**$W= z2o#HoDJb;y#He+Y?wO;$!DFt=-P;}SA-#^$k%V|JaQyKT+MbS-X0p!PPAg^5-E9lEu{ckQ9dSsk6p3+)$w1>eYZ883AFw|`cYqBAA{ET z=LqTtQa+!$At9MlfMk$e!4a%?&ch%yIAYS{@^R8h*9_dekOO0rld@Bjb`yj4Nlr4e zo0H7KHPZx7xW>mh$@Dabvj7WqaBG4aK)|huQ$l+W|sr;9fpfR}6EhL!W%AeJmCzg7^Ege6Ix{^n39^U&%; zcv7PaoBrrXn3+G4)I#>=@lac;tToKex1uuR&8|?}LvvvSU)URIj^cOsh6a*(ubP6_ z4n)jvl_agDk(S85Xh-yTtZY}pS{Le0>aFvy&c7OZ_1(hg%=@pt^Xm7n#w+(NZ(FTA zlr$K_1FQ2k)~ zC-pJ%%u4&2gpo>GiX+yA-e|{)Wyj*NCF}j(<&KrQ)`a=!mr~r=j&DdYo%yj6Gg`u3 zcTT*1;?_%XLnV+zZwR-)Hl0TJbv$V_h0ou%W(S~Dg{yDAe6s*33fb*9U%82ht2cy{ z{O$ps24LUckxNaTo1jwm}gmV_d#54=3@_;14;^ za&i|Nn1#wJsAQ^ni0N?;u!Y=3(;i?d>4e9}$tOK-FYqogGU25e&>As&d78(@Io!#~ zW}S$uWi$Lk<#CUBoQ!{(8FsRY2-EJrX?zTN!2iNj}LebCUca%5r(d|?bQ z`p6XUGkC`)IrRu|wb$(ePZ@`gFf|B8koOoTANL?eXSSmf#Nr$=4URTW2KT@?^8zYH z(uGCT8O`N>@!QeVLNMg700umcDKAVh>@2?vVh;<9^WJOTZyyh~1uumv*R}dE9zMMw zk4WR%@?dBF`-U8=Zw&UV>rCOMuxH*0^H*RDSB9yu2QK?fUr73EoqnFWPK7%n#6r)S zrFz9u{q8{AVqff7BJTIB)g4-?JM{5Dysj-_X^ZRHACN1$ z%}P8&NHJBxV=V?Nd1`+4+U$)haZOpowW_J$w3c;1_U!euVb2X`dyOSDA`1H*~p@v{b(ohoFc1IW1@#{E@-`oc@+O{wjaV=bk zHZAy8HPs*k2A3-D4=u6FO&?rdc6~6Ls6QIFw>=~%A;~Yd*TnTl-Z>q` z7n>GO-`}%TxzzSS_5Fj(%^-JfYk<=#7YGn(#)ZHgO<42J=@1dqANfUX-EHaZ_B%aq z^hDgZPOTcMmufm*!JFwi}Bq|$K4L)jt`!=L;ymVvjc`Z&3F%m+#o3J-V~#?P>Ga-{Da zuI1F(I1*<>fg=cOI44}Q5PEX@FLKIM_S8iVw{v)%UBxN9taH>m;o(#$kmL+_W&)hz z)IcZd7J8#6ynzWXM5tgdMfsFU3HvGpb2xR6&+VB>gHo=EDG#5wE0|YcJRE^KV^Bna zBTye40k-)Vhr2lZJc~Fkts`)&t9x3G1OSA%Ilfy81d1#@CCtL|ku=y*c(hZ@jqn-u5N@L&c92f31q| zd@*J{_y&O_`L+!M!sp~~AD3WS^J5H`SvWvm3eW%l*n$;SysM6xcR{(XgfkRFxvu12 zS)>{Lsi~s^`}>-JLHHVg zvU}l%oD`>U-1LaQ!`y60(d)y4k_{Qk@EXE~g3l^3x%!a`Wm7T|p(7-;PWUmFgUuGi zhJ|vhAJ%z(0>2$5$c@eHbTU&55ho;l`Sg@%P)1tO1V!|V{18Gr6ystjh%04mPb%Zb zgLnqgmia{C0#3AwQjRY1G9F@7bs75=q^_D$36eb(WgJxX;^bCdntePBayOM6N=hxZ zFf%>to&%;}I1?A33bOFL4_{={3VjOR1MvKddWX11vDn8T>QhY;LYvKS6t@CfT#SMC z_tK|Ot>77C~DfQo0E=T_%Dl+I&oO zd3G$|*)su6L~b(pRZL&1O#GJVSubiMLvaT&6jd&M%d{~J z{hRpv5Ixt3$B%rdW8ggkz0^Pz`OEnR0^4ylNs&wL>1l$Bk zv?<=Au?{&l{fdk%5cdjePA`6cuCEwK1H|m4{USe~Ad83onbK2+Z0v^K0(y9fP@8>1 zsVH_Pl#|AsQ3-nbR%%QC76B&vgwn0qHz7ymcpCx6Ce&n~xiBpf(GH3nHhD>)30*-| z^l#6rgjZ^-Feh-{%0$~77+pyzJ8=(S2ON?kcPS#5&(Chk=ad=z+?jm|t(vo&t{O4- zP?ocs^8XWl?rqeze&p84OPQVafAUywBP(+J)hR#sHqw?;`lO$G8(EnH@r0k7uA=gL zNqM^Szxafwo34hIxmf)z?<3Feum1_(dOpNo=rD0xnrSjMCmQuDxC0+Bi)^#pt3hR0YwWGgLz$yD2-Yw+& zQTQ}3fj(Q~kdWmwmrn!J^1wesbzm1cU$J_fr0HgG$N5|A_-=`0&KWrUq;nNqb zoOu+E`JH!-Tof5!Y5118GojaL5>mLeaj%DHDw=F zq8t*Y;J*gsIn}f`Fz!THf%h3Xn&BWIZ|4pnz%vL?(bw5Vb`A^zfD68XOy`t$TnYv^ zGNRc4r{)iFrVMog4TqDtrzUCEUdylu9+}=u0QDyEPV(MzrUMnB)yo`1H=cqro`PW2 zBcqO!(9>{@r(D~-$9o5YL>46TO}Ih42nB*z6a!*WG>C;jkb)1jJI960G^d>MI#V6W zX>cY?(VP-JCp@D8PK9!*ND*J?_D}nm|Hk(XCM8GA`Tdhj5PcA_Jkx|~WYgo|lILV& z=xi(}_ot!=KsR*Ul+&c1ys^q6RBLMTgHB?%dp4hPIB0p6N;4X>2<=~ki$!xC#2%|L zwSKSzw_!th!b@!s6(<}lvcc67c_?l^98&&5V^}XPd#fWVi@M%ZF78j*TH@t< zmZlTsM;;U>%6mR-`D8lw^6*bzjh{RhFLs1_z+?YbOJpi~`tHnPZK9$%Ue>ZSmMA;? zK%OY;`c(SK@fh{;PkZCtuf(n2xYqsa;Olc`*VOK zjax(VhZ^1dzH9r!?UDAVBxbIPYpTI{rM9f8x2~wSM&{nW`sUT8t%-`(xcVrt9mEX} zUON~e-d4Y`m-G9@uUZXpX~U_ zx!9SrE1hQ(mX|}FPX~p%qSDB|g~{m1N|Alhy|nNC;7Do!- zE_t)$du7q7_h;Xk{r;6iN%Io*;VVCS<*$bmC2jG-_E6Wul8R{ao8_SszXd83m4(#s zP#IxOUAWblR2m_A%ih|bR2srH5UmK45i)9uS*nvt{rj@pmayk`+2Wq0((?Y9+wRB< zw=XUcNo7T}2ui{iHjJtQd9Z84f|<9hnQB%{HPN~EufB72xgxQnJ8tR;o=g(@HKKHd zD1B?s+b{mfi_y`g)2n6sma7wGM^=ee2-F07vtfX=YJed}R?5W%nWwq&8gba-rM&Bo z4_Bmi9^_@WZ+@(Zk4yYB)U!yrM((&u9L@~EEi}SEQiwoZCLuo-kH|f^f3>eI>+vSB zOq>D5A{3b(GZ_?%7)E|XKB1HI`-oH?w3p3W$g=R++-82b?Ef0Mz8U24{K~JWNHwV; zwfkViDDl{2{CTM|bQd&Y8V8Fe<4sg1lu76{rP`~`zU6ygwu4QH&*mzy@THJ=9?H;fuAIDE7A zpbzUamLYgUNrTw#5yq49TareAHo-al%(y6-79-8j$|7zBWra{ylviejvf{k55-2Op zEo;u`>M&z?e|a8};5CC4Xm>EKydFFd)#pn$9Z4$kg{b@NzS9x1e*?QXzklFv{#-iv zc(ars%f9Xm4mf=ySKk2Y@2GdIamwcn%(l22 zCoj4fP>V168-@Rs8v7y0Iu4+Np$M=Fmqz)X4OIy-0JAEPGpGg4Z;xA8wBiT*mW)$;ZQd~OQ< zP&yC4T>1RAafoffUsx&Azz*k0<#47ID3bnGZ_mI$5BxU;^de9&tdCbjImxM$oPMZ{ z>~r>=a`v_Lb`G{3=WrWyg-6hqGEENi3i6!NmNGvMGgncq!p389m{+#vtf&)&M;bN$ z&o8gfW;bw3pfn%(;tJZBv8hS0O8QYCjNuecr`tc`baGmk+wBA}zLJm1z?{HuK*$P0 zAu}{r%su!j%64yPRrNx! zJxR#y$M5aQrEnp!eo}mm`o-| z#~u`@J#5BFeOXdBxS`Ro$!l;m#lIOlV(7S_(s*=q!YBOT_)5@<+^|E&8HB5v%3(-WXBXxB>WS4-+&x5QeGfv&66CG~}&anPIPk=aDCJ!ajxSi7`$ zxi-<#5o)2K-W#2dDllXDh7HpigD>%0IHz>NsVJ|{=>$yx%^;li`v}dd+UXpf zLjM!OZ(m5qV3yHs9^L*9)hiGKOnA?y7)|B_+j2H#>q`53Ug9lbk)?~wCY}}0_t26A zepL_5BKsCCRX#Gv tuple[bool, int]: + """Heuristic: top ~28% of a dossier crop should contain bright nametag pixels.""" + arr = np.array(im.convert("RGB")) + h, w, _ = arr.shape + band = arr[: max(1, h // 3), :] + white = (band[:, :, 0] > 165) & (band[:, :, 1] > 150) & (band[:, :, 2] > 110) + green = (band[:, :, 1] > 185) & (band[:, :, 0] < 130) + orange = (band[:, :, 0] > 190) & (band[:, :, 1] > 90) & (band[:, :, 1] < 180) & (band[:, :, 2] < 100) + bright = int(white.sum() + green.sum() + orange.sum()) + # Tiny crops still need a handful of lit pixels; larger crops need more. + need = max(25, (w * h) // 400) + return bright >= need, bright + + +def normalize(path: Path, scale: int = 2) -> dict: + im = Image.open(path).convert("RGB") + w, h = im.size + is_fullframe = w >= 1600 or h >= 1000 + stem = path.stem + if stem.endswith("-dossier"): + out = path + method = "unity_or_existing_crop" + elif is_fullframe: + # Do not pretend pixel heuristics can find the card reliably on 2880p. + out = path.with_name(path.stem + "-dossier.png") + # Top-left fallback only so agents have *something*; mark nametag unknown. + panel = im.crop((0, int(h * 0.08), min(w, int(w * 0.22)), int(h * 0.08) + min(280, h // 4))) + panel.resize((panel.width * scale, panel.height * scale), Image.NEAREST).save(out) + method = "fullframe_fallback_topleft" + else: + out = path.with_name(path.stem + "-dossier.png") + im.resize((w * scale, h * scale), Image.NEAREST).save(out) + method = "player_paste_upscale" + + check_im = Image.open(out) + ok, bright = analyze_nametag(check_im) + if method == "fullframe_fallback_topleft": + ok = False # never trust full-frame fallback for PASS + + report = { + "source": str(path), + "dossier": str(out), + "method": method, + "size": [check_im.width, check_im.height], + "nametag_bright_pixels": bright, + "nametag_ok": ok, + "is_fullframe_source": is_fullframe, + } + report_path = Path(str(out).replace(".png", ".json")) + if report_path.suffix != ".json": + report_path = out.with_suffix(".json") + # Prefer sibling *-dossier.json next to crop. + if out.name.endswith("-dossier.png"): + report_path = out.with_name(out.stem + ".json") + report_path.write_text(json.dumps(report, indent=2) + "\n") + return report + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("images", nargs="+", type=Path) + ap.add_argument("--strict", action="store_true", help="Exit 1 if nametag band looks empty") + args = ap.parse_args() + + worst = 0 + for path in args.images: + if not path.is_file(): + print(f"MISSING {path}", file=sys.stderr) + worst = 1 + continue + report = normalize(path) + status = "OK" if report["nametag_ok"] else "WARN_NO_NAMETAG" + if report["method"] == "fullframe_fallback_topleft": + status = "WARN_FULLFRAME" + print( + f"{status} {report['dossier']} - prefer Unity '*-dossier.png' from harness screenshot action", + file=sys.stderr, + ) + worst = max(worst, 1 if args.strict else 0) + else: + print( + f"{status} {report['dossier']} method={report['method']} " + f"bright={report['nametag_bright_pixels']} size={report['size']}" + ) + if args.strict and not report["nametag_ok"]: + worst = 1 + return worst + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh index b4ab309..0e813ba 100755 --- a/scripts/harness-run.sh +++ b/scripts/harness-run.sh @@ -168,6 +168,15 @@ queue_and_wait() { rg -n "\[IdleSpectator\]\[(HARNESS|ASSERT|SNAP)\]|dismissed window|retry id=" "$LOG" | tail -n 50 || true fi if [[ "$result_status" == "PASS" ]]; then + # Validate Unity-written dossier crops (exact UI rect), not 2880p full frames. + sleep 1.0 + mapfile -t dossier_crops < <(compgen -G "$HARNESS/hud-*-dossier.png" || true) + if ((${#dossier_crops[@]} > 0)); then + python3 "$ROOT/scripts/crop-dossier-hud.py" --strict "${dossier_crops[@]}" \ + | tee /tmp/idle-dossier-crop.txt || true + else + echo "NOTE: no hud-*-dossier.png yet (need mod build with in-engine crop)" >&2 + fi return 0 fi return 1