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
This commit is contained in:
DazedAnon 2026-07-17 16:47:17 -05:00
parent c11b87aa2e
commit 8a4314be6f
7 changed files with 181 additions and 55 deletions

View file

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

View file

@ -2163,9 +2163,8 @@ public static class Chronicle
/// <summary>
/// Camera for a dead subject.
/// When <paramref name="preferPlace"/> is true (default), pan to death place / skull (no unit follow).
/// Killer follow is only via <see cref="FocusFallenKiller"/>.
/// Dossier always shows the fallen unit's last living state (not the killer).
/// When <paramref name="preferPlace"/> is true (default), pan to death place / skull (no unit follow)
/// and bind the dossier to the fallen unit. Killer follow is only via <see cref="FocusFallenKiller"/>.
/// </summary>
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;
}
/// <summary>Camera follow on the living killer of a fallen Lore subject.</summary>
/// <summary>
/// Camera follow + dossier on the living killer of a fallen Lore subject.
/// Lore detail stays on the fallen archive; only the idle dossier retargets.
/// </summary>
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(

View file

@ -161,9 +161,15 @@ public static class ChronicleHud
}
}
/// <summary>Ignore outside-click dismiss briefly after skull open / Keep.</summary>
/// <summary>Ignore outside-click dismiss briefly after Lore open / Keep.</summary>
public static float IgnoreDismissUntil => _ignoreDismissUntil;
/// <summary>Arm a short grace so the open click does not instantly dismiss Lore.</summary>
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;
/// <summary>True when Lore detail was opened via L/books and should track idle focus.</summary>
@ -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;

View file

@ -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"),

View file

@ -3,7 +3,7 @@ using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Shared dismiss for skull-opened Lore + paused dossier pin.
/// Outside-click dismiss for Lore + idle dossier. Always turns Idle Spectator off.
/// </summary>
public static class InspectUi
{
@ -15,9 +15,20 @@ public static class InspectUi
GraveSessionActive = true;
}
/// <summary>
/// Close Lore / dossier pin, clear grave scope, and force Idle Spectator off.
/// </summary>
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;
}
}
}

View file

@ -350,6 +350,14 @@ public static class WatchCaption
}
}
/// <summary>Hide dossier after outside-click / inspect dismiss (idle already off).</summary>
public static void HideForDismiss()
{
_pinnedWhilePaused = false;
ClearStatusBanner();
SetVisible(false);
}
public static void ClearPausePin()
{
_pinnedWhilePaused = false;

View file

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