From 57d2e70476895f579ca5638a2398fdba10999c20 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 16 Jul 2026 20:46:22 -0500 Subject: [PATCH] Rework directo focus --- IdleSpectator/AgentHarness.cs | 128 ++++++++++ IdleSpectator/CameraDirector.cs | 12 +- IdleSpectator/EventLivePipelinesHarness.cs | 9 +- IdleSpectator/Events/EventFeedUtil.cs | 18 ++ IdleSpectator/Events/EventPresentability.cs | 61 +++++ IdleSpectator/Events/EventReason.cs | 4 +- IdleSpectator/Events/InterestDropLog.cs | 20 ++ .../Events/Patches/BuildingEventPatches.cs | 81 +----- IdleSpectator/HarnessScenarios.cs | 77 ++++++ IdleSpectator/InterestDirector.cs | 139 ++++++++++- IdleSpectator/UnitDossier.cs | 177 +++++++++++-- IdleSpectator/mod.json | 4 +- docs/camera-presentability-plan.md | 232 ++++++++++++++++++ docs/event-audit.md | 10 +- docs/event-e2e.md | 11 + docs/event-live-verification-plan.md | 5 + docs/event-reason.md | 2 + scripts/harness-run.sh | 1 + 18 files changed, 870 insertions(+), 121 deletions(-) create mode 100644 IdleSpectator/Events/EventPresentability.cs create mode 100644 docs/camera-presentability-plan.md 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