Chronicle

This commit is contained in:
DazedAnon 2026-07-14 16:25:28 -05:00
parent 43cc098ac9
commit 3d6d8f27f3
11 changed files with 1138 additions and 227 deletions

View file

@ -86,7 +86,7 @@ A loaded world is still required.
| `director_tiers` | Curiosity accept, Action interrupt, curiosity blocked by Action |
| `discovery` | Present dedupe + force drain + tip match |
| `world_log` | Story/Epic interest interrupt via collector |
| `chronicle_smoke` | Life-event chronicle inject + HUD open/jump (not watch-notes) |
| `chronicle_smoke` | History inject, death-cause samples, World feed isolation, History\|World tabs, HUD jump |
| `smoke` | Short enable/focus health check |
| `tip_match` | Tip asset must match focused unit |
| `regression` | Shell suite of all gate scenarios above |

View file

@ -3,7 +3,7 @@ using HarmonyLib;
namespace IdleSpectator;
/// <summary>
/// Feeds Chronicle from real creature life events (kills, lovers, best friends).
/// Feeds character History from kills, deaths, lovers, and best friends.
/// </summary>
[HarmonyPatch]
public static class ActorChroniclePatches
@ -15,6 +15,42 @@ public static class ActorChroniclePatches
Chronicle.NoteKill(__instance, pDeadUnit);
}
[HarmonyPatch(typeof(Actor), "die")]
[HarmonyPrefix]
public static void PrefixDie(Actor __instance, out Actor __state)
{
__state = null;
try
{
if (__instance == null)
{
return;
}
BaseSimObject attacker = __instance.attackedBy;
if (attacker != null && !attacker.isRekt() && attacker.isActor() && attacker != __instance)
{
__state = attacker.a;
}
}
catch
{
__state = null;
}
}
[HarmonyPatch(typeof(Actor), "die")]
[HarmonyPostfix]
public static void PostfixDie(Actor __instance, AttackType pType, bool pCountDeath, Actor __state)
{
if (!pCountDeath || __instance == null)
{
return;
}
Chronicle.NoteDeath(__instance, pType, __state);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.becomeLoversWith))]
[HarmonyPostfix]
public static void PostfixLovers(Actor __instance, Actor pTarget)

View file

@ -316,7 +316,7 @@ public static class AgentHarness
case "chronicle_note":
{
bool ok = Chronicle.ForceNoteCurrentFocus(
string.IsNullOrEmpty(cmd.label) ? "Slew" : cmd.label);
string.IsNullOrEmpty(cmd.label) ? "Killed" : cmd.label);
if (ok)
{
_cmdOk++;
@ -326,7 +326,81 @@ public static class AgentHarness
_cmdFail++;
}
Emit(cmd, ok, detail: $"chronicle_count={Chronicle.Count} caption={WatchCaption.LastHeadline}");
Emit(cmd, ok, detail:
$"history={Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId())} caption={WatchCaption.LastHeadline}");
break;
}
case "chronicle_clear":
{
Chronicle.ClearSession();
_cmdOk++;
Emit(cmd, ok: true, detail: "chronicle_cleared");
break;
}
case "chronicle_world_force":
{
string label = !string.IsNullOrEmpty(cmd.label)
? cmd.label
: (!string.IsNullOrEmpty(cmd.value) ? cmd.value : "War: harness vs test");
bool ok = Chronicle.ForceWorldNote(label);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"world_count={Chronicle.WorldCount} label={label}");
break;
}
case "chronicle_death_sample":
{
string typeName = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "Age";
bool ok = Chronicle.ForceDeathSample(typeName);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
ChronicleEntry latest = Chronicle.Latest;
Emit(cmd, ok, detail: $"type={typeName} latest={(latest != null ? latest.Line : "")}");
break;
}
case "chronicle_tab":
{
string tab = (cmd.value ?? "").Trim().ToLowerInvariant();
if (tab == "world")
{
ChronicleHud.SetTab(ChronicleTab.World);
}
else
{
ChronicleHud.SetTab(ChronicleTab.History);
tab = "history";
}
Chronicle.ShowHud();
bool ok = ChronicleHud.ActiveTab.ToString().Equals(tab, System.StringComparison.OrdinalIgnoreCase);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"tab={ChronicleHud.ActiveTab}");
break;
}
@ -343,7 +417,7 @@ public static class AgentHarness
_cmdFail++;
}
Emit(cmd, open, detail: $"hud={open}");
Emit(cmd, open, detail: $"hud={open} tab={ChronicleHud.ActiveTab}");
break;
}
@ -989,17 +1063,23 @@ public static class AgentHarness
case "chronicle_count":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
int have = Chronicle.Count;
int have = Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId());
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
if (cmp == "min" || cmp == ">=" || string.IsNullOrEmpty(cmp))
{
pass = have >= want;
detail = $"count={have} min={want}";
detail = $"history_count={have} min={want}";
}
else if (cmp == "world")
{
have = Chronicle.WorldCount;
pass = have >= want;
detail = $"world_count={have} min={want}";
}
else
{
pass = have == want;
detail = $"count={have} want={want}";
detail = $"history_count={have} want={want}";
}
break;
@ -1020,6 +1100,25 @@ public static class AgentHarness
detail = $"latest='{line}' needle='{needle}'";
break;
}
case "chronicle_world_latest_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
ChronicleEntry latest = Chronicle.LatestWorld;
string line = latest != null ? latest.Line : "";
pass = latest != null
&& !string.IsNullOrEmpty(needle)
&& line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
detail = $"world_latest='{line}' needle='{needle}'";
break;
}
case "chronicle_tab":
{
string want = (cmd.value ?? "history").Trim().ToLowerInvariant();
string have = ChronicleHud.ActiveTab.ToString().ToLowerInvariant();
pass = have == want;
detail = $"tab={have} want={want}";
break;
}
default:
pass = false;
detail = "unknown expect: " + expect;

View file

@ -6,6 +6,7 @@ namespace IdleSpectator;
public enum ChronicleKind
{
Death,
Kill,
Lover,
Friend,
@ -13,62 +14,176 @@ public enum ChronicleKind
Other
}
public enum ChronicleTab
{
History,
World
}
public sealed class ChronicleEntry
{
public float CreatedAt;
public ChronicleKind Kind;
public long UnitId;
public long SubjectId;
public long OtherId;
public string Name = "";
public string SpeciesId = "";
public string Line = "";
public Vector3 Position;
/// <summary>Back-compat for harness / jump helpers.</summary>
public long UnitId => SubjectId;
}
/// <summary>
/// Session ring buffer of notable life/world events (kills, lovers, WorldLog), not spectator watch notes.
/// Session chronicles: per-character History + separate World feed (WorldLog Story/Epic).
/// </summary>
public static class Chronicle
{
public const KeyCode ToggleKey = KeyCode.F9;
private const int MaxEntries = 300;
private const int MaxHistoryPerUnit = 40;
private const int MaxWorldEntries = 100;
private static readonly List<ChronicleEntry> Entries = new List<ChronicleEntry>();
private static readonly Dictionary<long, List<ChronicleEntry>> Histories =
new Dictionary<long, List<ChronicleEntry>>();
private static readonly List<ChronicleEntry> WorldEntries = new List<ChronicleEntry>();
private static readonly HashSet<string> LoverPairKeys = new HashSet<string>();
private static readonly HashSet<string> FriendPairKeys = new HashSet<string>();
private static readonly HashSet<long> DeathLogged = new HashSet<long>();
private static bool _hudReady;
private static long _lastHistorySubjectId;
public static int Count
public static int Count => HistoryCountFor(CurrentHistorySubjectId()) + WorldCount;
public static int WorldCount
{
get
{
lock (Entries)
lock (WorldEntries)
{
return Entries.Count;
return WorldEntries.Count;
}
}
}
public static bool HudVisible => ChronicleHud.Visible;
public static IReadOnlyList<ChronicleEntry> Snapshot()
public static long LastHistorySubjectId => _lastHistorySubjectId;
public static int HistoryCountFor(long subjectId)
{
lock (Entries)
if (subjectId == 0)
{
return Entries.ToArray();
return 0;
}
lock (Histories)
{
return Histories.TryGetValue(subjectId, out List<ChronicleEntry> list) ? list.Count : 0;
}
}
public static IReadOnlyList<ChronicleEntry> SnapshotForFocus()
{
return SnapshotForSubject(CurrentHistorySubjectId());
}
public static IReadOnlyList<ChronicleEntry> SnapshotForSubject(long subjectId)
{
if (subjectId == 0)
{
return System.Array.Empty<ChronicleEntry>();
}
lock (Histories)
{
if (!Histories.TryGetValue(subjectId, out List<ChronicleEntry> list))
{
return System.Array.Empty<ChronicleEntry>();
}
return list.ToArray();
}
}
public static IReadOnlyList<ChronicleEntry> SnapshotWorld()
{
lock (WorldEntries)
{
return WorldEntries.ToArray();
}
}
/// <summary>Latest history entry for the current/last focus (harness + jump).</summary>
public static ChronicleEntry Latest
{
get
{
lock (Entries)
IReadOnlyList<ChronicleEntry> list = SnapshotForFocus();
return list.Count == 0 ? null : list[list.Count - 1];
}
}
public static ChronicleEntry LatestWorld
{
get
{
lock (WorldEntries)
{
return Entries.Count == 0 ? null : Entries[Entries.Count - 1];
return WorldEntries.Count == 0 ? null : WorldEntries[WorldEntries.Count - 1];
}
}
}
public static long CurrentHistorySubjectId()
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
try
{
long id = MoveCamera._focus_unit.getID();
if (id != 0)
{
_lastHistorySubjectId = id;
return id;
}
}
catch
{
// fall through
}
}
return _lastHistorySubjectId;
}
public static string CurrentHistoryTitle()
{
long id = CurrentHistorySubjectId();
if (id == 0)
{
return "";
}
Actor unit = FindUnitById(id);
if (unit != null)
{
return $"{SafeName(unit)} ({SpeciesOf(unit)})";
}
IReadOnlyList<ChronicleEntry> list = SnapshotForSubject(id);
if (list.Count > 0)
{
ChronicleEntry e = list[list.Count - 1];
if (!string.IsNullOrEmpty(e.Name))
{
return string.IsNullOrEmpty(e.SpeciesId) ? e.Name : $"{e.Name} ({e.SpeciesId})";
}
}
return "Unit #" + id;
}
public static void EnsureWindow()
{
if (!ModSettings.ChronicleEnabled)
@ -87,7 +202,7 @@ public static class Chronicle
if (ChronicleHud.IsReady)
{
_hudReady = true;
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, life-event side panel)");
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World)");
}
}
catch (System.Exception ex)
@ -98,13 +213,20 @@ public static class Chronicle
public static void ClearSession()
{
lock (Entries)
lock (Histories)
{
Entries.Clear();
Histories.Clear();
}
lock (WorldEntries)
{
WorldEntries.Clear();
}
LoverPairKeys.Clear();
FriendPairKeys.Clear();
DeathLogged.Clear();
_lastHistorySubjectId = 0;
}
public static void PollInput()
@ -155,7 +277,7 @@ public static class Chronicle
return false;
}
Actor unit = FindUnitById(entry.UnitId);
Actor unit = FindUnitById(entry.SubjectId);
if (unit != null && unit.isAlive())
{
CameraDirector.FocusUnit(unit);
@ -163,6 +285,17 @@ public static class Chronicle
return true;
}
if (entry.OtherId != 0)
{
Actor other = FindUnitById(entry.OtherId);
if (other != null && other.isAlive())
{
CameraDirector.FocusUnit(other);
WatchCaption.SetFromActor(other);
return true;
}
}
if (entry.Position != Vector3.zero)
{
CameraDirector.PanTo(entry.Position);
@ -179,9 +312,27 @@ public static class Chronicle
return;
}
// Keep last history subject sticky when focus dies / drops briefly.
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
try
{
long id = MoveCamera._focus_unit.getID();
if (id != 0)
{
_lastHistorySubjectId = id;
}
}
catch
{
// ignore
}
}
ChronicleHud.UpdateLive();
}
/// <summary>Killer POV - character history only.</summary>
public static void NoteKill(Actor killer, Actor victim)
{
if (!ModSettings.ChronicleEnabled || killer == null || victim == null)
@ -189,14 +340,38 @@ public static class Chronicle
return;
}
string killerName = SafeName(killer);
string victimName = SafeName(victim);
string killerSpecies = SpeciesOf(killer);
string victimSpecies = SpeciesOf(victim);
Append(
AppendHistory(
ChronicleKind.Kill,
killer,
$"Slew: {killerName} ({killerSpecies}) felled {victimName} ({victimSpecies})");
victim,
$"Killed {SafeName(victim)} ({SpeciesOf(victim)})");
}
/// <summary>Victim POV - cause of death on character history.</summary>
public static void NoteDeath(Actor victim, AttackType attackType, Actor killer)
{
if (!ModSettings.ChronicleEnabled || victim == null)
{
return;
}
long id = 0;
try
{
id = victim.getID();
}
catch
{
return;
}
if (id == 0 || !DeathLogged.Add(id))
{
return;
}
string line = FormatDeathCause(victim, attackType, killer);
AppendHistory(ChronicleKind.Death, victim, killer, line);
}
public static void NoteLovers(Actor a, Actor b)
@ -212,10 +387,8 @@ public static class Chronicle
return;
}
Append(
ChronicleKind.Lover,
a,
$"Lovers: {SafeName(a)} and {SafeName(b)}");
AppendHistory(ChronicleKind.Lover, a, b, $"Became lovers with {SafeName(b)}");
AppendHistory(ChronicleKind.Lover, b, a, $"Became lovers with {SafeName(a)}");
}
public static void NoteBestFriends(Actor a, Actor b)
@ -231,10 +404,8 @@ public static class Chronicle
return;
}
Append(
ChronicleKind.Friend,
a,
$"Friends: {SafeName(a)} befriended {SafeName(b)}");
AppendHistory(ChronicleKind.Friend, a, b, $"Befriended {SafeName(b)}");
AppendHistory(ChronicleKind.Friend, b, a, $"Befriended {SafeName(a)}");
}
public static void NoteWorldLog(WorldLogMessage message, string label)
@ -267,15 +438,18 @@ public static class Chronicle
// ignore
}
Append(
ChronicleKind.World,
unit,
$"World: {label}",
fallbackPosition: pos);
string line = label;
AppendWorld(unit, line, pos);
// Mirror onto that unit's History when WorldLog points at someone.
if (unit != null)
{
AppendHistory(ChronicleKind.World, unit, null, line);
}
}
/// <summary>Harness: inject a life-event style line for the current focus.</summary>
public static bool ForceNoteCurrentFocus(string prefix = "Slew")
/// <summary>Harness: inject a life-event style line on the focused unit's History.</summary>
public static bool ForceNoteCurrentFocus(string prefix = "Killed")
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
@ -284,15 +458,162 @@ public static class Chronicle
Actor unit = MoveCamera._focus_unit;
UnitDossier dossier = UnitDossier.FromActor(unit);
string kindPrefix = string.IsNullOrEmpty(prefix) ? "Slew" : prefix;
Append(
string kindPrefix = string.IsNullOrEmpty(prefix) ? "Killed" : prefix;
AppendHistory(
ChronicleKind.Other,
unit,
null,
$"{kindPrefix}: {dossier.Name} ({dossier.SpeciesId}) - life event");
return true;
}
private static void Append(ChronicleKind kind, Actor unit, string line, Vector3 fallbackPosition = default)
/// <summary>Harness: inject a World-feed line (does not touch character History).</summary>
public static bool ForceWorldNote(string label)
{
if (string.IsNullOrEmpty(label))
{
return false;
}
AppendWorld(null, label, Vector3.zero);
return true;
}
/// <summary>Harness: append a formatted death-cause line for the focused unit.</summary>
public static bool ForceDeathSample(string attackTypeName, Actor killer = null)
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
return false;
}
if (!System.Enum.TryParse(attackTypeName, ignoreCase: true, out AttackType attackType))
{
return false;
}
Actor victim = MoveCamera._focus_unit;
string line = FormatDeathCause(victim, attackType, killer);
AppendHistory(ChronicleKind.Death, victim, killer, line);
return true;
}
public static string FormatDeathCause(Actor victim, AttackType attackType, Actor killer)
{
string killerLabel = killer != null
? $"{SafeName(killer)} ({SpeciesOf(killer)})"
: "";
switch (attackType)
{
case AttackType.Age:
return "Died of old age";
case AttackType.Starvation:
return "Starved to death";
case AttackType.Fire:
return IsOnLava(victim) ? "Burned in lava" : "Burned to death";
case AttackType.Drowning:
case AttackType.Water:
return "Drowned";
case AttackType.Poison:
return "Died of poison";
case AttackType.Plague:
return "Died of plague";
case AttackType.Infection:
return "Died of infection";
case AttackType.Tumor:
return "Died of a tumor";
case AttackType.Acid:
return "Dissolved in acid";
case AttackType.AshFever:
return "Died of ash fever";
case AttackType.Gravity:
return "Fell to their death";
case AttackType.Explosion:
return "Died in an explosion";
case AttackType.Divine:
return "Struck down by divine power";
case AttackType.Eaten:
return string.IsNullOrEmpty(killerLabel)
? "Was eaten"
: $"Eaten by {killerLabel}";
case AttackType.Weapon:
case AttackType.Other:
case AttackType.Smile:
if (!string.IsNullOrEmpty(killerLabel))
{
return $"Killed by {killerLabel}";
}
return attackType == AttackType.Weapon ? "Killed in combat" : "Died";
case AttackType.Metamorphosis:
return "Transformed away";
case AttackType.None:
return "Died";
default:
return string.IsNullOrEmpty(killerLabel)
? $"Died ({attackType})"
: $"Killed by {killerLabel}";
}
}
private static bool IsOnLava(Actor victim)
{
try
{
WorldTile tile = victim.current_tile;
return tile != null && tile.Type != null && tile.Type.lava;
}
catch
{
return false;
}
}
private static void AppendHistory(ChronicleKind kind, Actor subject, Actor other, string line)
{
if (subject == null || string.IsNullOrEmpty(line))
{
return;
}
long subjectId = subject.getID();
if (subjectId == 0)
{
return;
}
ChronicleEntry entry = new ChronicleEntry
{
CreatedAt = Time.unscaledTime,
Kind = kind,
SubjectId = subjectId,
OtherId = other != null ? other.getID() : 0,
Name = SafeName(subject),
SpeciesId = SpeciesOf(subject),
Line = line,
Position = subject.current_position
};
lock (Histories)
{
if (!Histories.TryGetValue(subjectId, out List<ChronicleEntry> list))
{
list = new List<ChronicleEntry>();
Histories[subjectId] = list;
}
list.Add(entry);
while (list.Count > MaxHistoryPerUnit)
{
list.RemoveAt(0);
}
}
LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {line}");
}
private static void AppendWorld(Actor unit, string line, Vector3 fallbackPosition)
{
if (string.IsNullOrEmpty(line))
{
@ -302,24 +623,25 @@ public static class Chronicle
ChronicleEntry entry = new ChronicleEntry
{
CreatedAt = Time.unscaledTime,
Kind = kind,
UnitId = unit != null ? unit.getID() : 0,
Kind = ChronicleKind.World,
SubjectId = unit != null ? unit.getID() : 0,
OtherId = 0,
Name = unit != null ? SafeName(unit) : "",
SpeciesId = unit != null ? SpeciesOf(unit) : "",
Line = line,
Position = unit != null ? unit.current_position : fallbackPosition
};
lock (Entries)
lock (WorldEntries)
{
Entries.Add(entry);
while (Entries.Count > MaxEntries)
WorldEntries.Add(entry);
while (WorldEntries.Count > MaxWorldEntries)
{
Entries.RemoveAt(0);
WorldEntries.RemoveAt(0);
}
}
LogService.LogInfo("[IdleSpectator][CHRONICLE] " + line);
LogService.LogInfo("[IdleSpectator][CHRONICLE][World] " + line);
}
private static string PairKey(long a, long b)

View file

@ -1,5 +1,4 @@
using System.Collections.Generic;
using NeoModLoader.General;
using NeoModLoader.services;
using UnityEngine;
using UnityEngine.Events;
@ -8,25 +7,34 @@ using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Non-modal right-side chronicle HUD. Works with focus/spectator - does not open a ScrollWindow.
/// Styled as a compact event feed (inspired by vanilla tip/tooltip denseness, not a modal unit sheet).
/// Compact bottom-right chronicle card with History | World tabs.
/// </summary>
public static class ChronicleHud
{
private const float PanelWidth = 300f;
private const int MaxVisibleRows = 14;
private const float PanelWidth = 248f;
private const float PanelHeight = 178f;
private const int MaxVisibleRows = 5;
private const float RowHeight = 20f;
private static GameObject _root;
private static RectTransform _content;
private static Text _header;
private static Text _titleText;
private static Text _historyTabLabel;
private static Text _worldTabLabel;
private static Image _historyTabBg;
private static Image _worldTabBg;
private static readonly List<GameObject> Rows = new List<GameObject>();
private static bool _visible;
private static int _lastCount = -1;
private static long _lastSubjectId = -1;
private static ChronicleTab _tab = ChronicleTab.History;
public static bool Visible => _visible && _root != null && _root.activeSelf;
public static bool IsReady => _root != null;
public static ChronicleTab ActiveTab => _tab;
public static void EnsureBuilt()
{
if (_root != null)
@ -39,7 +47,7 @@ public static class ChronicleHud
return;
}
Canvas canvas = ResolveCanvas();
Canvas canvas = HudCanvas.Resolve();
if (canvas == null)
{
return;
@ -49,50 +57,54 @@ public static class ChronicleHud
_root.transform.SetParent(canvas.transform, false);
RectTransform rt = _root.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(1f, 0.5f);
rt.anchorMax = new Vector2(1f, 0.5f);
rt.pivot = new Vector2(1f, 0.5f);
rt.sizeDelta = new Vector2(PanelWidth, 440f);
rt.anchoredPosition = new Vector2(-10f, 0f);
rt.anchorMin = new Vector2(1f, 0f);
rt.anchorMax = new Vector2(1f, 0f);
rt.pivot = new Vector2(1f, 0f);
rt.sizeDelta = new Vector2(PanelWidth, PanelHeight);
rt.anchoredPosition = new Vector2(-12f, 12f);
Image bg = _root.GetComponent<Image>();
bg.color = new Color(0.05f, 0.06f, 0.08f, 0.82f);
bg.color = new Color(0.05f, 0.06f, 0.08f, 0.65f);
bg.raycastTarget = true;
CanvasGroup group = _root.GetComponent<CanvasGroup>();
group.blocksRaycasts = true;
group.interactable = true;
_header = MakeText(_root.transform, "Header", "Chronicle · F9", 12);
RectTransform headerRt = _header.GetComponent<RectTransform>();
headerRt.anchorMin = new Vector2(0f, 1f);
headerRt.anchorMax = new Vector2(1f, 1f);
headerRt.pivot = new Vector2(0.5f, 1f);
headerRt.sizeDelta = new Vector2(-16f, 24f);
headerRt.anchoredPosition = new Vector2(0f, -8f);
_header.fontStyle = FontStyle.Bold;
_header.alignment = TextAnchor.MiddleLeft;
_header.color = new Color(0.92f, 0.9f, 0.82f, 1f);
BuildTabButton(_root.transform, "HistoryTab", "History", -6f, 0f, out _historyTabBg, out _historyTabLabel,
() => SetTab(ChronicleTab.History));
BuildTabButton(_root.transform, "WorldTab", "World", -6f, 72f, out _worldTabBg, out _worldTabLabel,
() => SetTab(ChronicleTab.World));
Text sub = MakeText(_root.transform, "Sub", "Life & world events · click to jump", 9);
RectTransform subRt = sub.GetComponent<RectTransform>();
subRt.anchorMin = new Vector2(0f, 1f);
subRt.anchorMax = new Vector2(1f, 1f);
subRt.pivot = new Vector2(0.5f, 1f);
subRt.sizeDelta = new Vector2(-16f, 16f);
subRt.anchoredPosition = new Vector2(0f, -30f);
sub.alignment = TextAnchor.MiddleLeft;
sub.color = new Color(0.65f, 0.68f, 0.72f, 1f);
Text f9 = HudCanvas.MakeText(_root.transform, "F9", "F9", 9);
RectTransform f9Rt = f9.GetComponent<RectTransform>();
f9Rt.anchorMin = new Vector2(1f, 1f);
f9Rt.anchorMax = new Vector2(1f, 1f);
f9Rt.pivot = new Vector2(1f, 1f);
f9Rt.sizeDelta = new Vector2(28f, 16f);
f9Rt.anchoredPosition = new Vector2(-8f, -8f);
f9.alignment = TextAnchor.MiddleRight;
f9.color = new Color(0.55f, 0.58f, 0.62f, 1f);
_titleText = HudCanvas.MakeText(_root.transform, "Title", "", 9);
RectTransform titleRt = _titleText.GetComponent<RectTransform>();
titleRt.anchorMin = new Vector2(0f, 1f);
titleRt.anchorMax = new Vector2(1f, 1f);
titleRt.pivot = new Vector2(0.5f, 1f);
titleRt.sizeDelta = new Vector2(-14f, 16f);
titleRt.anchoredPosition = new Vector2(0f, -26f);
_titleText.alignment = TextAnchor.MiddleLeft;
_titleText.color = new Color(0.78f, 0.8f, 0.84f, 1f);
GameObject scrollGo = new GameObject("Scroll", typeof(RectTransform), typeof(Image), typeof(ScrollRect), typeof(Mask));
scrollGo.transform.SetParent(_root.transform, false);
RectTransform scrollRt = scrollGo.GetComponent<RectTransform>();
scrollRt.anchorMin = new Vector2(0f, 0f);
scrollRt.anchorMax = new Vector2(1f, 1f);
scrollRt.offsetMin = new Vector2(8f, 10f);
scrollRt.offsetMax = new Vector2(-8f, -50f);
scrollRt.offsetMin = new Vector2(6f, 6f);
scrollRt.offsetMax = new Vector2(-6f, -44f);
Image scrollBg = scrollGo.GetComponent<Image>();
scrollBg.color = new Color(0f, 0f, 0f, 0.08f);
scrollBg.color = new Color(0f, 0f, 0f, 0.05f);
Mask mask = scrollGo.GetComponent<Mask>();
mask.showMaskGraphic = false;
@ -111,7 +123,7 @@ public static class ChronicleHud
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
layout.spacing = 2f;
layout.spacing = 1f;
layout.padding = new RectOffset(0, 0, 0, 0);
ContentSizeFitter fitter = contentGo.GetComponent<ContentSizeFitter>();
@ -125,9 +137,17 @@ public static class ChronicleHud
scroll.movementType = ScrollRect.MovementType.Clamped;
scroll.viewport = scrollRt;
RefreshTabChrome();
_root.SetActive(false);
_visible = false;
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, non-modal side panel)");
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World)");
}
public static void SetTab(ChronicleTab tab)
{
_tab = tab;
RefreshTabChrome();
Rebuild(force: true);
}
public static void SetVisible(bool visible)
@ -158,7 +178,12 @@ public static class ChronicleHud
return;
}
if (Chronicle.Count != _lastCount)
long subjectId = Chronicle.CurrentHistorySubjectId();
int count = _tab == ChronicleTab.History
? Chronicle.HistoryCountFor(subjectId)
: Chronicle.WorldCount;
if (count != _lastCount || subjectId != _lastSubjectId)
{
Rebuild(force: true);
}
@ -173,15 +198,50 @@ public static class ChronicleHud
}
ClearRows();
IReadOnlyList<ChronicleEntry> entries = Chronicle.Snapshot();
_lastCount = entries.Count;
RefreshTabChrome();
if (entries.Count == 0)
long subjectId = Chronicle.CurrentHistorySubjectId();
_lastSubjectId = subjectId;
IReadOnlyList<ChronicleEntry> entries;
if (_tab == ChronicleTab.World)
{
GameObject empty = BuildStaticRow("Waiting for life events...");
empty.transform.SetParent(_content, false);
Rows.Add(empty);
return;
entries = Chronicle.SnapshotWorld();
_lastCount = entries.Count;
if (_titleText != null)
{
_titleText.text = "World events";
}
if (entries.Count == 0)
{
AddEmpty("No world events yet");
return;
}
}
else
{
entries = Chronicle.SnapshotForFocus();
_lastCount = entries.Count;
string title = Chronicle.CurrentHistoryTitle();
if (_titleText != null)
{
_titleText.text = string.IsNullOrEmpty(title)
? "Focus a creature to see history"
: title;
}
if (subjectId == 0)
{
AddEmpty("Focus a creature to see history");
return;
}
if (entries.Count == 0)
{
AddEmpty("No history yet");
return;
}
}
int shown = 0;
@ -198,6 +258,78 @@ public static class ChronicleHud
}
}
private static void AddEmpty(string text)
{
GameObject empty = BuildStaticRow(text);
empty.transform.SetParent(_content, false);
Rows.Add(empty);
}
private static void BuildTabButton(
Transform parent,
string name,
string label,
float yFromTop,
float x,
out Image bg,
out Text text,
UnityAction onClick)
{
GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
RectTransform rt = go.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.pivot = new Vector2(0f, 1f);
rt.sizeDelta = new Vector2(68f, 16f);
rt.anchoredPosition = new Vector2(8f + x, yFromTop);
bg = go.GetComponent<Image>();
bg.color = new Color(0.15f, 0.16f, 0.2f, 0.9f);
text = HudCanvas.MakeText(go.transform, "Label", label, 9);
RectTransform textRt = text.GetComponent<RectTransform>();
textRt.anchorMin = Vector2.zero;
textRt.anchorMax = Vector2.one;
textRt.offsetMin = Vector2.zero;
textRt.offsetMax = Vector2.zero;
text.alignment = TextAnchor.MiddleCenter;
text.raycastTarget = false;
Button button = go.GetComponent<Button>();
button.onClick.AddListener(onClick);
}
private static void RefreshTabChrome()
{
Color active = new Color(0.28f, 0.32f, 0.4f, 0.95f);
Color idle = new Color(0.12f, 0.13f, 0.16f, 0.7f);
Color activeText = new Color(0.95f, 0.93f, 0.88f, 1f);
Color idleText = new Color(0.65f, 0.68f, 0.72f, 1f);
if (_historyTabBg != null)
{
_historyTabBg.color = _tab == ChronicleTab.History ? active : idle;
}
if (_worldTabBg != null)
{
_worldTabBg.color = _tab == ChronicleTab.World ? active : idle;
}
if (_historyTabLabel != null)
{
_historyTabLabel.color = _tab == ChronicleTab.History ? activeText : idleText;
_historyTabLabel.fontStyle = _tab == ChronicleTab.History ? FontStyle.Bold : FontStyle.Normal;
}
if (_worldTabLabel != null)
{
_worldTabLabel.color = _tab == ChronicleTab.World ? activeText : idleText;
_worldTabLabel.fontStyle = _tab == ChronicleTab.World ? FontStyle.Bold : FontStyle.Normal;
}
}
private static void ClearRows()
{
foreach (GameObject row in Rows)
@ -215,15 +347,15 @@ public static class ChronicleHud
{
GameObject go = new GameObject("Empty", typeof(RectTransform), typeof(LayoutElement));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 24f, 26f);
rt.sizeDelta = new Vector2(PanelWidth - 16f, RowHeight);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = 26f;
le.preferredHeight = 26f;
le.minHeight = RowHeight;
le.preferredHeight = RowHeight;
Text label = MakeText(go.transform, "Text", text, 9);
Stretch(label.GetComponent<RectTransform>(), 4f);
Text label = HudCanvas.MakeText(go.transform, "Text", text, 9);
Stretch(label.GetComponent<RectTransform>(), 2f);
label.alignment = TextAnchor.MiddleLeft;
label.color = new Color(0.7f, 0.72f, 0.75f, 0.95f);
label.color = new Color(0.65f, 0.68f, 0.72f, 0.95f);
return go;
}
@ -231,23 +363,23 @@ public static class ChronicleHud
{
GameObject go = new GameObject($"Row_{index}", typeof(RectTransform), typeof(Image), typeof(Button), typeof(LayoutElement));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 24f, 32f);
rt.sizeDelta = new Vector2(PanelWidth - 16f, RowHeight);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = 32f;
le.preferredHeight = 32f;
le.minHeight = RowHeight;
le.preferredHeight = RowHeight;
Image bg = go.GetComponent<Image>();
bg.color = new Color(0.12f, 0.13f, 0.16f, 0.55f);
bg.color = new Color(1f, 1f, 1f, 0.02f);
Text label = MakeText(go.transform, "Text", Truncate(entry.Line, 58), 9);
Stretch(label.GetComponent<RectTransform>(), 6f);
Text label = HudCanvas.MakeText(go.transform, "Text", Truncate(entry.Line, 44), 9);
Stretch(label.GetComponent<RectTransform>(), 3f);
label.alignment = TextAnchor.MiddleLeft;
label.color = ColorForKind(entry.Kind);
Button button = go.GetComponent<Button>();
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
colors.highlightedColor = new Color(1.2f, 1.2f, 1.25f, 1f);
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
button.colors = colors;
@ -265,6 +397,8 @@ public static class ChronicleHud
{
switch (kind)
{
case ChronicleKind.Death:
return new Color(0.75f, 0.7f, 0.75f, 1f);
case ChronicleKind.Kill:
return new Color(0.95f, 0.55f, 0.45f, 1f);
case ChronicleKind.Lover:
@ -278,55 +412,12 @@ public static class ChronicleHud
}
}
private static Canvas ResolveCanvas()
{
if (CanvasMain.instance != null)
{
if (CanvasMain.instance.canvas_ui != null)
{
return CanvasMain.instance.canvas_ui;
}
if (CanvasMain.instance.canvas_tooltip != null)
{
return CanvasMain.instance.canvas_tooltip;
}
}
return Object.FindObjectOfType<Canvas>();
}
private static Text MakeText(Transform parent, string name, string value, int size)
{
Text text = new GameObject(name, typeof(RectTransform), typeof(Text)).GetComponent<Text>();
text.transform.SetParent(parent, false);
try
{
OT.InitializeCommonText(text);
}
catch
{
text.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
}
text.text = value ?? "";
text.fontSize = size;
text.resizeTextForBestFit = true;
text.resizeTextMinSize = 8;
text.resizeTextMaxSize = size;
text.color = Color.white;
text.raycastTarget = false;
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.verticalOverflow = VerticalWrapMode.Truncate;
return text;
}
private static void Stretch(RectTransform rt, float pad)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(pad, 2f);
rt.offsetMax = new Vector2(-pad, -2f);
rt.offsetMin = new Vector2(pad, 1f);
rt.offsetMax = new Vector2(-pad, -1f);
}
private static string Truncate(string s, int max)

View file

@ -397,6 +397,7 @@ internal static class HarnessScenarios
Step("ch2", "set_setting", expect: "enabled", value: "true"),
Step("ch3", "set_setting", expect: "show_dossier_caption", value: "true"),
Step("ch4", "set_setting", expect: "chronicle_enabled", value: "true"),
Step("ch4b", "chronicle_clear"),
Step("ch5", "pick_unit", asset: "auto"),
Step("ch6", "spectator", value: "off"),
Step("ch7", "spectator", value: "on"),
@ -405,19 +406,44 @@ internal static class HarnessScenarios
Step("ch10", "assert", expect: "health"),
Step("ch11", "assert", expect: "dossier_contains", value: "auto"),
Step("ch12", "assert", expect: "caption_contains", value: "auto"),
Step("ch13", "chronicle_force", label: "Slew"),
// 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: "Slew"),
Step("ch15", "assert", expect: "chronicle_latest_contains", value: "Killed"),
Step("ch15b", "assert", expect: "chronicle_latest_contains", value: "auto"),
Step("ch16", "chronicle_open"),
Step("ch17", "wait", wait: 0.25f),
Step("ch18", "assert", expect: "focus", value: "true"),
Step("ch19", "assert", expect: "health"),
Step("ch20", "chronicle_jump"),
Step("ch21", "wait", wait: 0.25f),
Step("ch22", "assert", expect: "focus", value: "true"),
Step("ch23", "assert", expect: "dossier_contains", value: "auto"),
Step("ch24", "assert", expect: "no_bad"),
// Death-cause wording samples (victim POV)
Step("ch16a", "chronicle_death_sample", value: "Age"),
Step("ch16b", "assert", expect: "chronicle_latest_contains", value: "old age"),
Step("ch16c", "chronicle_death_sample", value: "Starvation"),
Step("ch16d", "assert", expect: "chronicle_latest_contains", value: "Starved"),
Step("ch16e", "chronicle_death_sample", value: "Drowning"),
Step("ch16f", "assert", expect: "chronicle_latest_contains", value: "Drown"),
Step("ch16g", "chronicle_death_sample", value: "Fire"),
Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "Burned"),
// World feed is separate from History
Step("ch17a", "chronicle_world_force", label: "War: harness_alpha vs harness_beta"),
Step("ch17b", "assert", expect: "chronicle_count", value: "1", label: "world"),
Step("ch17c", "assert", expect: "chronicle_world_latest_contains", value: "harness_alpha"),
Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "Burned"),
// Tabs
Step("ch18", "chronicle_open"),
Step("ch18b", "chronicle_tab", value: "history"),
Step("ch18c", "assert", expect: "chronicle_tab", value: "history"),
Step("ch18d", "chronicle_tab", value: "world"),
Step("ch18e", "assert", expect: "chronicle_tab", value: "world"),
Step("ch18f", "chronicle_tab", value: "history"),
Step("ch18g", "wait", wait: 0.25f),
Step("ch19", "assert", expect: "focus", value: "true"),
Step("ch20", "assert", expect: "health"),
Step("ch21", "chronicle_jump"),
Step("ch22", "wait", wait: 0.25f),
Step("ch23", "assert", expect: "focus", value: "true"),
Step("ch24", "assert", expect: "dossier_contains", value: "auto"),
Step("ch25", "assert", expect: "no_bad"),
Step("ch99", "snapshot"),
};
}

View file

@ -0,0 +1,54 @@
using NeoModLoader.General;
using UnityEngine;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Shared canvas / text helpers for compact non-modal HUD cards.
/// </summary>
public static class HudCanvas
{
public static Canvas Resolve()
{
if (CanvasMain.instance != null)
{
if (CanvasMain.instance.canvas_ui != null)
{
return CanvasMain.instance.canvas_ui;
}
if (CanvasMain.instance.canvas_tooltip != null)
{
return CanvasMain.instance.canvas_tooltip;
}
}
return Object.FindObjectOfType<Canvas>();
}
public static Text MakeText(Transform parent, string name, string value, int size)
{
Text text = new GameObject(name, typeof(RectTransform), typeof(Text)).GetComponent<Text>();
text.transform.SetParent(parent, false);
try
{
OT.InitializeCommonText(text);
}
catch
{
text.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
}
text.text = value ?? "";
text.fontSize = size;
text.resizeTextForBestFit = true;
text.resizeTextMinSize = 8;
text.resizeTextMaxSize = size;
text.color = Color.white;
text.raycastTarget = false;
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.verticalOverflow = VerticalWrapMode.Truncate;
return text;
}
}

View file

@ -3,7 +3,7 @@
"enabled": "Enable Idle Spectator (I)",
"show_watch_reasons": "Show brief watch tips",
"show_dossier_caption": "Show creature dossier caption while watching",
"chronicle_enabled": "Enable Character Chronicle side panel (F9)",
"chronicle_enabled": "Enable Chronicle panel (F9) - character History + World events",
"debug_state_probe": "Log focus/power-bar state (for debugging)",
"IdleSpectator Chronicle": "Character Chronicle"
}

View file

@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
@ -13,6 +12,7 @@ public sealed class UnitDossier
public string Name = "";
public string SpeciesId = "";
public string Headline = "";
public string ReasonLine = "";
public string DetailLine = "";
public string CaptionText = "";
public float Score;
@ -35,6 +35,7 @@ public sealed class UnitDossier
if (actor == null || !actor.isAlive())
{
d.Headline = "Nobody";
d.ReasonLine = "";
d.DetailLine = "no living unit";
d.CaptionText = d.Headline;
return d;
@ -58,14 +59,10 @@ public sealed class UnitDossier
d.Score = WorldActivityScanner.ScoreActorPublic(actor, speciesCounts);
CollectReasons(d, actor, speciesCounts);
string tierPrefix = watchTier.HasValue ? $"Watching [{watchTier.Value}]: " : "";
d.Headline = tierPrefix + (string.IsNullOrEmpty(watchLabel)
? WorldActivityScanner.FormatUnitLabelPublic(actor, speciesCounts)
: watchLabel);
d.Headline = $"{d.Name} ({d.SpeciesId})";
d.ReasonLine = BuildReasonLine(d, watchTier, watchLabel, actor, speciesCounts);
d.DetailLine = BuildDetailLine(d);
d.CaptionText = string.IsNullOrEmpty(d.DetailLine)
? d.Headline
: d.Headline + "\n" + d.DetailLine;
d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine);
return d;
}
@ -78,11 +75,173 @@ public sealed class UnitDossier
return (CaptionText ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (Headline ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (ReasonLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (DetailLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (Name ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (SpeciesId ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string JoinCaption(string headline, string reason, string detail)
{
StringBuilder sb = new StringBuilder();
if (!string.IsNullOrEmpty(headline))
{
sb.Append(headline);
}
if (!string.IsNullOrEmpty(reason))
{
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(reason);
}
if (!string.IsNullOrEmpty(detail))
{
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(detail);
}
return sb.ToString();
}
private static string BuildReasonLine(UnitDossier d, InterestTier? watchTier, string watchLabel, Actor actor,
Dictionary<string, int> speciesCounts)
{
string tierPart = watchTier.HasValue ? watchTier.Value.ToString() : "";
string why = "";
if (!string.IsNullOrEmpty(watchLabel))
{
why = ShortenWatchLabel(watchLabel, d);
}
else
{
why = ShortenWatchLabel(
WorldActivityScanner.FormatUnitLabelPublic(actor, speciesCounts), d);
// Ambient focus: FormatUnitLabel often equals the headline ("Name (species)").
if (IsRedundantWithHeadline(why, d))
{
why = FirstFlavorTag(d);
}
}
if (IsRedundantWithHeadline(why, d))
{
why = "";
}
if (string.IsNullOrEmpty(tierPart))
{
return why ?? "";
}
if (string.IsNullOrEmpty(why) || why.IndexOf(tierPart, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return tierPart;
}
return tierPart + " · " + why;
}
private static string FirstFlavorTag(UnitDossier d)
{
for (int i = 0; i < d.ScoreReasons.Count; i++)
{
string reason = Humanize(d.ScoreReasons[i]);
if (string.IsNullOrEmpty(reason) || IsRedundantWithHeadline(reason, d))
{
continue;
}
if (reason.IndexOf("kills", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
continue;
}
return reason;
}
return "";
}
private static bool IsRedundantWithHeadline(string text, UnitDossier d)
{
if (string.IsNullOrEmpty(text) || d == null)
{
return true;
}
if (string.Equals(text, d.Headline, System.StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (string.Equals(text, d.Name, System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(text, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase))
{
return true;
}
// "Name (species)" variants already covered by headline.
if (!string.IsNullOrEmpty(d.Name) && !string.IsNullOrEmpty(d.SpeciesId)
&& text.IndexOf(d.Name, System.StringComparison.OrdinalIgnoreCase) >= 0
&& text.IndexOf(d.SpeciesId, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
return false;
}
private static string ShortenWatchLabel(string label, UnitDossier d)
{
if (string.IsNullOrEmpty(label))
{
return "";
}
string s = label.Trim();
if (s.StartsWith("New species:", System.StringComparison.OrdinalIgnoreCase))
{
return "New species";
}
if (s.StartsWith("Watching [", System.StringComparison.OrdinalIgnoreCase))
{
int close = s.IndexOf(']');
if (close > 0 && close + 1 < s.Length)
{
s = s.Substring(close + 1).TrimStart(':', ' ');
}
}
// Drop trailing "Name" duplicates like "Lone wolf: Waf" -> keep short form.
int colon = s.IndexOf(':');
if (colon > 0 && colon < 28)
{
s = s.Substring(0, colon).Trim();
}
if (d != null && IsRedundantWithHeadline(s, d))
{
return "";
}
if (s.Length > 36)
{
s = s.Substring(0, 33) + "...";
}
return s;
}
private static void CollectReasons(UnitDossier d, Actor actor, Dictionary<string, int> speciesCounts)
{
if (d.IsFavorite)
@ -120,10 +279,6 @@ public sealed class UnitDossier
{
d.ScoreReasons.Add("10+ kills");
}
else if (d.Kills > 0)
{
d.ScoreReasons.Add($"{d.Kills} kills");
}
if (d.IsLoneSpecies)
{
@ -137,10 +292,9 @@ public sealed class UnitDossier
if (d.Renown >= 100)
{
d.ScoreReasons.Add($"renown {d.Renown}");
d.ScoreReasons.Add("renown " + d.Renown);
}
// A few trait ids for flavor (keep short).
try
{
if (actor.hasTraits())
@ -153,7 +307,7 @@ public sealed class UnitDossier
continue;
}
d.ScoreReasons.Add(trait.id);
d.ScoreReasons.Add(Humanize(trait.id));
n++;
if (n >= 2)
{
@ -171,46 +325,89 @@ public sealed class UnitDossier
private static string BuildDetailLine(UnitDossier d)
{
StringBuilder sb = new StringBuilder();
sb.Append(d.SpeciesId);
if (d.Age > 0)
{
sb.Append(" · age ").Append(d.Age);
sb.Append("age ").Append(d.Age);
}
if (d.Kills > 0)
{
sb.Append(" · ").Append(d.Kills).Append(" kills");
if (sb.Length > 0)
{
sb.Append(" · ");
}
sb.Append(d.Kills).Append(" kills");
}
if (!string.IsNullOrEmpty(d.KingdomName))
if (!string.IsNullOrEmpty(d.KingdomName)
&& !string.Equals(d.KingdomName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)
&& !string.Equals(d.KingdomName, d.Name, System.StringComparison.OrdinalIgnoreCase))
{
sb.Append(" · ").Append(d.KingdomName);
if (sb.Length > 0)
{
sb.Append(" · ");
}
sb.Append(d.KingdomName);
}
else if (!string.IsNullOrEmpty(d.CityName))
else if (!string.IsNullOrEmpty(d.CityName)
&& !string.Equals(d.CityName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)
&& !string.Equals(d.CityName, d.Name, System.StringComparison.OrdinalIgnoreCase))
{
sb.Append(" · ").Append(d.CityName);
if (sb.Length > 0)
{
sb.Append(" · ");
}
sb.Append(d.CityName);
}
for (int i = 0; i < d.ScoreReasons.Count && i < 4; i++)
int tags = 0;
for (int i = 0; i < d.ScoreReasons.Count && tags < 2; i++)
{
string reason = d.ScoreReasons[i];
// Skip reasons already implied by headline pieces.
if (reason == "fighting" || reason == "king" || reason == "favorite")
if (string.IsNullOrEmpty(reason))
{
continue;
}
if (sb.ToString().IndexOf(reason, System.StringComparison.OrdinalIgnoreCase) >= 0)
if (reason.IndexOf("kills", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
continue;
}
sb.Append(" · ").Append(reason);
string shown = Humanize(reason);
if (IsRedundantWithHeadline(shown, d)
|| sb.ToString().IndexOf(shown, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (!string.IsNullOrEmpty(d.ReasonLine)
&& d.ReasonLine.IndexOf(shown, System.StringComparison.OrdinalIgnoreCase) >= 0))
{
continue;
}
if (sb.Length > 0)
{
sb.Append(" · ");
}
sb.Append(shown);
tags++;
}
return sb.ToString();
}
private static string Humanize(string raw)
{
if (string.IsNullOrEmpty(raw))
{
return "";
}
return raw.Replace('_', ' ');
}
private static string SafeName(Actor actor)
{
try

View file

@ -1,17 +1,25 @@
using NeoModLoader.General;
using NeoModLoader.services;
using UnityEngine;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Persistent "who am I watching" caption while Idle Spectator is active.
/// Persistent compact dossier card (top-left) while Idle Spectator is active.
/// Replaces WorldTip for dossier text so AFK watching does not spam giant orange toasts.
/// </summary>
public static class WatchCaption
{
private const float RefreshSeconds = 4.5f;
private const float PanelWidth = 260f;
private const float PanelHeight = 72f;
private static float _lastShownAt = -999f;
private static GameObject _root;
private static Text _nameText;
private static Text _reasonText;
private static Text _metaText;
private static UnitDossier _current;
private static bool _visible;
public static UnitDossier Current => _current;
@ -27,15 +35,7 @@ public static class WatchCaption
LastHeadline = "";
LastDetail = "";
LastCaptionText = "";
_lastShownAt = -999f;
try
{
WorldTip.hideNow();
}
catch
{
// ignore
}
SetVisible(false);
}
public static void SetFromInterest(InterestEvent interest)
@ -52,7 +52,9 @@ public static class WatchCaption
LastDetail = "";
LastCaptionText = LastHeadline;
_current = null;
ShowNow(LastCaptionText, force: true);
EnsureBuilt();
ApplyLines(LastHeadline, "", "");
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
}
return;
@ -68,7 +70,9 @@ public static class WatchCaption
LastHeadline = dossier.Headline ?? "";
LastDetail = dossier.DetailLine ?? "";
LastCaptionText = dossier.CaptionText ?? "";
ShowNow(LastCaptionText, force: true);
EnsureBuilt();
ApplyLines(dossier.Headline, dossier.ReasonLine, dossier.DetailLine);
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
LogService.LogInfo("[IdleSpectator][CAPTION] " + LastCaptionText.Replace("\n", " | "));
}
@ -76,36 +80,118 @@ public static class WatchCaption
{
if (!SpectatorMode.Active || !ModSettings.ShowDossierCaption)
{
if (_visible)
{
SetVisible(false);
}
return;
}
if (string.IsNullOrEmpty(LastCaptionText))
if (_root == null && !string.IsNullOrEmpty(LastCaptionText))
{
return;
}
EnsureBuilt();
if (_current != null)
{
ApplyLines(_current.Headline, _current.ReasonLine, _current.DetailLine);
}
else
{
ApplyLines(LastHeadline, "", LastDetail);
}
if (Time.unscaledTime - _lastShownAt < RefreshSeconds)
{
return;
SetVisible(true);
}
// Refresh so the tip does not fade away during long AFK watches.
ShowNow(LastCaptionText, force: false);
}
private static void ShowNow(string text, bool force)
private static void ApplyLines(string name, string reason, string meta)
{
if (!ModSettings.ShowDossierCaption || string.IsNullOrEmpty(text))
if (_nameText != null)
{
_nameText.text = string.IsNullOrEmpty(name) ? "" : name;
}
if (_reasonText != null)
{
_reasonText.text = string.IsNullOrEmpty(reason) ? "" : reason;
}
if (_metaText != null)
{
_metaText.text = string.IsNullOrEmpty(meta) ? "" : meta;
}
}
private static void SetVisible(bool visible)
{
EnsureBuilt();
if (_root == null)
{
return;
}
if (!force && Time.unscaledTime - _lastShownAt < 1f)
_visible = visible;
_root.SetActive(visible);
}
private static void EnsureBuilt()
{
if (_root != null)
{
return;
}
WorldTip.showNow(text, pTranslate: false, pPosition: "top", pTime: RefreshSeconds + 2f);
_lastShownAt = Time.unscaledTime;
Canvas canvas = HudCanvas.Resolve();
if (canvas == null)
{
return;
}
_root = new GameObject("IdleSpectatorDossierHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup));
_root.transform.SetParent(canvas.transform, false);
RectTransform rt = _root.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.pivot = new Vector2(0f, 1f);
rt.sizeDelta = new Vector2(PanelWidth, PanelHeight);
rt.anchoredPosition = new Vector2(12f, -12f);
Image bg = _root.GetComponent<Image>();
bg.color = new Color(0.05f, 0.06f, 0.08f, 0.72f);
bg.raycastTarget = false;
CanvasGroup group = _root.GetComponent<CanvasGroup>();
group.blocksRaycasts = false;
group.interactable = false;
_nameText = HudCanvas.MakeText(_root.transform, "Name", "", 12);
PlaceLine(_nameText.GetComponent<RectTransform>(), -6f, 22f);
_nameText.fontStyle = FontStyle.Bold;
_nameText.color = new Color(0.95f, 0.93f, 0.88f, 1f);
_nameText.alignment = TextAnchor.MiddleLeft;
_reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 10);
PlaceLine(_reasonText.GetComponent<RectTransform>(), -28f, 18f);
_reasonText.color = new Color(0.95f, 0.72f, 0.38f, 1f);
_reasonText.alignment = TextAnchor.MiddleLeft;
_metaText = HudCanvas.MakeText(_root.transform, "Meta", "", 9);
PlaceLine(_metaText.GetComponent<RectTransform>(), -46f, 18f);
_metaText.color = new Color(0.72f, 0.75f, 0.78f, 1f);
_metaText.alignment = TextAnchor.MiddleLeft;
_root.SetActive(false);
_visible = false;
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (top-left card)");
}
private static void PlaceLine(RectTransform lineRt, float yFromTop, float height)
{
lineRt.anchorMin = new Vector2(0f, 1f);
lineRt.anchorMax = new Vector2(1f, 1f);
lineRt.pivot = new Vector2(0.5f, 1f);
lineRt.sizeDelta = new Vector2(-16f, height);
lineRt.anchoredPosition = new Vector2(0f, yFromTop);
}
}

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.8.0",
"description": "AFK Idle Spectator (I) + Chronicle life-event HUD (F9). Dossier captions, kills/lovers/WorldLog chronicle.",
"version": "0.9.1",
"description": "AFK Idle Spectator (I) + Chronicle History|World (F9). Character death causes, life events.",
"GUID": "com.dazed.idlespectator"
}