diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index e6788bb..ba54b9a 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -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)) diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 25c64ad..b5d7d7c 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -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". diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index f23ae6e..1b5c46f 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -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(); } + /// + /// 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. + /// + 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; diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index a5a6f27..8033faa 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -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" } diff --git a/docs/event-audit.md b/docs/event-audit.md index 4848cb5..ecaba80 100644 --- a/docs/event-audit.md +++ b/docs/event-audit.md @@ -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`). diff --git a/docs/event-e2e.md b/docs/event-e2e.md index d564603..43c8322 100644 --- a/docs/event-e2e.md +++ b/docs/event-e2e.md @@ -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): diff --git a/scripts/soak-audit-player-log.sh b/scripts/soak-audit-player-log.sh index 6f2ab69..ce28cd8 100755 --- a/scripts/soak-audit-player-log.sh +++ b/scripts/soak-audit-player-log.sh @@ -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'