diff --git a/IdleSpectator/ActorChroniclePatches.cs b/IdleSpectator/ActorChroniclePatches.cs index d153192..372da98 100644 --- a/IdleSpectator/ActorChroniclePatches.cs +++ b/IdleSpectator/ActorChroniclePatches.cs @@ -27,6 +27,9 @@ public static class ActorChroniclePatches return; } + // Capture living dossier before die() clears state - used by Fallen archive. + Chronicle.CaptureFinalDossier(__instance); + BaseSimObject attacker = __instance.attackedBy; if (attacker != null && !attacker.isRekt() && attacker.isActor() && attacker != __instance) { diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 6364e9e..25b52b0 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -395,9 +395,17 @@ public static class AgentHarness { Chronicle.ShowHud(); string tabRaw = (cmd.value ?? cmd.label ?? "world").Trim().ToLowerInvariant(); - if (tabRaw == "characters" || tabRaw == "character" || tabRaw == "chars" || tabRaw == "history") + if (tabRaw == "fallen" || tabRaw == "dead") { - ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + ChronicleHud.SetTab(ChronicleHud.LoreTab.Fallen); + } + else if (tabRaw == "living" + || tabRaw == "characters" + || tabRaw == "character" + || tabRaw == "chars" + || tabRaw == "history") + { + ChronicleHud.SetTab(ChronicleHud.LoreTab.Living); } else { @@ -422,7 +430,7 @@ public static class AgentHarness case "lore_search": { Chronicle.ShowHud(); - ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + ChronicleHud.SetTab(ChronicleHud.LoreTab.Living); ChronicleHud.SetSearch(cmd.value ?? cmd.label ?? ""); _cmdOk++; Emit(cmd, ok: true, detail: $"search='{ChronicleHud.SearchText}'"); @@ -432,7 +440,7 @@ public static class AgentHarness case "lore_favorites": { Chronicle.ShowHud(); - ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + ChronicleHud.SetTab(ChronicleHud.LoreTab.Living); string v = (cmd.value ?? "true").Trim().ToLowerInvariant(); bool on = v != "false" && v != "0" && v != "off"; ChronicleHud.SetFavoritesOnly(on); @@ -444,7 +452,7 @@ public static class AgentHarness case "lore_pick": { Chronicle.ShowHud(); - ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + ChronicleHud.SetTab(ChronicleHud.LoreTab.Living); string needle = cmd.value ?? cmd.label ?? ""; if (needle == "auto") { @@ -489,7 +497,7 @@ public static class AgentHarness case "lore_pick_fallen": { Chronicle.ShowHud(); - ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + ChronicleHud.SetTab(ChronicleHud.LoreTab.Fallen); string needle = cmd.value ?? cmd.label ?? ""; if (needle == "auto") { @@ -508,7 +516,7 @@ public static class AgentHarness Emit(cmd, ok, detail: - $"picked={ok} detail={ChronicleHud.DetailUnitId} follow={ChronicleHud.FollowFocus} hist={Chronicle.HistoryCountFor(ChronicleHud.DetailUnitId)}"); + $"picked={ok} detail={ChronicleHud.DetailUnitId} follow={ChronicleHud.FollowFocus} hist={Chronicle.HistoryCountFor(ChronicleHud.DetailUnitId)} fallenFocus={Chronicle.LastFallenFocusDetail} manner={Chronicle.LatestDeathMannerFor(ChronicleHud.DetailUnitId)}"); break; } @@ -518,7 +526,43 @@ public static class AgentHarness string name = !string.IsNullOrEmpty(cmd.label) ? cmd.label : "Orphan"; string species = !string.IsNullOrEmpty(cmd.asset) ? cmd.asset : "creature"; string prefix = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "Fallen"; - long id = Chronicle.ForceOrphanHistory(name, species, n, prefix); + AttackType attackType = AttackType.Age; + if (!string.IsNullOrEmpty(cmd.expect) + && System.Enum.TryParse(cmd.expect, ignoreCase: true, out AttackType parsedAttack)) + { + attackType = parsedAttack; + } + + long killerId = 0; + Vector3 deathPos = Vector3.zero; + string mode = (cmd.tier ?? "").Trim().ToLowerInvariant(); + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + if (mode == "killer" && focus != null && focus.isAlive()) + { + killerId = focus.getID(); + deathPos = focus.current_position; + } + else if (mode == "place") + { + if (focus != null) + { + deathPos = focus.current_position; + } + else if (MoveCamera.instance != null) + { + deathPos = MoveCamera.instance.transform.position; + deathPos.z = 0f; + } + + if (deathPos == Vector3.zero) + { + deathPos = new Vector3(48f, 48f, 0f); + } + } + + long id = Chronicle.ForceOrphanHistory( + name, species, n, prefix, attackType, killerId, deathPos); + DeathManner manner = Chronicle.LatestDeathMannerFor(id); bool ok = id != 0 && Chronicle.HistoryCountFor(id) >= n; if (ok) { @@ -529,7 +573,11 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, ok, detail: $"orphanId={id} hist={Chronicle.HistoryCountFor(id)} want={n}"); + Emit( + cmd, + ok, + detail: + $"orphanId={id} hist={Chronicle.HistoryCountFor(id)} want={n} manner={manner} killer={killerId} mode={mode}"); break; } @@ -1491,6 +1539,42 @@ public static class AgentHarness $"follow={have} want={want} detailId={ChronicleHud.DetailUnitId}"; break; } + case "fallen_focus": + { + string want = (cmd.value ?? "").Trim().ToLowerInvariant(); + string have = (Chronicle.LastFallenFocusDetail ?? "").Trim().ToLowerInvariant(); + if (want == "killer" || want.StartsWith("killer")) + { + pass = have.StartsWith("killer:"); + } + else if (want == "place") + { + pass = have == "place"; + } + else if (want == "none") + { + pass = have == "none" || string.IsNullOrEmpty(have); + } + else + { + pass = have == want; + } + + detail = $"fallenFocus={Chronicle.LastFallenFocusDetail} want={cmd.value}"; + break; + } + case "death_manner": + { + long id = ChronicleHud.DetailUnitId; + DeathManner have = Chronicle.LatestDeathMannerFor(id); + string haveLabel = Chronicle.DeathMannerLabel(have); + string want = (cmd.value ?? "").Trim().ToLowerInvariant(); + pass = id != 0 + && (haveLabel == want + || have.ToString().Equals(want, System.StringComparison.OrdinalIgnoreCase)); + detail = $"manner={have} label={haveLabel} want={cmd.value} detailId={id}"; + break; + } case "lore_follows_focus": { long focusId = WatchCaption.CurrentUnitId; @@ -1548,6 +1632,104 @@ public static class AgentHarness $"pinned={WatchCaption.PinnedWhilePaused} visible={WatchCaption.Visible} idle={SpectatorMode.Active} want={want}"; break; } + case "dossier_unit_matches_lore": + { + long dossierId = WatchCaption.CurrentUnitId; + long loreId = ChronicleHud.DetailUnitId; + pass = dossierId != 0 && loreId != 0 && dossierId == loreId; + detail = + $"dossierId={dossierId} loreId={loreId} headline={WatchCaption.LastHeadline}"; + break; + } + case "dossier_species": + { + string want = (cmd.value ?? cmd.asset ?? "").Trim(); + string have = WatchCaption.Current != null ? WatchCaption.Current.SpeciesId ?? "" : ""; + pass = !string.IsNullOrEmpty(want) + && have.Equals(want, System.StringComparison.OrdinalIgnoreCase); + detail = + $"species={have} want={want} headline={WatchCaption.LastHeadline} avatarLive={DossierAvatar.ShowingLiveAvatar} avatarSpecies={DossierAvatar.ShowingSpeciesFallback}"; + break; + } + case "dossier_matches_focus": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + UnitDossier dossier = WatchCaption.Current; + string focusSpecies = focus?.asset != null ? focus.asset.id : ""; + long focusId = 0; + try + { + focusId = focus != null ? focus.getID() : 0; + } + catch + { + focusId = 0; + } + + pass = focus != null + && focus.isAlive() + && dossier != null + && dossier.UnitId == focusId + && !string.IsNullOrEmpty(focusSpecies) + && focusSpecies.Equals(dossier.SpeciesId ?? "", System.StringComparison.OrdinalIgnoreCase) + && DossierAvatar.ShowingLiveAvatar + && !DossierAvatar.ShowingSpeciesFallback; + detail = + $"focusId={focusId} dossierId={(dossier != null ? dossier.UnitId : 0)} focusSpecies={focusSpecies} dossierSpecies={(dossier != null ? dossier.SpeciesId : "")} avatarLive={DossierAvatar.ShowingLiveAvatar} avatarSpecies={DossierAvatar.ShowingSpeciesFallback}"; + break; + } + case "dossier_avatar_live": + { + pass = DossierAvatar.ShowingLiveAvatar && !DossierAvatar.ShowingSpeciesFallback; + detail = + $"avatarLive={DossierAvatar.ShowingLiveAvatar} avatarSpecies={DossierAvatar.ShowingSpeciesFallback} dossierId={WatchCaption.CurrentUnitId}"; + break; + } + case "dossier_avatar_species": + { + pass = DossierAvatar.ShowingSpeciesFallback && !DossierAvatar.ShowingLiveAvatar; + detail = + $"avatarLive={DossierAvatar.ShowingLiveAvatar} avatarSpecies={DossierAvatar.ShowingSpeciesFallback} dossierId={WatchCaption.CurrentUnitId}"; + break; + } + case "fallen_place_no_follow": + { + // Place focus must clear unit follow so the camera stays at the death site. + pass = Chronicle.LastFallenFocusDetail == "place" && !MoveCamera.hasFocusUnit(); + detail = + $"fallenFocus={Chronicle.LastFallenFocusDetail} hasFocus={MoveCamera.hasFocusUnit()} pos={Chronicle.LastFallenFocusPosition}"; + break; + } + case "fallen_killer_camera": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + long focusId = 0; + try + { + focusId = focus != null ? focus.getID() : 0; + } + catch + { + focusId = 0; + } + + string detailStr = Chronicle.LastFallenFocusDetail ?? ""; + bool killerTag = detailStr.StartsWith("killer:"); + long killerId = 0; + if (killerTag) + { + long.TryParse(detailStr.Substring("killer:".Length), out killerId); + } + + pass = killerTag + && killerId != 0 + && focusId == killerId + && WatchCaption.CurrentUnitId != 0 + && WatchCaption.CurrentUnitId != focusId; + detail = + $"fallenFocus={detailStr} focusId={focusId} dossierId={WatchCaption.CurrentUnitId}"; + break; + } case "lore_zoom_blocked": { MoveCamera cam = MoveCamera.instance; @@ -1619,15 +1801,37 @@ public static class AgentHarness case "lore_tab": { string wantRaw = (cmd.value ?? cmd.label ?? "world").Trim().ToLowerInvariant(); - ChronicleHud.LoreTab want = wantRaw == "characters" || wantRaw == "character" - || wantRaw == "chars" || wantRaw == "history" - ? ChronicleHud.LoreTab.Characters - : ChronicleHud.LoreTab.World; + ChronicleHud.LoreTab want = ChronicleHud.LoreTab.World; + if (wantRaw == "fallen" || wantRaw == "dead") + { + want = ChronicleHud.LoreTab.Fallen; + } + else if (wantRaw == "living" + || wantRaw == "characters" + || wantRaw == "character" + || wantRaw == "chars" + || wantRaw == "history") + { + want = ChronicleHud.LoreTab.Living; + } + pass = Chronicle.HudVisible && ChronicleHud.ActiveTab == want; detail = $"hud={Chronicle.HudVisible} tab={ChronicleHud.ActiveTab} want={want} recent={Chronicle.RecentFocusCount}"; break; } + case "fallen_list_top": + { + string want = (cmd.value ?? cmd.label ?? "").Trim(); + string have = ChronicleHud.LastListTopTitle ?? ""; + pass = Chronicle.HudVisible + && ChronicleHud.ActiveTab == ChronicleHud.LoreTab.Fallen + && ChronicleHud.DetailUnitId == 0 + && !string.IsNullOrEmpty(want) + && have.IndexOf(want, System.StringComparison.OrdinalIgnoreCase) >= 0; + detail = $"top='{have}' want='{want}' tab={ChronicleHud.ActiveTab}"; + break; + } case "lore_recent": { int want = ParseCountExpect(cmd, defaultValue: 1); diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index 27bea76..a6475ae 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -8,12 +8,12 @@ public static class CameraDirector public static string LastWatchLabel { get; private set; } = ""; public static string LastWatchAssetId { get; private set; } = ""; - public static void FocusUnit(Actor actor) + public static void FocusUnit(Actor actor, bool updateCaption = true) { if (actor != null && actor.isAlive()) { RetargetFollow(actor); - if (SpectatorMode.Active) + if (updateCaption && SpectatorMode.Active) { WatchCaption.SetFromActor(actor); } diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index 55b2318..aa65a51 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -16,6 +16,21 @@ public enum ChronicleKind AgeChapter } +/// How a character died - used for Fallen list labels and icons. +public enum DeathManner +{ + None = 0, + Slain, + OldAge, + Starvation, + Drowned, + Burned, + Plague, + Accident, + Divine, + Other +} + public sealed class ChronicleEntry { public float CreatedAt; @@ -33,17 +48,42 @@ public sealed class ChronicleEntry /// Chronicle-voice line for HUD; falls back to . public string LoreLine = ""; public Vector3 Position; + /// True when was recorded from a real world location. + public bool HasWorldPosition; public string AssetId = ""; public string AgeId = ""; public string AgeName = ""; /// True when this row is a curated character legend in World Memory. public bool IsLegend; + /// Vanilla name when is Death. + public string AttackTypeName = ""; + /// Classified death manner for Fallen UI. + public DeathManner DeathManner; /// Back-compat for harness / jump helpers. public long UnitId => SubjectId; public string HudLine => !string.IsNullOrEmpty(LoreLine) ? LoreLine : (Line ?? ""); + /// Resolved death manner (stored, or inferred from line / killer). + public DeathManner ResolvedDeathManner + { + get + { + if (Kind != ChronicleKind.Death) + { + return DeathManner.None; + } + + if (DeathManner != DeathManner.None) + { + return DeathManner; + } + + return Chronicle.InferDeathManner(AttackTypeName, OtherId != 0, Line); + } + } + /// Plain date-prefixed line for logs / asserts (uses lore when present). public string DisplayLine { @@ -103,6 +143,8 @@ public static class Chronicle private static readonly HashSet LoverPairKeys = new HashSet(); private static readonly HashSet FriendPairKeys = new HashSet(); private static readonly HashSet DeathLogged = new HashSet(); + private static readonly Dictionary FinalDossiers = + new Dictionary(); private static readonly HashSet LandmarkDedupe = new HashSet(); private static readonly Dictionary LandmarksPerAge = new Dictionary(); private static readonly List RecentFocusIds = new List(); @@ -350,6 +392,7 @@ public static class Chronicle LoverPairKeys.Clear(); FriendPairKeys.Clear(); DeathLogged.Clear(); + FinalDossiers.Clear(); LandmarkDedupe.Clear(); LandmarksPerAge.Clear(); RecentFocusIds.Clear(); @@ -395,7 +438,7 @@ public static class Chronicle EnsureWindow(); if (ChronicleHud.DetailUnitId == 0) { - ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + ChronicleHud.SetTab(ChronicleHud.LoreTab.Living); } ChronicleHud.SetVisible(true); @@ -575,20 +618,31 @@ public static class Chronicle return true; } + /// Which cast the Characters browser should show. + public enum CharacterListScope + { + Living, + Fallen + } + /// - /// Character History rows: recent focuses first, then other history subjects / favorites. + /// Living: favorites, then recently watched, then by event count. + /// Fallen: newest death / event first. /// public static List ListCharacters( string search = "", bool favoritesOnly = false, - int max = 40) + int max = 40, + CharacterListScope scope = CharacterListScope.Living) { var byId = new Dictionary(); string needle = (search ?? "").Trim().ToLowerInvariant(); + bool wantLiving = scope == CharacterListScope.Living; + bool wantFallen = scope == CharacterListScope.Fallen; void Upsert(Actor actor, bool recent, int recentIndex) { - if (actor == null || !actor.isAlive()) + if (!wantLiving || actor == null || !actor.isAlive()) { return; } @@ -643,7 +697,8 @@ public static class Chronicle SpeciesId = species, HistoryCount = HistoryCountFor(id), IsFavorite = fav, - IsAlive = true + IsAlive = true, + SortTime = LatestActivityTime(id) }; byId[id] = row; } @@ -659,135 +714,157 @@ public static class Chronicle row.IsFavorite = fav; row.HistoryCount = HistoryCountFor(id); + row.SortTime = LatestActivityTime(id); } - List recentCopy; - lock (RecentFocusIds) + if (wantLiving) { - recentCopy = new List(RecentFocusIds); - } - - for (int i = 0; i < recentCopy.Count; i++) - { - Upsert(FindUnitById(recentCopy[i]), recent: true, recentIndex: i); - } - - foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) - { - if (actor == null || !actor.isAlive()) + List recentCopy; + lock (RecentFocusIds) { - continue; + recentCopy = new List(RecentFocusIds); } - bool fav = false; - try + for (int i = 0; i < recentCopy.Count; i++) { - fav = actor.isFavorite(); - } - catch - { - fav = false; + Upsert(FindUnitById(recentCopy[i]), recent: true, recentIndex: i); } - long id = 0; - try + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { - id = actor.getID(); - } - catch - { - continue; - } - - bool hasHist = HistoryCountFor(id) > 0; - if (!fav && !hasHist && !byId.ContainsKey(id)) - { - continue; - } - - Upsert(actor, recent: false, recentIndex: -1); - } - - // History subjects that may still be named from entries even if not matched above. - List histIds; - lock (Histories) - { - histIds = new List(Histories.Keys); - } - - for (int i = 0; i < histIds.Count; i++) - { - long id = histIds[i]; - if (byId.ContainsKey(id)) - { - continue; - } - - Actor live = FindUnitById(id); - if (live != null) - { - Upsert(live, recent: false, recentIndex: -1); - continue; - } - - if (favoritesOnly) - { - continue; - } - - IReadOnlyList snap = SnapshotForSubject(id); - if (snap == null || snap.Count == 0) - { - continue; - } - - ChronicleEntry last = snap[snap.Count - 1]; - string name = last?.Name ?? ""; - string species = last?.SpeciesId ?? ""; - if (!string.IsNullOrEmpty(needle)) - { - string hay = (name + " " + species).ToLowerInvariant(); - if (hay.IndexOf(needle, System.StringComparison.Ordinal) < 0) + if (actor == null || !actor.isAlive()) { continue; } + + bool fav = false; + try + { + fav = actor.isFavorite(); + } + catch + { + fav = false; + } + + long id = 0; + try + { + id = actor.getID(); + } + catch + { + continue; + } + + bool hasHist = HistoryCountFor(id) > 0; + if (!fav && !hasHist && !byId.ContainsKey(id)) + { + continue; + } + + Upsert(actor, recent: false, recentIndex: -1); + } + } + + if (wantFallen) + { + // History subjects that may still be named from entries even if not matched above. + List histIds; + lock (Histories) + { + histIds = new List(Histories.Keys); } - byId[id] = new ChronicleCharacterSummary + for (int i = 0; i < histIds.Count; i++) { - UnitId = id, - Name = name, - SpeciesId = species, - HistoryCount = snap.Count, - IsFavorite = false, - IsAlive = false, - IsRecent = false - }; + long id = histIds[i]; + if (byId.ContainsKey(id)) + { + continue; + } + + Actor live = FindUnitById(id); + if (live != null && live.isAlive()) + { + continue; + } + + if (favoritesOnly) + { + continue; + } + + IReadOnlyList snap = SnapshotForSubject(id); + if (snap == null || snap.Count == 0) + { + continue; + } + + ChronicleEntry last = snap[snap.Count - 1]; + string name = last?.Name ?? ""; + string species = last?.SpeciesId ?? ""; + if (!string.IsNullOrEmpty(needle)) + { + string hay = (name + " " + species).ToLowerInvariant(); + if (hay.IndexOf(needle, System.StringComparison.Ordinal) < 0) + { + continue; + } + } + + byId[id] = new ChronicleCharacterSummary + { + UnitId = id, + Name = name, + SpeciesId = species, + HistoryCount = snap.Count, + IsFavorite = false, + IsAlive = false, + IsRecent = false, + DeathManner = LatestDeathMannerFor(id), + SortTime = LatestActivityTime(id) + }; + } } var list = new List(byId.Values); list.Sort((a, b) => { - if (a.IsAlive != b.IsAlive) + if (wantFallen || (!a.IsAlive && !b.IsAlive)) { - return a.IsAlive ? -1 : 1; - } - - if (a.IsRecent != b.IsRecent) - { - return a.IsRecent ? -1 : 1; - } - - if (a.IsRecent && b.IsRecent) - { - return a.RecentIndex.CompareTo(b.RecentIndex); + // Fallen: most recent event first. + if (a.SortTime != b.SortTime) + { + return b.SortTime.CompareTo(a.SortTime); + } + + if (a.HistoryCount != b.HistoryCount) + { + return b.HistoryCount.CompareTo(a.HistoryCount); + } + + return string.Compare(a.Title, b.Title, System.StringComparison.OrdinalIgnoreCase); } + // Living: favorites, recently watched, event count, name. if (a.IsFavorite != b.IsFavorite) { return a.IsFavorite ? -1 : 1; } + int ra = a.IsRecent && a.RecentIndex >= 0 ? a.RecentIndex : int.MaxValue; + int rb = b.IsRecent && b.RecentIndex >= 0 ? b.RecentIndex : int.MaxValue; + if (ra != rb) + { + return ra.CompareTo(rb); + } + + if (a.HistoryCount != b.HistoryCount) + { + return b.HistoryCount.CompareTo(a.HistoryCount); + } + return string.Compare(a.Title, b.Title, System.StringComparison.OrdinalIgnoreCase); }); @@ -799,6 +876,22 @@ public static class Chronicle return list; } + private static float LatestActivityTime(long subjectId) + { + IReadOnlyList snap = SnapshotForSubject(subjectId); + float best = 0f; + for (int i = 0; i < snap.Count; i++) + { + ChronicleEntry e = snap[i]; + if (e != null && e.CreatedAt > best) + { + best = e.CreatedAt; + } + } + + return best; + } + public static void Update() { if (!ModSettings.ChronicleEnabled || !Config.game_loaded || World.world == null) @@ -867,7 +960,7 @@ public static class Chronicle } string line = FormatDeathCause(victim, attackType, killer); - AppendHistory(ChronicleKind.Death, victim, killer, line); + AppendHistory(ChronicleKind.Death, victim, killer, line, attackType); } public static void NoteLovers(Actor a, Actor b) @@ -976,7 +1069,14 @@ public static class Chronicle /// /// Harness: inject history for a subject id with no living unit (Fallen list). /// - public static long ForceOrphanHistory(string name, string species, int count, string labelPrefix = "Fallen") + public static long ForceOrphanHistory( + string name, + string species, + int count, + string labelPrefix = "Fallen", + AttackType attackType = AttackType.Age, + long killerId = 0, + Vector3 deathPos = default) { count = Mathf.Clamp(count, 1, MaxHistoryPerUnit); long id; @@ -988,6 +1088,40 @@ public static class Chronicle string who = string.IsNullOrEmpty(name) ? "Nameless" : name; string sp = string.IsNullOrEmpty(species) ? "creature" : species; string prefix = string.IsNullOrEmpty(labelPrefix) ? "Fallen" : labelPrefix; + bool hasKiller = killerId != 0; + DeathManner manner = ClassifyDeathManner(attackType, hasKiller); + string baseCause; + switch (manner) + { + case DeathManner.Slain: + baseCause = hasKiller ? "Killed by foe" : "Killed in combat"; + break; + case DeathManner.OldAge: + baseCause = "Died of old age"; + break; + case DeathManner.Starvation: + baseCause = "Starved to death"; + break; + case DeathManner.Drowned: + baseCause = "Drowned"; + break; + case DeathManner.Burned: + baseCause = "Burned to death"; + break; + case DeathManner.Plague: + baseCause = "Died of plague"; + break; + case DeathManner.Divine: + baseCause = "Struck down by divine power"; + break; + case DeathManner.Accident: + baseCause = "Died in an explosion"; + break; + default: + baseCause = "Died"; + break; + } + RefreshAgeChapter(force: false); StampWorldDate(out double worldTime, out string dateLabel); @@ -1001,7 +1135,8 @@ public static class Chronicle for (int i = 0; i < count; i++) { - string line = count > 1 ? $"{prefix} #{i + 1}" : prefix; + string line = count > 1 ? $"{baseCause} ({prefix} #{i + 1})" : baseCause; + string lore = LoreProse.Rewrite(ChronicleKind.Death, line); list.Add(new ChronicleEntry { CreatedAt = Time.unscaledTime, @@ -1009,12 +1144,17 @@ public static class Chronicle DateLabel = dateLabel, Kind = ChronicleKind.Death, SubjectId = id, + OtherId = killerId, Name = who, SpeciesId = sp, Line = line, - LoreLine = line, + LoreLine = lore, + Position = deathPos, + HasWorldPosition = deathPos.sqrMagnitude > 0.0001f, AgeId = _currentAgeId, - AgeName = _currentAgeName + AgeName = _currentAgeName, + AttackTypeName = attackType.ToString(), + DeathManner = manner }); } @@ -1025,7 +1165,10 @@ public static class Chronicle } NoteFocusId(id); - LogService.LogInfo($"[IdleSpectator][CHRONICLE] orphan history id={id} count={count} name={who}"); + LogService.LogInfo( + $"[IdleSpectator][CHRONICLE] orphan history id={id} count={count} name={who} manner={manner} killer={killerId}"); + + FinalDossiers[id] = UnitDossier.FromFallenArchive(id, who, sp, manner); return id; } @@ -1064,10 +1207,318 @@ public static class Chronicle Actor victim = MoveCamera._focus_unit; string line = FormatDeathCause(victim, attackType, killer); - AppendHistory(ChronicleKind.Death, victim, killer, line); + AppendHistory(ChronicleKind.Death, victim, killer, line, attackType); return true; } + public static DeathManner ClassifyDeathManner(AttackType attackType, bool hasKiller) + { + switch (attackType) + { + case AttackType.Age: + return DeathManner.OldAge; + case AttackType.Starvation: + return DeathManner.Starvation; + case AttackType.Drowning: + case AttackType.Water: + return DeathManner.Drowned; + case AttackType.Fire: + return DeathManner.Burned; + case AttackType.Plague: + case AttackType.Infection: + case AttackType.Poison: + case AttackType.Tumor: + case AttackType.AshFever: + return DeathManner.Plague; + case AttackType.Divine: + return DeathManner.Divine; + case AttackType.Gravity: + case AttackType.Explosion: + case AttackType.Acid: + case AttackType.Metamorphosis: + return DeathManner.Accident; + case AttackType.Eaten: + case AttackType.Weapon: + case AttackType.Smile: + case AttackType.Other: + return hasKiller ? DeathManner.Slain : DeathManner.Other; + case AttackType.None: + return hasKiller ? DeathManner.Slain : DeathManner.Other; + default: + return hasKiller ? DeathManner.Slain : DeathManner.Other; + } + } + + public static DeathManner InferDeathManner(string attackTypeName, bool hasKiller, string line) + { + if (!string.IsNullOrEmpty(attackTypeName) + && System.Enum.TryParse(attackTypeName, ignoreCase: true, out AttackType parsed)) + { + return ClassifyDeathManner(parsed, hasKiller); + } + + string raw = line ?? ""; + if (raw.IndexOf("old age", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return DeathManner.OldAge; + } + + if (raw.IndexOf("Starved", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return DeathManner.Starvation; + } + + if (raw.IndexOf("Drown", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return DeathManner.Drowned; + } + + if (raw.IndexOf("Burn", System.StringComparison.OrdinalIgnoreCase) >= 0 + || raw.IndexOf("lava", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return DeathManner.Burned; + } + + if (raw.IndexOf("plague", System.StringComparison.OrdinalIgnoreCase) >= 0 + || raw.IndexOf("poison", System.StringComparison.OrdinalIgnoreCase) >= 0 + || raw.IndexOf("infection", System.StringComparison.OrdinalIgnoreCase) >= 0 + || raw.IndexOf("tumor", System.StringComparison.OrdinalIgnoreCase) >= 0 + || raw.IndexOf("ash fever", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return DeathManner.Plague; + } + + if (raw.IndexOf("divine", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return DeathManner.Divine; + } + + if (raw.IndexOf("explosion", System.StringComparison.OrdinalIgnoreCase) >= 0 + || raw.IndexOf("acid", System.StringComparison.OrdinalIgnoreCase) >= 0 + || raw.IndexOf("Fell to", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return DeathManner.Accident; + } + + if (hasKiller + || raw.IndexOf("Killed by", System.StringComparison.OrdinalIgnoreCase) >= 0 + || raw.IndexOf("Eaten by", System.StringComparison.OrdinalIgnoreCase) >= 0 + || raw.IndexOf("Was eaten", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return DeathManner.Slain; + } + + return DeathManner.Other; + } + + public static string DeathMannerLabel(DeathManner manner) + { + switch (manner) + { + case DeathManner.Slain: + return "slain"; + case DeathManner.OldAge: + return "old age"; + case DeathManner.Starvation: + return "starved"; + case DeathManner.Drowned: + return "drowned"; + case DeathManner.Burned: + return "burned"; + case DeathManner.Plague: + return "plague"; + case DeathManner.Accident: + return "accident"; + case DeathManner.Divine: + return "divine"; + case DeathManner.Other: + return "dead"; + default: + return "fallen"; + } + } + + /// Newest death event for a subject, if any. + public static ChronicleEntry LatestDeathEntry(long subjectId) + { + IReadOnlyList snap = SnapshotForSubject(subjectId); + for (int i = snap.Count - 1; i >= 0; i--) + { + ChronicleEntry e = snap[i]; + if (e != null && e.Kind == ChronicleKind.Death) + { + return e; + } + } + + return null; + } + + public static DeathManner LatestDeathMannerFor(long subjectId) + { + ChronicleEntry death = LatestDeathEntry(subjectId); + return death != null ? death.ResolvedDeathManner : DeathManner.None; + } + + /// Harness: how the last fallen focus resolved (killer:id | place | none). + public static string LastFallenFocusDetail { get; private set; } = ""; + + /// Harness: world position used for the last place-style fallen focus. + public static Vector3 LastFallenFocusPosition { get; private set; } = Vector3.zero; + + /// Snapshot living dossier just before die() - for Fallen archive UI. + public static void CaptureFinalDossier(Actor victim) + { + if (victim == null || !victim.isAlive()) + { + return; + } + + long id = 0; + try + { + id = victim.getID(); + } + catch + { + return; + } + + if (id == 0) + { + return; + } + + UnitDossier dossier = UnitDossier.FromActor(victim); + if (dossier == null || dossier.UnitId == 0) + { + return; + } + + FinalDossiers[id] = dossier; + } + + /// Last living dossier for a fallen subject, or a minimal archive rebuild. + public static UnitDossier ResolveFallenDossier(long unitId) + { + if (unitId == 0) + { + return null; + } + + if (FinalDossiers.TryGetValue(unitId, out UnitDossier cached) && cached != null) + { + return cached; + } + + ChronicleEntry death = LatestDeathEntry(unitId); + ChronicleEntry any = death; + if (any == null) + { + IReadOnlyList snap = SnapshotForSubject(unitId); + if (snap != null && snap.Count > 0) + { + any = snap[snap.Count - 1]; + } + } + + if (any == null) + { + return null; + } + + DeathManner manner = death != null ? death.ResolvedDeathManner : DeathManner.None; + UnitDossier rebuilt = UnitDossier.FromFallenArchive(unitId, any.Name, any.SpeciesId, manner); + FinalDossiers[unitId] = rebuilt; + return rebuilt; + } + + /// + /// Camera for a dead subject: living killer if possible, else last death / history position. + /// Dossier always shows the fallen unit's last living state (not the killer). + /// + public static bool FocusFallenSubject(long unitId) + { + LastFallenFocusDetail = "none"; + LastFallenFocusPosition = Vector3.zero; + if (unitId == 0) + { + return false; + } + + UnitDossier fallen = ResolveFallenDossier(unitId); + if (fallen != null) + { + WatchCaption.PinWhilePaused(); + WatchCaption.SetFromDossier(fallen); + } + + ChronicleEntry death = LatestDeathEntry(unitId); + if (death != null && death.OtherId != 0) + { + Actor killer = FindUnitById(death.OtherId); + if (killer != null && killer.isAlive()) + { + CameraDirector.FocusUnit(killer, updateCaption: false); + LastFallenFocusDetail = "killer:" + death.OtherId; + LogService.LogInfo( + $"[IdleSpectator][LORE] fallen focus killer id={death.OtherId} victim={unitId}"); + return true; + } + } + + if (TryResolveFallenPlace(unitId, death, out Vector3 pos)) + { + // Drop any prior follow target or the camera eases back away from the death site. + CameraDirector.ClearFollow(); + CameraDirector.PanTo(pos); + LastFallenFocusDetail = "place"; + LastFallenFocusPosition = pos; + LogService.LogInfo($"[IdleSpectator][LORE] fallen focus place victim={unitId} pos={pos}"); + return true; + } + + return fallen != null; + } + + private static bool EntryHasWorldPosition(ChronicleEntry e) + { + if (e == null) + { + return false; + } + + if (e.HasWorldPosition) + { + return true; + } + + // Legacy session entries before HasWorldPosition existed. + return e.Position.sqrMagnitude > 0.0001f; + } + + private static bool TryResolveFallenPlace(long unitId, ChronicleEntry death, out Vector3 pos) + { + pos = Vector3.zero; + if (EntryHasWorldPosition(death)) + { + pos = death.Position; + return true; + } + + IReadOnlyList snap = SnapshotForSubject(unitId); + for (int i = snap.Count - 1; i >= 0; i--) + { + if (EntryHasWorldPosition(snap[i])) + { + pos = snap[i].Position; + return true; + } + } + + return false; + } + public static string FormatDeathCause(Actor victim, AttackType attackType, Actor killer) { string killerLabel = killer != null @@ -1140,7 +1591,12 @@ public static class Chronicle } } - private static void AppendHistory(ChronicleKind kind, Actor subject, Actor other, string line) + private static void AppendHistory( + ChronicleKind kind, + Actor subject, + Actor other, + string line, + AttackType? attackType = null) { if (subject == null || string.IsNullOrEmpty(line)) { @@ -1169,10 +1625,25 @@ public static class Chronicle Line = line, LoreLine = lore, Position = subject.current_position, + HasWorldPosition = true, AgeId = _currentAgeId, AgeName = _currentAgeName }; + if (kind == ChronicleKind.Death) + { + bool hasKiller = other != null; + if (attackType.HasValue) + { + entry.AttackTypeName = attackType.Value.ToString(); + entry.DeathManner = ClassifyDeathManner(attackType.Value, hasKiller); + } + else + { + entry.DeathManner = InferDeathManner("", hasKiller, line); + } + } + lock (Histories) { if (!Histories.TryGetValue(subjectId, out List list)) @@ -1281,6 +1752,7 @@ public static class Chronicle Line = rawLine ?? "", LoreLine = string.IsNullOrEmpty(loreLine) ? (rawLine ?? "") : loreLine, Position = subject != null ? subject.current_position : fallbackPosition, + HasWorldPosition = subject != null || fallbackPosition.sqrMagnitude > 0.0001f, AssetId = assetId ?? "", AgeId = ageId, AgeName = _currentAgeName, diff --git a/IdleSpectator/ChronicleCharacterSummary.cs b/IdleSpectator/ChronicleCharacterSummary.cs index cb22512..524fa35 100644 --- a/IdleSpectator/ChronicleCharacterSummary.cs +++ b/IdleSpectator/ChronicleCharacterSummary.cs @@ -1,6 +1,6 @@ namespace IdleSpectator; -/// One row in the Lore panel Character History tab. +/// One row in the Lore panel Living / Fallen tabs. public sealed class ChronicleCharacterSummary { public long UnitId; @@ -11,6 +11,9 @@ public sealed class ChronicleCharacterSummary public bool IsAlive; public bool IsRecent; public int RecentIndex = -1; + public DeathManner DeathManner; + /// Newest event time for Fallen sort (unscaledTime). + public float SortTime; public string Title { @@ -30,14 +33,17 @@ public sealed class ChronicleCharacterSummary } } + public string DeathLabel => + !IsAlive ? Chronicle.DeathMannerLabel(DeathManner) : ""; + public string Detail { get { string hist = HistoryCount <= 0 ? "no history" : HistoryCount + " events"; - if (IsRecent) + if (!IsAlive && DeathManner != DeathManner.None) { - return "recent · " + hist; + return DeathLabel + " · " + hist; } return hist; diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index e88ee43..9aa50eb 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -8,7 +8,7 @@ using UnityEngine.UI; namespace IdleSpectator; /// -/// Bottom-right Lore panel (L): World Memory + Character History tabs. +/// Bottom-right Lore panel (L): World Memory + Living + Fallen tabs. /// Sized in canvas units (same scaler as the dossier) to stay clear of top-right. /// public static class ChronicleHud @@ -16,7 +16,8 @@ public static class ChronicleHud public enum LoreTab { World = 0, - Characters = 1 + Living = 1, + Fallen = 2 } private const float PanelWidth = 268f; @@ -32,7 +33,7 @@ public static class ChronicleHud private const float TabH = 15f; private const float CharToolsH = 17f; private const float ScrollSensitivity = 55f; - private const string ChromeMarker = "LoreV4"; + private const string ChromeMarker = "LoreV5"; private static GameObject _root; private static RectTransform _rootRt; @@ -50,7 +51,8 @@ public static class ChronicleHud private static GameObject _tabsBar; private static GameObject _worldTabBtn; - private static GameObject _charsTabBtn; + private static GameObject _livingTabBtn; + private static GameObject _fallenTabBtn; private static GameObject _charTools; private static InputField _searchField; private static Button _favFilterBtn; @@ -63,9 +65,12 @@ public static class ChronicleHud private static GameObject _backBtn; private static Text _backBtnLabel; - /// Rows actually built in the last Characters detail rebuild (harness). + /// Rows actually built in the last character detail rebuild (harness). public static int LastDetailHistoryRows { get; private set; } + /// Title of the first row in the last Living/Fallen list rebuild (harness). + public static string LastListTopTitle { get; private set; } = ""; + public static bool Visible => _visible && _root != null && _root.activeSelf; public static bool IsReady => _root != null; @@ -78,6 +83,8 @@ public static class ChronicleHud public static long DetailUnitId => _detailUnitId; + private static bool IsCastTab => _tab == LoreTab.Living || _tab == LoreTab.Fallen; + /// True when Lore detail was opened via L/books and should track idle focus. public static bool FollowFocus => _followFocus; @@ -188,7 +195,7 @@ public static class ChronicleHud _root.SetActive(false); _visible = false; ApplyTabChrome(); - LogService.LogInfo("[IdleSpectator] Lore HUD ready (L, World + Characters)"); + LogService.LogInfo("[IdleSpectator] Lore HUD ready (L, World + Living + Fallen)"); } private static void BuildTabs() @@ -203,10 +210,12 @@ public static class ChronicleHud tabsRt.sizeDelta = new Vector2(-12f, TabH); _worldTabBtn = BuildTabButton(_tabsBar.transform, "WorldTab", "World", () => SetTab(LoreTab.World)); - _charsTabBtn = BuildTabButton(_tabsBar.transform, "CharsTab", "Characters", () => SetTab(LoreTab.Characters)); + _livingTabBtn = BuildTabButton(_tabsBar.transform, "LivingTab", "Living", () => SetTab(LoreTab.Living)); + _fallenTabBtn = BuildTabButton(_tabsBar.transform, "FallenTab", "Fallen", () => SetTab(LoreTab.Fallen)); - PlaceTab(_worldTabBtn, 0f); - PlaceTab(_charsTabBtn, 1f); + PlaceTab(_worldTabBtn, 0f, 3f); + PlaceTab(_livingTabBtn, 1f, 3f); + PlaceTab(_fallenTabBtn, 2f, 3f); } private static GameObject BuildTabButton(Transform parent, string name, string label, UnityAction onClick) @@ -240,14 +249,14 @@ public static class ChronicleHud return go; } - private static void PlaceTab(GameObject tab, float index) + private static void PlaceTab(GameObject tab, float index, float count = 3f) { RectTransform rt = tab.GetComponent(); - float w = 0.5f; + float w = 1f / Mathf.Max(1f, count); rt.anchorMin = new Vector2(index * w, 0f); rt.anchorMax = new Vector2((index + 1f) * w, 1f); rt.offsetMin = new Vector2(index == 0f ? 0f : 1f, 0f); - rt.offsetMax = new Vector2(index == 0f ? -1f : 0f, 0f); + rt.offsetMax = new Vector2(index >= count - 1f ? 0f : -1f, 0f); } private static void BuildCharTools() @@ -306,7 +315,7 @@ public static class ChronicleHud favRt.sizeDelta = new Vector2(18f, 0f); favRt.anchoredPosition = Vector2.zero; - _backBtn = BuildTabButton(_charTools.transform, "BackBtn", "← Characters", ClearUnitDetail); + _backBtn = BuildTabButton(_charTools.transform, "BackBtn", "← Living", ClearUnitDetail); _backBtnLabel = _backBtn.transform.Find("Label")?.GetComponent(); RectTransform backRt = _backBtn.GetComponent(); backRt.anchorMin = new Vector2(0f, 0f); @@ -400,7 +409,7 @@ public static class ChronicleHud return; } - bool chars = _tab == LoreTab.Characters; + bool chars = IsCastTab; bool detail = chars && _detailUnitId != 0; float top = TitleBandH + 2f; if (!detail) @@ -586,7 +595,7 @@ public static class ChronicleHud _detailUnitId = 0; _followFocus = false; - _tab = LoreTab.Characters; + _tab = LoreTab.Living; SetVisible(true); } @@ -612,7 +621,7 @@ public static class ChronicleHud } /// - /// Show one unit's full chronicle in Lore Characters. + /// Show one unit's full chronicle in Lore (Living or Fallen tab). /// true (L/books) retargets with idle; false (list) pins the archive. /// public static void OpenUnitHistory(long unitId, bool pauseIdle = true, bool followFocus = false) @@ -625,7 +634,6 @@ public static class ChronicleHud EnsureBuilt(); _detailUnitId = unitId; _followFocus = followFocus; - _tab = LoreTab.Characters; if (pauseIdle && SpectatorMode.Active) { @@ -633,14 +641,16 @@ public static class ChronicleHud } bool live = Chronicle.FocusCameraForBrowse(unitId); + _tab = live ? LoreTab.Living : LoreTab.Fallen; if (live) { WatchCaption.PinWhilePaused(); } - else if (pauseIdle) + else { - // Dead / missing: keep current dossier if any; still pause idle for archive reading. + // Dead / missing: keep dossier if any; pan to killer or death place. WatchCaption.PinWhilePaused(); + Chronicle.FocusFallenSubject(unitId); } if (pauseIdle) @@ -660,7 +670,7 @@ public static class ChronicleHud _followFocus = false; _lastCharFingerprint = int.MinValue; ApplyTabChrome(); - if (Visible && _tab == LoreTab.Characters) + if (Visible && IsCastTab) { Rebuild(force: true); } @@ -675,7 +685,7 @@ public static class ChronicleHud _favoritesOnly = on; RefreshFavFilterVisual(); - if (_tab == LoreTab.Characters) + if (IsCastTab) { Rebuild(force: true); } @@ -693,7 +703,7 @@ public static class ChronicleHud } _searchText = next; - if (_tab == LoreTab.Characters) + if (IsCastTab) { Rebuild(force: true); } @@ -704,13 +714,14 @@ public static class ChronicleHud { EnsureBuilt(); ClearUnitDetail(); - SetTab(LoreTab.Characters); - List list = Chronicle.ListCharacters(_searchText, _favoritesOnly); + SetTab(LoreTab.Living); + List list = Chronicle.ListCharacters( + _searchText, _favoritesOnly, scope: Chronicle.CharacterListScope.Living); string needle = (nameNeedle ?? "").Trim().ToLowerInvariant(); for (int i = 0; i < list.Count; i++) { ChronicleCharacterSummary row = list[i]; - if (row == null || !row.IsAlive) + if (row == null) { continue; } @@ -733,13 +744,14 @@ public static class ChronicleHud { EnsureBuilt(); ClearUnitDetail(); - SetTab(LoreTab.Characters); - List list = Chronicle.ListCharacters(_searchText, _favoritesOnly); + SetTab(LoreTab.Fallen); + List list = Chronicle.ListCharacters( + _searchText, favoritesOnly: false, scope: Chronicle.CharacterListScope.Fallen); string needle = (nameNeedle ?? "").Trim().ToLowerInvariant(); for (int i = 0; i < list.Count; i++) { ChronicleCharacterSummary row = list[i]; - if (row == null || row.IsAlive || row.HistoryCount <= 0) + if (row == null || row.HistoryCount <= 0) { continue; } @@ -765,7 +777,7 @@ public static class ChronicleHud private static void OnSearchChanged(string value) { _searchText = value ?? ""; - if (_tab == LoreTab.Characters) + if (IsCastTab) { Rebuild(force: true); } @@ -773,7 +785,7 @@ public static class ChronicleHud private static void ApplyTabChrome() { - bool chars = _tab == LoreTab.Characters; + bool chars = IsCastTab; bool detail = chars && _detailUnitId != 0; if (_tabsBar != null) { @@ -799,7 +811,8 @@ public static class ChronicleHud if (_favFilterBtn != null) { - _favFilterBtn.gameObject.SetActive(chars && !detail); + // Favorites filter is Living-only. + _favFilterBtn.gameObject.SetActive(_tab == LoreTab.Living && !detail); } if (_backBtn != null) @@ -807,12 +820,13 @@ public static class ChronicleHud _backBtn.SetActive(detail); if (_backBtnLabel != null) { - _backBtnLabel.text = "← Characters"; + _backBtnLabel.text = _tab == LoreTab.Fallen ? "← Fallen" : "← Living"; } } StyleTab(_worldTabBtn, _tab == LoreTab.World); - StyleTab(_charsTabBtn, _tab == LoreTab.Characters); + StyleTab(_livingTabBtn, _tab == LoreTab.Living); + StyleTab(_fallenTabBtn, _tab == LoreTab.Fallen); ApplyScrollInsets(); RefreshTitle(); } @@ -824,7 +838,7 @@ public static class ChronicleHud return; } - if (_tab == LoreTab.Characters) + if (IsCastTab) { if (_detailUnitId != 0) { @@ -833,7 +847,7 @@ public static class ChronicleHud return; } - _titleText.text = "Characters"; + _titleText.text = _tab == LoreTab.Fallen ? "Fallen" : "Living"; return; } @@ -927,7 +941,7 @@ public static class ChronicleHud /// private static bool TryRetargetFollowFocus() { - if (!_followFocus || _detailUnitId == 0 || _tab != LoreTab.Characters) + if (!_followFocus || _detailUnitId == 0 || !IsCastTab) { return false; } @@ -960,7 +974,7 @@ public static class ChronicleHud /// public static void OnIdleResumed() { - if (!Visible || _tab != LoreTab.Characters || _detailUnitId == 0) + if (!Visible || !IsCastTab || _detailUnitId == 0) { return; } @@ -994,7 +1008,14 @@ public static class ChronicleHud return hash; } - List list = Chronicle.ListCharacters(_searchText, _favoritesOnly, max: 12); + List list = Chronicle.ListCharacters( + _searchText, + _tab == LoreTab.Living && _favoritesOnly, + max: 12, + scope: _tab == LoreTab.Fallen + ? Chronicle.CharacterListScope.Fallen + : Chronicle.CharacterListScope.Living); + hash = (hash * 31) + (int)_tab; hash = (hash * 31) + list.Count; for (int i = 0; i < list.Count && i < 8; i++) { @@ -1018,7 +1039,7 @@ public static class ChronicleHud ApplyTabChrome(); ClearRows(); - if (_tab == LoreTab.Characters) + if (IsCastTab) { RebuildCharacters(); return; @@ -1066,24 +1087,37 @@ public static class ChronicleHud private static void RebuildCharacters() { RefreshTitle(); + LastListTopTitle = ""; if (_detailUnitId != 0) { RebuildUnitHistoryDetail(_detailUnitId); return; } - List list = Chronicle.ListCharacters(_searchText, _favoritesOnly); + Chronicle.CharacterListScope scope = _tab == LoreTab.Fallen + ? Chronicle.CharacterListScope.Fallen + : Chronicle.CharacterListScope.Living; + bool favOnly = scope == Chronicle.CharacterListScope.Living && _favoritesOnly; + List list = Chronicle.ListCharacters(_searchText, favOnly, scope: scope); _lastCharFingerprint = CharacterFingerprint(); if (list.Count == 0) { - AddEmpty(_favoritesOnly ? "No favorites match" : "No characters yet"); + if (scope == Chronicle.CharacterListScope.Fallen) + { + AddEmpty("No fallen yet"); + } + else + { + AddEmpty(favOnly ? "No favorites match" : "No living yet"); + } + return; } - bool wroteRecentHeader = false; - bool wroteAliveHeader = false; - bool wroteFallenHeader = false; + LastListTopTitle = list[0]?.Title ?? ""; + + bool wroteFavoritesHeader = false; int shown = 0; for (int i = 0; i < list.Count; i++) { @@ -1093,26 +1127,12 @@ public static class ChronicleHud continue; } - if (row.IsRecent) + if (scope == Chronicle.CharacterListScope.Living + && (favOnly || row.IsFavorite) + && !wroteFavoritesHeader) { - if (!wroteRecentHeader) - { - AddSection("Recent"); - wroteRecentHeader = true; - } - } - else if (row.IsAlive) - { - if (!wroteAliveHeader) - { - AddSection(_favoritesOnly ? "Favorites" : "History"); - wroteAliveHeader = true; - } - } - else if (!wroteFallenHeader) - { - AddSection("Fallen"); - wroteFallenHeader = true; + AddSection("Favorites"); + wroteFavoritesHeader = true; } GameObject go = BuildCharacterRow(row, shown); @@ -1137,7 +1157,14 @@ public static class ChronicleHud return; } - AddSection(entries.Count + " events"); + string section = entries.Count + " events"; + ChronicleEntry death = Chronicle.LatestDeathEntry(unitId); + if (death != null) + { + section = Chronicle.DeathMannerLabel(death.ResolvedDeathManner) + " · " + entries.Count + " events"; + } + + AddSection(section); int shown = 0; int cap = Chronicle.MaxHistoryPerUnit; for (int i = 0; i < entries.Count; i++) @@ -1195,7 +1222,11 @@ public static class ChronicleHud kindRt.pivot = new Vector2(0.5f, 1f); kindRt.anchoredPosition = new Vector2(9f, -5f); kindIcon.raycastTarget = false; - HudIcons.Apply(kindIcon, HudIcons.ForChronicleKind(entry.Kind)); + HudIcons.Apply( + kindIcon, + entry.Kind == ChronicleKind.Death + ? HudIcons.ForDeathManner(entry.ResolvedDeathManner) + : HudIcons.ForChronicleKind(entry.Kind)); Text label = HudCanvas.MakeText(go.transform, "Text", entry.DisplayLineRich, 8); RectTransform labelRt = label.GetComponent(); @@ -1204,7 +1235,9 @@ public static class ChronicleHud labelRt.offsetMin = new Vector2(18f, 2f); labelRt.offsetMax = new Vector2(-4f, -2f); label.alignment = TextAnchor.UpperLeft; - label.color = ColorForKind(entry.Kind); + label.color = entry.Kind == ChronicleKind.Death + ? ColorForDeathManner(entry.ResolvedDeathManner, listRow: false) + : ColorForKind(entry.Kind); label.supportRichText = true; label.resizeTextForBestFit = false; label.horizontalOverflow = HorizontalWrapMode.Wrap; @@ -1397,7 +1430,7 @@ public static class ChronicleHud ? new Color(0.95f, 0.85f, 0.45f, 0.07f) : (summary.IsAlive ? new Color(1f, 1f, 1f, 0.03f) - : new Color(0.55f, 0.45f, 0.5f, 0.06f)); + : ColorForDeathManner(summary.DeathManner, listRow: true)); bg.raycastTarget = true; Image kindIcon = HudCanvas.MakeIcon(go.transform, "Kind", KindIconSize); @@ -1411,12 +1444,15 @@ public static class ChronicleHud kindIcon, summary.IsFavorite ? HudIcons.Favorite() - : (HudIcons.FromUiIcon("iconUnit") ?? HudIcons.FromUiIcon("iconPopulation"))); + : (summary.IsAlive + ? (HudIcons.FromUiIcon("iconUnit") ?? HudIcons.FromUiIcon("iconPopulation")) + : HudIcons.ForDeathManner(summary.DeathManner))); string title = summary.Title; if (!summary.IsAlive && !string.IsNullOrEmpty(title)) { - title += " · fallen"; + string manner = summary.DeathLabel; + title += string.IsNullOrEmpty(manner) ? " · fallen" : " · " + manner; } Text label = HudCanvas.MakeText(go.transform, "Text", title, 8); @@ -1428,7 +1464,7 @@ public static class ChronicleHud label.alignment = TextAnchor.MiddleLeft; label.color = summary.IsAlive ? new Color(0.9f, 0.9f, 0.92f, 1f) - : new Color(0.62f, 0.58f, 0.64f, 0.95f); + : ColorForDeathManner(summary.DeathManner, listRow: false); label.horizontalOverflow = HorizontalWrapMode.Overflow; label.raycastTarget = false; @@ -1541,6 +1577,49 @@ public static class ChronicleHud } } + private static Color ColorForDeathManner(DeathManner manner, bool listRow) + { + float a = listRow ? 0.08f : 1f; + Color c; + switch (manner) + { + case DeathManner.Slain: + c = new Color(0.95f, 0.45f, 0.42f, a); + break; + case DeathManner.OldAge: + c = new Color(0.72f, 0.7f, 0.78f, a); + break; + case DeathManner.Starvation: + c = new Color(0.85f, 0.7f, 0.45f, a); + break; + case DeathManner.Drowned: + c = new Color(0.45f, 0.65f, 0.9f, a); + break; + case DeathManner.Burned: + c = new Color(0.95f, 0.55f, 0.28f, a); + break; + case DeathManner.Plague: + c = new Color(0.55f, 0.85f, 0.4f, a); + break; + case DeathManner.Divine: + c = new Color(0.95f, 0.9f, 0.45f, a); + break; + case DeathManner.Accident: + c = new Color(0.75f, 0.55f, 0.85f, a); + break; + default: + c = new Color(0.62f, 0.58f, 0.64f, a); + break; + } + + if (!listRow) + { + c.a = 0.95f; + } + + return c; + } + private static void StretchFull(RectTransform rt, float pad) { rt.anchorMin = Vector2.zero; diff --git a/IdleSpectator/DossierAvatar.cs b/IdleSpectator/DossierAvatar.cs index 6877136..4bf9b2e 100644 --- a/IdleSpectator/DossierAvatar.cs +++ b/IdleSpectator/DossierAvatar.cs @@ -21,6 +21,12 @@ public static class DossierAvatar public static bool UsingVanilla => _usingVanilla; + /// Live unit portrait is active (not the fallen species fallback). + public static bool ShowingLiveAvatar => _shownActorId > 0; + + /// Fallen archive species-icon portrait is showing. + public static bool ShowingSpeciesFallback => _shownActorId == -2; + /// True if is inside the live portrait rect. public static bool ContainsScreenPoint(Vector2 screenPoint) { @@ -113,6 +119,8 @@ public static class DossierAvatar long id = actor.getID(); if (_usingVanilla && _instance != null && _shownActorId == id && _instance.gameObject.activeSelf) { + // Always clear species fallback - it can linger above the live portrait. + HideFallback(); try { _instance.updateTileSprite(); @@ -129,6 +137,8 @@ public static class DossierAvatar { try { + HideFallback(); + _instance.gameObject.SetActive(true); _instance.show_banner_kingdom = false; _instance.show_banner_clan = false; if (_instance.kingdomBanner != null) @@ -167,6 +177,54 @@ public static class DossierAvatar public static void ClearActor() { _shownActorId = -1; + HideVanillaInstance(); + HideFallback(); + } + + /// Species icon portrait when the unit is gone (Fallen archive). + public static void ShowSpecies(string speciesId) + { + if (_host == null) + { + return; + } + + Sprite sprite = HudIcons.FromSpeciesId(speciesId); + HideVanillaInstance(); + + if (_fallbackSprite == null) + { + BuildFallback(_host.sizeDelta.x > 1f ? _host.sizeDelta.x : 44f); + } + + if (_fallbackBg != null) + { + _fallbackBg.gameObject.SetActive(true); + _fallbackBg.sprite = null; + _fallbackBg.color = new Color(0.04f, 0.045f, 0.055f, 0.95f); + _fallbackBg.type = Image.Type.Simple; + } + + if (_fallbackSprite != null) + { + _fallbackSprite.gameObject.SetActive(true); + HudIcons.Apply(_fallbackSprite, sprite, preserveAspect: true); + _fallbackSprite.enabled = sprite != null; + if (sprite != null) + { + float w = Mathf.Max(1f, sprite.rect.width); + float h = Mathf.Max(1f, sprite.rect.height); + float max = _host.sizeDelta.x; + float scale = (max * 0.85f) / Mathf.Max(w, h); + _fallbackSprite.rectTransform.sizeDelta = new Vector2(w * scale, h * scale); + } + } + + _shownActorId = -2; + } + + private static void HideVanillaInstance() + { if (_usingVanilla && _instance != null) { try @@ -178,10 +236,19 @@ public static class DossierAvatar // ignore } } + } + private static void HideFallback() + { if (_fallbackSprite != null) { _fallbackSprite.enabled = false; + _fallbackSprite.gameObject.SetActive(false); + } + + if (_fallbackBg != null) + { + _fallbackBg.gameObject.SetActive(false); } } @@ -343,8 +410,10 @@ public static class DossierAvatar if (_fallbackSprite != null) { + _fallbackSprite.gameObject.SetActive(true); Sprite live = HudIcons.FromActorLive(actor); HudIcons.Apply(_fallbackSprite, live, preserveAspect: true); + _fallbackSprite.enabled = live != null; if (live != null) { float w = Mathf.Max(1f, live.rect.width); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 9ada114..3c3d60f 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -474,7 +474,7 @@ internal static class HarnessScenarios Step("ch18m", "lore_open_focus"), Step("ch18n", "wait", wait: 0.3f), Step("ch18o", "assert", expect: "idle", value: "false"), - Step("ch18o2", "assert", expect: "lore_tab", value: "characters"), + Step("ch18o2", "assert", expect: "lore_tab", value: "living"), Step("ch18o3", "assert", expect: "lore_detail", value: "true"), Step("ch18o3b", "assert", expect: "lore_follow", value: "true"), Step("ch18o4", "assert", expect: "lore_history_shown", value: "40", label: "min"), @@ -518,26 +518,98 @@ internal static class HarnessScenarios Step("ch18p11", "assert", expect: "dossier_lore_history_match"), Step("ch18p12", "screenshot", value: "hud-lore-empty-history.png"), Step("ch18p13", "wait", wait: 0.55f), - // Fallen (dead) archive: pinned, not follow-mode. + // Fallen (dead) archive: pinned; old age → death place. Step("ch18d1", "lore_back"), - Step("ch18d2", "chronicle_orphan", label: "Oldbone", asset: "skeleton", value: "Fallen", count: 5), + Step("ch18d1b", "focus", asset: "auto"), + Step("ch18d1c", "wait", wait: 0.2f), + Step( + "ch18d2", + "chronicle_orphan", + label: "Oldbone", + asset: "skeleton", + value: "Fallen", + count: 5, + expect: "Age", + tier: "place"), Step("ch18d3", "lore_pick_fallen", value: "Oldbone"), Step("ch18d4", "wait", wait: 0.25f), Step("ch18d5", "assert", expect: "lore_detail", value: "true"), Step("ch18d6", "assert", expect: "lore_follow", value: "false"), Step("ch18d7", "assert", expect: "lore_history_shown", value: "5", label: "exact"), + Step("ch18d7b", "assert", expect: "fallen_focus", value: "place"), + Step("ch18d7c", "assert", expect: "death_manner", value: "old age"), + Step("ch18d7d", "assert", expect: "dossier_contains", value: "Oldbone"), + Step("ch18d7e", "assert", expect: "dossier_unit_matches_lore"), + Step("ch18d7f", "assert", expect: "dossier_species", value: "skeleton"), + Step("ch18d7g", "assert", expect: "dossier_avatar_species"), + Step("ch18d7h", "assert", expect: "fallen_place_no_follow"), Step("ch18d8", "screenshot", value: "hud-lore-fallen.png"), Step("ch18d9", "wait", wait: 0.55f), - // Character list browse (pinned archive). - Step("ch18q", "lore_tab", value: "characters"), + // Fallen slain → living killer focus. + Step("ch18k1", "lore_back"), + Step("ch18k2", "spawn", asset: "human", count: 1), + Step("ch18k3", "focus", asset: "auto"), + Step("ch18k4", "wait", wait: 0.25f), + Step( + "ch18k5", + "chronicle_orphan", + label: "Slainbone", + asset: "skeleton", + value: "Murder", + count: 3, + expect: "Weapon", + tier: "killer"), + Step("ch18k6", "lore_pick_fallen", value: "Slainbone"), + Step("ch18k7", "wait", wait: 0.25f), + Step("ch18k8", "assert", expect: "lore_detail", value: "true"), + Step("ch18k9", "assert", expect: "lore_follow", value: "false"), + Step("ch18k10", "assert", expect: "fallen_focus", value: "killer"), + Step("ch18k11", "assert", expect: "death_manner", value: "slain"), + Step("ch18k11b", "assert", expect: "dossier_contains", value: "Slainbone"), + Step("ch18k11c", "assert", expect: "dossier_unit_matches_lore"), + Step("ch18k11d", "assert", expect: "dossier_species", value: "skeleton"), + Step("ch18k11e", "assert", expect: "dossier_avatar_species"), + Step("ch18k11f", "assert", expect: "fallen_killer_camera"), + Step("ch18k12", "screenshot", value: "hud-lore-fallen-slain.png"), + Step("ch18k13", "wait", wait: 0.55f), + // Regression: fallen species portrait must not stick onto the next living dossier. + Step("ch18av1", "lore_back"), + Step("ch18av1b", "lore_close"), + Step("ch18av1c", "spectator", value: "on"), + Step("ch18av1d", "wait", wait: 0.2f), + Step("ch18av2", "spawn", asset: "human", count: 1), + Step("ch18av3", "focus", asset: "auto"), + Step("ch18av4", "wait", wait: 0.25f), + Step("ch18av5", "assert", expect: "dossier_matches_focus"), + Step("ch18av6", "assert", expect: "dossier_species", value: "human"), + Step("ch18av7", "assert", expect: "dossier_avatar_live"), + Step("ch18av8", "screenshot", value: "hud-dossier-after-fallen.png"), + Step("ch18av9", "wait", wait: 0.55f), + // Also verify list-pick living after a fallen species portrait. + Step("ch18av10", "lore_pick_fallen", value: "Oldbone"), + Step("ch18av11", "wait", wait: 0.2f), + Step("ch18av12", "assert", expect: "dossier_avatar_species"), + Step("ch18av13", "lore_back"), + Step("ch18av14", "lore_pick", value: "auto"), + Step("ch18av15", "wait", wait: 0.25f), + Step("ch18av16", "assert", expect: "dossier_matches_focus"), + Step("ch18av17", "assert", expect: "dossier_avatar_live"), + Step("ch18av18", "screenshot", value: "hud-dossier-after-fallen-pick.png"), + Step("ch18av19", "wait", wait: 0.55f), + // Character list browse (pinned archive) - Living tab. + Step("ch18q", "lore_tab", value: "living"), Step("ch18q0", "lore_back"), - Step("ch18q2", "assert", expect: "lore_tab", value: "characters"), + Step("ch18q2", "assert", expect: "lore_tab", value: "living"), Step("ch18q3", "assert", expect: "lore_recent", value: "1", label: "min"), Step("ch18q4", "lore_pick", value: "auto"), Step("ch18q5", "wait", wait: 0.25f), Step("ch18q6", "assert", expect: "idle", value: "false"), Step("ch18q7", "assert", expect: "lore_detail", value: "true"), Step("ch18q7b", "assert", expect: "lore_follow", value: "false"), + Step("ch18q7c", "assert", expect: "dossier_unit_matches_lore"), + Step("ch18q7d", "assert", expect: "dossier_matches_focus"), + Step("ch18q7e", "assert", expect: "dossier_avatar_live"), + Step("ch18q7f", "assert", expect: "lore_tab", value: "living"), Step("ch18q8", "screenshot", value: "hud-lore-characters.png"), Step("ch18q9", "wait", wait: 0.55f), // Resume idle: pinned list pick flips back to Follow mode. @@ -548,6 +620,14 @@ internal static class HarnessScenarios Step("ch18q11d", "focus", asset: "auto"), Step("ch18q11e", "wait", wait: 0.35f), Step("ch18q11f", "assert", expect: "lore_follows_focus"), + // Fallen tab: newest death first (Slainbone after Oldbone). + Step("ch18ft1", "lore_back"), + Step("ch18ft2", "lore_tab", value: "fallen"), + Step("ch18ft3", "wait", wait: 0.2f), + Step("ch18ft4", "assert", expect: "lore_tab", value: "fallen"), + Step("ch18ft5", "assert", expect: "fallen_list_top", value: "Slainbone"), + Step("ch18ft6", "screenshot", value: "hud-lore-fallen-tab.png"), + Step("ch18ft7", "wait", wait: 0.55f), Step("ch18q12", "lore_tab", value: "world"), Step("ch19", "assert", expect: "focus", value: "true"), Step("ch20", "assert", expect: "health"), diff --git a/IdleSpectator/HudIcons.cs b/IdleSpectator/HudIcons.cs index ca42857..283b296 100644 --- a/IdleSpectator/HudIcons.cs +++ b/IdleSpectator/HudIcons.cs @@ -76,6 +76,29 @@ public static class HudIcons return FromAsset(actor.asset); } + public static Sprite FromSpeciesId(string speciesId) + { + if (string.IsNullOrEmpty(speciesId)) + { + return FromUiIcon("iconQuestionMark"); + } + + try + { + ActorAsset asset = AssetManager.actor_library?.get(speciesId); + if (asset != null) + { + return FromAsset(asset); + } + } + catch + { + // fall through + } + + return FromUiIcon("iconQuestionMark"); + } + /// /// Live unit sprite as shown in inspect UI (phenotype/kingdom tint), not the species icon. /// @@ -138,6 +161,31 @@ public static class HudIcons } } + public static Sprite ForDeathManner(DeathManner manner) + { + switch (manner) + { + case DeathManner.Slain: + return FromUiIcon("iconKills") ?? FromUiIcon("iconAttack") ?? ForChronicleKind(ChronicleKind.Death); + case DeathManner.OldAge: + return FromUiIcon("iconAge") ?? FromUiIcon("iconClock") ?? ForChronicleKind(ChronicleKind.Death); + case DeathManner.Starvation: + return FromUiIcon("iconHunger") ?? FromUiIcon("iconFood") ?? ForChronicleKind(ChronicleKind.Death); + case DeathManner.Drowned: + return FromUiIcon("iconRain") ?? FromUiIcon("iconWater") ?? ForChronicleKind(ChronicleKind.Death); + case DeathManner.Burned: + return FromUiIcon("iconFire") ?? FromUiIcon("iconLava") ?? ForChronicleKind(ChronicleKind.Death); + case DeathManner.Plague: + return FromUiIcon("iconPlague") ?? FromUiIcon("iconDisease") ?? ForChronicleKind(ChronicleKind.Death); + case DeathManner.Divine: + return FromUiIcon("iconDivineLight") ?? FromUiIcon("iconGodFinger") ?? ForChronicleKind(ChronicleKind.Death); + case DeathManner.Accident: + return FromUiIcon("iconBomb") ?? FromUiIcon("iconExplosion") ?? ForChronicleKind(ChronicleKind.Death); + default: + return ForChronicleKind(ChronicleKind.Death); + } + } + public static Sprite Favorite() => FromUiIcon("iconFavorite") ?? FromUiIcon("iconFavoriteStar") ?? FromUiIcon("iconStar"); diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 0e87cb7..ea3f26d 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -42,6 +42,31 @@ public sealed class UnitDossier public ActorTrait Trait; } + /// + /// Minimal dossier for archive subjects with no living unit (harness orphans / legacy deaths). + /// + public static UnitDossier FromFallenArchive( + long unitId, + string name, + string speciesId, + DeathManner manner = DeathManner.None) + { + UnitDossier d = new UnitDossier(); + d.UnitId = unitId; + d.Name = string.IsNullOrEmpty(name) ? "Nameless" : name; + d.SpeciesId = string.IsNullOrEmpty(speciesId) ? "creature" : speciesId; + d.Headline = string.IsNullOrEmpty(d.SpeciesId) + ? d.Name + : $"{d.Name} ({d.SpeciesId})"; + string mannerLabel = Chronicle.DeathMannerLabel(manner); + d.ReasonLine = string.IsNullOrEmpty(mannerLabel) || manner == DeathManner.None + ? "Fallen" + : char.ToUpperInvariant(mannerLabel[0]) + mannerLabel.Substring(1); + d.DetailLine = ""; + d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine); + return d; + } + public static UnitDossier FromActor(Actor actor, InterestTier? watchTier = null, string watchLabel = null) { UnitDossier d = new UnitDossier(); diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 3729c88..a0fca77 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -412,6 +412,33 @@ public static class WatchCaption LogService.LogInfo("[IdleSpectator][CAPTION] " + LastCaptionText.Replace("\n", " | ")); } + /// + /// Show a stored dossier with no living actor (Fallen archive last living state). + /// + public static void SetFromDossier(UnitDossier dossier) + { + if (dossier == null || dossier.UnitId == 0) + { + return; + } + + if (!_pinnedWhilePaused) + { + ClearStatusBanner(); + } + + _current = dossier; + _boundActor = null; + LastHeadline = dossier.Headline ?? ""; + LastDetail = dossier.DetailLine ?? ""; + LastCaptionText = dossier.CaptionText ?? ""; + EnsureBuilt(); + ApplyVisual(null, dossier); + SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused)); + LogService.LogInfo( + "[IdleSpectator][CAPTION] fallen dossier " + LastCaptionText.Replace("\n", " | ")); + } + public static void Update() { // History / manual pause: keep dossier pinned even after the banner timer expires. @@ -604,7 +631,7 @@ public static class WatchCaption int shown = FillHistory(id); // Re-run layout only when history presence flips or line count changes. - bool hasBody = _boundActor != null; + bool hasBody = _current != null; bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; int traits = 0; @@ -630,11 +657,25 @@ public static class WatchCaption return; } - _boundActor = actor; + bool hasLive = actor != null + && actor.isAlive() + && dossier != null + && actor.getID() == dossier.UnitId; + _boundActor = hasLive ? actor : null; if (_speciesIcon != null) { - HudIcons.Apply(_speciesIcon, actor != null ? HudIcons.FromActor(actor) : null); + Sprite species = null; + if (hasLive) + { + species = HudIcons.FromActor(actor); + } + else if (dossier != null) + { + species = HudIcons.FromSpeciesId(dossier.SpeciesId); + } + + HudIcons.Apply(_speciesIcon, species); } bool hasSex = false; @@ -665,10 +706,11 @@ public static class WatchCaption : ""; } - bool hasBody = dossier != null && actor != null; + bool hasBody = dossier != null && dossier.UnitId != 0; DossierAvatar.SetActive(hasBody); - bool hasLevel = hasBody; + // Live units always show level; archive snapshots hide a blank 0. + bool hasLevel = hasBody && (hasLive || dossier.Level > 0); if (_levelIcon != null) { HudIcons.Apply(_levelIcon, hasLevel ? HudIcons.Level() : null); @@ -681,10 +723,14 @@ public static class WatchCaption _levelValue.gameObject.SetActive(hasLevel); } - if (hasBody) + if (hasLive) { DossierAvatar.Show(actor); } + else if (hasBody) + { + DossierAvatar.ShowSpecies(dossier.SpeciesId); + } else { DossierAvatar.ClearActor(); @@ -729,7 +775,13 @@ public static class WatchCaption { UnitDossier.TraitChip chip = dossier.TopTraits[i]; slot.Root.SetActive(true); - HudIcons.Apply(slot.Icon, HudIcons.FromTrait(chip.Trait)); + Sprite traitSprite = chip != null ? HudIcons.FromTrait(chip.Trait) : null; + if (traitSprite == null && chip != null && !string.IsNullOrEmpty(chip.Id)) + { + traitSprite = HudIcons.FromUiIcon(chip.Id) ?? HudIcons.FromUiIcon("icon" + chip.Id); + } + + HudIcons.Apply(slot.Icon, traitSprite); string name = chip.Name ?? ""; if (slot.Label != null) { @@ -782,7 +834,7 @@ public static class WatchCaption } Relayout(hasBody, traitCount, hasTask, hasReason, historyCount); - RefreshFavoriteVisual(actor); + RefreshFavoriteVisual(hasLive ? actor : null); } private static int FillHistory(long unitId) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 4c80fd2..77bb180 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.12.6", - "description": "AFK Idle Spectator (I) + Lore (L). L/books follow focus; Characters list is a pinned archive including Fallen.", + "version": "0.12.14", + "description": "AFK Idle Spectator (I) + Lore (L). Tabs: World / Living / Fallen (newest first).", "GUID": "com.dazed.idlespectator" }