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