diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index d9f1a9b..6ffbeaa 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -1971,6 +1971,10 @@ public static class AgentHarness
DoInterestInject(cmd);
break;
+ case "presentability_probe":
+ DoPresentabilityProbe(cmd);
+ break;
+
case "interest_force_session":
DoInterestForceSession(cmd);
break;
@@ -2130,6 +2134,26 @@ public static class AgentHarness
break;
}
+ case "camera_clear_focus":
+ {
+ // Simulate vanilla clearing follow mid-idle (death / spectator desync).
+ CameraDirector.ClearFollow();
+ bool cleared = !MoveCamera.hasFocusUnit()
+ || MoveCamera._focus_unit == null
+ || !MoveCamera._focus_unit.isAlive();
+ if (cleared)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok: cleared, detail: $"cleared={cleared} idle={SpectatorMode.Active}");
+ break;
+ }
+
case "interest_expire_pending":
{
string needle = cmd.value ?? "";
@@ -2733,6 +2757,86 @@ public static class AgentHarness
Emit(cmd, ok: true, detail: $"fed id={assetId} key={key} evt={entry.EventStrength:0.#}");
}
+ ///
+ /// Proves unpresentable Labels are rejected at Register (selection = presentation).
+ ///
+ private static void DoPresentabilityProbe(HarnessCommand cmd)
+ {
+ if (!WorldReady())
+ {
+ _cmdFail++;
+ Emit(cmd, ok: false, detail: "world_not_ready");
+ return;
+ }
+
+ Actor unit = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset)
+ ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
+ if (unit == null)
+ {
+ _cmdFail++;
+ Emit(cmd, ok: false, detail: "no unit for presentability_probe");
+ return;
+ }
+
+ InterestDropLog.Clear();
+ string name = EventFeedUtil.SafeName(unit);
+ string mode = (cmd.value ?? "fall").Trim().ToLowerInvariant();
+ string label;
+ if (mode == "good" || mode == "presentable")
+ {
+ label = string.IsNullOrEmpty(cmd.label)
+ ? EventReason.Fight(unit, null)
+ : cmd.label;
+ if (!label.StartsWith(name, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(name))
+ {
+ label = EventPresentability.EnsureSubjectLedLabel(unit, label);
+ }
+ }
+ else
+ {
+ // Article-led fall shape that dossier blanks via identity_filter.
+ label = string.IsNullOrEmpty(cmd.label)
+ ? ("A Maple Tree falls near " + (string.IsNullOrEmpty(name) ? "someone" : name))
+ : cmd.label;
+ }
+
+ string key = "probe:presentability:" + EventFeedUtil.SafeId(unit) + ":" + Time.unscaledTime.ToString("0.###");
+ InterestCandidate registered = EventFeedUtil.Register(
+ key,
+ "probe",
+ "Spectacle",
+ label,
+ "presentability_probe",
+ 88f,
+ unit.current_position,
+ unit);
+
+ bool expectReject = mode != "good" && mode != "presentable";
+ bool ok;
+ string detail;
+ if (expectReject)
+ {
+ ok = registered == null && InterestDropLog.RecentContains("unpresentable");
+ detail = $"reject label='{label}' registered={registered != null} drops='{InterestDropLog.FormatRecent(4)}'";
+ }
+ else
+ {
+ ok = registered != null && EventPresentability.WouldShow(registered);
+ detail = $"accept label='{label}' registered={registered != null} key={(registered != null ? registered.Key : "")}";
+ }
+
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok: ok, detail: detail);
+ }
+
///
/// Injects a Signal from a domain catalog while the harness is Busy
/// (domain feeds themselves early-out on Busy, so register here).
@@ -3479,6 +3583,30 @@ public static class AgentHarness
detail = $"bad={StateProbe.BadEventCount}";
break;
}
+ case "drop_contains":
+ {
+ string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
+ pass = !string.IsNullOrEmpty(needle) && InterestDropLog.RecentContains(needle);
+ detail = $"needle='{needle}' drops='{InterestDropLog.FormatRecent(8)}'";
+ break;
+ }
+ case "no_placeholder_tip":
+ {
+ string formatted = CameraDirector.LastFormattedWatchTip ?? "";
+ pass = formatted.IndexOf("something interesting", StringComparison.OrdinalIgnoreCase) < 0;
+ detail = $"formattedTip='{formatted}'";
+ break;
+ }
+ case "has_focus":
+ {
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool has = MoveCamera.hasFocusUnit()
+ && MoveCamera._focus_unit != null
+ && MoveCamera._focus_unit.isAlive();
+ pass = has == want;
+ detail = $"hasFocus={has} want={want}";
+ break;
+ }
case "tip_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs
index 0bf7c1c..e6788bb 100644
--- a/IdleSpectator/CameraDirector.cs
+++ b/IdleSpectator/CameraDirector.cs
@@ -7,6 +7,7 @@ public static class CameraDirector
{
public static string LastWatchLabel { get; private set; } = "";
public static string LastWatchAssetId { get; private set; } = "";
+ public static string LastFormattedWatchTip { get; private set; } = "";
public static void FocusUnit(Actor actor, bool updateCaption = true)
{
@@ -33,6 +34,7 @@ public static class CameraDirector
}
string tip = FormatWatchTip(interest);
+ LastFormattedWatchTip = tip;
// LastWatchLabel is tip/harness telemetry only - never dossier reason truth.
LastWatchLabel = interest.Label ?? "";
LastWatchAssetId = interest.AssetId ?? "";
@@ -81,10 +83,12 @@ public static class CameraDirector
public static string FormatWatchTip(InterestEvent interest)
{
- string reason = string.IsNullOrEmpty(interest.Label)
- ? "something interesting"
- : interest.Label;
- return $"Watching: {reason}";
+ if (interest == null || string.IsNullOrEmpty(interest.Label))
+ {
+ return "Watching";
+ }
+
+ return "Watching: " + interest.Label;
}
public static void ClearFollow()
diff --git a/IdleSpectator/EventLivePipelinesHarness.cs b/IdleSpectator/EventLivePipelinesHarness.cs
index 0e9903c..f268951 100644
--- a/IdleSpectator/EventLivePipelinesHarness.cs
+++ b/IdleSpectator/EventLivePipelinesHarness.cs
@@ -1545,24 +1545,25 @@ public static class EventLivePipelinesHarness
}
destroy.Invoke(building, null);
+ // Ambient destroy is Layer B only (class demotion) - no A interest key.
if (InterestRegistry.CountKeysContaining("building:destroy") > 0
|| InterestRegistry.CountKeysContaining("building:") > 0)
{
EventLiveCoverageLedger.Claim(
"building",
"building_destroy",
- "id",
+ "fail",
"Building.startDestroyBuilding",
- "busy_bypass=ForceLiveEventFeeds");
+ "unexpected_a_interest_after_class_demote");
}
else
{
EventLiveCoverageLedger.Claim(
"building",
"building_destroy",
- "unsupported",
+ "id_drop",
"Building.startDestroyBuilding",
- "invoked_but_no_interest_key");
+ "ambient_b_only_class_demote;busy_bypass=ForceLiveEventFeeds");
}
}
catch (Exception ex)
diff --git a/IdleSpectator/Events/EventFeedUtil.cs b/IdleSpectator/Events/EventFeedUtil.cs
index bf68a66..752f4a3 100644
--- a/IdleSpectator/Events/EventFeedUtil.cs
+++ b/IdleSpectator/Events/EventFeedUtil.cs
@@ -1,3 +1,4 @@
+using System;
using UnityEngine;
namespace IdleSpectator;
@@ -99,6 +100,12 @@ public static class EventFeedUtil
MaxWatch = maxWatch,
Completion = completion
};
+ if (!EventPresentability.WouldShow(candidate))
+ {
+ InterestDropLog.Record("unpresentable", candidate.Label ?? key);
+ return null;
+ }
+
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
@@ -141,10 +148,21 @@ public static class EventFeedUtil
candidate.RelatedId = SafeId(candidate.RelatedUnit);
}
+ // Harness injects synthetic tip labels for director tests; dossier still blanks them.
+ // Production feeds (Source != harness) must be presentable to enter the registry.
+ if (!IsHarnessSource(candidate.Source) && !EventPresentability.WouldShow(candidate))
+ {
+ InterestDropLog.Record("unpresentable", candidate.Label ?? candidate.Key);
+ return null;
+ }
+
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
+ private static bool IsHarnessSource(string source) =>
+ string.Equals(source, "harness", StringComparison.OrdinalIgnoreCase);
+
public static long SafeId(Actor actor)
{
if (actor == null)
diff --git a/IdleSpectator/Events/EventPresentability.cs b/IdleSpectator/Events/EventPresentability.cs
new file mode 100644
index 0000000..853774f
--- /dev/null
+++ b/IdleSpectator/Events/EventPresentability.cs
@@ -0,0 +1,61 @@
+namespace IdleSpectator;
+
+///
+/// Single source of truth: would the orange dossier show this EventLed Label?
+/// Selection must equal presentation - unpresentable beats must not win Watching.
+///
+public static class EventPresentability
+{
+ ///
+ /// True when the candidate may own Layer A camera.
+ /// Empty Label is only allowed for CharacterLed fill (task chip, no orange reason).
+ ///
+ public static bool WouldShow(InterestCandidate scene)
+ {
+ if (scene == null)
+ {
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(scene.Label))
+ {
+ return scene.LeadKind == InterestLeadKind.CharacterLed;
+ }
+
+ return !string.IsNullOrEmpty(UnitDossier.EvaluateSceneLabel(scene, recordDrops: false));
+ }
+
+ ///
+ /// Shown orange reason, or empty when blanked by identity / ownership rules.
+ ///
+ public static string ShownReason(InterestCandidate scene, bool recordDrops = true)
+ {
+ return UnitDossier.EvaluateSceneLabel(scene, recordDrops);
+ }
+
+ ///
+ /// Harness synthetic tips ("HoldAction") must be subject-led EventReason shape
+ /// so the presentability gate matches production dossier rules.
+ ///
+ public static string EnsureSubjectLedLabel(Actor subject, string label)
+ {
+ if (string.IsNullOrEmpty(label))
+ {
+ return label ?? "";
+ }
+
+ string name = EventFeedUtil.SafeName(subject);
+ if (string.IsNullOrEmpty(name))
+ {
+ return label;
+ }
+
+ if (label.StartsWith(name, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return label;
+ }
+
+ // "Name is in scene: HoldAction" → EventSentence + subject-led first token.
+ return name + " is in scene: " + label;
+ }
+}
diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs
index 0fdb7ea..f02ffd7 100644
--- a/IdleSpectator/Events/EventReason.cs
+++ b/IdleSpectator/Events/EventReason.cs
@@ -176,8 +176,8 @@ public static class EventReason
if (!string.IsNullOrEmpty(n))
{
return string.IsNullOrEmpty(id)
- ? "A building falls near " + n
- : "A " + id + " falls near " + n;
+ ? n + " is near a falling building"
+ : n + " is near a falling " + id;
}
}
diff --git a/IdleSpectator/Events/InterestDropLog.cs b/IdleSpectator/Events/InterestDropLog.cs
index bb134e1..d347f3b 100644
--- a/IdleSpectator/Events/InterestDropLog.cs
+++ b/IdleSpectator/Events/InterestDropLog.cs
@@ -84,6 +84,26 @@ public static class InterestDropLog
return list;
}
+ public static bool RecentContains(string needle, int max = 24)
+ {
+ if (string.IsNullOrEmpty(needle))
+ {
+ return false;
+ }
+
+ List lines = Snapshot(max);
+ for (int i = 0; i < lines.Count; i++)
+ {
+ if (!string.IsNullOrEmpty(lines[i])
+ && lines[i].IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public static string FormatRecent(int max = 8)
{
List lines = Snapshot(max);
diff --git a/IdleSpectator/Events/Patches/BuildingEventPatches.cs b/IdleSpectator/Events/Patches/BuildingEventPatches.cs
index 9713ac1..fa89e5b 100644
--- a/IdleSpectator/Events/Patches/BuildingEventPatches.cs
+++ b/IdleSpectator/Events/Patches/BuildingEventPatches.cs
@@ -1,6 +1,5 @@
using ai.behaviours;
using HarmonyLib;
-using UnityEngine;
namespace IdleSpectator;
@@ -12,7 +11,7 @@ public static class BuildingEventPatches
[HarmonyPostfix]
public static void PostfixDamageBuilding(Actor pActor, BehResult __result)
{
- // Routine siege chips are Layer B noise. Camera waits for consume/destroy.
+ // Routine siege chips are Layer B noise. Camera waits for consume.
_ = pActor;
_ = __result;
}
@@ -44,80 +43,10 @@ public static class BuildingEventPatches
[HarmonyPostfix]
public static void PostfixStartDestroy(Building __instance)
{
- if (__instance == null || !AgentHarness.LiveFeedsAllowed)
- {
- return;
- }
-
- try
- {
- Vector3 pos = Vector3.zero;
- try
- {
- WorldTile tile = __instance.current_tile;
- if (tile != null)
- {
- pos = tile.posV3;
- }
- }
- catch
- {
- pos = Vector3.zero;
- }
-
- string buildingName = "";
- try
- {
- if (__instance.asset != null)
- {
- buildingName = __instance.asset.id ?? "";
- }
- }
- catch
- {
- buildingName = "";
- }
-
- Actor near = pos != Vector3.zero
- ? EventFeedUtil.NearestUnit(pos, 48f)
- : null;
- string label = EventReason.BuildingFalls(buildingName, near);
- if (near == null)
- {
- if (pos == Vector3.zero)
- {
- return;
- }
-
- string locKey = "building:destroy:" + buildingName + ":loc";
- EventFeedUtil.Register(
- locKey,
- "building",
- "Spectacle",
- label,
- string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName,
- 88f,
- pos,
- follow: null,
- locationOnly: true);
- return;
- }
-
- string key = "building:destroy:" + buildingName + ":" + EventFeedUtil.SafeId(near);
- EventFeedUtil.Register(
- key,
- "building",
- "Spectacle",
- label,
- string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName,
- 88f,
- near.current_position,
- near);
- }
- catch
- {
- // ignore API mismatches
- }
+ // Ambient object/building falls are Layer B only.
+ // Nearest-unit attach + "A Tree falls near X" was winning Watching then blanking via identity_filter.
+ // Intentional spectacle remains building_consume (actor-led eat).
+ _ = __instance;
}
private static void Emit(string eventId, float strength, Actor actor, string label)
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 12b4815..e3b8be8 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -44,6 +44,9 @@ internal static class HarnessScenarios
case "watch_reason":
case "watch_reason_clarity":
return WatchReasonClarity();
+ case "camera_presentability":
+ case "presentability":
+ return CameraPresentability();
case "director_action_priority":
case "action_priority":
return DirectorActionPriority();
@@ -178,6 +181,7 @@ internal static class HarnessScenarios
Nested("reg_action_priority", "director_action_priority"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
+ Nested("reg_presentability", "camera_presentability"),
Nested("reg_event_coverage", "event_suite"),
Nested("reg_chronicle", "chronicle_smoke"),
Nested("reg_activity", "activity_idle_smoke"),
@@ -500,6 +504,7 @@ internal static class HarnessScenarios
Step("c34", "assert", expect: "tip_contains", value: "auto"),
Step("c35", "assert", expect: "power_bar", value: "false"),
Step("c36", "assert", expect: "no_bad"),
+ Step("c36b", "assert", expect: "no_placeholder_tip"),
Step("c40", "watch", asset: "auto", label: "Retarget A: {asset}", tier: "Curiosity"),
Step("c41", "wait", wait: 0.25f),
@@ -952,6 +957,77 @@ internal static class HarnessScenarios
};
}
+ ///
+ /// Selection = presentation: unpresentable Labels never register A;
+ /// presentable EventReason does; focus survives max_cap; no placeholder tip.
+ ///
+ private static List CameraPresentability()
+ {
+ return new List
+ {
+ Step("cp0", "dismiss_windows"),
+ Step("cp1", "wait_world"),
+ Step("cp2", "set_setting", expect: "enabled", value: "true"),
+ Step("cp3", "fast_timing", value: "true"),
+ Step("cp4", "pick_unit", asset: "auto"),
+ Step("cp5", "spectator", value: "off"),
+ Step("cp6", "spectator", value: "on"),
+ Step("cp7", "focus", asset: "auto"),
+ Step("cp8", "reset_counters"),
+
+ // Article-led fall shape must not enter the registry.
+ Step("cp10", "presentability_probe", value: "fall"),
+ Step("cp11", "assert", expect: "drop_contains", value: "unpresentable"),
+ Step("cp12", "assert", expect: "interest_no_key", value: "probe:presentability"),
+
+ // Subject-led fight Label may register.
+ Step("cp20", "presentability_probe", value: "good"),
+ Step("cp21", "assert", expect: "interest_has_key", value: "probe:presentability"),
+ // Clear probe pending so force-session owns the tip (regression worlds are noisy).
+ Step("cp22", "interest_expire_pending", value: ""),
+ Step("cp23", "interest_end_session"),
+
+ // Presentable tip takes Watching; never placeholder.
+ Step("cp30", "interest_force_session", asset: "auto", label: "{a} is fighting", tier: "Action", expect: "cp_fight"),
+ Step("cp30b", "assert", expect: "tip_contains", value: "is fighting"),
+ Step("cp31", "director_run", wait: 0.6f),
+ Step("cp32", "assert", expect: "tip_contains", value: "is fighting"),
+ Step("cp33", "assert", expect: "no_placeholder_tip"),
+ Step("cp34", "assert", expect: "has_focus", value: "true"),
+
+ // Mid-scene camera clear must restore focus within one director tick (Phase D).
+ Step("cp35", "reset_counters"),
+ Step("cp36", "camera_clear_focus"),
+ Step("cp37", "director_run", wait: 0.25f),
+ Step("cp38", "assert", expect: "has_focus", value: "true"),
+ // Intentional clear trips one focus-dropped BAD; after repair there must be no gap spam.
+ Step("cp38b", "reset_counters"),
+ Step("cp39", "director_run", wait: 0.4f),
+ Step("cp39b", "assert", expect: "no_bad"),
+
+ // Max-cap end must keep a living focus unit.
+ Step("cp40", "age_current", wait: 9f),
+ Step("cp41", "director_run", wait: 1.2f),
+ Step("cp42", "assert", expect: "has_focus", value: "true"),
+ Step("cp43", "reset_counters"),
+ Step("cp43b", "director_run", wait: 0.4f),
+ Step("cp43c", "assert", expect: "no_bad"),
+ Step("cp44", "assert", expect: "no_placeholder_tip"),
+
+ // Clear again with no current scene after max_cap.
+ Step("cp50", "interest_end_session"),
+ Step("cp51", "camera_clear_focus"),
+ Step("cp52", "director_run", wait: 0.25f),
+ Step("cp53", "assert", expect: "has_focus", value: "true"),
+ Step("cp53b", "reset_counters"),
+ Step("cp54", "director_run", wait: 0.4f),
+ Step("cp54b", "assert", expect: "no_bad"),
+
+ Step("cp90", "fast_timing", value: "false"),
+ Step("cp99", "snapshot"),
+ };
+ }
+
///
/// Watch-reason row must explain why the camera is here (event), not who (title/name).
///
@@ -984,6 +1060,7 @@ internal static class HarnessScenarios
Step("wr21", "assert", expect: "dossier_not_contains", value: "Action ·"),
Step("wr21b", "assert", expect: "dossier_not_contains", value: "Live ·"),
Step("wr22", "assert", expect: "dossier_not_contains", value: "King Isemward"),
+ Step("wr22b", "assert", expect: "no_placeholder_tip"),
Step("wr23", "screenshot", value: "hud-watch-reason-identity.png"),
Step("wr24", "wait", wait: 0.55f),
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index 0958e97..fcbe9cb 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -374,6 +374,8 @@ public static class InterestDirector
// Pending events own the camera - never Character-fill over them.
if (InterestRegistry.HasPendingEvent())
{
+ // Still repair a missing follow so death mid-scene does not flash the power bar.
+ MaintainCameraFocus(now);
return;
}
@@ -388,6 +390,8 @@ public static class InterestDirector
{
TryCharacterFill(force: false);
}
+
+ MaintainCameraFocus(now);
}
private static void UpdateSessionLiveness(float now, float onCurrent)
@@ -413,6 +417,14 @@ public static class InterestDirector
return;
}
+ // No living subject left: do not sit in quiet_grace with an empty camera.
+ if (!_current.HasFollowUnit
+ && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
+ {
+ EndCurrent(now, reason: "follow_lost");
+ return;
+ }
+
// Ambient: end promptly when the vignette goes cold - do not hold for minCameraDwell.
if (IsAmbientShot(_current))
{
@@ -456,6 +468,12 @@ public static class InterestDirector
if (_current.HasFollowUnit)
{
+ // Living subject still owned - reattach if vanilla cleared focus mid-scene.
+ if (!HasLivingCameraFocus() || MoveCamera._focus_unit != _current.FollowUnit)
+ {
+ CameraDirector.Watch(_current.ToInterestEvent());
+ }
+
return;
}
@@ -472,6 +490,59 @@ public static class InterestDirector
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
+ ///
+ /// Keep a living focus unit whenever Idle Spectator is on.
+ /// Covers death mid-combat and quiet_grace windows where vanilla cleared follow.
+ ///
+ private static void MaintainCameraFocus(float now)
+ {
+ if (!SpectatorMode.Active)
+ {
+ return;
+ }
+
+ if (HasLivingCameraFocus())
+ {
+ return;
+ }
+
+ if (_current != null)
+ {
+ if (_current.HasFollowUnit)
+ {
+ CameraDirector.Watch(_current.ToInterestEvent());
+ if (HasLivingCameraFocus())
+ {
+ return;
+ }
+ }
+
+ if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
+ {
+ _current.FollowUnit = _current.RelatedUnit;
+ _current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
+ CameraDirector.Watch(_current.ToInterestEvent());
+ if (HasLivingCameraFocus())
+ {
+ return;
+ }
+ }
+
+ // Orphan scene (no living follow/related) - end and refill.
+ EndCurrent(now, reason: "follow_lost");
+ return;
+ }
+
+ EnsureIdleFocus(now);
+ }
+
+ private static bool HasLivingCameraFocus()
+ {
+ return MoveCamera.hasFocusUnit()
+ && MoveCamera._focus_unit != null
+ && MoveCamera._focus_unit.isAlive();
+ }
+
private static void EndCurrent(float now, string reason)
{
if (_current != null)
@@ -494,6 +565,37 @@ public static class InterestDirector
_interrupted = null;
_current = null;
_inactiveSince = -999f;
+ EnsureIdleFocus(now);
+ }
+
+ ///
+ /// After scene end, keep a living focus unit so idle never flashes the power bar.
+ ///
+ private static void EnsureIdleFocus(float now)
+ {
+ if (!SpectatorMode.Active)
+ {
+ return;
+ }
+
+ if (HasLivingCameraFocus())
+ {
+ return;
+ }
+
+ TryCharacterFill(force: true);
+ if (HasLivingCameraFocus())
+ {
+ return;
+ }
+
+ Actor any = EventFeedUtil.AnyAliveUnit();
+ if (any != null)
+ {
+ CameraDirector.FocusUnit(any);
+ }
+
+ _ = now;
}
private static InterestCandidate SelectNext(float now)
@@ -515,10 +617,19 @@ public static class InterestDirector
continue;
}
- // Drop stale / not-worth-watching shots from the pending set.
+ // Drop stale / not-worth-watching / unpresentable shots from the pending set.
+ // Harness synthetic labels may be unpresentable (dossier blanks); keep them for tip tests.
+ bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase);
+ bool presentable = harness || EventPresentability.WouldShow(c);
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
- || IsStaleFreshLifeMoment(c, now))
+ || IsStaleFreshLifeMoment(c, now)
+ || !presentable)
{
+ if (!presentable)
+ {
+ InterestDropLog.Record("unpresentable", c.Label ?? c.Key);
+ }
+
InterestRegistry.Remove(c.Key);
PendingScratch.RemoveAt(i);
}
@@ -1139,6 +1250,14 @@ public static class InterestDirector
return;
}
+ if (!EventPresentability.WouldShow(next)
+ && !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase))
+ {
+ InterestDropLog.Record("unpresentable", next.Label ?? next.Key);
+ InterestRegistry.Remove(next.Key);
+ return;
+ }
+
if (_current != null
&& _current.Resumable
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin)
@@ -1188,15 +1307,15 @@ public static class InterestDirector
return;
}
- // Harness batches inject their own candidates; never let a live Action/Story
- // ambient fill steal the camera before trigger_interest / discovery steps.
- if (AgentHarness.Busy && ambient.Score >= InterestScoringConfig.W.noticeScoreMin)
+ // Character fill never owns an orange reason - empty Label + CharacterLed.
+ // Force fill score so RegisterDirect does not publish as EventLed.
+ ambient.Label = "";
+ float fillCap = InterestScoringConfig.W.fillScoreMax > 0f
+ ? InterestScoringConfig.W.fillScoreMax * 0.5f
+ : 20f;
+ if (ambient.Score > fillCap || AgentHarness.Busy)
{
- ambient.Score = InterestScoringConfig.W.fillScoreMax * 0.5f;
- if (string.IsNullOrEmpty(ambient.Label))
- {
- ambient.Label = "Ambient";
- }
+ ambient.Score = fillCap;
}
InterestCandidate candidate = InterestFeeds.RegisterDirect(ambient);
diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs
index 0504296..df8921f 100644
--- a/IdleSpectator/UnitDossier.cs
+++ b/IdleSpectator/UnitDossier.cs
@@ -292,17 +292,54 @@ public sealed class UnitDossier
}
}
- return SceneLabelBeat(scene, d) ?? "";
+ return EvaluateSceneLabel(scene, d, recordDrops: true) ?? "";
}
- private static string SceneLabelBeat(InterestCandidate scene, UnitDossier d)
+ ///
+ /// Shared with - dossier orange reason rules.
+ ///
+ public static string EvaluateSceneLabel(InterestCandidate scene, bool recordDrops = true)
{
if (scene == null || string.IsNullOrEmpty(scene.Label))
{
return "";
}
- string beat = EventLabelBeat(scene.Label, d);
+ UnitDossier d = new UnitDossier();
+ try
+ {
+ if (scene.FollowUnit != null && scene.FollowUnit.isAlive())
+ {
+ d.UnitId = scene.FollowUnit.getID();
+ d.Name = SafeName(scene.FollowUnit);
+ d.SpeciesId = scene.FollowUnit.asset != null ? scene.FollowUnit.asset.id : "";
+ }
+ else if (!string.IsNullOrEmpty(scene.SpeciesId))
+ {
+ d.SpeciesId = scene.SpeciesId;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return EvaluateSceneLabel(scene, d, recordDrops);
+ }
+
+ private static string EvaluateSceneLabel(InterestCandidate scene, UnitDossier d, bool recordDrops)
+ {
+ return SceneLabelBeat(scene, d, recordDrops) ?? "";
+ }
+
+ private static string SceneLabelBeat(InterestCandidate scene, UnitDossier d, bool recordDrops = true)
+ {
+ if (scene == null || string.IsNullOrEmpty(scene.Label))
+ {
+ return "";
+ }
+
+ string beat = EventLabelBeat(scene.Label, d, recordDrops);
if (string.IsNullOrEmpty(beat))
{
return "";
@@ -311,14 +348,22 @@ public sealed class UnitDossier
// Ownership: never let the reason name a living stranger (not subject/related).
if (ReasonNamesStranger(beat, scene))
{
- InterestDropLog.Record("identity_filter", "stranger:" + beat);
+ if (recordDrops)
+ {
+ InterestDropLog.Record("identity_filter", "stranger:" + beat);
+ }
+
return "";
}
// Ownership: reject beats that lead with someone else's proper name.
if (LeadsWithForeignName(beat, scene))
{
- InterestDropLog.Record("identity_filter", "foreign:" + beat);
+ if (recordDrops)
+ {
+ InterestDropLog.Record("identity_filter", "foreign:" + beat);
+ }
+
return "";
}
@@ -352,8 +397,45 @@ public sealed class UnitDossier
return false;
}
- // Leading token looks like a proper name that is not the owned subject/related.
- return first.Length >= 3 && char.IsLetter(first[0]);
+ // Only reject when the leading token is another living unit's name.
+ // Do not treat "Age" / "War" / catalog nouns as foreign proper names.
+ if (World.world?.units == null || first.Length < 3)
+ {
+ return false;
+ }
+
+ try
+ {
+ foreach (Actor unit in World.world.units)
+ {
+ if (unit == null || !unit.isAlive())
+ {
+ continue;
+ }
+
+ if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
+ {
+ continue;
+ }
+
+ string name = EventFeedUtil.SafeName(unit);
+ if (string.IsNullOrEmpty(name) || name.Length < 3)
+ {
+ continue;
+ }
+
+ if (first.Equals(FirstToken(name), System.StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return false;
}
private static string FirstToken(string text)
@@ -506,7 +588,7 @@ public sealed class UnitDossier
return false;
}
- private static string EventLabelBeat(string label, UnitDossier d)
+ private static string EventLabelBeat(string label, UnitDossier d, bool recordDrops = true)
{
if (string.IsNullOrEmpty(label))
{
@@ -529,7 +611,11 @@ public sealed class UnitDossier
{
if (IsWeakFlavorBeat(s, d))
{
- InterestDropLog.Record("identity_filter", "weak:" + s);
+ if (recordDrops)
+ {
+ InterestDropLog.Record("identity_filter", "weak:" + s);
+ }
+
return "";
}
@@ -538,7 +624,11 @@ public sealed class UnitDossier
if (IsIdentityWhy(s, d) || IsWeakFlavorBeat(s, d))
{
- InterestDropLog.Record("identity_filter", s);
+ if (recordDrops)
+ {
+ InterestDropLog.Record("identity_filter", s);
+ }
+
return "";
}
@@ -553,6 +643,11 @@ public sealed class UnitDossier
}
string t = text.Trim();
+ if (t.StartsWith("New species", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
if (t.StartsWith("Lone ", System.StringComparison.OrdinalIgnoreCase)
|| t.Equals("Lone of kind", System.StringComparison.OrdinalIgnoreCase)
|| t.Equals("lone of kind", System.StringComparison.OrdinalIgnoreCase))
@@ -562,9 +657,12 @@ public sealed class UnitDossier
if (d != null
&& !string.IsNullOrEmpty(d.SpeciesId)
- && t.IndexOf(d.SpeciesId, System.StringComparison.OrdinalIgnoreCase) >= 0
+ && ContainsWholeName(t, d.SpeciesId)
&& t.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) < 0
- && t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) < 0)
+ && t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) < 0
+ && t.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) < 0
+ && t.IndexOf(':') < 0
+ && !LooksLikeEventSentence(t))
{
return true;
}
@@ -585,19 +683,20 @@ public sealed class UnitDossier
}
string t = text.Trim();
- if (t.StartsWith("King ", System.StringComparison.OrdinalIgnoreCase)
- || t.StartsWith("King of ", System.StringComparison.OrdinalIgnoreCase)
- || t.StartsWith("Leader ", System.StringComparison.OrdinalIgnoreCase)
+ // Pure title crumbs only - not WorldLog beats like "King left:" / "King killed:".
+ if (t.StartsWith("King of ", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Leader of ", System.StringComparison.OrdinalIgnoreCase)
- || t.StartsWith("Favorite", System.StringComparison.OrdinalIgnoreCase))
+ || t.Equals("King", System.StringComparison.OrdinalIgnoreCase)
+ || t.Equals("Leader", System.StringComparison.OrdinalIgnoreCase)
+ || t.Equals("Favorite", System.StringComparison.OrdinalIgnoreCase))
{
return true;
}
- // Pure title leftovers after ShortenWatchLabel ("King", "Leader", "Favorite").
- if (t.Equals("King", System.StringComparison.OrdinalIgnoreCase)
- || t.Equals("Leader", System.StringComparison.OrdinalIgnoreCase)
- || t.Equals("Favorite", System.StringComparison.OrdinalIgnoreCase))
+ // "Favorite …" title leftover without an event colon / fell beat.
+ if (t.StartsWith("Favorite", System.StringComparison.OrdinalIgnoreCase)
+ && t.IndexOf(':') < 0
+ && t.IndexOf("fell", System.StringComparison.OrdinalIgnoreCase) < 0)
{
return true;
}
@@ -637,6 +736,7 @@ public sealed class UnitDossier
string t = text;
return t.IndexOf(" is ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" · ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" became ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" joins ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" leaves ", System.StringComparison.OrdinalIgnoreCase) >= 0
@@ -648,6 +748,7 @@ public sealed class UnitDossier
|| t.IndexOf(" summons ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" begins ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" ends ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.EndsWith(" ends", System.StringComparison.OrdinalIgnoreCase)
|| t.IndexOf(" burns ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" welcomes ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" parted ", System.StringComparison.OrdinalIgnoreCase) >= 0
@@ -663,11 +764,18 @@ public sealed class UnitDossier
|| t.IndexOf(" mates ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" appears ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" finishes ", System.StringComparison.OrdinalIgnoreCase) >= 0
- || t.IndexOf(" bloodlust ", System.StringComparison.OrdinalIgnoreCase) >= 0;
+ || t.IndexOf(" bloodlust ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" killed", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" fell", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" from ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.StartsWith("Rebellion:", System.StringComparison.OrdinalIgnoreCase)
+ || t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase)
+ || t.IndexOf(':') >= 0;
}
///
/// "Name verb …" EventReason form (covers hatches/mates/appears without an "is" helper).
+ /// Also accepts authored middot crumbs ("Name · phrase") and possessives ("Name's family ends").
/// Rejects identity crumbs like "Name (species, …)".
///
private static bool StartsWithSubjectVerbPhrase(string text, string name)
@@ -683,7 +791,32 @@ public sealed class UnitDossier
}
int i = name.Length;
- if (i >= text.Length || text[i] != ' ')
+ if (i >= text.Length)
+ {
+ return false;
+ }
+
+ // "Name · library / happiness crumb"
+ if (text[i] == ' '
+ && i + 1 < text.Length
+ && (text[i + 1] == '·' || text[i + 1] == '•'))
+ {
+ return true;
+ }
+
+ // "Name's family ends"
+ if (text[i] == '\''
+ && i + 2 < text.Length
+ && (text[i + 1] == 's' || text[i + 1] == 'S')
+ && text[i + 2] == ' ')
+ {
+ int afterPossessive = i + 3;
+ return afterPossessive < text.Length
+ && char.IsLetter(text[afterPossessive])
+ && text[afterPossessive] != '(';
+ }
+
+ if (text[i] != ' ')
{
return false;
}
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 2bbdcf5..7e1dd10 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.25.38",
- "description": "AFK Idle Spectator (I) + Lore (L). Catalog-wide event inject E2E suite.",
+ "version": "0.25.47",
+ "description": "AFK Idle Spectator (I) + Lore (L). Focus hold repair mid-idle.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/docs/camera-presentability-plan.md b/docs/camera-presentability-plan.md
new file mode 100644
index 0000000..5c3604a
--- /dev/null
+++ b/docs/camera-presentability-plan.md
@@ -0,0 +1,232 @@
+# Camera presentability plan
+
+Goal: IdleSpectator never starts **Watching** on a beat the orange dossier would blank or refuse.
+Ambient object-falls stop competing with real story (combat, birth, plot, kingdom) without chasing per-asset denylists.
+
+This plan follows live Player.log audits after event-live verification (Phases 0–5 done).
+Coverage of *firing* is largely solved.
+Idle *quality* fails on ranking / ownership mismatch, not missing catalogs.
+
+## Problem (from live idle)
+
+| Symptom | Root |
+|---------|------|
+| ~60% watches are vegetation / object falls | Building destroy feed registers A at high strength |
+| Tip → `identity_filter` → `max_cap` loop | Selection ≠ presentation: `"A Maple Tree falls near X"` fails dossier identity rules |
+| Combat sticky via `below_margin` while falls cut in | Falls outrank or interrupt poorly; sticky fights do not lose focus cleanly |
+| `no_focus_while_idle` BAD after `max_cap` / `follow_lost` / `quiet_grace` | Focus hold across scene end is broken |
+| Tip `"Watching: something interesting"` | Empty Label placeholder in `CameraDirector.FormatWatchTip` |
+
+## Principles (do not violate)
+
+1. **Selection equals presentation.**
+ If `UnitDossier.EventLabelBeat` / scene identity rules would empty the reason for the follow subject, that candidate must not win Watching.
+2. **Policy overlays classes, not asset ids.**
+ Demote "ambient building/object fall" as a feed family or WorldLog shape predicate.
+ Do not maintain Maple / Poop / Mineral id lists.
+3. **Game remains source of truth for inventory.**
+ Authored dials set strength / CreatesInterest / watch windows.
+ They do not invent which buildings exist.
+4. **Layer B stays honest.**
+ Falls may still hit Activity / Life when useful.
+ Demotion means "not camera A," not "erase from the world."
+5. **One invariant over many patches.**
+ Prefer a shared presentability gate at register / rank time over scattered feed hacks.
+
+## Architecture target
+
+```
+observe → confirm → emit Label
+ ↓
+ presentability gate (same rules as UnitDossier)
+ ↓
+ CreatesInterest / strength policy (class overlay)
+ ↓
+ InterestDirector rank → Watching
+ ↓
+ dossier shows the same Label (or scene ends)
+```
+
+Drop reason for the new gate: `unpresentable` (or reuse `identity_filter` with a clear prefix) in `InterestDropLog`.
+
+## Phase A - Presentability gate (structural, do first)
+
+**Invariant:** unpresentable beats never become the active EventLed scene.
+
+### A1. Shared check
+
+Extract a single helper used by both dossier and director, e.g. `EventPresentability.WouldShow(Actor subject, string label)` (or static on `UnitDossier`).
+
+It must mirror:
+
+- identity / weak flavor rejection
+- `LeadsWithForeignName` / stranger scrub for the follow subject
+- empty / placeholder Labels
+
+Do **not** fork a second, looser copy of the rules.
+
+### A2. Gate at publish or rank
+
+Preferred order:
+
+1. Soft reject in `EventFeedUtil.Register` when Label fails for `follow` (record drop, return null) - catches most unit-led noise early.
+2. Hard reject in `InterestDirector` before adopting a candidate (defense in depth for location-only / race cases).
+
+Location-only falls without a subject: either fail presentability by policy, or never register as A (Phase B).
+
+### A3. Harness
+
+Add focused asserts (new scenario or nest in existing director smoke):
+
+| Case | Expect |
+|------|--------|
+| Register Label that dossier blanks for follow | no Watching; drop logged |
+| Register subject-led presentable Label | Watching + orange reason match Label |
+| Inject / live path that previously `identity_filter` after cut | no cut-in |
+
+Exit: idle session no longer shows Watching → immediate `identity_filter` on the same tip as the dominant pattern.
+
+Gate: `critical_smoke` + a dedicated presentability scenario ×3.
+
+## Phase B - Demote ambient fall class (policy overlay)
+
+**Class:** unit-adjacent or location-only **building/object destroy** ambient (trees, plants, minerals, mushrooms, poop, etc.).
+
+Not in scope for demotion: intentional spectacle consumes (`building_consume` / eat when an actor is clearly the subject and Label is subject-led), war siege set-pieces if we later mark them Signal.
+
+### B1. Predicate, not id list
+
+In `BuildingEventPatches.PostfixStartDestroy` (and any WorldLog twin if it feeds the same shape):
+
+- Default destroy → Layer B only (`CreatesInterest=false` / skip `EventFeedUtil.Register`, still Activity if desired).
+- Or register A only when a **strong subject-led** story exists (actor is clearly damaging/consuming, not "nearest unit to a falling bush").
+
+Nearest-unit attach for ambient falls is the bug pattern.
+Prefer: no follow invent from proximity for A.
+
+### B2. Catalog dials
+
+If WorldLog building-destroy ids are camera today, set class default `camera: false` / low strength via discovery overlay rules - still not per maple id.
+
+Update `docs/event-audit.md` Layer A noise notes when done.
+
+### B3. Harness
+
+| Case | Expect |
+|------|--------|
+| Destroy ambient vegetation near a unit | no A cut; optional B log |
+| `BehConsumeTargetBuilding` with living eater | still A, subject-led Label |
+| Building destroy pipeline in `event_live_pipelines` | `id_drop` or B-only as authored; not FAIL |
+
+Exit: live idle fall watch share collapses; combat/birth/plot reclaim camera time.
+
+## Phase C - Ban empty Watching tip
+
+### C1. Product rule
+
+Never show `Watching: something interesting`.
+
+Options (pick one, keep simple):
+
+- Refuse to start Watching without a non-empty presentable Label.
+- Or tip omits reason / uses task chip only when Label empty (Character fill already uses empty Label intentionally - do not use FormatWatchTip placeholder there).
+
+Remove or guard the fallback in `CameraDirector.FormatWatchTip`.
+
+### C2. Harness
+
+Assert tip never equals / contains the placeholder string during idle EventLed scenes.
+
+## Phase D - Focus hold across scene end
+
+Separate from fall demotion; required for idle quality.
+
+### D1. Reproduce
+
+`no_focus_while_idle` after `max_cap`, `follow_lost`, `quiet_grace`.
+Reproduce closest to end-user idle (spectate, wait for scene churn) before patching.
+
+### D2. Fix
+
+When an EventLed scene ends and Character fill / next candidate should continue idle:
+
+- Keep or immediately reacquire a living focus unit.
+- Do not clear follow in a way that flashes the power bar or trips BAD.
+- Every director tick: if Idle is on and camera has no living focus, reattach current follow/related or `EnsureIdleFocus` (do not wait for quiet_grace).
+
+Document intended end-of-scene focus ownership in `event-audit.md` interrupt/hold section.
+
+### D3. Harness
+
+Assert: during SpectatorMode, after forced `max_cap` / quiet_grace end, `MoveCamera.hasFocusUnit()` remains true (or is restored within one director tick) unless world has no living units.
+
+## Phase E - Optional Label rework (only if we want some falls as A)
+
+Only after A+B.
+
+Rewrite `EventReason.BuildingFalls` to subject-led form if product wants rare A falls:
+
+- Bad: `A Maple Tree falls near Boh` (foreign-led / identity_filter)
+- Better: `Boh is near a falling maple tree` (only if Phase B still allows that class as A)
+
+Do not use Label rewrite as a substitute for the gate or class demotion.
+
+## Phase F - Docs + idle quality gate
+
+1. Cross-link from `event-reason.md` and `event-audit.md`.
+2. Add a short "idle quality" checklist to `event-e2e.md`:
+ - no Watching → identity_filter churn on same tip
+ - no placeholder tip
+ - no `no_focus_while_idle` BAD spike in a quiet idle window
+ - fall class not dominating watches
+3. Optional: lightweight Player.log auditor script or harness probe counting `identity_filter` after Watching tips in a timed idle soak.
+
+## Implementation status
+
+| Phase | Status |
+|-------|--------|
+| A Presentability gate | Done - `EventPresentability` + Register/SelectNext/SwitchTo; harness bypass for synthetic tips |
+| B Fall class demotion | Done - `Building.startDestroyBuilding` is B-only; consume stays A |
+| C Placeholder tip ban | Done - `FormatWatchTip` returns `Watching` when Label empty |
+| D Focus hold | Done - `MaintainCameraFocus` every tick; orphan scenes end without quiet_grace gap |
+| E Label rewrite | Done early - `BuildingFalls` subject-led (for residual / Activity prose) |
+| F Docs + idle quality gate | Done - `camera_presentability` in regression; docs synced |
+
+## Order of work
+
+| Order | Phase | Why first |
+|------:|-------|-----------|
+| 1 | A Presentability gate | Fixes whole contradiction class forever |
+| 2 | B Fall class demotion | Stops ambient spam without id lists |
+| 3 | C Placeholder tip ban | Cheap, visible polish; blocks empty A |
+| 4 | D Focus hold | Fixes BAD / churn; independent root cause |
+| 5 | E Label rewrite | Optional; only if some falls stay A |
+| 6 | F Docs / soak gate | Locks the bar |
+
+Do not start with per-id JSON demotions.
+Do not demote only and skip the gate.
+
+## Non-goals
+
+- Hand-authored denylist of building asset ids
+- Treating inject coverage as idle-quality proof
+- Making every WorldLog message camera-worthy
+- Solving spell / biome / boat unload unsupported fires in this plan
+- Beh-positive `family_group_join` (separate observation work)
+
+## Success criteria
+
+- Presentability helper is the single source of truth for "would orange show this?"
+- Ambient object-falls are B-by-default (class predicate)
+- Zero Watching cuts whose tip is immediately identity-filtered
+- Zero `"something interesting"` tips in EventLed Watching
+- Idle soak: `no_focus_while_idle` BAD count stays near zero across scene churn
+- Regression still green (`critical_smoke` ×3; `regression` after A–D)
+
+## See also
+
+- [event-reason.md](event-reason.md) - A/B layers, EventReason, dossier filters
+- [event-audit.md](event-audit.md) - dials, interrupt / hold / grace
+- [event-live-verification-plan.md](event-live-verification-plan.md) - fire accuracy (done)
+- [event-e2e.md](event-e2e.md) - harness suite map
+- [event-observation.md](event-observation.md) - observe → confirm → emit
diff --git a/docs/event-audit.md b/docs/event-audit.md
index 776e1ad..0c5b282 100644
--- a/docs/event-audit.md
+++ b/docs/event-audit.md
@@ -53,7 +53,9 @@ Happiness Aggregates stay civic mass (B unit camera).
Status B: brief FX/cooldowns + egg gain only.
-Noise still skipped at patch (not dial): building_damage chips, boat_load.
+Noise still skipped at patch (not dial): building_damage chips, boat_load, **ambient building/object destroy** (falls are Layer B; consume stays A).
+
+Presentability: production `EventFeedUtil.Register` rejects Labels the dossier would blank (`unpresentable` drop). See [camera-presentability-plan.md](camera-presentability-plan.md).
## Full surface
@@ -67,8 +69,14 @@ See [world-event-inventory.md](world-event-inventory.md) for live library counts
4. Soft peer rotates on EventLed only after `minCameraDwell` (15s), never during protected sticky hold without margin.
5. Quiet grace 6s: still-worth-watching filter, then Character fill with empty reason.
6. Drops recorded in `InterestDropLog`.
+7. Focus continuity: while Idle Spectator is on, a missing living camera focus is repaired the same director tick (reattach scene follow/related, else ambient fill / any alive unit). Orphan scenes (no living follow/related) end as `follow_lost` instead of sitting in quiet_grace with an empty camera.
## Mutation gap triage
`mutation_discovery` refreshes `.harness/mutation_gaps.tsv`.
Open rows are unexplained Wire candidates only.
+
+## Idle quality (next)
+
+Fire accuracy is covered by [event-live-verification-plan.md](event-live-verification-plan.md).
+Idle watch quality (presentability gate, ambient fall demotion, focus hold, tip placeholder) is tracked in [camera-presentability-plan.md](camera-presentability-plan.md).
diff --git a/docs/event-e2e.md b/docs/event-e2e.md
index f7bb72a..5ff7bdc 100644
--- a/docs/event-e2e.md
+++ b/docs/event-e2e.md
@@ -30,6 +30,17 @@ Observe → confirm → emit: [`event-observation.md`](event-observation.md).
Flake budget is proven (`--repeat 3` PASS); it remains heavier than day-to-day.
+## Idle quality (camera presentability)
+
+See [`camera-presentability-plan.md`](camera-presentability-plan.md).
+
+| Check | Scenario / assert |
+|-------|-------------------|
+| Unpresentable Label rejected at Register | `camera_presentability` / `presentability_probe` |
+| No `Watching: something interesting` | `no_placeholder_tip` |
+| Focus survives max_cap | `camera_presentability` / `has_focus` |
+| Ambient building falls are B-only | live patch + pipelines `id_drop` |
+
## Coverage levels
Recorded in `.harness/event-live-coverage.tsv` (gitignored; regenerated each live run).
diff --git a/docs/event-live-verification-plan.md b/docs/event-live-verification-plan.md
index 5916821..c413f56 100644
--- a/docs/event-live-verification-plan.md
+++ b/docs/event-live-verification-plan.md
@@ -212,3 +212,8 @@ Scenarios: `event_live_apply_loop` + `event_live_relationship` nest in `event_su
- `fail == 0` on live runs
- New catalog ids show up as `none` until a runner claims them
- Coverage vocabulary never equates inject or `feed` with production-accurate trigger proof
+
+## After this plan
+
+Live *fire* proof is largely done.
+Idle *watch quality* (selection = presentation, ambient fall class demotion, focus hold, tip placeholder) is a separate track: [camera-presentability-plan.md](camera-presentability-plan.md).
diff --git a/docs/event-reason.md b/docs/event-reason.md
index a2882df..071dfaf 100644
--- a/docs/event-reason.md
+++ b/docs/event-reason.md
@@ -79,9 +79,11 @@ See `scoring-model.json`.
`UnitDossier.EventLabelBeat` keeps EventReason / `Name verb …` sentences.
It rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`).
Active EventLed A scenes should not silently blank a valid Label.
+`EventPresentability.WouldShow` is the shared gate so unpresentable Labels never win Watching.
## See also
+- [`camera-presentability-plan.md`](camera-presentability-plan.md) - selection = presentation; fall class demotion
- [`event-e2e.md`](event-e2e.md) - catalog inject + live outcome suite
- [`event-observation.md`](event-observation.md) - observe → confirm → emit (Beh Continue is not success)
- [`event-audit.md`](event-audit.md) - sources, MaxWatch, A demotion set
diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh
index fe82880..e9cbfd4 100755
--- a/scripts/harness-run.sh
+++ b/scripts/harness-run.sh
@@ -37,6 +37,7 @@ REGRESSION_SCENARIOS=(
director_action_priority
discovery
world_log
+ camera_presentability
event_suite
chronicle_smoke
activity_idle_smoke