Perfecting the dossier hud

This commit is contained in:
DazedAnon 2026-07-14 17:59:37 -05:00
parent 343c4c686e
commit 9ae7f7ffa1
13 changed files with 971 additions and 168 deletions

View file

@ -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

View file

@ -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/<name>.png` via Unity ScreenCapture
## Screenshots
Harness `screenshot` writes:
- `IdleSpectator/.harness/<name>.png` - full frame
- `IdleSpectator/.harness/<name>-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`:

View file

@ -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))

View file

@ -118,6 +118,30 @@ public static class Chronicle
}
}
/// <summary>Newest-first slice for compact dossier HUD.</summary>
public static IReadOnlyList<ChronicleEntry> LatestForSubject(long subjectId, int max)
{
if (max <= 0)
{
return System.Array.Empty<ChronicleEntry>();
}
IReadOnlyList<ChronicleEntry> 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<ChronicleEntry> SnapshotWorld()
{
lock (WorldEntries)

View file

@ -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<RectTransform>();
_host.anchorMin = new Vector2(0f, 1f);

View file

@ -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"),

View file

@ -19,6 +19,8 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
/// <summary>Absolute path to this mod's folder (parent of mod.json).</summary>
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;

View file

@ -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<ActorTrait> ranked = new List<ActorTrait>();
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
}
}
/// <summary>
/// Spectator-facing interest: Legendary/Epic over Normal, non-default over species baseline,
/// Positive/Negative over bland Other. Ties break on rarer birth rates.
/// </summary>
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

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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; } = "";
/// <summary>Harness: newest mini-history line currently shown (if any).</summary>
public static string LastHistoryPreview { get; private set; } = "";
/// <summary>Harness: comma-joined top trait labels currently shown.</summary>
public static string LastTraitsPreview { get; private set; } = "";
public static Vector2 LastPanelSize { get; private set; }
public static bool LastLayoutOk { get; private set; }
/// <summary>Screen-pixel rect of the dossier card (Unity bottom-left origin), if visible.</summary>
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<ChronicleEntry> 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)
/// <summary>Left-pack nametag on the root: [species] Name [lv]n [task] [sex].</summary>
private static float PlaceHeader(float yFromTop, bool hasTask)
{
float x = PadX;
if (_speciesIcon != null)
{
RectTransform rt = _speciesIcon.GetComponent<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
@ -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<RectTransform>();
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;
}
/// <summary>Vertical trait stack in the body-right slot when History is empty.</summary>
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>(), panelHeight))
&& (_traitsRow == null || !_traitsRow.activeSelf
|| LineInside(_traitsRow.GetComponent<RectTransform>(), panelHeight));
|| LineInside(_traitsRow.GetComponent<RectTransform>(), panelHeight))
&& (_historyCol == null || !_historyCol.activeSelf
|| LineInside(_historyCol.GetComponent<RectTransform>(), 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<Image>();
@ -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)

View file

@ -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"
}

Binary file not shown.

112
scripts/crop-dossier-hud.py Executable file
View file

@ -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())

View file

@ -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