Implement gravestone features and enhance Lore interactions.

- Add gravestone management with max stack and lifetime settings
- Introduce new commands for grave interactions in scenarios
- Update Lore handling to include gravestone context and details
- Refactor death cause formatting for improved readability
- Increment version to 0.28.11 in mod.json
This commit is contained in:
DazedAnon 2026-07-17 16:32:10 -05:00
parent 750a303d89
commit c11b87aa2e
19 changed files with 2426 additions and 130 deletions

1
.gitignore vendored
View file

@ -49,6 +49,7 @@ desktop.ini
# Local tooling (portable SDKs, decomp dumps, etc.)
.dotnet/
.local/
.tmp-reflect/
*.decompiled.cs
# local test arm files

View file

@ -52,6 +52,7 @@ public static class ActorChroniclePatches
}
Chronicle.NoteDeath(__instance, pType, __state);
GraveMarkers.AddDeath(__instance);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.becomeLoversWith))]

View file

@ -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 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);
bool ok = ModSettings.TrySetBool(key, val);
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))

View file

@ -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;
}
/// <summary>Capitalized manner label for Lore / dossier (matches grave subtitle style).</summary>
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";
}
}
/// <summary>
/// Short capitalized grave / Fallen-list flavor, AttackType-specific when known.
/// </summary>
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);
}
/// <summary>Punchy Title-Case manner for a known <see cref="AttackType"/>.</summary>
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;
}
/// <summary>Newest death event for a subject, if any.</summary>
public static ChronicleEntry LatestDeathEntry(long subjectId)
{
@ -2038,10 +2162,12 @@ public static class Chronicle
}
/// <summary>
/// Camera for a dead subject: living killer if possible, else last death / history position.
/// Camera for a dead subject.
/// When <paramref name="preferPlace"/> is true (default), pan to death place / skull (no unit follow).
/// Killer follow is only via <see cref="FocusFallenKiller"/>.
/// Dossier always shows the fallen unit's last living state (not the killer).
/// </summary>
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;
}
/// <summary>Living killer for a fallen subject, or null.</summary>
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;
}
/// <summary>Camera follow on the living killer of a fallen Lore subject.</summary>
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;

View file

@ -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<GameObject> Rows = new List<GameObject>();
private static readonly List<GraveMarkers.GraveEntry> GraveEntriesScratch =
new List<GraveMarkers.GraveEntry>(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;
/// <summary>Lore is browsing one tile's grave stack (from a skull click).</summary>
private static bool _graveStackActive;
private static int _graveTileX;
private static int _graveTileY;
private static bool _openingGraveStack;
private static float _ignoreDismissUntil;
/// <summary>Rows actually built in the last character detail rebuild (harness).</summary>
public static int LastDetailHistoryRows { get; private set; }
@ -109,6 +124,46 @@ public static class ChronicleHud
public static long DetailUnitId => _detailUnitId;
/// <summary>True when Lore is scoped to a gravestone stack (skull open).</summary>
public static bool GraveStackScopeActive => _graveStackActive;
public static int GraveStackTileX => _graveTileX;
public static int GraveStackTileY => _graveTileY;
/// <summary>Filtered stack list rows from the last grave-stack rebuild (harness).</summary>
public static int GraveStackFilteredCount { get; private set; }
/// <summary>
/// Grave stack search should span the tools row (Keep/Del live in the title band).
/// </summary>
public static bool GraveSearchFillsTools
{
get
{
if (!_graveStackActive || _searchField == null || _charTools == null)
{
return false;
}
RectTransform searchRt = _searchField.GetComponent<RectTransform>();
RectTransform toolsRt = _charTools.GetComponent<RectTransform>();
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;
}
}
/// <summary>Ignore outside-click dismiss briefly after skull open / Keep.</summary>
public static float IgnoreDismissUntil => _ignoreDismissUntil;
private static bool IsCastTab => _tab == LoreTab.Living || _tab == LoreTab.Fallen;
/// <summary>True when Lore detail was opened via L/books and should track idle focus.</summary>
@ -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<Text>();
PlaceTitleTool(_keepGraveBtn, -58f, 34f);
_keepGraveBtn.SetActive(false);
_deleteGraveBtn = BuildTabButton(
_root.transform, "DeleteGrave", "Del", () => { DeleteActiveGraveStack(); });
_deleteGraveLabel = _deleteGraveBtn.transform.Find("Label")?.GetComponent<Text>();
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<RectTransform>();
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<Text>();
RectTransform backRt = _backBtn.GetComponent<RectTransform>();
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<Text>();
RectTransform killerRt = _focusKillerBtn.GetComponent<RectTransform>();
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<RectTransform>();
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
/// </summary>
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);
}
/// <summary>Open Lore scoped to one gravestone tile (skull click / harness).</summary>
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();
}
/// <summary>Remove the open grave stack from the world and close Lore.</summary>
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<Image>() : 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);
}
}
/// <summary>
/// Show one unit's full chronicle in Lore (Living or Fallen tab).
/// <paramref name="followFocus"/> 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.
/// </summary>
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);
}
/// <summary>Harness / Lore chrome: follow the living killer of the open fallen subject.</summary>
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
if (refreshRt != null)
{
// Search | Refresh | Fav on both Living and Fallen.
refreshRt.anchoredPosition = new Vector2(-19f, 0f);
}
}
@ -976,14 +1264,63 @@ public static class ChronicleHud
{
_backBtn.SetActive(detail);
if (_backBtnLabel != null)
{
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<RectTransform>();
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<ActivityEntry> 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);

View file

@ -0,0 +1,88 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Screen-space count badges over multi-entry grave stacks (pooled, on-screen only).
/// </summary>
public static class GraveCountBadges
{
private const int PoolSize = 64;
private static readonly List<GraveMarkers.GraveStack> Scratch = new List<GraveMarkers.GraveStack>(256);
private static readonly List<Badge> Pool = new List<Badge>(PoolSize);
private static Transform _root;
private static bool _built;
private sealed class Badge
{
public GameObject Go;
public RectTransform Rt;
public Text Label;
}
/// <summary>Badges retired - stack size lives in Lore title. Kept so old pools hide cleanly.</summary>
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<RectTransform>();
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<RectTransform>();
rt.sizeDelta = new Vector2(14f, 10f);
Image img = go.GetComponent<Image>();
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<RectTransform>();
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;
}
}

108
IdleSpectator/GraveHover.cs Normal file
View file

@ -0,0 +1,108 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// World skull hover highlight + click → Lore (no separate grave window).
/// </summary>
public static class GraveHover
{
private const float HitRadius = 1.35f;
/// <summary>Stack key under mouse - drives skull hover flicker.</summary>
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;
}
}
}

View file

@ -0,0 +1,688 @@
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Session gravestone stacks keyed by world tile. Drawn via QuantumSprites; click opens Lore.
/// </summary>
public static class GraveMarkers
{
public const int PerStackSoftCap = 40;
public const string QuantumAssetId = "idle_spectator_gravestones";
/// <summary>World skull size - small tile marker, still hover/clickable.</summary>
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<GraveEntry> Entries = new List<GraveEntry>();
public float LastActivityAt;
public bool HasFavorite;
/// <summary>Kept via Lore Keep button - skips TTL; gold skull.</summary>
public bool Permanent;
public long Key => PackKey(TileX, TileY);
}
private static readonly Dictionary<long, GraveStack> Stacks = new Dictionary<long, GraveStack>();
private static readonly List<long> EvictScratch = new List<long>(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);
}
/// <summary>Harness / orphan path: place a grave entry at an explicit tile.</summary>
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;
}
/// <summary>Newest matching grave entry by display name (any stack).</summary>
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<GraveStack> into)
{
into.Clear();
lock (Stacks)
{
foreach (GraveStack stack in Stacks.Values)
{
into.Add(stack);
}
}
}
public static void SnapshotEntries(GraveStack stack, List<GraveEntry> 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;
}
}
/// <summary>Remove one tile stack (Delete in Lore). Returns false if missing.</summary>
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<long, GraveStack> 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<long, GraveStack> 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<long, GraveStack> 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<QuantumSpriteGroupSystem>();
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);
}
}
}
}

View file

@ -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
};
}
/// <summary>
/// Gravestone stacks: skull → Lore stack list/search/Keep, place focus, Killer, cap.
/// </summary>
private static List<HarnessCommand> GravestoneSmoke()
{
return new List<HarnessCommand>
{
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");

View file

@ -0,0 +1,62 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Shared dismiss for skull-opened Lore + paused dossier pin.
/// </summary>
public static class InspectUi
{
/// <summary>True after a skull open until <see cref="DismissAll"/>.</summary>
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();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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