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 0000000..5286356 Binary files /dev/null and b/scripts/__pycache__/crop-dossier-hud.cpython-314.pyc differ diff --git a/scripts/crop-dossier-hud.py b/scripts/crop-dossier-hud.py new file mode 100755 index 0000000..03cc954 --- /dev/null +++ b/scripts/crop-dossier-hud.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Validate / normalize IdleSpectator dossier crops for agent visual checks. + +Prefer Unity-written ``*-dossier.png`` files (exact UI rect via ReadPixels). +For player pastes or legacy full frames, produce a readable 2x nearest upscale. + +Usage: + ./scripts/crop-dossier-hud.py --strict IdleSpectator/.harness/hud-chronicle_smoke-dossier.png + ./scripts/crop-dossier-hud.py IdleSpectator/.harness/hud-chronicle_smoke.png # warns: use -dossier.png +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + + +def analyze_nametag(im: Image.Image) -> 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