Fix focus handoff

This commit is contained in:
DazedAnon 2026-07-16 21:54:44 -05:00
parent e4c00b4bbb
commit 3ed85d01d1
7 changed files with 114 additions and 40 deletions

View file

@ -39,48 +39,67 @@ public static class CameraDirector
LastWatchLabel = interest.Label ?? "";
LastWatchAssetId = interest.AssetId ?? "";
LogService.LogInfo($"[IdleSpectator] {tip}");
WatchCaption.SetFromInterest(interest);
Actor follow = interest.HasFollowUnit
? interest.FollowUnit
: null;
// Species discovery: only attach a species-matched unit, never a random stranger.
if (follow == null && !string.IsNullOrEmpty(interest.AssetId) && interest.AssetId != "live_battle"
&& interest.AssetId != "scored_unit" && interest.Label != null && interest.Label.StartsWith("New species:"))
{
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
}
// Location-only civic: pan only. Do not invent a stranger FollowUnit.
Actor follow = ResolveFollowUnit(interest);
if (follow != null && follow.isAlive())
{
// If this is a species event, refuse mismatched units.
if (interest.Label != null && interest.Label.StartsWith("New species:")
&& !string.IsNullOrEmpty(interest.AssetId)
&& follow.asset != null && follow.asset.id != interest.AssetId)
{
Actor matched = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
if (matched == null)
{
return;
}
follow = matched;
}
// Focus first, then nametag - same order as FocusUnit, so tip/focus/dossier
// never diverge across a handoff frame.
RetargetFollow(follow);
WatchCaption.SetFromActor(follow);
return;
}
// No owned unit: pan only if we are not already following someone.
// Never clear an existing focus unit here - that flashes the power bar.
// No accepted living follow: tip already logged. Do not bind a rejected FollowUnit
// (dead / species mismatch) as the dossier subject.
if (!interest.HasFollowUnit)
{
WatchCaption.SetFromInterest(interest);
}
if (!MoveCamera.hasFocusUnit())
{
PanCamera(interest.Position);
}
}
private static Actor ResolveFollowUnit(InterestEvent interest)
{
if (interest == null)
{
return null;
}
Actor follow = interest.HasFollowUnit ? interest.FollowUnit : null;
// Species discovery: only attach a species-matched unit, never a random stranger.
bool speciesTip = interest.Label != null
&& interest.Label.StartsWith("New species:");
if (follow == null
&& speciesTip
&& !string.IsNullOrEmpty(interest.AssetId)
&& interest.AssetId != "live_battle"
&& interest.AssetId != "scored_unit")
{
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
}
if (follow == null || !follow.isAlive())
{
return null;
}
if (speciesTip
&& !string.IsNullOrEmpty(interest.AssetId)
&& follow.asset != null
&& follow.asset.id != interest.AssetId)
{
return WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
}
return follow;
}
public static string FormatWatchTip(InterestEvent interest)
{
if (interest == null || string.IsNullOrEmpty(interest.Label))

View file

@ -1062,6 +1062,7 @@ internal static class HarnessScenarios
Step("cf23", "combat_maintain_focus"),
Step("cf24", "assert", expect: "has_focus", value: "true"),
Step("cf25", "assert", expect: "tip_matches_focus"),
Step("cf25b", "assert", expect: "dossier_matches_focus"),
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
// Cold combat must not keep "is fighting".

View file

@ -561,12 +561,60 @@ public static class WatchCaption
SetVisible(true);
}
// Safety net: any focus change that skipped SetFromActor (or rematched follow)
// must not leave the nametag on the previous person for a frame.
ReconcileDossierToFocus();
RefreshLivePortrait();
RefreshLiveTask();
RefreshOwnedReason();
RefreshHistoryIfChanged();
}
/// <summary>
/// While Idle Spectator is live, dossier nametag must track the camera focus unit.
/// Skips pause/fallen pins where the archive subject intentionally differs from focus.
/// </summary>
private static void ReconcileDossierToFocus()
{
if (_pinnedWhilePaused || !SpectatorMode.Active)
{
return;
}
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
return;
}
Actor focus = MoveCamera._focus_unit;
if (!focus.isAlive())
{
return;
}
long focusId = 0;
try
{
focusId = focus.getID();
}
catch
{
return;
}
if (focusId == 0)
{
return;
}
if (_current != null && _current.UnitId == focusId)
{
return;
}
SetFromActor(focus);
}
private static bool HasStatusBanner()
{
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.25.51",
"description": "AFK Idle Spectator (I) + Lore (L). Combat focus + tip alignment.",
"version": "0.25.52",
"description": "AFK Idle Spectator (I) + Lore (L). Dossier nametag tracks focus on handoff.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -85,6 +85,7 @@ Done (see [camera-presentability-plan.md](camera-presentability-plan.md) and `co
- Combat tip/focus alignment (highest-scored participant; tip subject matches focus;
clears "is fighting" when combat goes cold)
**Polish next:** dossier caption/nametag sync if tip+focus match but the nametag briefly shows someone else.
Done (0.25.52): dossier nametag tracks focus on handoff (`CameraDirector.Watch` focus-then-caption;
`WatchCaption.ReconcileDossierToFocus`; `combat_focus` asserts `dossier_matches_focus`).
Soak audit helper: `./scripts/soak-audit-player-log.sh [seconds]` (or pass `--since-bytes N`).

View file

@ -42,6 +42,7 @@ See [`camera-presentability-plan.md`](camera-presentability-plan.md).
| Ambient building falls are B-only | live patch + pipelines `id_drop` |
| Status-overlap onset without live status | `status_overlap_camera` / `status_overlap_absent` |
| Combat tip subject matches focus | `combat_focus` / `tip_matches_focus` |
| Combat dossier nametag matches focus | `combat_focus` / `dossier_matches_focus` |
| Cold combat clears fight tip | `combat_focus` / `tip_not_contains` fighting |
Live idle soak helper (Player.log window):

View file

@ -5,7 +5,8 @@
# ./scripts/soak-audit-player-log.sh # last 120s wall wait, then audit new bytes
# ./scripts/soak-audit-player-log.sh 60 # wait 60s then audit
# ./scripts/soak-audit-player-log.sh --since-bytes N # audit from byte offset (no wait)
# ./scripts/soak-audit-player-log.sh --no-wait # audit from current EOF backward? no - from mark file
# ./scripts/soak-audit-player-log.sh --since-bytes N 60 # wait 60s, then audit from N
# ./scripts/soak-audit-player-log.sh --no-wait # audit empty window from current EOF
#
# Env:
# PLAYER_LOG - override Player.log path
@ -46,15 +47,18 @@ if [[ ! -f "$LOG" ]]; then
exit 1
fi
MARKED_FROM_EOF=0
if [[ -z "$SINCE_BYTES" ]]; then
SINCE_BYTES=$(wc -c < "$LOG" | tr -d ' ')
if [[ "$SECONDS_WAIT" -gt 0 ]]; then
echo "Marked offset $SINCE_BYTES; waiting ${SECONDS_WAIT}s (leave Idle Spectator running) ..."
sleep "$SECONDS_WAIT"
else
echo "No wait and no --since-bytes: auditing from current EOF (empty window)."
echo "Tip: ./scripts/soak-audit-player-log.sh 60"
fi
MARKED_FROM_EOF=1
fi
if [[ "$SECONDS_WAIT" -gt 0 ]]; then
echo "Marked offset $SINCE_BYTES; waiting ${SECONDS_WAIT}s (leave Idle Spectator running) ..."
sleep "$SECONDS_WAIT"
elif [[ "$MARKED_FROM_EOF" -eq 1 ]]; then
echo "No wait and no --since-bytes: auditing from current EOF (empty window)."
echo "Tip: ./scripts/soak-audit-player-log.sh 60"
fi
python3 - "$LOG" "$SINCE_BYTES" <<'PY'