Rework directo focus
This commit is contained in:
parent
3cac264bd7
commit
57d2e70476
18 changed files with 870 additions and 121 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
61
IdleSpectator/Events/EventPresentability.cs
Normal file
61
IdleSpectator/Events/EventPresentability.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
232
docs/camera-presentability-plan.md
Normal file
232
docs/camera-presentability-plan.md
Normal 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 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
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ REGRESSION_SCENARIOS=(
|
|||
director_action_priority
|
||||
discovery
|
||||
world_log
|
||||
camera_presentability
|
||||
event_suite
|
||||
chronicle_smoke
|
||||
activity_idle_smoke
|
||||
|
|
|
|||
Loading…
Reference in a new issue