From 8a4314be6fe3f51ee2d1b4d16aa8700378b4372f Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Fri, 17 Jul 2026 16:47:17 -0500 Subject: [PATCH] Enhance Lore interactions and improve dismiss behavior. - Allow multiple named entries for grave force command on the same tile - Update dismiss behavior to clear Lore and dossier while keeping idle off - Introduce new methods for managing dismiss grace period - Refactor focus handling for fallen subjects and their killers - Increment version to 0.28.13 in mod.json --- IdleSpectator/AgentHarness.cs | 83 +++++++++++++++++++++++++------ IdleSpectator/Chronicle.cs | 39 ++++++--------- IdleSpectator/ChronicleHud.cs | 15 ++++-- IdleSpectator/HarnessScenarios.cs | 22 ++++++-- IdleSpectator/InspectUi.cs | 65 +++++++++++++++++++++--- IdleSpectator/WatchCaption.cs | 8 +++ IdleSpectator/mod.json | 4 +- 7 files changed, 181 insertions(+), 55 deletions(-) diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 51f3576..cffc6c5 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -1846,9 +1846,16 @@ public static class AgentHarness 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); + // value=name or "A,B,C" (same tile), count=entries, tier="dx,dy" offset from focus/camera. + // Comma names / count>1 share one captured tile so a walking focus cannot split the stack. + string rawName = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "GraveDummy"; + string[] named = rawName.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < named.Length; i++) + { + named[i] = named[i].Trim(); + } + + int n = named.Length > 1 ? named.Length : Math.Max(1, cmd.count); Vector3 pos = Vector3.zero; if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) { @@ -1882,7 +1889,9 @@ public static class AgentHarness int before = GraveMarkers.EntryCountAt(tx, ty); for (int i = 0; i < n; i++) { - string nm = n > 1 ? gName + i : gName; + string nm = named.Length > 1 + ? named[i] + : (n > 1 ? rawName + i : rawName); long id = Chronicle.ForceOrphanHistory( nm, !string.IsNullOrEmpty(cmd.asset) ? cmd.asset : "human", @@ -2143,12 +2152,25 @@ public static class AgentHarness case "inspect_dismiss": { InspectUi.DismissAll(); - _cmdOk++; + bool cleared = !SpectatorMode.Active + && !Chronicle.HudVisible + && !WatchCaption.Visible + && !ChronicleHud.GraveStackScopeActive + && !InspectUi.GraveSessionActive; + if (cleared) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + Emit( cmd, - ok: true, + cleared, detail: - $"graveSession={InspectUi.GraveSessionActive} lore={Chronicle.HudVisible} scope={ChronicleHud.GraveStackScopeActive}"); + $"cleared={cleared} idle={SpectatorMode.Active} lore={Chronicle.HudVisible} dossier={WatchCaption.Visible} scope={ChronicleHud.GraveStackScopeActive} graveSession={InspectUi.GraveSessionActive}"); break; } @@ -6127,6 +6149,17 @@ public static class AgentHarness switch (expect) { + case "inspect_cleared": + { + pass = !SpectatorMode.Active + && !Chronicle.HudVisible + && !WatchCaption.Visible + && !ChronicleHud.GraveStackScopeActive + && !InspectUi.GraveSessionActive; + detail = + $"idle={SpectatorMode.Active} lore={Chronicle.HudVisible} dossier={WatchCaption.Visible} scope={ChronicleHud.GraveStackScopeActive} graveSession={InspectUi.GraveSessionActive}"; + break; + } case "idle": case "spectator": { @@ -7160,10 +7193,32 @@ public static class AgentHarness ChronicleEntry latest = Chronicle.Latest; string line = latest != null ? latest.Line : ""; string display = latest != null ? latest.DisplayLine : ""; - pass = latest != null - && !string.IsNullOrEmpty(needle) - && line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0; - detail = $"latest='{display}' needle='{needle}' date='{(latest != null ? latest.DateLabel : "")}'"; + bool inLatest = latest != null + && !string.IsNullOrEmpty(needle) + && line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0; + // Live lover/friend patches can append after a death sample in the same wait; + // still pass when the needle exists on the focus subject's recent history. + bool inRecent = false; + if (!inLatest && !string.IsNullOrEmpty(needle)) + { + IReadOnlyList snap = Chronicle.SnapshotForFocus(); + for (int i = snap.Count - 1; i >= 0 && i >= snap.Count - 8; i--) + { + ChronicleEntry e = snap[i]; + if (e != null + && !string.IsNullOrEmpty(e.Line) + && e.Line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0) + { + inRecent = true; + display = e.DisplayLine; + break; + } + } + } + + pass = inLatest || inRecent; + detail = + $"latest='{(latest != null ? latest.DisplayLine : "")}' hit='{display}' needle='{needle}' date='{(latest != null ? latest.DateLabel : "")}' recent={inRecent}"; break; } case "chronicle_latest_dated": @@ -9096,10 +9151,10 @@ public static class AgentHarness pass = killerTag && killerId != 0 && focusId == killerId - && WatchCaption.CurrentUnitId != 0 - && WatchCaption.CurrentUnitId != focusId; + && WatchCaption.CurrentUnitId == focusId + && DossierAvatar.ShowingLiveAvatar; detail = - $"fallenFocus={detailStr} focusId={focusId} dossierId={WatchCaption.CurrentUnitId}"; + $"fallenFocus={detailStr} focusId={focusId} dossierId={WatchCaption.CurrentUnitId} avatarLive={DossierAvatar.ShowingLiveAvatar}"; break; } case "lore_zoom_blocked": diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index 6870463..0c2b9fe 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -2163,9 +2163,8 @@ public static class Chronicle /// /// 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). + /// When is true (default), pan to death place / skull (no unit follow) + /// and bind the dossier to the fallen unit. Killer follow is only via . /// public static bool FocusFallenSubject(long unitId, bool preferPlace = true) { @@ -2176,27 +2175,23 @@ public static class Chronicle return false; } - UnitDossier fallen = ResolveFallenDossier(unitId); - if (fallen != null) - { - WatchCaption.PinWhilePaused(); - WatchCaption.SetFromDossier(fallen); - } - ChronicleEntry death = LatestDeathEntry(unitId); if (!preferPlace && death != null && death.OtherId != 0) { Actor killer = FindUnitById(death.OtherId); if (killer != null && killer.isAlive()) { - CameraDirector.FocusUnit(killer, updateCaption: false); - LastFallenFocusDetail = "killer:" + death.OtherId; - LogService.LogInfo( - $"[IdleSpectator][LORE] fallen focus killer id={death.OtherId} victim={unitId}"); - return true; + return FocusFallenKiller(unitId); } } + UnitDossier fallen = ResolveFallenDossier(unitId); + if (fallen != null) + { + WatchCaption.PinWhilePaused(); + WatchCaption.SetFromDossier(fallen); + } + // Place / skull path: never keep following a living unit (e.g. prior killer focus). CameraDirector.ClearFollow(); if (TryResolveFallenPlace(unitId, death, out Vector3 pos)) @@ -2235,7 +2230,10 @@ public static class Chronicle return killer; } - /// Camera follow on the living killer of a fallen Lore subject. + /// + /// Camera follow + dossier on the living killer of a fallen Lore subject. + /// Lore detail stays on the fallen archive; only the idle dossier retargets. + /// public static bool FocusFallenKiller(long unitId) { Actor killer = FindLivingKillerFor(unitId); @@ -2244,14 +2242,9 @@ public static class Chronicle return false; } - UnitDossier fallen = ResolveFallenDossier(unitId); - if (fallen != null) - { - WatchCaption.PinWhilePaused(); - WatchCaption.SetFromDossier(fallen); - } - CameraDirector.FocusUnit(killer, updateCaption: false); + WatchCaption.PinWhilePaused(); + WatchCaption.SetFromActor(killer); LastFallenFocusDetail = "killer:" + killer.getID(); LastFallenFocusPosition = Vector3.zero; LogService.LogInfo( diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index a9dde0a..6ce846b 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -161,9 +161,15 @@ public static class ChronicleHud } } - /// Ignore outside-click dismiss briefly after skull open / Keep. + /// Ignore outside-click dismiss briefly after Lore open / Keep. public static float IgnoreDismissUntil => _ignoreDismissUntil; + /// Arm a short grace so the open click does not instantly dismiss Lore. + public static void ArmDismissGrace(float seconds = 0.4f) + { + _ignoreDismissUntil = Time.unscaledTime + Mathf.Max(0.05f, seconds); + } + private static bool IsCastTab => _tab == LoreTab.Living || _tab == LoreTab.Fallen; /// True when Lore detail was opened via L/books and should track idle focus. @@ -748,6 +754,7 @@ public static class ChronicleHud _root.SetActive(visible); if (visible) { + ArmDismissGrace(); Rebuild(force: true); } else @@ -871,7 +878,7 @@ public static class ChronicleHud _graveStackActive = true; _graveTileX = tileX; _graveTileY = tileY; - _ignoreDismissUntil = Time.unscaledTime + 0.4f; + ArmDismissGrace(); _searchText = ""; if (_searchField != null) { @@ -944,7 +951,7 @@ public static class ChronicleHud return; } - _ignoreDismissUntil = Time.unscaledTime + 0.4f; + ArmDismissGrace(); RefreshKeepGraveVisual(); } @@ -963,7 +970,7 @@ public static class ChronicleHud return false; } - _ignoreDismissUntil = Time.unscaledTime + 0.4f; + ArmDismissGrace(); LogService.LogInfo($"[IdleSpectator][LORE] deleted grave tile={tx},{ty}"); InspectUi.DismissAll(); return true; diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 8f416cb..79aca40 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -721,6 +721,16 @@ internal static class HarnessScenarios Step("ie23", "assert", expect: "idle", value: "false"), Step("ie24", "assert", expect: "no_bad"), + // Outside dismiss closes Lore + dossier and keeps idle off (no auto-resume). + Step("ie30", "spectator", value: "on"), + Step("ie31", "focus", asset: "auto"), + Step("ie32", "lore_open_focus"), + Step("ie33", "wait", wait: 0.5f), + Step("ie34", "assert", expect: "idle", value: "false"), + Step("ie35", "inspect_dismiss"), + Step("ie36", "assert", expect: "inspect_cleared"), + Step("ie37", "assert", expect: "idle", value: "false"), + Step("ie90", "fast_timing", value: "false"), Step("ie99", "snapshot"), }; @@ -2575,6 +2585,7 @@ internal static class HarnessScenarios Step("ch18k11g", "wait", wait: 0.2f), Step("ch18k11h", "assert", expect: "fallen_focus", value: "killer"), Step("ch18k11i", "assert", expect: "fallen_killer_camera"), + Step("ch18k11i2", "assert", expect: "dossier_matches_focus"), Step("ch18k11j", "assert", expect: "fallen_killer_banner"), Step("ch18k12", "screenshot", value: "hud-lore-fallen-slain.png"), Step("ch18k13", "wait", wait: 0.55f), @@ -2691,10 +2702,8 @@ internal static class HarnessScenarios 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), + // Stack three named deaths on one captured tile (unit can walk between steps). + Step("gv10", "grave_force", value: "GraveAlice,GraveBob,GraveCara"), Step("gv13", "assert", expect: "grave_stacks", count: 1, label: "eq"), Step("gv14", "assert", expect: "grave_entries", count: 3), Step("gv15", "grave_open"), @@ -2714,10 +2723,12 @@ internal static class HarnessScenarios 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. + // Outside click / dismiss closes Lore + dossier and turns idle off. Step("gv26b", "inspect_dismiss"), Step("gv26c", "assert", expect: "grave_stack_scope", value: "false"), Step("gv26d", "assert", expect: "lore_detail", value: "false"), + Step("gv26d1", "assert", expect: "idle", value: "false"), + Step("gv26d2", "assert", expect: "inspect_cleared"), // Delete removes the skull and closes Lore. Step("gv26e0", "grave_clear"), Step("gv26e", "grave_force", value: "GraveDel", count: 1), @@ -2750,6 +2761,7 @@ internal static class HarnessScenarios Step("gv37", "wait", wait: 0.2f), Step("gv38", "assert", expect: "fallen_focus", value: "killer"), Step("gv39", "assert", expect: "fallen_killer_camera"), + Step("gv39b", "assert", expect: "dossier_matches_focus"), // 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"), diff --git a/IdleSpectator/InspectUi.cs b/IdleSpectator/InspectUi.cs index c54600e..9b13501 100644 --- a/IdleSpectator/InspectUi.cs +++ b/IdleSpectator/InspectUi.cs @@ -3,7 +3,7 @@ using UnityEngine; namespace IdleSpectator; /// -/// Shared dismiss for skull-opened Lore + paused dossier pin. +/// Outside-click dismiss for Lore + idle dossier. Always turns Idle Spectator off. /// public static class InspectUi { @@ -15,9 +15,20 @@ public static class InspectUi GraveSessionActive = true; } + /// + /// Close Lore / dossier pin, clear grave scope, and force Idle Spectator off. + /// public static void DismissAll() { GraveSessionActive = false; + // Outside dismiss must not auto-resume idle when Lore was opened via L. + ChronicleHud.NotifyManualIdleToggle(); + + if (SpectatorMode.Active) + { + SpectatorMode.SetActive(false, quiet: true); + } + ChronicleHud.ClearGraveStackScope(); if (ChronicleHud.DetailUnitId != 0) { @@ -29,13 +40,15 @@ public static class InspectUi ChronicleHud.SetVisible(false); } - WatchCaption.ClearPausePin(); - WatchCaption.ClearStatusBanner(); + WatchCaption.HideForDismiss(); } public static void Update() { - if (!GraveSessionActive) + bool loreOpen = ChronicleHud.Visible; + bool dossierOpen = WatchCaption.Visible; + bool idleOn = SpectatorMode.Active; + if (!loreOpen && !dossierOpen && !idleOn && !GraveSessionActive) { return; } @@ -50,13 +63,51 @@ public static class InspectUi return; } - if (ChronicleHud.IsPointerOverPanel() - || WatchCaption.IsPointerOverPanel()) + if (ChronicleHud.IsTypingSearch) + { + return; + } + + if (ChronicleHud.IsPointerOverPanel() || WatchCaption.IsPointerOverPanel()) + { + return; + } + + // Skull click opens Lore this frame; leave the new session alone. + if (IsClickingGraveSkull()) { return; } - // Click outside Lore / dossier closes the skull inspect session. DismissAll(); } + + private static bool IsClickingGraveSkull() + { + if (!ModSettings.GravestonesEnabled || GraveMarkers.StackCount <= 0) + { + return false; + } + + try + { + if (World.world != null && World.world.isOverUI()) + { + return false; + } + + WorldTile tile = World.world.getMouseTilePosCachedFrame() ?? World.world.getMouseTilePos(); + if (tile != null && GraveMarkers.TryGetStack(tile.x, tile.y, out _)) + { + return true; + } + + Vector2 mouse = World.world.getMousePos(); + return GraveMarkers.FindNearestStack(mouse, 1.35f) != null; + } + catch + { + return false; + } + } } diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 41473b6..388ecb1 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -350,6 +350,14 @@ public static class WatchCaption } } + /// Hide dossier after outside-click / inspect dismiss (idle already off). + public static void HideForDismiss() + { + _pinnedWhilePaused = false; + ClearStatusBanner(); + SetVisible(false); + } + public static void ClearPausePin() { _pinnedWhilePaused = false; diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index ef8891f..77d4b07 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.28.11", - "description": "AFK Idle Spectator (I) + Lore (L). Flavored death lines, full-width grave search, no skull counts.", + "version": "0.28.13", + "description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.", "GUID": "com.dazed.idlespectator" }