diff --git a/.gitignore b/.gitignore
index 90ff492..ba09f75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,6 +49,7 @@ desktop.ini
# Local tooling (portable SDKs, decomp dumps, etc.)
.dotnet/
.local/
+.tmp-reflect/
*.decompiled.cs
# local test arm files
diff --git a/IdleSpectator/ActorChroniclePatches.cs b/IdleSpectator/ActorChroniclePatches.cs
index 372da98..060b091 100644
--- a/IdleSpectator/ActorChroniclePatches.cs
+++ b/IdleSpectator/ActorChroniclePatches.cs
@@ -52,6 +52,7 @@ public static class ActorChroniclePatches
}
Chronicle.NoteDeath(__instance, pType, __state);
+ GraveMarkers.AddDeath(__instance);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.becomeLoversWith))]
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 3db33e3..51f3576 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -1772,7 +1772,9 @@ public static class AgentHarness
&& focus.isAlive())
{
killerId = focus.getID();
- deathPos = focus.current_position;
+ // Offset so place/skull focus is distinct from following the killer.
+ Vector2 killerPos = focus.current_position;
+ deathPos = new Vector3(killerPos.x + 2.5f, killerPos.y, 0f);
}
else if (mode.IndexOf("place", System.StringComparison.Ordinal) >= 0
|| favorite
@@ -1803,6 +1805,20 @@ public static class AgentHarness
bool ok = id != 0
&& Chronicle.HistoryCountFor(id) >= n
&& (!favorite || Chronicle.IsFavoriteSubject(id));
+ if (ok && deathPos.sqrMagnitude > 0.0001f)
+ {
+ int tx = Mathf.FloorToInt(deathPos.x);
+ int ty = Mathf.FloorToInt(deathPos.y);
+ GraveMarkers.ForceAdd(
+ id,
+ name,
+ tx,
+ ty,
+ deathPos,
+ Chronicle.FormatGraveDeathSubtitle(id),
+ favorite);
+ }
+
if (ok)
{
_cmdOk++;
@@ -1816,7 +1832,342 @@ public static class AgentHarness
cmd,
ok,
detail:
- $"orphanId={id} hist={Chronicle.HistoryCountFor(id)} want={n} manner={manner} killer={killerId} mode={mode} fav={Chronicle.IsFavoriteSubject(id)}");
+ $"orphanId={id} hist={Chronicle.HistoryCountFor(id)} want={n} manner={manner} killer={killerId} mode={mode} fav={Chronicle.IsFavoriteSubject(id)} stacks={GraveMarkers.StackCount}");
+ break;
+ }
+
+ case "grave_clear":
+ {
+ GraveMarkers.Clear();
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: "graves_cleared");
+ break;
+ }
+
+ case "grave_force":
+ {
+ // value=name, count=entries on same tile, tier="dx,dy" tile offset from focus/camera
+ string gName = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "GraveDummy";
+ int n = Math.Max(1, cmd.count);
+ Vector3 pos = Vector3.zero;
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ pos = MoveCamera._focus_unit.current_position;
+ }
+ else if (MoveCamera.instance != null)
+ {
+ pos = MoveCamera.instance.transform.position;
+ pos.z = 0f;
+ }
+
+ if (pos.sqrMagnitude < 0.0001f)
+ {
+ pos = new Vector3(48f, 48f, 0f);
+ }
+
+ int tx = Mathf.FloorToInt(pos.x);
+ int ty = Mathf.FloorToInt(pos.y);
+ if (!string.IsNullOrEmpty(cmd.tier) && cmd.tier.Contains(","))
+ {
+ string[] parts = cmd.tier.Split(',');
+ if (parts.Length >= 2
+ && int.TryParse(parts[0].Trim(), out int ox)
+ && int.TryParse(parts[1].Trim(), out int oy))
+ {
+ tx += ox;
+ ty += oy;
+ pos = new Vector3(tx + 0.5f, ty + 0.5f, 0f);
+ }
+ }
+ int before = GraveMarkers.EntryCountAt(tx, ty);
+ for (int i = 0; i < n; i++)
+ {
+ string nm = n > 1 ? gName + i : gName;
+ long id = Chronicle.ForceOrphanHistory(
+ nm,
+ !string.IsNullOrEmpty(cmd.asset) ? cmd.asset : "human",
+ 1,
+ "Grave",
+ AttackType.Age,
+ 0,
+ pos,
+ favorite: false);
+ GraveMarkers.ForceAdd(
+ id, nm, tx, ty, pos, Chronicle.FormatGraveDeathSubtitle(id), favorite: false);
+ }
+
+ int after = GraveMarkers.EntryCountAt(tx, ty);
+ bool gOk = after >= before + n && GraveMarkers.StackCount >= 1;
+ if (gOk)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ gOk,
+ detail: $"tile={tx},{ty} entries={after} stacks={GraveMarkers.StackCount} added={n}");
+ break;
+ }
+
+ case "grave_open":
+ {
+ Vector3 pos = Vector3.zero;
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ pos = MoveCamera._focus_unit.current_position;
+ }
+ else if (MoveCamera.instance != null)
+ {
+ pos = MoveCamera.instance.transform.position;
+ pos.z = 0f;
+ }
+
+ int tx = Mathf.FloorToInt(pos.x);
+ int ty = Mathf.FloorToInt(pos.y);
+ if (!string.IsNullOrEmpty(cmd.value) && cmd.value.Contains(","))
+ {
+ string[] parts = cmd.value.Split(',');
+ if (parts.Length >= 2
+ && int.TryParse(parts[0].Trim(), out int px)
+ && int.TryParse(parts[1].Trim(), out int py))
+ {
+ tx = px;
+ ty = py;
+ }
+ }
+
+ bool opened = ChronicleHud.OpenGraveStack(tx, ty, pauseIdle: true);
+ if (opened)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ opened,
+ detail:
+ $"tile={tx},{ty} open={opened} scope={ChronicleHud.GraveStackScopeActive} detail={ChronicleHud.DetailUnitId} stacks={GraveMarkers.StackCount}");
+ break;
+ }
+
+ case "grave_search":
+ {
+ ChronicleHud.SetSearch(cmd.value ?? "");
+ _cmdOk++;
+ Emit(
+ cmd,
+ ok: true,
+ detail:
+ $"query='{ChronicleHud.SearchText}' filtered={ChronicleHud.GraveStackFilteredCount} scope={ChronicleHud.GraveStackScopeActive}");
+ break;
+ }
+
+ case "grave_open_lore":
+ {
+ // value=name: search any stack. Else focus/camera tile, then current Lore grave scope.
+ string needle = (cmd.value ?? "").Trim();
+ int tx = 0;
+ int ty = 0;
+ long unitId = 0;
+ GraveMarkers.GraveStack stack = null;
+ GraveMarkers.GraveEntry pick = null;
+
+ if (!string.IsNullOrEmpty(needle)
+ && GraveMarkers.TryFindEntryByName(needle, out pick, out stack))
+ {
+ tx = stack.TileX;
+ ty = stack.TileY;
+ unitId = pick.UnitId;
+ }
+ else
+ {
+ Vector3 pos = Vector3.zero;
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ pos = MoveCamera._focus_unit.current_position;
+ }
+ else if (MoveCamera.instance != null)
+ {
+ pos = MoveCamera.instance.transform.position;
+ pos.z = 0f;
+ }
+
+ tx = Mathf.FloorToInt(pos.x);
+ ty = Mathf.FloorToInt(pos.y);
+ if (!GraveMarkers.TryGetStack(tx, ty, out stack)
+ && ChronicleHud.GraveStackScopeActive)
+ {
+ tx = ChronicleHud.GraveStackTileX;
+ ty = ChronicleHud.GraveStackTileY;
+ GraveMarkers.TryGetStack(tx, ty, out stack);
+ }
+
+ if (stack != null && stack.Entries.Count > 0)
+ {
+ pick = stack.Entries[stack.Entries.Count - 1];
+ unitId = pick.UnitId;
+ }
+ }
+
+ bool loreOk = false;
+ if (unitId != 0 && stack != null)
+ {
+ InspectUi.MarkGraveSession();
+ ChronicleHud.OpenGraveStack(tx, ty, pauseIdle: true);
+ if (ChronicleHud.DetailUnitId != unitId)
+ {
+ ChronicleHud.OpenUnitHistory(
+ unitId, pauseIdle: true, followFocus: false, preferPlace: true);
+ }
+
+ loreOk = ChronicleHud.DetailUnitId == unitId
+ && Chronicle.LastFallenFocusDetail == "place";
+ }
+
+ if (loreOk)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ loreOk,
+ detail:
+ $"tile={tx},{ty} unit={unitId} detail={ChronicleHud.DetailUnitId} fallenFocus={Chronicle.LastFallenFocusDetail} scope={ChronicleHud.GraveStackScopeActive}");
+ break;
+ }
+
+ case "grave_set_permanent":
+ {
+ // value=true|false; optional tier="tx,ty" else Lore grave scope / focus tile.
+ int tx = ChronicleHud.GraveStackTileX;
+ int ty = ChronicleHud.GraveStackTileY;
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ Vector3 p = MoveCamera._focus_unit.current_position;
+ int ftx = Mathf.FloorToInt(p.x);
+ int fty = Mathf.FloorToInt(p.y);
+ if (GraveMarkers.TryGetStack(ftx, fty, out _))
+ {
+ tx = ftx;
+ ty = fty;
+ }
+ }
+
+ if (!ChronicleHud.GraveStackScopeActive
+ && !GraveMarkers.TryGetStack(tx, ty, out _)
+ && MoveCamera.hasFocusUnit()
+ && MoveCamera._focus_unit != null)
+ {
+ Vector3 p = MoveCamera._focus_unit.current_position;
+ tx = Mathf.FloorToInt(p.x);
+ ty = Mathf.FloorToInt(p.y);
+ }
+
+ if (!string.IsNullOrEmpty(cmd.tier) && cmd.tier.Contains(","))
+ {
+ string[] parts = cmd.tier.Split(',');
+ if (parts.Length >= 2
+ && int.TryParse(parts[0].Trim(), out int px)
+ && int.TryParse(parts[1].Trim(), out int py))
+ {
+ tx = px;
+ ty = py;
+ }
+ }
+
+ bool want = ParseBool(
+ !string.IsNullOrEmpty(cmd.value) ? cmd.value : cmd.expect,
+ defaultValue: true);
+ bool setOk = GraveMarkers.TrySetStackPermanent(tx, ty, want);
+ if (setOk && ChronicleHud.GraveStackScopeActive)
+ {
+ // Refresh Keep chrome.
+ ChronicleHud.OpenGraveStack(tx, ty, pauseIdle: false);
+ }
+
+ if (setOk)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ setOk,
+ detail:
+ $"tile={tx},{ty} permanent={GraveMarkers.IsStackPermanent(tx, ty)} want={want}");
+ break;
+ }
+
+ case "grave_delete":
+ {
+ int before = GraveMarkers.StackCount;
+ bool delOk = ChronicleHud.DeleteActiveGraveStack();
+ bool gone = delOk
+ && !ChronicleHud.GraveStackScopeActive
+ && GraveMarkers.StackCount < before;
+ if (gone)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ gone,
+ detail:
+ $"deleted={delOk} stacks={GraveMarkers.StackCount} before={before} scope={ChronicleHud.GraveStackScopeActive} lore={Chronicle.HudVisible}");
+ break;
+ }
+
+ case "inspect_dismiss":
+ {
+ InspectUi.DismissAll();
+ _cmdOk++;
+ Emit(
+ cmd,
+ ok: true,
+ detail:
+ $"graveSession={InspectUi.GraveSessionActive} lore={Chronicle.HudVisible} scope={ChronicleHud.GraveStackScopeActive}");
+ break;
+ }
+
+ case "lore_focus_killer":
+ {
+ bool kOk = ChronicleHud.FocusDetailKiller();
+ if (kOk)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ kOk,
+ detail: $"ok={kOk} fallenFocus={Chronicle.LastFallenFocusDetail} detail={ChronicleHud.DetailUnitId}");
break;
}
@@ -5735,8 +6086,22 @@ public static class AgentHarness
return;
}
- bool val = ParseBool(cmd.value, defaultValue: true);
- bool ok = ModSettings.TrySetBool(key, val);
+ bool ok;
+ string detail;
+ if (int.TryParse((cmd.value ?? "").Trim(), out int intVal)
+ && (key == ModSettings.GravestoneMaxStacksId
+ || key == ModSettings.GravestoneTtlSecondsId))
+ {
+ ok = ModSettings.TrySetInt(key, intVal);
+ detail = $"{key}={intVal}";
+ }
+ else
+ {
+ bool val = ParseBool(cmd.value, defaultValue: true);
+ ok = ModSettings.TrySetBool(key, val);
+ detail = $"{key}={val}";
+ }
+
if (ok)
{
_cmdOk++;
@@ -5746,7 +6111,7 @@ public static class AgentHarness
_cmdFail++;
}
- Emit(cmd, ok, detail: $"{key}={val}");
+ Emit(cmd, ok, detail: detail);
}
private static void DoAssert(HarnessCommand cmd)
@@ -8407,9 +8772,10 @@ public static class AgentHarness
{
bool want = ParseBool(cmd.value, defaultValue: true);
bool have = ChronicleHud.DetailUnitId != 0;
- pass = Chronicle.HudVisible && have == want;
+ // want=true: Lore open with a subject; want=false: no detail (panel may be closed).
+ pass = want ? (Chronicle.HudVisible && have) : !have;
detail =
- $"detailId={ChronicleHud.DetailUnitId} want={want} tab={ChronicleHud.ActiveTab} follow={ChronicleHud.FollowFocus}";
+ $"detailId={ChronicleHud.DetailUnitId} want={want} visible={Chronicle.HudVisible} tab={ChronicleHud.ActiveTab} follow={ChronicleHud.FollowFocus}";
break;
}
case "lore_follow":
@@ -8445,14 +8811,128 @@ public static class AgentHarness
detail = $"fallenFocus={Chronicle.LastFallenFocusDetail} want={cmd.value}";
break;
}
+ case "grave_stacks":
+ {
+ // Prefer value when set so want=0 works (count>0 would skip an explicit 0).
+ int want = !string.IsNullOrEmpty(cmd.value)
+ ? ParseInt(cmd.value, 0)
+ : cmd.count;
+ int have = GraveMarkers.StackCount;
+ string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
+ if (cmp == "min" || cmp == ">=" || string.IsNullOrEmpty(cmp))
+ {
+ pass = have >= want;
+ }
+ else if (cmp == "max" || cmp == "<=")
+ {
+ pass = have <= want;
+ }
+ else if (cmp == "eq" || cmp == "==")
+ {
+ pass = have == want;
+ }
+ else
+ {
+ pass = have >= want;
+ }
+
+ detail = $"stacks={have} want={want} cmp={cmp}";
+ break;
+ }
+ case "grave_entries":
+ {
+ int want = cmd.count > 0 ? cmd.count : ParseInt(cmd.value, 1);
+ Vector3 pos = Vector3.zero;
+ if (MoveCamera.instance != null)
+ {
+ pos = MoveCamera.instance.transform.position;
+ pos.z = 0f;
+ }
+
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ pos = MoveCamera._focus_unit.current_position;
+ }
+
+ int tx = Mathf.FloorToInt(pos.x);
+ int ty = Mathf.FloorToInt(pos.y);
+ int have = GraveMarkers.EntryCountAt(tx, ty);
+ pass = have >= want;
+ detail = $"tile={tx},{ty} entries={have} want={want}";
+ break;
+ }
+ case "grave_stack_scope":
+ case "grave_panel":
+ {
+ // grave_panel kept as alias during transition; means Lore grave-stack scope.
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = Chronicle.HudVisible && ChronicleHud.GraveStackScopeActive;
+ pass = have == want;
+ detail =
+ $"scope={ChronicleHud.GraveStackScopeActive} lore={Chronicle.HudVisible} want={want} detail={ChronicleHud.DetailUnitId} filtered={ChronicleHud.GraveStackFilteredCount}";
+ break;
+ }
+ case "grave_permanent":
+ {
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ int tx = ChronicleHud.GraveStackTileX;
+ int ty = ChronicleHud.GraveStackTileY;
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ Vector3 p = MoveCamera._focus_unit.current_position;
+ int ftx = Mathf.FloorToInt(p.x);
+ int fty = Mathf.FloorToInt(p.y);
+ if (GraveMarkers.TryGetStack(ftx, fty, out _))
+ {
+ tx = ftx;
+ ty = fty;
+ }
+ }
+
+ bool have = GraveMarkers.IsStackPermanent(tx, ty);
+ pass = have == want;
+ detail = $"tile={tx},{ty} permanent={have} want={want}";
+ break;
+ }
+ case "grave_search_filtered":
+ {
+ int want = cmd.count > 0 ? cmd.count : ParseInt(cmd.value, 1);
+ pass = ChronicleHud.GraveStackScopeActive
+ && ChronicleHud.DetailUnitId == 0
+ && ChronicleHud.GraveStackFilteredCount == want;
+ detail =
+ $"filtered={ChronicleHud.GraveStackFilteredCount} want={want} query='{ChronicleHud.SearchText}' scope={ChronicleHud.GraveStackScopeActive}";
+ break;
+ }
+ case "grave_search_layout":
+ {
+ bool fills = ChronicleHud.GraveSearchFillsTools;
+ pass = ChronicleHud.GraveStackScopeActive
+ && ChronicleHud.DetailUnitId == 0
+ && fills;
+ detail =
+ $"fills={fills} scope={ChronicleHud.GraveStackScopeActive} detail={ChronicleHud.DetailUnitId}";
+ break;
+ }
+ case "grave_death_subtitle":
+ {
+ string needle = (cmd.value ?? "").Trim();
+ long id = ChronicleHud.DetailUnitId;
+ string have = id != 0 ? Chronicle.FormatGraveDeathSubtitle(id) : "";
+ pass = !string.IsNullOrEmpty(needle)
+ && !string.IsNullOrEmpty(have)
+ && have.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
+ detail = $"subtitle='{have}' wantContains='{needle}' detailId={id}";
+ break;
+ }
case "death_manner":
{
long id = ChronicleHud.DetailUnitId;
DeathManner have = Chronicle.LatestDeathMannerFor(id);
string haveLabel = Chronicle.DeathMannerLabel(have);
- string want = (cmd.value ?? "").Trim().ToLowerInvariant();
+ string want = (cmd.value ?? "").Trim();
pass = id != 0
- && (haveLabel == want
+ && (haveLabel.Equals(want, System.StringComparison.OrdinalIgnoreCase)
|| have.ToString().Equals(want, System.StringComparison.OrdinalIgnoreCase));
detail = $"manner={have} label={haveLabel} want={cmd.value} detailId={id}";
break;
@@ -9421,6 +9901,16 @@ public static class AgentHarness
return defaultValue;
}
+ private static int ParseInt(string raw, int defaultValue)
+ {
+ if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v))
+ {
+ return v;
+ }
+
+ return defaultValue;
+ }
+
private static string Escape(string s)
{
if (string.IsNullOrEmpty(s))
diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs
index 46b012a..6870463 100644
--- a/IdleSpectator/Chronicle.cs
+++ b/IdleSpectator/Chronicle.cs
@@ -1625,37 +1625,8 @@ public static class Chronicle
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;
- }
+ Actor killerActor = hasKiller ? FindUnitById(killerId) : null;
+ string baseCause = FormatDeathCause(null, attackType, killerActor);
RefreshAgeChapter(force: false);
StampWorldDate(out double worldTime, out string dateLabel);
@@ -1878,33 +1849,186 @@ public static class Chronicle
return DeathManner.Other;
}
+ /// Capitalized manner label for Lore / dossier (matches grave subtitle style).
public static string DeathMannerLabel(DeathManner manner)
{
switch (manner)
{
case DeathManner.Slain:
- return "slain";
+ return "Slain";
case DeathManner.OldAge:
- return "old age";
+ return "Old Age";
case DeathManner.Starvation:
- return "starved";
+ return "Starved";
case DeathManner.Drowned:
- return "drowned";
+ return "Drowned";
case DeathManner.Burned:
- return "burned";
+ return "Burned";
case DeathManner.Plague:
- return "plague";
+ return "Plague";
case DeathManner.Accident:
- return "accident";
+ return "Accident";
case DeathManner.Divine:
- return "divine";
+ return "Divine";
case DeathManner.Other:
- return "dead";
+ return "Dead";
default:
- return "fallen";
+ return "Fallen";
}
}
+ ///
+ /// Short capitalized grave / Fallen-list flavor, AttackType-specific when known.
+ ///
+ public static string FormatGraveDeathSubtitle(long unitId)
+ {
+ ChronicleEntry death = LatestDeathEntry(unitId);
+ if (death == null)
+ {
+ return "Dead";
+ }
+
+ string killer = ResolveKillerDisplayName(death);
+ if (!string.IsNullOrEmpty(death.AttackTypeName)
+ && System.Enum.TryParse(death.AttackTypeName, ignoreCase: true, out AttackType attackType))
+ {
+ return FormatGraveFlavor(attackType, killer, death.Line);
+ }
+
+ return FormatGraveSubtitleFromAttackType(death.AttackTypeName, killer);
+ }
+
+ /// Punchy Title-Case manner for a known .
+ public static string FormatGraveFlavor(AttackType attackType, string killer, string deathLine = "")
+ {
+ string k = killer ?? "";
+ bool hasKiller = !string.IsNullOrEmpty(k);
+ string line = deathLine ?? "";
+ bool lava = line.IndexOf("lava", System.StringComparison.OrdinalIgnoreCase) >= 0;
+
+ switch (attackType)
+ {
+ case AttackType.Age:
+ return "Faded With Age";
+ case AttackType.Starvation:
+ return "Wasted From Hunger";
+ case AttackType.Fire:
+ return lava ? "Swallowed By Lava" : "Burned To Ash";
+ case AttackType.Drowning:
+ case AttackType.Water:
+ return "Lost To The Deep";
+ case AttackType.Poison:
+ return "Poison Took Them";
+ case AttackType.Plague:
+ return "Claimed By Plague";
+ case AttackType.Infection:
+ return "Rotted From Within";
+ case AttackType.Tumor:
+ return "Consumed By A Tumor";
+ case AttackType.Acid:
+ return "Dissolved In Acid";
+ case AttackType.AshFever:
+ return "Taken By Ash Fever";
+ case AttackType.Gravity:
+ return "Fell From The Heights";
+ case AttackType.Explosion:
+ return "Blown Apart";
+ case AttackType.Divine:
+ return "Smote By The Gods";
+ case AttackType.Eaten:
+ return hasKiller ? "Devoured by " + k : "Devoured";
+ case AttackType.Weapon:
+ return hasKiller ? "Slain by " + k : "Fell In Battle";
+ case AttackType.Smile:
+ return hasKiller ? "Slain by " + k : "Died Smiling";
+ case AttackType.Other:
+ return hasKiller ? "Slain by " + k : "Met A Strange End";
+ case AttackType.Metamorphosis:
+ return "Transformed Away";
+ case AttackType.None:
+ return hasKiller ? "Slain by " + k : "Met Their End";
+ default:
+ return hasKiller ? "Slain by " + k : "Dead";
+ }
+ }
+
+ private static string ResolveKillerDisplayName(ChronicleEntry death)
+ {
+ if (death == null || death.OtherId == 0)
+ {
+ return "";
+ }
+
+ Actor killer = FindUnitById(death.OtherId);
+ if (killer != null)
+ {
+ return SafeName(killer);
+ }
+
+ return TryExtractKillerFromLine(death.Line);
+ }
+
+ private static string TryExtractKillerFromLine(string line)
+ {
+ if (string.IsNullOrEmpty(line))
+ {
+ return "";
+ }
+
+ string[] prefixes =
+ {
+ "Was cut down by ",
+ "Was torn apart and eaten by ",
+ "Was slain by ",
+ "Died with a smile at ",
+ "Killed by ",
+ "Eaten by ",
+ "Slain by ",
+ "Devoured by "
+ };
+ for (int i = 0; i < prefixes.Length; i++)
+ {
+ string prefix = prefixes[i];
+ int at = line.IndexOf(prefix, System.StringComparison.OrdinalIgnoreCase);
+ if (at < 0)
+ {
+ continue;
+ }
+
+ string rest = line.Substring(at + prefix.Length).Trim();
+ if (prefix.IndexOf("smile", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ int hand = rest.IndexOf("'s hand", System.StringComparison.OrdinalIgnoreCase);
+ if (hand > 0)
+ {
+ rest = rest.Substring(0, hand).Trim();
+ }
+ }
+
+ int paren = rest.IndexOf(" (", System.StringComparison.Ordinal);
+ if (paren > 0)
+ {
+ rest = rest.Substring(0, paren).Trim();
+ }
+
+ return rest;
+ }
+
+ return "";
+ }
+
+ private static string FormatGraveSubtitleFromAttackType(string attackTypeName, string killer)
+ {
+ string t = (attackTypeName ?? "").Trim();
+ if (!string.IsNullOrEmpty(t)
+ && System.Enum.TryParse(t, ignoreCase: true, out AttackType attackType))
+ {
+ return FormatGraveFlavor(attackType, killer);
+ }
+
+ return string.IsNullOrEmpty(killer) ? "Dead" : "Slain by " + killer;
+ }
+
/// Newest death event for a subject, if any.
public static ChronicleEntry LatestDeathEntry(long subjectId)
{
@@ -2038,10 +2162,12 @@ public static class Chronicle
}
///
- /// Camera for a dead subject: living killer if possible, else last death / history position.
+ /// Camera for a dead subject.
+ /// When is true (default), pan to death place / skull (no unit follow).
+ /// Killer follow is only via .
/// Dossier always shows the fallen unit's last living state (not the killer).
///
- public static bool FocusFallenSubject(long unitId)
+ public static bool FocusFallenSubject(long unitId, bool preferPlace = true)
{
LastFallenFocusDetail = "none";
LastFallenFocusPosition = Vector3.zero;
@@ -2058,7 +2184,7 @@ public static class Chronicle
}
ChronicleEntry death = LatestDeathEntry(unitId);
- if (death != null && death.OtherId != 0)
+ if (!preferPlace && death != null && death.OtherId != 0)
{
Actor killer = FindUnitById(death.OtherId);
if (killer != null && killer.isAlive())
@@ -2071,10 +2197,10 @@ public static class Chronicle
}
}
+ // Place / skull path: never keep following a living unit (e.g. prior killer focus).
+ CameraDirector.ClearFollow();
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;
@@ -2082,9 +2208,57 @@ public static class Chronicle
return true;
}
+ LastFallenFocusDetail = "place";
return fallen != null;
}
+ /// Living killer for a fallen subject, or null.
+ public static Actor FindLivingKillerFor(long unitId)
+ {
+ if (unitId == 0)
+ {
+ return null;
+ }
+
+ ChronicleEntry death = LatestDeathEntry(unitId);
+ if (death == null || death.OtherId == 0)
+ {
+ return null;
+ }
+
+ Actor killer = FindUnitById(death.OtherId);
+ if (killer == null || !killer.isAlive())
+ {
+ return null;
+ }
+
+ return killer;
+ }
+
+ /// Camera follow on the living killer of a fallen Lore subject.
+ public static bool FocusFallenKiller(long unitId)
+ {
+ Actor killer = FindLivingKillerFor(unitId);
+ if (killer == null)
+ {
+ return false;
+ }
+
+ UnitDossier fallen = ResolveFallenDossier(unitId);
+ if (fallen != null)
+ {
+ WatchCaption.PinWhilePaused();
+ WatchCaption.SetFromDossier(fallen);
+ }
+
+ CameraDirector.FocusUnit(killer, updateCaption: false);
+ LastFallenFocusDetail = "killer:" + killer.getID();
+ LastFallenFocusPosition = Vector3.zero;
+ LogService.LogInfo(
+ $"[IdleSpectator][LORE] focus killer id={killer.getID()} victim={unitId}");
+ return true;
+ }
+
private static bool EntryHasWorldPosition(ChronicleEntry e)
{
if (e == null)
@@ -2128,62 +2302,75 @@ public static class Chronicle
string killerLabel = killer != null
? $"{SafeName(killer)} ({SpeciesOf(killer)})"
: "";
+ bool hasKiller = !string.IsNullOrEmpty(killerLabel);
switch (attackType)
{
case AttackType.Age:
- return "Died of old age";
+ return "Faded gently into old age";
case AttackType.Starvation:
- return "Starved to death";
+ return "Wasted away from hunger";
case AttackType.Fire:
- return IsOnLava(victim) ? "Burned in lava" : "Burned to death";
+ return IsOnLava(victim)
+ ? "Was swallowed by lava"
+ : "Burned until only ash remained";
case AttackType.Drowning:
case AttackType.Water:
- return "Drowned";
+ return "Sank beneath the water and never rose";
case AttackType.Poison:
- return "Died of poison";
+ return "Succumbed as poison flooded their veins";
case AttackType.Plague:
- return "Died of plague";
+ return "Was claimed by a spreading plague";
case AttackType.Infection:
- return "Died of infection";
+ return "Rotted from within as infection took hold";
case AttackType.Tumor:
- return "Died of a tumor";
+ return "Was consumed by a growing tumor";
case AttackType.Acid:
- return "Dissolved in acid";
+ return "Dissolved screaming in acid";
case AttackType.AshFever:
- return "Died of ash fever";
+ return "Burned out from ash fever";
case AttackType.Gravity:
- return "Fell to their death";
+ return "Fell from a height and broke upon the ground";
case AttackType.Explosion:
- return "Died in an explosion";
+ return "Was torn apart in an explosion";
case AttackType.Divine:
- return "Struck down by divine power";
+ return "Was smote by divine power";
case AttackType.Eaten:
- return string.IsNullOrEmpty(killerLabel)
- ? "Was eaten"
- : $"Eaten by {killerLabel}";
+ return hasKiller
+ ? $"Was torn apart and eaten by {killerLabel}"
+ : "Was torn apart and eaten";
case AttackType.Weapon:
- case AttackType.Other:
+ return hasKiller
+ ? $"Was cut down by {killerLabel}"
+ : "Fell in bitter combat";
case AttackType.Smile:
- if (!string.IsNullOrEmpty(killerLabel))
- {
- return $"Killed by {killerLabel}";
- }
-
- return attackType == AttackType.Weapon ? "Killed in combat" : "Died";
+ return hasKiller
+ ? $"Died with a smile at {killerLabel}'s hand"
+ : "Died with a strange smile";
+ case AttackType.Other:
+ return hasKiller
+ ? $"Was slain by {killerLabel}"
+ : "Met a strange and sudden end";
case AttackType.Metamorphosis:
- return "Transformed away";
+ return "Transformed away and left no body behind";
case AttackType.None:
- return "Died";
+ return hasKiller
+ ? $"Was slain by {killerLabel}"
+ : "Met their end";
default:
- return string.IsNullOrEmpty(killerLabel)
- ? $"Died ({attackType})"
- : $"Killed by {killerLabel}";
+ return hasKiller
+ ? $"Was slain by {killerLabel}"
+ : $"Died ({attackType})";
}
}
private static bool IsOnLava(Actor victim)
{
+ if (victim == null)
+ {
+ return false;
+ }
+
try
{
WorldTile tile = victim.current_tile;
diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs
index 82cdbdf..a9dde0a 100644
--- a/IdleSpectator/ChronicleHud.cs
+++ b/IdleSpectator/ChronicleHud.cs
@@ -39,7 +39,7 @@ public static class ChronicleHud
private const float TabH = 15f;
private const float CharToolsH = 17f;
private const float ScrollSensitivity = 55f;
- private const string ChromeMarker = "LoreV6";
+ private const string ChromeMarker = "LoreV10";
private static GameObject _root;
private static RectTransform _rootRt;
@@ -50,6 +50,8 @@ public static class ChronicleHud
private static Text _hotkeyText;
private static Image _titleIcon;
private static readonly List Rows = new List();
+ private static readonly List GraveEntriesScratch =
+ new List(64);
private static bool _visible;
private static int _lastCount = -1;
private static string _lastAgeId = "";
@@ -76,11 +78,24 @@ public static class ChronicleHud
private static bool _pausedIdleForLore;
private static GameObject _backBtn;
private static Text _backBtnLabel;
+ private static GameObject _focusKillerBtn;
+ private static Text _focusKillerLabel;
+ private static GameObject _keepGraveBtn;
+ private static Text _keepGraveLabel;
+ private static GameObject _deleteGraveBtn;
+ private static Text _deleteGraveLabel;
private static GameObject _detailPaneBar;
private static GameObject _detailActivityBtn;
private static GameObject _detailLifeBtn;
private static DetailPane _detailPane = DetailPane.Life;
+ /// Lore is browsing one tile's grave stack (from a skull click).
+ private static bool _graveStackActive;
+ private static int _graveTileX;
+ private static int _graveTileY;
+ private static bool _openingGraveStack;
+ private static float _ignoreDismissUntil;
+
/// Rows actually built in the last character detail rebuild (harness).
public static int LastDetailHistoryRows { get; private set; }
@@ -109,6 +124,46 @@ public static class ChronicleHud
public static long DetailUnitId => _detailUnitId;
+ /// True when Lore is scoped to a gravestone stack (skull open).
+ public static bool GraveStackScopeActive => _graveStackActive;
+
+ public static int GraveStackTileX => _graveTileX;
+ public static int GraveStackTileY => _graveTileY;
+
+ /// Filtered stack list rows from the last grave-stack rebuild (harness).
+ public static int GraveStackFilteredCount { get; private set; }
+
+ ///
+ /// Grave stack search should span the tools row (Keep/Del live in the title band).
+ ///
+ public static bool GraveSearchFillsTools
+ {
+ get
+ {
+ if (!_graveStackActive || _searchField == null || _charTools == null)
+ {
+ return false;
+ }
+
+ RectTransform searchRt = _searchField.GetComponent();
+ RectTransform toolsRt = _charTools.GetComponent();
+ if (searchRt == null || toolsRt == null || !searchRt.gameObject.activeInHierarchy)
+ {
+ return false;
+ }
+
+ // Full stretch within CharTools (no right inset reserved for Refresh/Fav).
+ return Mathf.Abs(searchRt.offsetMax.x) < 0.5f
+ && searchRt.anchorMin.x <= 0.01f
+ && searchRt.anchorMax.x >= 0.99f
+ && toolsRt.rect.width > 1f
+ && searchRt.rect.width >= toolsRt.rect.width - 1f;
+ }
+ }
+
+ /// Ignore outside-click dismiss briefly after skull open / Keep.
+ public static float IgnoreDismissUntil => _ignoreDismissUntil;
+
private static bool IsCastTab => _tab == LoreTab.Living || _tab == LoreTab.Fallen;
/// True when Lore detail was opened via L/books and should track idle focus.
@@ -146,12 +201,19 @@ public static class ChronicleHud
_searchField = null;
_backBtn = null;
_backBtnLabel = null;
+ _focusKillerBtn = null;
+ _focusKillerLabel = null;
+ _keepGraveBtn = null;
+ _keepGraveLabel = null;
+ _deleteGraveBtn = null;
+ _deleteGraveLabel = null;
_detailPaneBar = null;
_detailActivityBtn = null;
_detailLifeBtn = null;
_detailUnitId = 0;
_followFocus = false;
_detailPane = DetailPane.Life;
+ ClearGraveStackScope();
}
if (_root != null)
@@ -218,6 +280,23 @@ public static class ChronicleHud
_titleText.raycastTarget = false;
_titleText.horizontalOverflow = HorizontalWrapMode.Overflow;
+ // Grave Keep/Del live in the title band so search can use the full tools row.
+ _keepGraveBtn = BuildTabButton(_root.transform, "KeepGrave", "Keep", ToggleKeepGrave);
+ _keepGraveLabel = _keepGraveBtn.transform.Find("Label")?.GetComponent();
+ PlaceTitleTool(_keepGraveBtn, -58f, 34f);
+ _keepGraveBtn.SetActive(false);
+
+ _deleteGraveBtn = BuildTabButton(
+ _root.transform, "DeleteGrave", "Del", () => { DeleteActiveGraveStack(); });
+ _deleteGraveLabel = _deleteGraveBtn.transform.Find("Label")?.GetComponent();
+ if (_deleteGraveLabel != null)
+ {
+ _deleteGraveLabel.color = new Color(0.92f, 0.62f, 0.58f, 1f);
+ }
+
+ PlaceTitleTool(_deleteGraveBtn, -22f, 34f);
+ _deleteGraveBtn.SetActive(false);
+
BuildTabs();
BuildCharTools();
BuildScroll();
@@ -289,6 +368,21 @@ public static class ChronicleHud
rt.offsetMax = new Vector2(index >= count - 1f ? 0f : -1f, 0f);
}
+ private static void PlaceTitleTool(GameObject btn, float xFromRight, float width)
+ {
+ if (btn == null)
+ {
+ return;
+ }
+
+ RectTransform rt = btn.GetComponent();
+ rt.anchorMin = new Vector2(1f, 1f);
+ rt.anchorMax = new Vector2(1f, 1f);
+ rt.pivot = new Vector2(1f, 1f);
+ rt.sizeDelta = new Vector2(width, 14f);
+ rt.anchoredPosition = new Vector2(xFromRight, -4f);
+ }
+
private static void BuildCharTools()
{
_charTools = new GameObject("CharTools", typeof(RectTransform));
@@ -359,15 +453,24 @@ public static class ChronicleHud
_backBtnLabel = _backBtn.transform.Find("Label")?.GetComponent();
RectTransform backRt = _backBtn.GetComponent();
backRt.anchorMin = new Vector2(0f, 0f);
- backRt.anchorMax = new Vector2(0.36f, 1f);
+ backRt.anchorMax = new Vector2(0.28f, 1f);
backRt.offsetMin = Vector2.zero;
backRt.offsetMax = Vector2.zero;
_backBtn.SetActive(false);
+ _focusKillerBtn = BuildTabButton(_charTools.transform, "FocusKiller", "Killer", () => FocusDetailKiller());
+ _focusKillerLabel = _focusKillerBtn.transform.Find("Label")?.GetComponent();
+ RectTransform killerRt = _focusKillerBtn.GetComponent();
+ killerRt.anchorMin = new Vector2(0.29f, 0f);
+ killerRt.anchorMax = new Vector2(0.48f, 1f);
+ killerRt.offsetMin = Vector2.zero;
+ killerRt.offsetMax = Vector2.zero;
+ _focusKillerBtn.SetActive(false);
+
_detailPaneBar = new GameObject("DetailPanes", typeof(RectTransform));
_detailPaneBar.transform.SetParent(_charTools.transform, false);
RectTransform paneRt = _detailPaneBar.GetComponent();
- paneRt.anchorMin = new Vector2(0.38f, 0f);
+ paneRt.anchorMin = new Vector2(0.50f, 0f);
paneRt.anchorMax = new Vector2(1f, 1f);
paneRt.offsetMin = Vector2.zero;
paneRt.offsetMax = Vector2.zero;
@@ -590,7 +693,8 @@ public static class ChronicleHud
///
public static bool ShouldAbsorbScrollZoom()
{
- return IsPointerOverPanel() || WatchCaption.IsPointerOverPanel();
+ return IsPointerOverPanel()
+ || WatchCaption.IsPointerOverPanel();
}
public static bool IsPointerOverPanel()
@@ -648,6 +752,7 @@ public static class ChronicleHud
}
else
{
+ ClearGraveStackScope();
if (_searchField != null && EventSystem.current != null
&& EventSystem.current.currentSelectedGameObject == _searchField.gameObject)
{
@@ -704,6 +809,7 @@ public static class ChronicleHud
public static void OpenSyncedFromFocus()
{
EnsureBuilt();
+ ClearGraveStackScope();
long id = WatchCaption.CurrentUnitId;
if (id == 0)
{
@@ -725,6 +831,11 @@ public static class ChronicleHud
public static void SetTab(LoreTab tab)
{
EnsureBuilt();
+ if (!_openingGraveStack)
+ {
+ ClearGraveStackScope();
+ }
+
if (tab == LoreTab.World)
{
_detailUnitId = 0;
@@ -743,11 +854,149 @@ public static class ChronicleHud
Rebuild(force: true);
}
+ /// Open Lore scoped to one gravestone tile (skull click / harness).
+ public static bool OpenGraveStack(int tileX, int tileY, bool pauseIdle = true)
+ {
+ if (!GraveMarkers.TryGetStack(tileX, tileY, out GraveMarkers.GraveStack stack)
+ || stack.Entries.Count == 0)
+ {
+ return false;
+ }
+
+ EnsureBuilt();
+ _openingGraveStack = true;
+ try
+ {
+ InspectUi.MarkGraveSession();
+ _graveStackActive = true;
+ _graveTileX = tileX;
+ _graveTileY = tileY;
+ _ignoreDismissUntil = Time.unscaledTime + 0.4f;
+ _searchText = "";
+ if (_searchField != null)
+ {
+ _searchField.SetTextWithoutNotify("");
+ }
+
+ if (pauseIdle && SpectatorMode.Active)
+ {
+ _pausedIdleForLore = true;
+ SpectatorMode.SetActive(false, quiet: true);
+ }
+ else if (pauseIdle)
+ {
+ _pausedIdleForLore = false;
+ }
+
+ CameraDirector.ClearFollow();
+ CameraDirector.PanTo(stack.WorldPos);
+ WatchCaption.PinWhilePaused();
+ if (pauseIdle)
+ {
+ WatchCaption.ShowStatusBanner("Paused (viewing grave)");
+ }
+
+ if (stack.Entries.Count == 1)
+ {
+ long id = stack.Entries[stack.Entries.Count - 1].UnitId;
+ OpenUnitHistory(id, pauseIdle: false, followFocus: false, preferPlace: true);
+ _graveStackActive = true;
+ _graveTileX = tileX;
+ _graveTileY = tileY;
+ ApplyTabChrome();
+ return ChronicleHud.Visible && DetailUnitId == id;
+ }
+
+ _detailUnitId = 0;
+ _followFocus = false;
+ _detailPane = DetailPane.Life;
+ _tab = LoreTab.Fallen;
+ _lastCharFingerprint = int.MinValue;
+ SetVisible(true);
+ ApplyTabChrome();
+ Rebuild(force: true);
+ return Visible && GraveStackScopeActive && DetailUnitId == 0;
+ }
+ finally
+ {
+ _openingGraveStack = false;
+ }
+ }
+
+ public static void ClearGraveStackScope()
+ {
+ _graveStackActive = false;
+ _graveTileX = 0;
+ _graveTileY = 0;
+ GraveStackFilteredCount = 0;
+ }
+
+ private static void ToggleKeepGrave()
+ {
+ if (!_graveStackActive)
+ {
+ return;
+ }
+
+ bool next = !GraveMarkers.IsStackPermanent(_graveTileX, _graveTileY);
+ if (!GraveMarkers.TrySetStackPermanent(_graveTileX, _graveTileY, next))
+ {
+ return;
+ }
+
+ _ignoreDismissUntil = Time.unscaledTime + 0.4f;
+ RefreshKeepGraveVisual();
+ }
+
+ /// Remove the open grave stack from the world and close Lore.
+ public static bool DeleteActiveGraveStack()
+ {
+ if (!_graveStackActive)
+ {
+ return false;
+ }
+
+ int tx = _graveTileX;
+ int ty = _graveTileY;
+ if (!GraveMarkers.TryRemoveStack(tx, ty))
+ {
+ return false;
+ }
+
+ _ignoreDismissUntil = Time.unscaledTime + 0.4f;
+ LogService.LogInfo($"[IdleSpectator][LORE] deleted grave tile={tx},{ty}");
+ InspectUi.DismissAll();
+ return true;
+ }
+
+ private static void RefreshKeepGraveVisual()
+ {
+ if (_keepGraveLabel == null)
+ {
+ return;
+ }
+
+ bool kept = _graveStackActive && GraveMarkers.IsStackPermanent(_graveTileX, _graveTileY);
+ _keepGraveLabel.text = kept ? "Kept" : "Keep";
+ Image bg = _keepGraveBtn != null ? _keepGraveBtn.GetComponent() : null;
+ if (bg != null)
+ {
+ bg.color = kept
+ ? new Color(0.45f, 0.36f, 0.12f, 0.95f)
+ : new Color(0.12f, 0.13f, 0.16f, 0.95f);
+ }
+ }
+
///
/// Show one unit's full chronicle in Lore (Living or Fallen tab).
/// true (L/books) retargets with idle; false (list) pins the archive.
+ /// Fallen subjects always open on death place / skull; use Focus Killer for the killer.
///
- public static void OpenUnitHistory(long unitId, bool pauseIdle = true, bool followFocus = false)
+ public static void OpenUnitHistory(
+ long unitId,
+ bool pauseIdle = true,
+ bool followFocus = false,
+ bool preferPlace = true)
{
if (unitId == 0)
{
@@ -775,7 +1024,8 @@ public static class ChronicleHud
bool live = Chronicle.FocusCameraForBrowse(unitId);
if (!live)
{
- Chronicle.FocusFallenSubject(unitId);
+ // Default preferPlace=true: death place / skull. Killer only via Focus Killer.
+ Chronicle.FocusFallenSubject(unitId, preferPlace);
}
_tab = live ? LoreTab.Living : LoreTab.Fallen;
@@ -792,6 +1042,24 @@ public static class ChronicleHud
Rebuild(force: true);
}
+ /// Harness / Lore chrome: follow the living killer of the open fallen subject.
+ public static bool FocusDetailKiller()
+ {
+ if (_detailUnitId == 0 || _tab != LoreTab.Fallen)
+ {
+ return false;
+ }
+
+ bool ok = Chronicle.FocusFallenKiller(_detailUnitId);
+ if (ok)
+ {
+ WatchCaption.ShowStatusBanner(Chronicle.FallenPauseBanner());
+ }
+
+ ApplyTabChrome();
+ return ok;
+ }
+
public static void ClearUnitDetail()
{
_detailUnitId = 0;
@@ -951,23 +1219,43 @@ public static class ChronicleHud
}
}
+ bool grave = _graveStackActive && _tab == LoreTab.Fallen;
+
if (_searchField != null)
{
_searchField.gameObject.SetActive(chars && !detail);
+ if (chars && !detail)
+ {
+ RectTransform searchRt = _searchField.GetComponent();
+ if (searchRt != null)
+ {
+ // Living/Fallen: leave room for Refresh+Fav. Grave: full width (Keep/Del in title).
+ searchRt.offsetMax = new Vector2(grave ? 0f : -40f, 0f);
+ }
+ }
+ }
+
+ if (_titleText != null)
+ {
+ RectTransform titleRt = _titleText.GetComponent();
+ if (titleRt != null)
+ {
+ // Leave room for Keep+Del+L when grave-scoped.
+ titleRt.offsetMax = new Vector2(grave ? -96f : -22f, -2f);
+ }
}
if (_favFilterBtn != null)
{
- _favFilterBtn.gameObject.SetActive(chars && !detail);
+ _favFilterBtn.gameObject.SetActive(chars && !detail && !grave);
}
if (_refreshBtn != null)
{
- _refreshBtn.gameObject.SetActive(chars && !detail);
+ _refreshBtn.gameObject.SetActive(chars && !detail && !grave);
RectTransform refreshRt = _refreshBtn.GetComponent();
if (refreshRt != null)
{
- // Search | Refresh | Fav on both Living and Fallen.
refreshRt.anchoredPosition = new Vector2(-19f, 0f);
}
}
@@ -977,13 +1265,62 @@ public static class ChronicleHud
_backBtn.SetActive(detail);
if (_backBtnLabel != null)
{
- _backBtnLabel.text = _tab == LoreTab.Fallen ? "← Fallen" : "← Living";
+ if (grave)
+ {
+ _backBtnLabel.text = "← Grave";
+ }
+ else
+ {
+ _backBtnLabel.text = _tab == LoreTab.Fallen ? "← Fallen" : "← Living";
+ }
+ }
+ }
+
+ if (_focusKillerBtn != null)
+ {
+ bool showKiller = detail
+ && _tab == LoreTab.Fallen
+ && Chronicle.FindLivingKillerFor(_detailUnitId) != null;
+ _focusKillerBtn.SetActive(showKiller);
+ if (_focusKillerLabel != null)
+ {
+ _focusKillerLabel.text = "Killer";
+ }
+ }
+
+ if (_keepGraveBtn != null)
+ {
+ _keepGraveBtn.SetActive(grave);
+ if (grave)
+ {
+ PlaceTitleTool(_keepGraveBtn, -58f, 34f);
+ RefreshKeepGraveVisual();
+ }
+ }
+
+ if (_deleteGraveBtn != null)
+ {
+ _deleteGraveBtn.SetActive(grave);
+ if (grave)
+ {
+ PlaceTitleTool(_deleteGraveBtn, -22f, 34f);
}
}
if (_detailPaneBar != null)
{
_detailPaneBar.SetActive(detail);
+ if (detail)
+ {
+ RectTransform paneRt = _detailPaneBar.GetComponent();
+ if (paneRt != null)
+ {
+ // Full Activity|Life strip; Keep/Del sit in the title band.
+ paneRt.anchorMin = new Vector2(0.50f, 0f);
+ paneRt.anchorMax = new Vector2(1f, 1f);
+ }
+ }
+
StyleDetailPanes();
}
@@ -1017,6 +1354,13 @@ public static class ChronicleHud
return;
}
+ if (_graveStackActive && _tab == LoreTab.Fallen)
+ {
+ int n = GraveMarkers.EntryCountAt(_graveTileX, _graveTileY);
+ _titleText.text = n <= 1 ? "Grave" : $"Grave · {n}";
+ return;
+ }
+
_titleText.text = _tab == LoreTab.Fallen ? "Fallen" : "Living";
return;
}
@@ -1201,6 +1545,10 @@ public static class ChronicleHud
hash = (hash * 31) + (_searchText ?? "").GetHashCode();
hash = (hash * 31) + _detailUnitId.GetHashCode();
hash = (hash * 31) + (int)_tab;
+ hash = (hash * 31) + (_graveStackActive ? 1 : 0);
+ hash = (hash * 31) + _graveTileX;
+ hash = (hash * 31) + _graveTileY;
+ hash = (hash * 31) + GraveMarkers.EntryCountAt(_graveTileX, _graveTileY);
// Cheap dirty bits - never rescan all subjects every frame.
hash = (hash * 31) + Chronicle.HistoryRevision;
hash = (hash * 31) + Chronicle.HistorySubjectCount;
@@ -1291,6 +1639,12 @@ public static class ChronicleHud
return;
}
+ if (_graveStackActive && _tab == LoreTab.Fallen)
+ {
+ RebuildGraveStackList();
+ return;
+ }
+
Chronicle.CharacterListScope scope = _tab == LoreTab.Fallen
? Chronicle.CharacterListScope.Fallen
: Chronicle.CharacterListScope.Living;
@@ -1299,6 +1653,7 @@ public static class ChronicleHud
_lastCharFingerprint = CharacterFingerprint();
_builtAtRevision = Chronicle.HistoryRevision;
RefreshStaleVisual();
+ GraveStackFilteredCount = 0;
if (list.Count == 0)
{
@@ -1343,6 +1698,74 @@ public static class ChronicleHud
}
}
+ private static void RebuildGraveStackList()
+ {
+ RefreshTitle();
+ LastListTopTitle = "";
+ GraveStackFilteredCount = 0;
+ if (!GraveMarkers.TryGetStack(_graveTileX, _graveTileY, out GraveMarkers.GraveStack stack))
+ {
+ ClearGraveStackScope();
+ AddEmpty("Grave gone");
+ return;
+ }
+
+ GraveMarkers.SnapshotEntries(stack, GraveEntriesScratch);
+ string q = (_searchText ?? "").Trim();
+ _lastCharFingerprint = CharacterFingerprint();
+ _builtAtRevision = Chronicle.HistoryRevision;
+ RefreshStaleVisual();
+
+ int shown = 0;
+ // Newest first (same order as the old grave panel).
+ for (int i = GraveEntriesScratch.Count - 1; i >= 0; i--)
+ {
+ GraveMarkers.GraveEntry e = GraveEntriesScratch[i];
+ if (e == null)
+ {
+ continue;
+ }
+
+ if (q.Length > 0
+ && (e.DisplayName == null
+ || e.DisplayName.IndexOf(q, System.StringComparison.OrdinalIgnoreCase) < 0))
+ {
+ continue;
+ }
+
+ var row = new ChronicleCharacterSummary
+ {
+ UnitId = e.UnitId,
+ Name = e.DisplayName ?? "",
+ SpeciesId = "",
+ HistoryCount = Chronicle.HistoryCountFor(e.UnitId),
+ IsFavorite = e.Favorite,
+ IsAlive = false,
+ DeathManner = Chronicle.LatestDeathMannerFor(e.UnitId),
+ SortTime = e.CreatedAt
+ };
+ if (shown == 0)
+ {
+ LastListTopTitle = row.Title;
+ }
+
+ GameObject go = BuildCharacterRow(row, shown);
+ go.transform.SetParent(_content, false);
+ Rows.Add(go);
+ shown++;
+ if (shown >= MaxVisibleRows)
+ {
+ break;
+ }
+ }
+
+ GraveStackFilteredCount = shown;
+ if (shown == 0)
+ {
+ AddEmpty(q.Length > 0 ? "No names match" : "Empty grave");
+ }
+ }
+
private static void RebuildActivityDetail(long unitId)
{
IReadOnlyList entries =
@@ -1748,8 +2171,13 @@ public static class ChronicleHud
string title = summary.Title;
if (!summary.IsAlive && !string.IsNullOrEmpty(title))
{
- string manner = summary.DeathLabel;
- title += string.IsNullOrEmpty(manner) ? " · fallen" : " · " + manner;
+ string manner = Chronicle.FormatGraveDeathSubtitle(summary.UnitId);
+ if (string.IsNullOrEmpty(manner))
+ {
+ manner = summary.DeathLabel;
+ }
+
+ title += string.IsNullOrEmpty(manner) ? " · Fallen" : " · " + manner;
}
Text label = HudCanvas.MakeText(go.transform, "Text", title, 8);
diff --git a/IdleSpectator/GraveCountBadges.cs b/IdleSpectator/GraveCountBadges.cs
new file mode 100644
index 0000000..f99a7c1
--- /dev/null
+++ b/IdleSpectator/GraveCountBadges.cs
@@ -0,0 +1,88 @@
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace IdleSpectator;
+
+///
+/// Screen-space count badges over multi-entry grave stacks (pooled, on-screen only).
+///
+public static class GraveCountBadges
+{
+ private const int PoolSize = 64;
+ private static readonly List Scratch = new List(256);
+ private static readonly List Pool = new List(PoolSize);
+ private static Transform _root;
+ private static bool _built;
+
+ private sealed class Badge
+ {
+ public GameObject Go;
+ public RectTransform Rt;
+ public Text Label;
+ }
+
+ /// Badges retired - stack size lives in Lore title. Kept so old pools hide cleanly.
+ public static void Hide()
+ {
+ for (int i = 0; i < Pool.Count; i++)
+ {
+ if (Pool[i].Go != null)
+ {
+ Pool[i].Go.SetActive(false);
+ }
+ }
+ }
+
+ public static void Update()
+ {
+ Hide();
+ }
+
+ private static void EnsureBuilt()
+ {
+ if (_built)
+ {
+ return;
+ }
+
+ Canvas canvas = HudCanvas.Resolve();
+ if (canvas == null)
+ {
+ return;
+ }
+
+ GameObject rootGo = new GameObject("IdleGraveCountBadges", typeof(RectTransform));
+ rootGo.transform.SetParent(canvas.transform, false);
+ _root = rootGo.transform;
+ RectTransform rootRt = rootGo.GetComponent();
+ rootRt.anchorMin = Vector2.zero;
+ rootRt.anchorMax = Vector2.one;
+ rootRt.offsetMin = Vector2.zero;
+ rootRt.offsetMax = Vector2.zero;
+
+ for (int i = 0; i < PoolSize; i++)
+ {
+ GameObject go = new GameObject("Badge" + i, typeof(RectTransform), typeof(Image));
+ go.transform.SetParent(_root, false);
+ RectTransform rt = go.GetComponent();
+ rt.sizeDelta = new Vector2(14f, 10f);
+ Image img = go.GetComponent();
+ img.color = new Color(0.08f, 0.09f, 0.11f, 0.85f);
+ img.raycastTarget = false;
+ Text label = HudCanvas.MakeText(go.transform, "N", "2", 8);
+ RectTransform lrt = label.GetComponent();
+ lrt.anchorMin = Vector2.zero;
+ lrt.anchorMax = Vector2.one;
+ lrt.offsetMin = Vector2.zero;
+ lrt.offsetMax = Vector2.zero;
+ label.alignment = TextAnchor.MiddleCenter;
+ label.color = new Color(0.95f, 0.85f, 0.55f, 1f);
+ label.raycastTarget = false;
+ go.SetActive(false);
+ Pool.Add(new Badge { Go = go, Rt = rt, Label = label });
+ }
+
+ _built = true;
+ }
+}
diff --git a/IdleSpectator/GraveHover.cs b/IdleSpectator/GraveHover.cs
new file mode 100644
index 0000000..269fc52
--- /dev/null
+++ b/IdleSpectator/GraveHover.cs
@@ -0,0 +1,108 @@
+using UnityEngine;
+
+namespace IdleSpectator;
+
+///
+/// World skull hover highlight + click → Lore (no separate grave window).
+///
+public static class GraveHover
+{
+ private const float HitRadius = 1.35f;
+
+ /// Stack key under mouse - drives skull hover flicker.
+ public static long HighlightStackKey { get; private set; }
+
+ public static void Update()
+ {
+ HighlightStackKey = 0;
+ if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled)
+ {
+ return;
+ }
+
+ if (GraveMarkers.StackCount <= 0)
+ {
+ return;
+ }
+
+ bool overUi = false;
+ try
+ {
+ overUi = World.world != null && World.world.isOverUI();
+ }
+ catch
+ {
+ overUi = false;
+ }
+
+ if (overUi
+ && !ChronicleHud.IsPointerOverPanel()
+ && !WatchCaption.IsPointerOverPanel())
+ {
+ // Pointer on other game UI - no hover highlight / click.
+ }
+ else
+ {
+ GraveMarkers.GraveStack hover = ResolveHoverStack();
+ if (hover != null)
+ {
+ HighlightStackKey = hover.Key;
+ }
+ }
+
+ HandleWorldClick();
+ }
+
+ private static void HandleWorldClick()
+ {
+ if (!InputHelpers.GetMouseButtonDown(0))
+ {
+ return;
+ }
+
+ if (ChronicleHud.IsPointerOverPanel() || WatchCaption.IsPointerOverInteractive())
+ {
+ return;
+ }
+
+ try
+ {
+ if (World.world != null && World.world.isOverUI())
+ {
+ return;
+ }
+ }
+ catch
+ {
+ return;
+ }
+
+ GraveMarkers.GraveStack stack = ResolveHoverStack();
+ if (stack == null || stack.Entries.Count == 0)
+ {
+ return;
+ }
+
+ InspectUi.MarkGraveSession();
+ ChronicleHud.OpenGraveStack(stack.TileX, stack.TileY, pauseIdle: true);
+ }
+
+ private static GraveMarkers.GraveStack ResolveHoverStack()
+ {
+ try
+ {
+ WorldTile tile = World.world.getMouseTilePosCachedFrame() ?? World.world.getMouseTilePos();
+ if (tile != null && GraveMarkers.TryGetStack(tile.x, tile.y, out GraveMarkers.GraveStack atTile))
+ {
+ return atTile;
+ }
+
+ Vector2 mouse = World.world.getMousePos();
+ return GraveMarkers.FindNearestStack(mouse, HitRadius);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/IdleSpectator/GraveMarkers.cs b/IdleSpectator/GraveMarkers.cs
new file mode 100644
index 0000000..f9ec1c9
--- /dev/null
+++ b/IdleSpectator/GraveMarkers.cs
@@ -0,0 +1,688 @@
+using System.Collections.Generic;
+using NeoModLoader.services;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+///
+/// Session gravestone stacks keyed by world tile. Drawn via QuantumSprites; click opens Lore.
+///
+public static class GraveMarkers
+{
+ public const int PerStackSoftCap = 40;
+ public const string QuantumAssetId = "idle_spectator_gravestones";
+ /// World skull size - small tile marker, still hover/clickable.
+ public const float SkullBaseScale = 0.055f;
+
+ public sealed class GraveEntry
+ {
+ public long UnitId;
+ public string DisplayName = "";
+ public string MannerLabel = "";
+ public float ExpireAt;
+ public bool Favorite;
+ public float CreatedAt;
+ }
+
+ public sealed class GraveStack
+ {
+ public int TileX;
+ public int TileY;
+ public Vector3 WorldPos;
+ public readonly List Entries = new List();
+ public float LastActivityAt;
+ public bool HasFavorite;
+ /// Kept via Lore Keep button - skips TTL; gold skull.
+ public bool Permanent;
+
+ public long Key => PackKey(TileX, TileY);
+ }
+
+ private static readonly Dictionary Stacks = new Dictionary();
+ private static readonly List EvictScratch = new List(64);
+ private static Sprite _skullSprite;
+ private static bool _assetRegistered;
+ private static Color _tempTint = new Color(0.85f, 0.88f, 0.92f, 0.95f);
+ private static Color _permTint = new Color(0.92f, 0.78f, 0.42f, 0.98f);
+
+ public static int StackCount
+ {
+ get
+ {
+ lock (Stacks)
+ {
+ return Stacks.Count;
+ }
+ }
+ }
+
+ public static void Clear()
+ {
+ lock (Stacks)
+ {
+ Stacks.Clear();
+ }
+
+ }
+
+ public static void Update()
+ {
+ if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled)
+ {
+ if (StackCount > 0)
+ {
+ Clear();
+ }
+
+ return;
+ }
+
+ EnsureQuantumAsset();
+ Prune();
+ GraveHover.Update();
+ // Stack size is in the Lore title (Grave · N); no world count badges.
+ GraveCountBadges.Hide();
+ }
+
+ public static void AddDeath(Actor victim)
+ {
+ if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled || victim == null)
+ {
+ return;
+ }
+
+ if (ModSettings.GravestoneMaxStacks <= 0)
+ {
+ return;
+ }
+
+ long unitId = 0;
+ string name = "Nameless";
+ bool favorite = false;
+ Vector3 pos = Vector3.zero;
+ int tileX = 0;
+ int tileY = 0;
+ try
+ {
+ unitId = victim.getID();
+ name = victim.getName();
+ if (string.IsNullOrEmpty(name))
+ {
+ name = "Nameless";
+ }
+
+ favorite = victim.isFavorite();
+ if (victim.current_tile != null)
+ {
+ tileX = victim.current_tile.x;
+ tileY = victim.current_tile.y;
+ pos = victim.current_tile.posV3;
+ }
+ else
+ {
+ pos = victim.current_position;
+ tileX = Mathf.FloorToInt(pos.x);
+ tileY = Mathf.FloorToInt(pos.y);
+ }
+ }
+ catch
+ {
+ return;
+ }
+
+ if (unitId == 0)
+ {
+ return;
+ }
+
+ string mannerLabel = Chronicle.FormatGraveDeathSubtitle(unitId);
+ AddEntry(tileX, tileY, pos, unitId, name, mannerLabel, favorite);
+ }
+
+ /// Harness / orphan path: place a grave entry at an explicit tile.
+ public static bool ForceAdd(
+ long unitId,
+ string displayName,
+ int tileX,
+ int tileY,
+ Vector3 worldPos,
+ string mannerLabel = "",
+ bool favorite = false)
+ {
+ if (unitId == 0 || ModSettings.GravestoneMaxStacks <= 0)
+ {
+ return false;
+ }
+
+ if (worldPos.sqrMagnitude < 0.0001f)
+ {
+ worldPos = new Vector3(tileX + 0.5f, tileY + 0.5f, 0f);
+ }
+
+ AddEntry(
+ tileX,
+ tileY,
+ worldPos,
+ unitId,
+ string.IsNullOrEmpty(displayName) ? "Nameless" : displayName,
+ mannerLabel ?? "",
+ favorite);
+ return true;
+ }
+
+ public static int EntryCountAt(int tileX, int tileY)
+ {
+ long key = PackKey(tileX, tileY);
+ lock (Stacks)
+ {
+ return Stacks.TryGetValue(key, out GraveStack stack) ? stack.Entries.Count : 0;
+ }
+ }
+
+ public static bool TryGetStack(int tileX, int tileY, out GraveStack stack)
+ {
+ long key = PackKey(tileX, tileY);
+ lock (Stacks)
+ {
+ if (Stacks.TryGetValue(key, out stack))
+ {
+ return true;
+ }
+ }
+
+ stack = null;
+ return false;
+ }
+
+ public static bool TryGetStackAtWorld(Vector3 worldPos, out GraveStack stack)
+ {
+ int tileX = Mathf.FloorToInt(worldPos.x);
+ int tileY = Mathf.FloorToInt(worldPos.y);
+ return TryGetStack(tileX, tileY, out stack);
+ }
+
+ public static GraveStack FindNearestStack(Vector2 worldPos, float maxDist)
+ {
+ float maxSq = maxDist * maxDist;
+ GraveStack best = null;
+ float bestSq = maxSq;
+ lock (Stacks)
+ {
+ foreach (GraveStack stack in Stacks.Values)
+ {
+ float dx = stack.WorldPos.x - worldPos.x;
+ float dy = stack.WorldPos.y - worldPos.y;
+ float sq = dx * dx + dy * dy;
+ if (sq <= bestSq)
+ {
+ bestSq = sq;
+ best = stack;
+ }
+ }
+ }
+
+ return best;
+ }
+
+ /// Newest matching grave entry by display name (any stack).
+ public static bool TryFindEntryByName(string nameNeedle, out GraveEntry entry, out GraveStack stack)
+ {
+ entry = null;
+ stack = null;
+ if (string.IsNullOrEmpty(nameNeedle))
+ {
+ return false;
+ }
+
+ lock (Stacks)
+ {
+ foreach (GraveStack s in Stacks.Values)
+ {
+ for (int i = s.Entries.Count - 1; i >= 0; i--)
+ {
+ GraveEntry e = s.Entries[i];
+ if (e.DisplayName != null
+ && e.DisplayName.IndexOf(nameNeedle, System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ entry = e;
+ stack = s;
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public static void CopyStacks(List into)
+ {
+ into.Clear();
+ lock (Stacks)
+ {
+ foreach (GraveStack stack in Stacks.Values)
+ {
+ into.Add(stack);
+ }
+ }
+ }
+
+ public static void SnapshotEntries(GraveStack stack, List into)
+ {
+ into.Clear();
+ if (stack == null)
+ {
+ return;
+ }
+
+ lock (Stacks)
+ {
+ for (int i = 0; i < stack.Entries.Count; i++)
+ {
+ into.Add(stack.Entries[i]);
+ }
+ }
+ }
+
+ public static Color TempTint => _tempTint;
+
+ public static Color PermTint => _permTint;
+
+ public static bool TrySetStackPermanent(int tileX, int tileY, bool permanent)
+ {
+ long key = PackKey(tileX, tileY);
+ float now = Time.unscaledTime;
+ float ttl = ModSettings.GravestoneTtlSeconds;
+ lock (Stacks)
+ {
+ if (!Stacks.TryGetValue(key, out GraveStack stack))
+ {
+ return false;
+ }
+
+ stack.Permanent = permanent;
+ stack.LastActivityAt = now;
+ for (int i = 0; i < stack.Entries.Count; i++)
+ {
+ stack.Entries[i].ExpireAt = permanent
+ ? float.PositiveInfinity
+ : now + ttl;
+ }
+
+ return true;
+ }
+ }
+
+ public static bool IsStackPermanent(int tileX, int tileY)
+ {
+ long key = PackKey(tileX, tileY);
+ lock (Stacks)
+ {
+ return Stacks.TryGetValue(key, out GraveStack stack) && stack.Permanent;
+ }
+ }
+
+ /// Remove one tile stack (Delete in Lore). Returns false if missing.
+ public static bool TryRemoveStack(int tileX, int tileY)
+ {
+ long key = PackKey(tileX, tileY);
+ lock (Stacks)
+ {
+ return Stacks.Remove(key);
+ }
+ }
+
+ public static Sprite SkullSprite
+ {
+ get
+ {
+ if (_skullSprite == null)
+ {
+ _skullSprite = SpriteTextureLoader.getSprite("ui/Icons/iconSkulls")
+ ?? SpriteTextureLoader.getSprite("ui/Icons/iconDead")
+ ?? SpriteTextureLoader.getSprite("ui/icons/iconSkulls");
+ }
+
+ return _skullSprite;
+ }
+ }
+
+ public static long PackKey(int tileX, int tileY)
+ {
+ return ((long)tileX << 32) ^ (uint)tileY;
+ }
+
+ private static void AddEntry(
+ int tileX,
+ int tileY,
+ Vector3 worldPos,
+ long unitId,
+ string displayName,
+ string mannerLabel,
+ bool favorite)
+ {
+ float now = Time.unscaledTime;
+ float ttl = ModSettings.GravestoneTtlSeconds;
+ long key = PackKey(tileX, tileY);
+
+ lock (Stacks)
+ {
+ if (!Stacks.TryGetValue(key, out GraveStack stack))
+ {
+ stack = new GraveStack
+ {
+ TileX = tileX,
+ TileY = tileY,
+ WorldPos = worldPos.sqrMagnitude > 0.0001f
+ ? worldPos
+ : new Vector3(tileX + 0.5f, tileY + 0.5f, 0f),
+ LastActivityAt = now
+ };
+ Stacks[key] = stack;
+ }
+ else if (worldPos.sqrMagnitude > 0.0001f)
+ {
+ stack.WorldPos = worldPos;
+ }
+
+ float expireAt = stack.Permanent ? float.PositiveInfinity : now + ttl;
+
+ // Refresh existing entry for same unit if re-added.
+ for (int i = 0; i < stack.Entries.Count; i++)
+ {
+ if (stack.Entries[i].UnitId == unitId)
+ {
+ stack.Entries.RemoveAt(i);
+ break;
+ }
+ }
+
+ stack.Entries.Add(new GraveEntry
+ {
+ UnitId = unitId,
+ DisplayName = displayName,
+ MannerLabel = mannerLabel ?? "",
+ ExpireAt = expireAt,
+ Favorite = favorite,
+ CreatedAt = now
+ });
+ stack.LastActivityAt = now;
+ while (stack.Entries.Count > PerStackSoftCap)
+ {
+ stack.Entries.RemoveAt(0);
+ }
+
+ RefreshFavoriteFlag(stack);
+ EnforceStackCapUnlocked();
+ }
+ }
+
+ private static void Prune()
+ {
+ float now = Time.unscaledTime;
+ lock (Stacks)
+ {
+ EvictScratch.Clear();
+ foreach (KeyValuePair kv in Stacks)
+ {
+ GraveStack stack = kv.Value;
+ if (!stack.Permanent)
+ {
+ for (int i = stack.Entries.Count - 1; i >= 0; i--)
+ {
+ if (stack.Entries[i].ExpireAt <= now)
+ {
+ stack.Entries.RemoveAt(i);
+ }
+ }
+ }
+
+ if (stack.Entries.Count == 0)
+ {
+ EvictScratch.Add(kv.Key);
+ }
+ else
+ {
+ RefreshFavoriteFlag(stack);
+ }
+ }
+
+ for (int i = 0; i < EvictScratch.Count; i++)
+ {
+ Stacks.Remove(EvictScratch[i]);
+ }
+
+ EnforceStackCapUnlocked();
+ }
+ }
+
+ private static void EnforceStackCapUnlocked()
+ {
+ int max = ModSettings.GravestoneMaxStacks;
+ if (max <= 0)
+ {
+ Stacks.Clear();
+ return;
+ }
+
+ while (Stacks.Count > max)
+ {
+ long victimKey = 0;
+ float oldest = float.PositiveInfinity;
+ bool foundNonFav = false;
+ foreach (KeyValuePair kv in Stacks)
+ {
+ if (kv.Value.HasFavorite || kv.Value.Permanent)
+ {
+ continue;
+ }
+
+ if (kv.Value.LastActivityAt < oldest)
+ {
+ oldest = kv.Value.LastActivityAt;
+ victimKey = kv.Key;
+ foundNonFav = true;
+ }
+ }
+
+ if (!foundNonFav)
+ {
+ oldest = float.PositiveInfinity;
+ foreach (KeyValuePair kv in Stacks)
+ {
+ if (kv.Value.LastActivityAt < oldest)
+ {
+ oldest = kv.Value.LastActivityAt;
+ victimKey = kv.Key;
+ }
+ }
+ }
+
+ if (victimKey == 0 && Stacks.Count > 0)
+ {
+ foreach (long k in Stacks.Keys)
+ {
+ victimKey = k;
+ break;
+ }
+ }
+
+ if (victimKey == 0)
+ {
+ break;
+ }
+
+ Stacks.Remove(victimKey);
+ }
+ }
+
+ private static void RefreshFavoriteFlag(GraveStack stack)
+ {
+ bool fav = false;
+ for (int i = 0; i < stack.Entries.Count; i++)
+ {
+ if (stack.Entries[i].Favorite)
+ {
+ fav = true;
+ break;
+ }
+ }
+
+ stack.HasFavorite = fav;
+ }
+
+ private static void EnsureQuantumAsset()
+ {
+ if (_assetRegistered)
+ {
+ EnsureGroupSystem();
+ return;
+ }
+
+ try
+ {
+ if (AssetManager.quantum_sprites == null)
+ {
+ return;
+ }
+
+ QuantumSpriteAsset existing = null;
+ try
+ {
+ existing = AssetManager.quantum_sprites.get(QuantumAssetId);
+ }
+ catch
+ {
+ existing = null;
+ }
+
+ if (existing == null)
+ {
+ QuantumSpriteAsset asset = new QuantumSpriteAsset
+ {
+ id = QuantumAssetId,
+ id_prefab = "p_gameSprite",
+ base_scale = SkullBaseScale,
+ render_gameplay = true,
+ render_map = true,
+ default_amount = 64,
+ create_object = (QuantumSpriteAsset _, QuantumSprite q) =>
+ {
+ Sprite sprite = SkullSprite;
+ if (sprite != null)
+ {
+ q.setSprite(sprite);
+ }
+
+ try
+ {
+ q.sprite_renderer.sortingLayerID = SortingLayer.NameToID("Objects");
+ q.sprite_renderer.sortingOrder = 2;
+ }
+ catch
+ {
+ // sorting optional
+ }
+ },
+ draw_call = DrawGraves
+ };
+ AssetManager.quantum_sprites.add(asset);
+ LogService.LogInfo("[IdleSpectator] Registered gravestone QuantumSprite asset");
+ }
+
+ _assetRegistered = true;
+ EnsureGroupSystem();
+ }
+ catch (System.Exception ex)
+ {
+ LogService.LogInfo($"[IdleSpectator] Gravestone QuantumSprite register failed: {ex.Message}");
+ }
+ }
+
+ private static void EnsureGroupSystem()
+ {
+ try
+ {
+ QuantumSpriteAsset asset = AssetManager.quantum_sprites.get(QuantumAssetId);
+ if (asset == null)
+ {
+ return;
+ }
+
+ if (asset.group_system != null)
+ {
+ return;
+ }
+
+ QuantumSpriteGroupSystem system = new GameObject("IdleSpectatorGravesQS")
+ .AddComponent();
+ system.create(asset);
+ asset.group_system = system;
+ asset.group_system.turn_off_renderer = asset.turn_off_renderer;
+ }
+ catch (System.Exception ex)
+ {
+ LogService.LogInfo($"[IdleSpectator] Gravestone group_system init failed: {ex.Message}");
+ }
+ }
+
+ private static void DrawGraves(QuantumSpriteAsset asset)
+ {
+ if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled || asset?.group_system == null)
+ {
+ return;
+ }
+
+ Sprite sprite = SkullSprite;
+ if (sprite == null)
+ {
+ return;
+ }
+
+ // Keep tiny even if an older asset instance kept a larger base_scale.
+ asset.base_scale = SkullBaseScale;
+ long highlightKey = GraveHover.HighlightStackKey;
+ float pulse = 0.55f + 0.45f * Mathf.Abs(Mathf.Sin(Time.unscaledTime * 9f));
+ lock (Stacks)
+ {
+ foreach (GraveStack stack in Stacks.Values)
+ {
+ if (stack.Entries.Count == 0)
+ {
+ continue;
+ }
+
+ QuantumSprite qs = asset.group_system.getNext();
+ if (qs == null)
+ {
+ continue;
+ }
+
+ Vector3 pos = stack.WorldPos;
+ pos.y += 0.12f;
+ float scale = SkullBaseScale;
+ if (stack.Entries.Count > 1)
+ {
+ scale *= Mathf.Min(1.25f, 1f + 0.02f * (stack.Entries.Count - 1));
+ }
+
+ Color baseTint = stack.Permanent ? _permTint : _tempTint;
+ Color tint = baseTint;
+ bool highlight = highlightKey != 0 && stack.Key == highlightKey;
+ if (highlight)
+ {
+ // Bright flicker so hover/selection is obvious on a tiny sprite.
+ Color flash = stack.Permanent
+ ? new Color(1f, 0.95f, 0.55f, 1f)
+ : new Color(1f, 1f, 1f, 1f);
+ tint = Color.Lerp(baseTint, flash, pulse);
+ scale *= 1.12f + 0.1f * pulse;
+ }
+
+ qs.set(ref pos, scale);
+ qs.setSprite(sprite);
+ qs.setColor(ref tint);
+ }
+ }
+ }
+}
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 5f46bc8..8f416cb 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -116,6 +116,9 @@ internal static class HarnessScenarios
case "chronicle":
case "chronicle_smoke":
return ChronicleSmoke();
+ case "gravestones":
+ case "grave_smoke":
+ return GravestoneSmoke();
case "activity":
case "activity_idle_smoke":
return ActivityIdleSmoke();
@@ -216,6 +219,7 @@ internal static class HarnessScenarios
Nested("reg_presentability", "camera_presentability"),
Nested("reg_event_coverage", "event_suite"),
Nested("reg_chronicle", "chronicle_smoke"),
+ Nested("reg_gravestones", "gravestones"),
Nested("reg_activity", "activity_idle_smoke"),
Nested("reg_activity_status", "activity_status_smoke"),
Nested("reg_activity_happiness", "activity_happiness_smoke"),
@@ -2360,11 +2364,13 @@ internal static class HarnessScenarios
Step("ch3", "set_setting", expect: "show_dossier_caption", value: "true"),
Step("ch4", "set_setting", expect: "chronicle_enabled", value: "true"),
Step("ch4b", "chronicle_clear"),
- Step("ch5", "pick_unit", asset: "auto"),
+ // Fresh spawn so prior scenarios (lava / Miracle Born) cannot steal focus.
+ Step("ch5", "spawn", asset: "human", count: 1),
Step("ch6", "spectator", value: "off"),
Step("ch7", "spectator", value: "on"),
Step("ch8", "focus", asset: "auto"),
Step("ch9", "wait", wait: 0.35f),
+ Step("ch9b", "focus", asset: "auto"),
Step("ch10", "assert", expect: "health"),
Step("ch11", "assert", expect: "dossier_contains", value: "auto"),
Step("ch12", "assert", expect: "caption_contains", value: "auto"),
@@ -2399,15 +2405,15 @@ internal static class HarnessScenarios
Step("ch15i", "screenshot", value: "hud-dossier-long-hist.png"),
Step("ch15j", "wait", wait: 0.65f),
- // Death-cause wording samples (victim POV)
+ // Death-cause wording samples (victim POV) - flavored FormatDeathCause lines.
Step("ch16a", "chronicle_death_sample", value: "Age"),
Step("ch16b", "assert", expect: "chronicle_latest_contains", value: "old age"),
Step("ch16c", "chronicle_death_sample", value: "Starvation"),
- Step("ch16d", "assert", expect: "chronicle_latest_contains", value: "Starved"),
+ Step("ch16d", "assert", expect: "chronicle_latest_contains", value: "hunger"),
Step("ch16e", "chronicle_death_sample", value: "Drowning"),
- Step("ch16f", "assert", expect: "chronicle_latest_contains", value: "Drown"),
+ Step("ch16f", "assert", expect: "chronicle_latest_contains", value: "water"),
Step("ch16g", "chronicle_death_sample", value: "Fire"),
- Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "Burned"),
+ Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "ash"),
// Character kills/deaths must stay on character history, not World Memory.
Step("ch16i", "assert", expect: "world_memory_world_only"),
// Soft-cap prune: temporarily lower the cap, flood past it, then restore.
@@ -2423,7 +2429,7 @@ internal static class HarnessScenarios
Step("ch17a", "chronicle_world_force", label: "War: harness_alpha vs harness_beta"),
Step("ch17b", "assert", expect: "chronicle_count", value: "1", label: "world"),
Step("ch17c", "assert", expect: "world_memory_contains", value: "harness_alpha"),
- Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "Burned"),
+ Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "ash"),
Step("ch17e", "assert", expect: "world_memory_age"),
Step("ch17e2", "assert", expect: "world_memory_world_only"),
Step("ch17e3", "assert", expect: "chronicle_memory_capped"),
@@ -2532,7 +2538,7 @@ internal static class HarnessScenarios
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("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"),
@@ -2540,7 +2546,7 @@ internal static class HarnessScenarios
Step("ch18d7h", "assert", expect: "fallen_place_no_follow"),
Step("ch18d8", "screenshot", value: "hud-lore-fallen.png"),
Step("ch18d9", "wait", wait: 0.55f),
- // Fallen slain → living killer focus.
+ // Fallen slain → place/skull first; Killer button follows the living killer.
Step("ch18k1", "lore_back"),
Step("ch18k2", "spawn", asset: "human", count: 1),
Step("ch18k3", "focus", asset: "auto"),
@@ -2558,14 +2564,18 @@ internal static class HarnessScenarios
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("ch18k10", "assert", expect: "fallen_focus", value: "place"),
+ Step("ch18k10b", "assert", expect: "fallen_place_no_follow"),
+ 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("ch18k11g", "assert", expect: "fallen_killer_banner"),
+ Step("ch18k11f", "lore_focus_killer"),
+ Step("ch18k11g", "wait", wait: 0.2f),
+ Step("ch18k11h", "assert", expect: "fallen_focus", value: "killer"),
+ Step("ch18k11i", "assert", expect: "fallen_killer_camera"),
+ Step("ch18k11j", "assert", expect: "fallen_killer_banner"),
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.
@@ -2665,6 +2675,113 @@ internal static class HarnessScenarios
};
}
+ ///
+ /// Gravestone stacks: skull → Lore stack list/search/Keep, place focus, Killer, cap.
+ ///
+ private static List GravestoneSmoke()
+ {
+ return new List
+ {
+ Step("gv0", "wait_world"),
+ Step("gv1", "dismiss_windows"),
+ Step("gv2", "fast_timing", value: "true"),
+ Step("gv3", "set_setting", expect: "gravestones_enabled", value: "true"),
+ Step("gv5", "set_setting", expect: "gravestone_max_stacks", value: "1000"),
+ Step("gv6", "grave_clear"),
+ Step("gv7", "spawn", asset: "human", count: 1),
+ Step("gv8", "focus", asset: "auto"),
+ Step("gv9", "wait", wait: 0.25f),
+ // Stack three named deaths on the focus tile.
+ Step("gv10", "grave_force", value: "GraveAlice", count: 1),
+ Step("gv11", "grave_force", value: "GraveBob", count: 1),
+ Step("gv12", "grave_force", value: "GraveCara", count: 1),
+ Step("gv13", "assert", expect: "grave_stacks", count: 1, label: "eq"),
+ Step("gv14", "assert", expect: "grave_entries", count: 3),
+ Step("gv15", "grave_open"),
+ Step("gv16", "wait", wait: 0.15f),
+ Step("gv17", "assert", expect: "grave_stack_scope", value: "true"),
+ Step("gv17b", "assert", expect: "lore_detail", value: "false"),
+ Step("gv17c", "assert", expect: "grave_search_layout"),
+ Step("gv18", "grave_search", value: "Bob"),
+ Step("gv19", "wait", wait: 0.1f),
+ Step("gv20", "assert", expect: "grave_search_filtered", count: 1),
+ Step("gv21", "grave_search", value: ""),
+ // Per-grave Keep / Del in Lore chrome (not Mod Settings).
+ Step("gv21b", "grave_set_permanent", value: "true"),
+ Step("gv21c", "assert", expect: "grave_permanent", value: "true"),
+ Step("gv22", "grave_open_lore"),
+ Step("gv23", "wait", wait: 0.25f),
+ Step("gv24", "assert", expect: "lore_detail", value: "true"),
+ Step("gv25", "assert", expect: "fallen_focus", value: "place"),
+ Step("gv26", "assert", expect: "fallen_place_no_follow"),
+ // Outside click / dismiss closes Lore (+ grave scope) together.
+ Step("gv26b", "inspect_dismiss"),
+ Step("gv26c", "assert", expect: "grave_stack_scope", value: "false"),
+ Step("gv26d", "assert", expect: "lore_detail", value: "false"),
+ // Delete removes the skull and closes Lore.
+ Step("gv26e0", "grave_clear"),
+ Step("gv26e", "grave_force", value: "GraveDel", count: 1),
+ Step("gv26f", "grave_open"),
+ Step("gv26g", "assert", expect: "grave_stack_scope", value: "true"),
+ Step("gv26h", "grave_delete"),
+ Step("gv26i", "assert", expect: "grave_stacks", value: "0", label: "eq"),
+ Step("gv26j", "assert", expect: "grave_stack_scope", value: "false"),
+ // Killer path: orphan with living killer, place-open then Focus Killer button.
+ Step("gv27", "lore_back"),
+ Step("gv28", "lore_close"),
+ Step("gv28b", "grave_clear"),
+ Step("gv29", "spawn", asset: "human", count: 1),
+ Step("gv30", "focus", asset: "auto"),
+ Step("gv31", "wait", wait: 0.2f),
+ Step(
+ "gv32",
+ "chronicle_orphan",
+ label: "GraveSlain",
+ asset: "skeleton",
+ value: "Murder",
+ count: 1,
+ expect: "Weapon",
+ tier: "killer"),
+ Step("gv33", "grave_open_lore", value: "GraveSlain"),
+ Step("gv34", "wait", wait: 0.2f),
+ Step("gv34b", "assert", expect: "grave_death_subtitle", value: "Slain by"),
+ Step("gv35", "assert", expect: "fallen_focus", value: "place"),
+ Step("gv36", "lore_focus_killer"),
+ Step("gv37", "wait", wait: 0.2f),
+ Step("gv38", "assert", expect: "fallen_focus", value: "killer"),
+ Step("gv39", "assert", expect: "fallen_killer_camera"),
+ // Cap eviction: shrink max stacks and flood tiles via orphans at offset positions.
+ Step("gv40", "lore_close"),
+ Step("gv41", "set_setting", expect: "gravestone_max_stacks", value: "2"),
+ Step("gv42", "grave_clear"),
+ Step("gv43", "grave_force", value: "CapA", count: 1, tier: "0,0"),
+ Step("gv44", "grave_force", value: "CapB", count: 1, tier: "2,0"),
+ Step("gv45", "grave_force", value: "CapC", count: 1, tier: "4,0"),
+ Step("gv46", "assert", expect: "grave_stacks", count: 2, label: "eq"),
+ Step("gv47", "set_setting", expect: "gravestone_max_stacks", value: "1000"),
+ Step("gv48", "grave_clear"),
+ Step("gv50", "grave_force", value: "PermOne", count: 1),
+ Step("gv50b", "grave_open"),
+ Step("gv50c", "grave_set_permanent", value: "true"),
+ Step("gv50d", "assert", expect: "grave_permanent", value: "true"),
+ Step("gv51", "assert", expect: "grave_stacks", count: 1, label: "min"),
+ Step("gv52", "grave_set_permanent", value: "false"),
+ // Live death → gravestone
+ Step("gv53", "grave_clear"),
+ Step("gv54", "spawn", asset: "sheep", count: 1),
+ Step("gv55", "focus", asset: "auto"),
+ Step("gv56", "wait", wait: 0.2f),
+ Step("gv57", "kill_focus"),
+ Step("gv58", "wait", wait: 0.35f),
+ Step("gv59", "assert", expect: "grave_stacks", count: 1, label: "min"),
+ Step("gv60", "grave_clear"),
+ Step("gv61", "screenshot", value: "hud-gravestones.png"),
+ Step("gv62", "wait", wait: 0.4f),
+ Step("gv63", "fast_timing", value: "false"),
+ Step("gv64", "snapshot"),
+ };
+ }
+
private static HarnessCommand Nested(string id, string scenario)
{
return Step(id, "scenario", value: scenario, expect: "keep");
diff --git a/IdleSpectator/InspectUi.cs b/IdleSpectator/InspectUi.cs
new file mode 100644
index 0000000..c54600e
--- /dev/null
+++ b/IdleSpectator/InspectUi.cs
@@ -0,0 +1,62 @@
+using UnityEngine;
+
+namespace IdleSpectator;
+
+///
+/// Shared dismiss for skull-opened Lore + paused dossier pin.
+///
+public static class InspectUi
+{
+ /// True after a skull open until .
+ public static bool GraveSessionActive { get; private set; }
+
+ public static void MarkGraveSession()
+ {
+ GraveSessionActive = true;
+ }
+
+ public static void DismissAll()
+ {
+ GraveSessionActive = false;
+ ChronicleHud.ClearGraveStackScope();
+ if (ChronicleHud.DetailUnitId != 0)
+ {
+ ChronicleHud.ClearUnitDetail();
+ }
+
+ if (ChronicleHud.Visible)
+ {
+ ChronicleHud.SetVisible(false);
+ }
+
+ WatchCaption.ClearPausePin();
+ WatchCaption.ClearStatusBanner();
+ }
+
+ public static void Update()
+ {
+ if (!GraveSessionActive)
+ {
+ return;
+ }
+
+ if (!InputHelpers.GetMouseButtonDown(0))
+ {
+ return;
+ }
+
+ if (Time.unscaledTime < ChronicleHud.IgnoreDismissUntil)
+ {
+ return;
+ }
+
+ if (ChronicleHud.IsPointerOverPanel()
+ || WatchCaption.IsPointerOverPanel())
+ {
+ return;
+ }
+
+ // Click outside Lore / dossier closes the skull inspect session.
+ DismissAll();
+ }
+}
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index d28471c..589e78a 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -2939,6 +2939,29 @@ public static class InterestDirector
&& !ChronicleHud.IsPointerOverPanel()
&& (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1)))
{
+ // Grave skull click opens Lore; do not treat as idle pause.
+ if (InputHelpers.GetMouseButtonDown(0) && GraveMarkers.StackCount > 0)
+ {
+ try
+ {
+ WorldTile tile = World.world.getMouseTilePosCachedFrame() ?? World.world.getMouseTilePos();
+ if (tile != null && GraveMarkers.TryGetStack(tile.x, tile.y, out _))
+ {
+ return false;
+ }
+
+ Vector2 mouse = World.world.getMousePos();
+ if (GraveMarkers.FindNearestStack(mouse, 1.15f) != null)
+ {
+ return false;
+ }
+ }
+ catch
+ {
+ // fall through to manual pause
+ }
+ }
+
return true;
}
diff --git a/IdleSpectator/Locales/en.json b/IdleSpectator/Locales/en.json
index 4a0b766..cbb6e91 100644
--- a/IdleSpectator/Locales/en.json
+++ b/IdleSpectator/Locales/en.json
@@ -5,5 +5,8 @@
"show_dossier_caption": "Show creature dossier caption while watching",
"chronicle_enabled": "Enable Lore panel (L) - World Memory + Character History",
"debug_state_probe": "Log focus/power-bar state (for debugging)",
+ "gravestones_enabled": "Show gravestone skulls on death",
+ "gravestone_max_stacks": "Max gravestone stacks on the map",
+ "gravestone_ttl_seconds": "Grave lifetime in seconds (Keep on a grave skips this)",
"IdleSpectator Chronicle": "Lore"
}
diff --git a/IdleSpectator/LoreProse.cs b/IdleSpectator/LoreProse.cs
index 52670f7..ea1d311 100644
--- a/IdleSpectator/LoreProse.cs
+++ b/IdleSpectator/LoreProse.cs
@@ -11,9 +11,13 @@ public static class LoreProse
private static readonly Regex KillPattern =
new Regex(@"^Killed (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
private static readonly Regex KilledByPattern =
- new Regex(@"^Killed by (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
+ new Regex(
+ @"^(?:Killed by|Was cut down by|Was slain by) (.+) \(([^)]+)\)$",
+ RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private static readonly Regex EatenByPattern =
- new Regex(@"^Eaten by (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
+ new Regex(
+ @"^(?:Eaten by|Was torn apart and eaten by|Devoured by) (.+)(?: \(([^)]+)\))?$",
+ RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private static readonly Regex LoversPattern =
new Regex(@"^Became lovers with (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex FriendPattern =
@@ -107,42 +111,44 @@ public static class LoreProse
return "Devoured by " + eaten.Groups[1].Value;
}
+ // New FormatDeathCause lines are already flavored - keep them.
+ // Legacy strings still get a soft rewrite.
switch (raw)
{
case "Died of old age":
- return "Passed from old age";
+ return "Faded gently into old age";
case "Starved to death":
- return "Starved in lean seasons";
+ return "Wasted away from hunger";
case "Burned to death":
- return "Burned to ash";
+ return "Burned until only ash remained";
case "Burned in lava":
- return "Claimed by lava";
+ return "Was swallowed by lava";
case "Drowned":
- return "Drowned beneath the waves";
+ return "Sank beneath the water and never rose";
case "Died of poison":
- return "Succumbed to poison";
+ return "Succumbed as poison flooded their veins";
case "Died of plague":
- return "Taken by plague";
+ return "Was claimed by a spreading plague";
case "Died of infection":
- return "Taken by infection";
+ return "Rotted from within as infection took hold";
case "Died of a tumor":
- return "Claimed by a tumor";
+ return "Was consumed by a growing tumor";
case "Dissolved in acid":
- return "Dissolved in acid";
+ return "Dissolved screaming in acid";
case "Died of ash fever":
- return "Taken by ash fever";
+ return "Burned out from ash fever";
case "Fell to their death":
- return "Fell to their death";
+ return "Fell from a height and broke upon the ground";
case "Died in an explosion":
- return "Perished in an explosion";
+ return "Was torn apart in an explosion";
case "Struck down by divine power":
- return "Struck down by divine power";
+ return "Was smote by divine power";
case "Was eaten":
- return "Was eaten";
+ return "Was torn apart and eaten";
case "Killed in combat":
- return "Fell in combat";
+ return "Fell in bitter combat";
case "Transformed away":
- return "Transformed away";
+ return "Transformed away and left no body behind";
case "Died":
return "Met their end";
default:
diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs
index 31259b9..eec5957 100644
--- a/IdleSpectator/Main.cs
+++ b/IdleSpectator/Main.cs
@@ -107,6 +107,8 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
WatchCaption.Update();
Chronicle.Update();
+ GraveMarkers.Update();
+ InspectUi.Update();
FocusRelationshipArrows.PinFocusActor();
StateProbe.Update();
AutoTest.Update();
diff --git a/IdleSpectator/ModSettings.cs b/IdleSpectator/ModSettings.cs
index ffcc099..fe69235 100644
--- a/IdleSpectator/ModSettings.cs
+++ b/IdleSpectator/ModSettings.cs
@@ -16,6 +16,12 @@ public static class ModSettings
public const string ShowDossierCaptionId = "show_dossier_caption";
public const string ChronicleEnabledId = "chronicle_enabled";
public const string DebugStateProbeId = "debug_state_probe";
+ public const string GravestonesEnabledId = "gravestones_enabled";
+ public const string GravestoneMaxStacksId = "gravestone_max_stacks";
+ public const string GravestoneTtlSecondsId = "gravestone_ttl_seconds";
+
+ public const int DefaultGravestoneMaxStacks = 1000;
+ public const int DefaultGravestoneTtlSeconds = 300;
private static ModConfig _config;
@@ -29,6 +35,14 @@ public static class ModSettings
public static bool DebugStateProbe => GetBool(DebugStateProbeId, defaultValue: true);
+ public static bool GravestonesEnabled => GetBool(GravestonesEnabledId, defaultValue: true);
+
+ public static int GravestoneMaxStacks =>
+ GetInt(GravestoneMaxStacksId, DefaultGravestoneMaxStacks, min: 0, max: 2000);
+
+ public static int GravestoneTtlSeconds =>
+ GetInt(GravestoneTtlSecondsId, DefaultGravestoneTtlSeconds, min: 30, max: 1800);
+
public static ModConfig Load(ModDeclare declare)
{
string persistentPath = Path.Combine(Paths.ModsConfigPath, declare.UID + ".config");
@@ -52,6 +66,11 @@ public static class ModSettings
SpectatorMode.SetActive(false);
LogService.LogInfo("[IdleSpectator] Disabled via settings");
}
+
+ if (!enabled)
+ {
+ GraveMarkers.Clear();
+ }
}
public static bool TrySetBool(string id, bool value)
@@ -80,6 +99,26 @@ public static class ModSettings
}
}
+ public static bool TrySetInt(string id, int value)
+ {
+ if (_config == null || string.IsNullOrEmpty(id))
+ {
+ return false;
+ }
+
+ try
+ {
+ ModConfigItem item = _config[GroupId][id];
+ item.SetValue(value);
+ return true;
+ }
+ catch (System.Exception ex)
+ {
+ LogService.LogInfo($"[IdleSpectator] TrySetInt failed for {id}: {ex.Message}");
+ return false;
+ }
+ }
+
private static bool GetBool(string id, bool defaultValue)
{
if (_config == null)
@@ -103,4 +142,38 @@ public static class ModSettings
return defaultValue;
}
+
+ private static int GetInt(string id, int defaultValue, int min, int max)
+ {
+ if (_config == null)
+ {
+ return UnityEngine.Mathf.Clamp(defaultValue, min, max);
+ }
+
+ try
+ {
+ ModConfigItem item = _config[GroupId][id];
+ object value = item.GetValue();
+ if (value is int i)
+ {
+ return UnityEngine.Mathf.Clamp(i, min, max);
+ }
+
+ if (value is long l)
+ {
+ return UnityEngine.Mathf.Clamp((int)l, min, max);
+ }
+
+ if (value is float f)
+ {
+ return UnityEngine.Mathf.Clamp(UnityEngine.Mathf.RoundToInt(f), min, max);
+ }
+ }
+ catch
+ {
+ // Fall through to default.
+ }
+
+ return UnityEngine.Mathf.Clamp(defaultValue, min, max);
+ }
}
diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs
index eec24b0..7a08884 100644
--- a/IdleSpectator/UnitDossier.cs
+++ b/IdleSpectator/UnitDossier.cs
@@ -96,7 +96,7 @@ public sealed class UnitDossier
string mannerLabel = Chronicle.DeathMannerLabel(manner);
d.ReasonLine = string.IsNullOrEmpty(mannerLabel) || manner == DeathManner.None
? "Fallen"
- : char.ToUpperInvariant(mannerLabel[0]) + mannerLabel.Substring(1);
+ : mannerLabel;
d.DetailLine = "";
d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine);
return d;
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index f6eb192..41473b6 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -705,7 +705,7 @@ public static class WatchCaption
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
}
- private static void ClearStatusBanner()
+ public static void ClearStatusBanner()
{
_statusBanner = "";
_statusBannerUntil = 0f;
diff --git a/IdleSpectator/default_config.json b/IdleSpectator/default_config.json
index 828af55..ed10190 100644
--- a/IdleSpectator/default_config.json
+++ b/IdleSpectator/default_config.json
@@ -25,6 +25,25 @@
"Id": "debug_state_probe",
"Type": "SWITCH",
"BoolVal": true
+ },
+ {
+ "Id": "gravestones_enabled",
+ "Type": "SWITCH",
+ "BoolVal": true
+ },
+ {
+ "Id": "gravestone_max_stacks",
+ "Type": "INT_SLIDER",
+ "IntVal": 1000,
+ "MinIntVal": 0,
+ "MaxIntVal": 2000
+ },
+ {
+ "Id": "gravestone_ttl_seconds",
+ "Type": "INT_SLIDER",
+ "IntVal": 300,
+ "MinIntVal": 30,
+ "MaxIntVal": 1800
}
]
}
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 5adb4df..ef8891f 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.27.6",
- "description": "AFK Idle Spectator (I) + Lore (L). Variety valve, tip hysteresis, fight balance, lethal/lover parks.",
+ "version": "0.28.11",
+ "description": "AFK Idle Spectator (I) + Lore (L). Flavored death lines, full-width grave search, no skull counts.",
"GUID": "com.dazed.idlespectator"
}