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