Rework directo focus

This commit is contained in:
DazedAnon 2026-07-16 20:46:22 -05:00
parent 3cac264bd7
commit 57d2e70476
18 changed files with 870 additions and 121 deletions

View file

@ -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.#}");
}
/// <summary>
/// Proves unpresentable Labels are rejected at Register (selection = presentation).
/// </summary>
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);
}
/// <summary>
/// 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;

View file

@ -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()

View file

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

View file

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

View file

@ -0,0 +1,61 @@
namespace IdleSpectator;
/// <summary>
/// Single source of truth: would the orange dossier show this EventLed Label?
/// Selection must equal presentation - unpresentable beats must not win Watching.
/// </summary>
public static class EventPresentability
{
/// <summary>
/// True when the candidate may own Layer A camera.
/// Empty Label is only allowed for CharacterLed fill (task chip, no orange reason).
/// </summary>
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));
}
/// <summary>
/// Shown orange reason, or empty when blanked by identity / ownership rules.
/// </summary>
public static string ShownReason(InterestCandidate scene, bool recordDrops = true)
{
return UnitDossier.EvaluateSceneLabel(scene, recordDrops);
}
/// <summary>
/// Harness synthetic tips ("HoldAction") must be subject-led EventReason shape
/// so the presentability gate matches production dossier rules.
/// </summary>
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;
}
}

View file

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

View file

@ -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<string> 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<string> lines = Snapshot(max);

View file

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

View file

@ -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
};
}
/// <summary>
/// Selection = presentation: unpresentable Labels never register A;
/// presentable EventReason does; focus survives max_cap; no placeholder tip.
/// </summary>
private static List<HarnessCommand> CameraPresentability()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Watch-reason row must explain why the camera is here (event), not who (title/name).
/// </summary>
@ -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),

View file

@ -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");
}
/// <summary>
/// Keep a living focus unit whenever Idle Spectator is on.
/// Covers death mid-combat and quiet_grace windows where vanilla cleared follow.
/// </summary>
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);
}
/// <summary>
/// After scene end, keep a living focus unit so idle never flashes the power bar.
/// </summary>
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);

View file

@ -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)
/// <summary>
/// Shared with <see cref="EventPresentability"/> - dossier orange reason rules.
/// </summary>
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;
}
/// <summary>
/// "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, …)".
/// </summary>
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;
}

View file

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

View file

@ -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 05 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 AD)
## 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

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

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

View file

@ -37,6 +37,7 @@ REGRESSION_SCENARIOS=(
director_action_priority
discovery
world_log
camera_presentability
event_suite
chronicle_smoke
activity_idle_smoke