diff --git a/IdleSpectator/ActivityHappinessHarness.cs b/IdleSpectator/ActivityHappinessHarness.cs index cd1361a..ec9ca84 100644 --- a/IdleSpectator/ActivityHappinessHarness.cs +++ b/IdleSpectator/ActivityHappinessHarness.cs @@ -19,6 +19,7 @@ public sealed class ActivityHappinessAuditResult public int MissingPolicy; public int RequiredContextFailures; public int MissingEventStrength; + public int ProseRegressions; } /// @@ -43,6 +44,7 @@ public static class ActivityHappinessHarness int missingPolicy = 0; int requiredContextFailures = 0; int missingEventStrength = 0; + int proseRegressions = 0; var tsv = new StringBuilder(); tsv.AppendLine( "id\tvalue\tpsychopath\tpresentation\tlife\tcontext\toverlap\tevent_strength\tclause\tauthored\ticon\tlocale_leak\tnotes"); @@ -195,6 +197,35 @@ public static class ActivityHappinessHarness // Disk may be unavailable in some harness contexts. } + // Prose vs call-site regressions (Layer B honesty). + proseRegressions += CountProseRegression( + "just_born", + mustContain: new[] { "appears in the world", "brought into existence" }, + mustNotContain: new[] { "born into the world", "first breath" }); + proseRegressions += CountProseRegression( + "just_kissed", + mustContain: new[] { "mates", "intimacy" }, + mustNotContain: new[] { "kiss" }); + proseRegressions += CountProseRegression( + "just_got_out_of_egg", + mustContain: new[] { "hatches from an egg" }, + mustNotContain: Array.Empty()); + + // Camera demotion: ticker crumbs must stay Ambient (Layer B only). + string[] ambientRequired = + { + "just_received_gift", "just_gave_gift", "just_cried", "just_inspired", + "got_robbed", "lost_fight", "just_injured", "just_made_friend" + }; + for (int i = 0; i < ambientRequired.Length; i++) + { + HappinessCatalogEntry e = EventCatalog.Happiness.GetOrFallback(ambientRequired[i]); + if (e.Presentation == HappinessPresentationTier.Signal) + { + proseRegressions++; + } + } + bool passed = missingAuthored == 0 && missingIcon == 0 && localeLeaks == 0 @@ -202,7 +233,8 @@ public static class ActivityHappinessHarness && orphanAuthored == 0 && missingPolicy == 0 && requiredContextFailures == 0 - && missingEventStrength == 0; + && missingEventStrength == 0 + && proseRegressions == 0; return new ActivityHappinessAuditResult { @@ -216,14 +248,45 @@ public static class ActivityHappinessHarness MissingPolicy = missingPolicy, RequiredContextFailures = requiredContextFailures, MissingEventStrength = missingEventStrength, + ProseRegressions = proseRegressions, Detail = $"total={liveIds.Count} missingAuthored={missingAuthored} missingIcon={missingIcon} " + $"localeLeaks={localeLeaks} emptyPhrase={emptyPhrase} orphanAuthored={orphanAuthored} " + $"missingPolicy={missingPolicy} requiredContextFailures={requiredContextFailures} " - + $"missingEventStrength={missingEventStrength}" + + $"missingEventStrength={missingEventStrength} proseRegressions={proseRegressions}" }; } + private static int CountProseRegression(string id, string[] mustContain, string[] mustNotContain) + { + HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(id); + if (entry == null || entry.IsFallback) + { + return 1; + } + + string joined = string.Join(" ", entry.VariantsWithoutRelated ?? Array.Empty()) + + " " + + string.Join(" ", entry.VariantsWithRelated ?? Array.Empty()); + for (int i = 0; mustContain != null && i < mustContain.Length; i++) + { + if (joined.IndexOf(mustContain[i], StringComparison.OrdinalIgnoreCase) < 0) + { + return 1; + } + } + + for (int i = 0; mustNotContain != null && i < mustNotContain.Length; i++) + { + if (joined.IndexOf(mustNotContain[i], StringComparison.OrdinalIgnoreCase) >= 0) + { + return 1; + } + } + + return 0; + } + private static bool LooksLikeLocaleLeak(string text, string id) { if (string.IsNullOrEmpty(text)) diff --git a/IdleSpectator/ActivityStatusHarness.cs b/IdleSpectator/ActivityStatusHarness.cs index 8423889..1ab3577 100644 --- a/IdleSpectator/ActivityStatusHarness.cs +++ b/IdleSpectator/ActivityStatusHarness.cs @@ -184,6 +184,35 @@ public static class ActivityStatusHarness // Artifact write is best-effort. } + int cameraDemotionFailures = 0; + string[] bOnlyStatus = + { + "surprised", "confused", "motivated", "inspired", "magnetized", + "starving", "ash_fever", "powerup", "voices_in_my_head", "angry" + }; + for (int i = 0; i < bOnlyStatus.Length; i++) + { + StatusInterestEntry e = EventCatalog.Status.GetOrFallback(bOnlyStatus[i]); + if (e.CreatesInterest) + { + cameraDemotionFailures++; + } + } + + string[] aStatus = + { + "drowning", "burning", "frozen", "poisoned", "possessed", "cursed", + "pregnant", "crying", "rage", "stunned", "soul_harvested", "tantrum" + }; + for (int i = 0; i < aStatus.Length; i++) + { + StatusInterestEntry e = EventCatalog.Status.GetOrFallback(aStatus[i]); + if (!e.CreatesInterest) + { + cameraDemotionFailures++; + } + } + bool passed = liveIds.Count > 20 && missingAuthored == 0 && missingIcon == 0 @@ -191,7 +220,8 @@ public static class ActivityStatusHarness && emptyPhrase == 0 && orphanAuthored == 0 && missingInterest == 0 - && orphanInterest == 0; + && orphanInterest == 0 + && cameraDemotionFailures == 0; return new ActivityStatusAuditResult { @@ -207,7 +237,8 @@ public static class ActivityStatusHarness Detail = $"total={liveIds.Count} missingAuthored={missingAuthored} missingIcon={missingIcon} " + $"localeLeaks={localeLeaks} emptyPhrase={emptyPhrase} orphanAuthored={orphanAuthored} " - + $"missingInterest={missingInterest} orphanInterest={orphanInterest} passed={passed}" + + $"missingInterest={missingInterest} orphanInterest={orphanInterest} " + + $"cameraDemotionFailures={cameraDemotionFailures} passed={passed}" }; } diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 9b84965..f276c62 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -5744,8 +5744,14 @@ public static class AgentHarness private static void DoSnapshot(HarnessCommand cmd) { + string drops = InterestDropLog.FormatRecent(6); string snap = - $"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} score={InterestDirector.CurrentScoreLabel} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count}"; + $"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} score={InterestDirector.CurrentScoreLabel} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count} drops={InterestDropLog.Count}"; + if (!string.IsNullOrEmpty(drops)) + { + snap += " recentDrop=[" + drops + "]"; + } + _cmdOk++; Emit(cmd, ok: true, detail: snap); LogService.LogInfo("[IdleSpectator][SNAP] " + snap); diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Happiness.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Happiness.cs index e7d8a71..4c98347 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.Happiness.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.Happiness.cs @@ -67,7 +67,7 @@ public static partial class EventCatalog { Id = "got_robbed", EventStrength = 50f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.RelatedOptional, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Independent, @@ -93,7 +93,7 @@ public static partial class EventCatalog { Id = "lost_fight", EventStrength = 55f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Independent, @@ -147,7 +147,7 @@ public static partial class EventCatalog { Id = "just_received_gift", EventStrength = 50f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.RelatedOptional, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Independent, @@ -160,7 +160,7 @@ public static partial class EventCatalog { Id = "just_gave_gift", EventStrength = 50f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.RelatedOptional, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Independent, @@ -510,7 +510,7 @@ public static partial class EventCatalog { Id = "just_cried", EventStrength = 30f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Continuation, @@ -566,7 +566,7 @@ public static partial class EventCatalog // Game id uses British spelling; status asset is magnetized. Id = "just_magnetised", EventStrength = 45f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Continuation, @@ -580,7 +580,7 @@ public static partial class EventCatalog { Id = "just_forced_power", EventStrength = 45f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Independent, @@ -607,7 +607,7 @@ public static partial class EventCatalog { Id = "strange_urge", EventStrength = 45f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Continuation, @@ -621,7 +621,7 @@ public static partial class EventCatalog { Id = "just_had_tantrum", EventStrength = 45f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Continuation, @@ -635,7 +635,7 @@ public static partial class EventCatalog { Id = "just_felt_the_divine", EventStrength = 45f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Independent, @@ -648,7 +648,7 @@ public static partial class EventCatalog { Id = "just_enchanted", EventStrength = 45f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Continuation, @@ -662,7 +662,7 @@ public static partial class EventCatalog { Id = "just_inspired", EventStrength = 45f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Continuation, @@ -729,7 +729,7 @@ public static partial class EventCatalog { Id = "just_found_house", EventStrength = 55f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ProjectToLife, Overlap = HappinessOverlapPolicy.Independent, @@ -742,7 +742,7 @@ public static partial class EventCatalog { Id = "just_lost_house", EventStrength = 65f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ProjectToLife, Overlap = HappinessOverlapPolicy.Independent, @@ -755,7 +755,7 @@ public static partial class EventCatalog { Id = "just_made_friend", EventStrength = 50f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.RelatedOptional, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.CanonicalMilestone, @@ -769,7 +769,7 @@ public static partial class EventCatalog { Id = "just_injured", EventStrength = 65f, - Presentation = HappinessPresentationTier.Signal, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ProjectToLife, Overlap = HappinessOverlapPolicy.Independent, diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Status.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Status.cs index 45d0a6f..b4f782c 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.Status.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.Status.cs @@ -34,23 +34,23 @@ public static partial class EventCatalog Add("drowning", 50f, "StatusTransformation", true); Add("stunned", 50f, "StatusTransformation", true); Add("rage", 62f, "StatusTransformation", true); - Add("angry", 48f, "StatusTransformation", true); + Add("angry", 48f, "StatusAmbient", false); Add("cursed", 60f, "StatusTransformation", true); Add("soul_harvested", 75f, "StatusTransformation", true); - Add("voices_in_my_head", 55f, "StatusTransformation", true); + Add("voices_in_my_head", 55f, "StatusAmbient", false); Add("tantrum", 52f, "StatusTransformation", true); Add("egg", 40f, "StatusTransformation", false); - Add("magnetized", 55f, "StatusTransformation", true); + Add("magnetized", 55f, "StatusAmbient", false); Add("invincible", 40f, "StatusTransformation", false); - Add("powerup", 54f, "StatusTransformation", true); + Add("powerup", 54f, "StatusAmbient", false); Add("enchanted", 52f, "StatusTransformation", true); - Add("ash_fever", 50f, "StatusTransformation", true); - Add("starving", 48f, "StatusTransformation", true); - Add("surprised", 42f, "StatusTransformation", true); - Add("confused", 40f, "StatusTransformation", true); - Add("strange_urge", 45f, "StatusTransformation", true); - Add("inspired", 42f, "Emotion", true); - Add("motivated", 40f, "Emotion", true); + Add("ash_fever", 50f, "StatusAmbient", false); + Add("starving", 48f, "StatusAmbient", false); + Add("surprised", 42f, "StatusAmbient", false); + Add("confused", 40f, "StatusAmbient", false); + Add("strange_urge", 45f, "StatusAmbient", false); + Add("inspired", 42f, "Emotion", false); + Add("motivated", 40f, "Emotion", false); Add("fell_in_love", 42f, "Relationship", false); Add("pregnant", 58f, "LifeChapter", true); Add("pregnant_parthenogenesis", 58f, "LifeChapter", true); diff --git a/IdleSpectator/Events/EventCatalog.cs b/IdleSpectator/Events/EventCatalog.cs index a7df8d7..438d142 100644 --- a/IdleSpectator/Events/EventCatalog.cs +++ b/IdleSpectator/Events/EventCatalog.cs @@ -2,8 +2,12 @@ namespace IdleSpectator; /// /// IdleSpectator event inventory (one type, split across partial files). +/// +/// Layer B (ticker): Activity / Life prose for live ids - completeness. +/// Layer A (story camera): only CameraWorthy beats register interest and cut the camera. +/// /// Call sites: EventCatalog.Status / .Happiness / .Combat / … -/// Director only ranks; feeds register from these dials. +/// Director only ranks A candidates; it does not author inventory. /// /// Domain files under Events/Catalogs/: /// EventCatalog.Status.cs @@ -16,11 +20,32 @@ namespace IdleSpectator; /// EventCatalog.Relationship.cs /// EventCatalog.Disaster.cs /// EventCatalog.WarType.cs -/// EventCatalog.Libraries.cs (Spell, Power, Decision, Item, SubspeciesTrait, Gene, Phenotype, WorldLaw, Biome) +/// EventCatalog.Libraries.cs /// Combat policy lives in this file. /// public static partial class EventCatalog { + /// + /// Layer A gate for happiness: Signal may own the camera; Ambient/Aggregate are B-only. + /// + public static bool IsCameraWorthy(HappinessPresentationTier presentation) => + presentation == HappinessPresentationTier.Signal; + + /// Layer A gate for discrete / status / worldlog rows. + public static bool IsCameraWorthy(bool createsInterest) => createsInterest; + + public static bool IsCameraWorthy(DiscreteEventEntry entry) => + entry != null && entry.CreatesInterest && !entry.IsFallback; + + public static bool IsCameraWorthy(StatusInterestEntry entry) => + entry != null && entry.CreatesInterest && !entry.IsFallback; + + public static bool IsCameraWorthy(WorldLogEventEntry entry) => + entry != null && entry.CreatesInterest && !entry.IsFallback; + + public static bool IsCameraWorthy(HappinessCatalogEntry entry) => + entry != null && IsCameraWorthy(entry.Presentation); + /// Combat camera policy (activity + scanner share one key / dials). public static class Combat { diff --git a/IdleSpectator/Events/Feeds/BookInterestFeed.cs b/IdleSpectator/Events/Feeds/BookInterestFeed.cs index c35b7ae..c580fe3 100644 --- a/IdleSpectator/Events/Feeds/BookInterestFeed.cs +++ b/IdleSpectator/Events/Feeds/BookInterestFeed.cs @@ -13,8 +13,9 @@ public static class BookInterestFeed } DiscreteEventEntry entry = EventCatalog.Book.GetOrFallback(bookTypeId); - if (!entry.CreatesInterest) + if (!EventCatalog.IsCameraWorthy(entry)) { + InterestDropLog.Record("createsInterest=false", "book"); return; } diff --git a/IdleSpectator/Events/Feeds/DeferredInterestFeed.cs b/IdleSpectator/Events/Feeds/DeferredInterestFeed.cs index b344009..641eb09 100644 --- a/IdleSpectator/Events/Feeds/DeferredInterestFeed.cs +++ b/IdleSpectator/Events/Feeds/DeferredInterestFeed.cs @@ -41,8 +41,15 @@ public static class DeferredInterestFeed public static void EmitPower(string powerId, Vector3 position, Actor nearUnit) { DiscreteEventEntry entry = EventCatalog.Power.GetOrFallback(powerId); - if (!entry.CreatesInterest || AgentHarness.Busy) + if (AgentHarness.Busy) { + InterestDropLog.Record("busy", "power"); + return; + } + + if (!EventCatalog.IsCameraWorthy(entry)) + { + InterestDropLog.Record("createsInterest=false", "power:" + (powerId ?? "")); return; } @@ -110,8 +117,9 @@ public static class DeferredInterestFeed } DiscreteEventEntry entry = EventCatalog.WorldLaw.GetOrFallback(lawId); - if (!entry.CreatesInterest) + if (!EventCatalog.IsCameraWorthy(entry)) { + InterestDropLog.Record("createsInterest=false", entry.Id ?? ""); return; } @@ -135,8 +143,9 @@ public static class DeferredInterestFeed } DiscreteEventEntry entry = EventCatalog.Biome.GetOrFallback(biomeId); - if (!entry.CreatesInterest) + if (!EventCatalog.IsCameraWorthy(entry)) { + InterestDropLog.Record("createsInterest=false", entry.Id ?? ""); return; } @@ -166,8 +175,9 @@ public static class DeferredInterestFeed } DiscreteEventEntry entry = lookup(assetId); - if (!entry.CreatesInterest) + if (!EventCatalog.IsCameraWorthy(entry)) { + InterestDropLog.Record("createsInterest=false", entry.Id ?? ""); return; } diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index b92b993..ab75c2c 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -24,6 +24,7 @@ public static class InterestFeeds HappinessDrain.Clear(); _lastScannerAt = -999f; CivicBoostUntil.Clear(); + InterestDropLog.Clear(); } public static ulong HappinessCursor => _happinessCursor; @@ -94,8 +95,11 @@ public static class InterestFeeds } WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(message.asset_id); - if (!entry.CreatesInterest) + if (!EventCatalog.IsCameraWorthy(entry)) { + InterestDropLog.Record( + "createsInterest=false", + "worldlog:" + (message.asset_id ?? "")); return; } @@ -370,7 +374,7 @@ public static class InterestFeeds } // Egg timer end is the reliable hatch beat. Happiness just_got_out_of_egg is often - // suppressed (no emotions / still flagged egg), so status loss owns the camera reason. + // suppressed (no emotions), so status loss owns the camera reason. if (!gained && string.Equals(statusId.Trim(), "egg", StringComparison.OrdinalIgnoreCase)) { EmitHatch(actor, "status_egg_loss"); @@ -379,12 +383,14 @@ public static class InterestFeeds if (!gained) { + InterestDropLog.Record("status_loss_ignored", statusId); return; } StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId); - if (!entry.CreatesInterest) + if (!EventCatalog.IsCameraWorthy(entry)) { + InterestDropLog.Record("createsInterest=false", "status:" + statusId); return; } @@ -535,20 +541,22 @@ public static class InterestFeeds private static void IngestHappiness(HappinessOccurrence occ) { - if (occ == null || !occ.Applied || occ.SuppressedByPsychopath) + if (occ == null) { return; } - // Harness scenarios inject via SourceHook.Harness; ignore live world noise while Busy. + if (!occ.Applied || occ.SuppressedByPsychopath) + { + InterestDropLog.Record( + occ.SuppressedByPsychopath ? "suppressed" : "not_applied", + occ.EffectId ?? ""); + return; + } + if (AgentHarness.Busy && occ.SourceHook != HappinessSourceHook.Harness) { - return; - } - - if (occ.Presentation == HappinessPresentationTier.Ambient) - { - // Enrichment only - never owns the camera. + InterestDropLog.Record("busy", occ.EffectId ?? ""); return; } @@ -578,6 +586,14 @@ public static class InterestFeeds StampCivicBoost(kingdom, occ.EffectId, 25f + Mathf.Min(40f, occ.TotalAffectedCount)); } + InterestDropLog.Record("aggregate", occ.EffectId ?? ""); + return; + } + + if (!EventCatalog.IsCameraWorthy(occ.Presentation)) + { + // Layer B only - never owns the camera. + InterestDropLog.Record("ambient", occ.EffectId ?? ""); return; } diff --git a/IdleSpectator/Events/Feeds/MetaInterestFeed.cs b/IdleSpectator/Events/Feeds/MetaInterestFeed.cs index 67fac70..dac7cfd 100644 --- a/IdleSpectator/Events/Feeds/MetaInterestFeed.cs +++ b/IdleSpectator/Events/Feeds/MetaInterestFeed.cs @@ -13,8 +13,9 @@ public static class MetaInterestFeed } DiscreteEventEntry entry = EventCatalog.Era.GetOrFallback(eraId); - if (!entry.CreatesInterest) + if (!EventCatalog.IsCameraWorthy(entry)) { + InterestDropLog.Record("createsInterest=false", "meta"); return; } diff --git a/IdleSpectator/Events/Feeds/PlotInterestFeed.cs b/IdleSpectator/Events/Feeds/PlotInterestFeed.cs index 7aa90aa..506a76f 100644 --- a/IdleSpectator/Events/Feeds/PlotInterestFeed.cs +++ b/IdleSpectator/Events/Feeds/PlotInterestFeed.cs @@ -15,8 +15,9 @@ public static class PlotInterestFeed } DiscreteEventEntry entry = EventCatalog.Plot.GetOrFallback(plotAssetId); - if (!entry.CreatesInterest) + if (!EventCatalog.IsCameraWorthy(entry)) { + InterestDropLog.Record("createsInterest=false", "plot"); return; } diff --git a/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs b/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs index b5abe46..70adf77 100644 --- a/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs +++ b/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs @@ -13,8 +13,9 @@ public static class RelationshipInterestFeed } DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback(eventId); - if (!entry.CreatesInterest) + if (!EventCatalog.IsCameraWorthy(entry)) { + InterestDropLog.Record("createsInterest=false", "relationship:" + (eventId ?? "")); return; } diff --git a/IdleSpectator/Events/Feeds/WarInterestFeed.cs b/IdleSpectator/Events/Feeds/WarInterestFeed.cs index 3fc8657..4fdb0dd 100644 --- a/IdleSpectator/Events/Feeds/WarInterestFeed.cs +++ b/IdleSpectator/Events/Feeds/WarInterestFeed.cs @@ -60,8 +60,9 @@ public static class WarInterestFeed { string warTypeId = ReadWarTypeId(war); WarTypeInterestEntry entry = EventCatalog.WarType.GetOrFallback(warTypeId); - if (!entry.CreatesInterest) + if (entry == null || !EventCatalog.IsCameraWorthy(entry.CreatesInterest)) { + InterestDropLog.Record("createsInterest=false", "war:" + (warTypeId ?? "")); return false; } diff --git a/IdleSpectator/Events/InterestDropLog.cs b/IdleSpectator/Events/InterestDropLog.cs new file mode 100644 index 0000000..bb134e1 --- /dev/null +++ b/IdleSpectator/Events/InterestDropLog.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Text; +using NeoModLoader.services; + +namespace IdleSpectator; + +/// +/// Ring buffer of why a beat stayed Layer B or failed to take / keep Layer A camera. +/// +public static class InterestDropLog +{ + public const int Capacity = 48; + + private static readonly string[] Buffer = new string[Capacity]; + private static int _write; + private static int _count; + private static float _lastPlayerLogAt = -999f; + private const float PlayerLogInterval = 8f; + + public static int Count => _count; + + public static void Clear() + { + _write = 0; + _count = 0; + for (int i = 0; i < Capacity; i++) + { + Buffer[i] = null; + } + } + + public static void Record(string reason, string detail = "") + { + if (string.IsNullOrEmpty(reason)) + { + return; + } + + string line = string.IsNullOrEmpty(detail) + ? reason + : (reason + ": " + detail); + Buffer[_write] = line; + _write = (_write + 1) % Capacity; + if (_count < Capacity) + { + _count++; + } + + float now = UnityEngine.Time.unscaledTime; + if (now - _lastPlayerLogAt >= PlayerLogInterval) + { + _lastPlayerLogAt = now; + try + { + LogService.LogInfo("[IdleSpectator][DROP] " + line); + } + catch + { + // ignore log failures + } + } + } + + /// Newest-first snapshot for harness / diagnostics. + public static List Snapshot(int max = 12) + { + var list = new List(Math.Min(max, _count)); + if (_count == 0 || max <= 0) + { + return list; + } + + int n = Math.Min(max, _count); + for (int i = 0; i < n; i++) + { + int idx = (_write - 1 - i + Capacity) % Capacity; + if (!string.IsNullOrEmpty(Buffer[idx])) + { + list.Add(Buffer[idx]); + } + } + + return list; + } + + public static string FormatRecent(int max = 8) + { + List lines = Snapshot(max); + if (lines.Count == 0) + { + return ""; + } + + var sb = new StringBuilder(); + for (int i = 0; i < lines.Count; i++) + { + if (i > 0) + { + sb.Append(" | "); + } + + sb.Append(lines[i]); + } + + return sb.ToString(); + } +} diff --git a/IdleSpectator/Events/Patches/TraitEventPatches.cs b/IdleSpectator/Events/Patches/TraitEventPatches.cs index c63d63a..3d565bd 100644 --- a/IdleSpectator/Events/Patches/TraitEventPatches.cs +++ b/IdleSpectator/Events/Patches/TraitEventPatches.cs @@ -113,8 +113,9 @@ public static class TraitEventPatches } DiscreteEventEntry entry = EventCatalog.Trait.GetOrFallback(traitId); - if (!entry.CreatesInterest) + if (!EventCatalog.IsCameraWorthy(entry)) { + InterestDropLog.Record("createsInterest=false", "trait:" + (traitId ?? "")); return; } diff --git a/IdleSpectator/HappinessEventRouter.cs b/IdleSpectator/HappinessEventRouter.cs index 62b9938..b19d391 100644 --- a/IdleSpectator/HappinessEventRouter.cs +++ b/IdleSpectator/HappinessEventRouter.cs @@ -183,6 +183,7 @@ public static class HappinessEventRouter _suppressedSinceClear++; } + InterestDropLog.Record("no_emotions_or_egg", effectId ?? ""); var occ = BuildOccurrence( subject, effectId, diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 3cea0bb..0958e97 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -552,12 +552,14 @@ public static class InterestDirector // Queued too long - miss the beat. if (now - c.CreatedAt > 5.5f) { + InterestDropLog.Record("stale", c.HappinessEffectId + " queue"); return true; } Actor unit = c.FollowUnit; if (unit == null || !unit.isAlive()) { + InterestDropLog.Record("stale", c.HappinessEffectId + " dead"); return true; } @@ -569,6 +571,7 @@ public static class InterestDirector { if (!unit.isBaby() && unit.getAge() > 1) { + InterestDropLog.Record("stale", "just_born grown"); return true; } } @@ -876,11 +879,17 @@ public static class InterestDirector if (_current != null && (_current.Completion == InterestCompletionKind.CombatActive || _current.Completion == InterestCompletionKind.StatusPhase - || _current.Completion == InterestCompletionKind.HappinessGrief)) + || _current.Completion == InterestCompletionKind.HappinessGrief + || IsStickyStoryScene(_current))) { float graceCur = _current.TotalScore; float graceNext = candidate.TotalScore; - return graceNext >= graceCur + InterestScoringConfig.W.cutInMargin + float graceMargin = Mathf.Max( + InterestScoringConfig.W.cutInMargin, + InterestScoringConfig.W.stickyCutInMargin > 0f + ? InterestScoringConfig.W.stickyCutInMargin + : 50f); + return graceNext >= graceCur + graceMargin && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true); } @@ -899,6 +908,10 @@ public static class InterestDirector bool nextFill = InterestScoring.IsFillScore(nextScore); bool curAmbient = IsAmbientShot(_current); bool curFill = curAmbient || InterestScoring.IsFillScore(curScore); + bool sticky = IsStickyStoryScene(_current); + float cutMargin = sticky + ? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f) + : w.cutInMargin; // Ambient fill: any real event may take the camera immediately (no min dwell). if (curAmbient && !nextFill && !IsAmbientShot(candidate)) @@ -906,12 +919,25 @@ public static class InterestDirector return true; } - // Instant score-margin cut - no settle grace, no MinDwell block. - if (nextScore >= curScore + w.cutInMargin) + // Same-arc refresh (combat/hatch/grief keys) may replace without full margin. + if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack) { return true; } + // Instant score-margin cut - no settle grace, no MinDwell block. + if (nextScore >= curScore + cutMargin) + { + return true; + } + + if (sticky && nextScore < curScore + cutMargin) + { + InterestDropLog.Record( + "below_margin", + $"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"); + } + // Fill never cuts a protected non-fill session without margin (already failed above). if (nextFill && !curFill && protectedScene) { @@ -924,8 +950,8 @@ public static class InterestDirector return false; } - // Protected EventLed hold: only margin cut-in (handled above). - if (protectedScene && !curAmbient) + // Protected / sticky EventLed hold: only margin or same-arc (handled above). + if ((protectedScene || sticky) && !curAmbient) { return false; } @@ -941,6 +967,89 @@ public static class InterestDirector && nextScore >= curScore - w.rotateSlack; } + private static bool IsStickyStoryScene(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (c.Completion == InterestCompletionKind.CombatActive + || c.Completion == InterestCompletionKind.StatusPhase + || c.Completion == InterestCompletionKind.HappinessGrief) + { + return true; + } + + // Hatch FixedDwell is a story beat - hold against weak peers. + if (!string.IsNullOrEmpty(c.HappinessEffectId) + && (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) + || string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + if (!string.IsNullOrEmpty(c.Key) + && (c.Key.StartsWith("combat:", StringComparison.Ordinal) + || c.Key.StartsWith("hatch:", StringComparison.Ordinal))) + { + return true; + } + + return false; + } + + private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next) + { + if (current == null || next == null) + { + return false; + } + + if (!string.IsNullOrEmpty(current.Key) + && !string.IsNullOrEmpty(next.Key) + && string.Equals(current.Key, next.Key, StringComparison.Ordinal)) + { + return true; + } + + // Combat / hatch share key prefixes per subject. + if (!string.IsNullOrEmpty(current.Key) && !string.IsNullOrEmpty(next.Key)) + { + if (SameKeyPrefix(current.Key, next.Key, "combat:") + || SameKeyPrefix(current.Key, next.Key, "hatch:")) + { + return true; + } + } + + if (current.Completion == InterestCompletionKind.HappinessGrief + && next.Completion == InterestCompletionKind.HappinessGrief + && current.SubjectId != 0 + && current.SubjectId == next.SubjectId) + { + return true; + } + + return false; + } + + private static bool SameKeyPrefix(string a, string b, string prefix) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix)) + { + return false; + } + + if (!a.StartsWith(prefix, StringComparison.Ordinal) + || !b.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + return string.Equals(a, b, StringComparison.Ordinal); + } + /// /// Still-worth-watching filter: drop stale queued moments; keep live sticky conditions /// and fresh FixedDwell; during grace, brand-new fires are always eligible. diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index 7eed888..f252e39 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -28,6 +28,8 @@ public class ScoringWeights // Director policy (score-native; replaces InterestTier preemption). public float cutInMargin = 35f; + /// Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch). + public float stickyCutInMargin = 50f; public float rotateSlack = 12f; public float fillScoreMax = 55f; public float noticeScoreMin = 70f; diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 9fb8357..f9d86ab 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -284,12 +284,14 @@ 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); return ""; } // Ownership: reject beats that lead with someone else's proper name. if (LeadsWithForeignName(beat, scene)) { + InterestDropLog.Record("identity_filter", "foreign:" + beat); return ""; } @@ -494,9 +496,22 @@ public sealed class UnitDossier } } - // EventReason sentences are dossier-ready - keep names; only drop identity crumbs. + // EventReason / Name-verb sentences are dossier-ready. + if (LooksLikeEventSentence(s) + || (!string.IsNullOrEmpty(d?.Name) && StartsWithSubjectVerbPhrase(s, d.Name))) + { + if (IsWeakFlavorBeat(s, d)) + { + InterestDropLog.Record("identity_filter", "weak:" + s); + return ""; + } + + return CleanBeat(s); + } + if (IsIdentityWhy(s, d) || IsWeakFlavorBeat(s, d)) { + InterestDropLog.Record("identity_filter", s); return ""; } diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index fd74dc3..48cfc90 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.21.3", - "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", + "version": "0.22.0", + "description": "AFK Idle Spectator (I) + Lore (L). Layer B ticker + Layer A story camera.", "GUID": "com.dazed.idlespectator" } diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json index 060ed5a..0aa2c6e 100644 --- a/IdleSpectator/scoring-model.json +++ b/IdleSpectator/scoring-model.json @@ -11,6 +11,7 @@ "noveltyMultiplier": 4, "cutInMargin": 35, + "stickyCutInMargin": 50, "rotateSlack": 12, "fillScoreMax": 55, "noticeScoreMin": 70, diff --git a/docs/event-audit.md b/docs/event-audit.md index d8076d5..f48df8b 100644 --- a/docs/event-audit.md +++ b/docs/event-audit.md @@ -1,21 +1,21 @@ -# Event audit (post-expand) +# Event audit (A/B layers) -Inventory policy after the full gap expand. -Camera interrupt is score-margin only (`cutInMargin`); no Signal/Ambient ownership gate. +**Layer B (ticker):** Activity / Life coverage for live library ids - honest prose, completeness. +**Layer A (story camera):** only `EventCatalog.IsCameraWorthy` beats register interest and cut the camera. **Single inventory:** [`EventCatalog`](../IdleSpectator/Events/EventCatalog.cs) (partials under `Events/Catalogs/`). -Nested domains: `Status`, `Happiness`, `WorldLog`, `Trait`, `Combat`, `Spell`, … -Feeds/patches register from those dials; `InterestDirector` only ranks candidates. +Feeds/patches register A from those dials; `InterestDirector` only ranks candidates. ## Dials -| Dial | Role | -|------|------| -| `CreatesInterest` | Whether a live id may register at all | -| `EventStrength` | Base contribution to `TotalScore` (interrupt via +`cutInMargin`) | -| `Completion` | Hold-until-complete kind | -| `MaxWatch` | Hard cap (class defaults in `scoring-model.json`) | -| `EventReason` | Orange dossier sentence | +| Dial | Layer | Role | +|------|-------|------| +| Happiness `Presentation` | A if Signal | Ambient/Aggregate = B-only | +| `CreatesInterest` | A gate | Discrete / status / WorldLog / libraries | +| `EventStrength` | A rank | Base `TotalScore` (interrupt via margin) | +| `Completion` | A hold | Hold-until-complete kind | +| `MaxWatch` | A hold | Hard cap (`scoring-model.json`) | +| `EventReason` / Label | A display | Orange dossier sentence | ## MaxWatch class defaults @@ -27,56 +27,38 @@ Feeds/patches register from those dials; `InterestDirector` only ranks candidate | Combat | 60 | `maxWatchCombat` | | Epic world | 50 | `maxWatchEpic` | -Peer rotate / soft cut on EventLed floors at `minCameraDwell` (15s). Score-margin interrupts bypass that floor. -Ambient / Character fill is exempt - events may take the camera immediately. +Peer rotate / soft cut on EventLed floors at `minCameraDwell` (15s). +Score-margin interrupts bypass that floor; sticky A scenes use `stickyCutInMargin`. +Ambient / Character fill is exempt - A events may take the camera immediately. ## Sources (wired) | Source | Primary owner | Reason factory | Notes | |--------|---------------|----------------|-------| -| WorldLog | `InterestFeeds.OnWorldLogMessage` | catalog / EventReason | Strength from `EventCatalog.WorldLog` | -| Happiness | `IngestHappiness` | `EventCatalog.Happiness` + EventReason | Prose must match game call sites - see [event-prose-audit.md](event-prose-audit.md) | -| Status | `OnStatusChange` | EventReason.Status | `drowning` CreatesInterest=true, strength ~50; notable via CharacterSignificance | -| Scanner combat | WorldActivityScanner | EventReason.Fight / Battle | Completion CombatActive; dials `EventCatalog.Combat` | -| War | WarEventPatches + WarInterestFeed.Tick | war type labels | | -| Plot | PlotEventPatches + PlotInterestFeed.Tick | `EventCatalog.Plot` | | -| Relationship | RelationshipEventPatches + happiness lovers | EventReason.* | | -| Trait | TraitEventPatches | EventReason.Trait | Spawn window filters routine traits | -| Building | BuildingEventPatches | EventReason.Building* | Eat/damage/falls | -| Boat | BoatEventPatches | EventReason.Boat | | -| Book | BookEventPatches | `EventCatalog.Book` sentences | | -| Era / Meta | MetaEventPatches | EventReason.MetaNew / era labels | | -| Spell | CombatActionLibrary.tryToCastSpell | EventReason.Library | Ambient CreatesInterest=false except spectacle ids | -| Power | PlayerControl.clickedFinal | EventReason.HumanizeId | Spectacle ids create interest | -| Decision | setDecisionCooldown | EventReason.Library | IsCameraWorthy filter | -| Item | ItemManager | EventReason.Library | Legendary CreatesInterest | -| Gene | Subspecies.mutateFrom | EventReason.Library | Ambient CreatesInterest=false | -| Phenotype | generatePhenotypeAndShade | EventReason.Library | | -| World law | WorldLaws.enable / toggle | EventReason.HumanizeId | Location-only | -| Biome | catalog ready | EventReason.HumanizeId | Ambient CreatesInterest=false; emit via power terraform path | -| Subspecies trait | SubspeciesManager biome hooks | EventReason.Library | | +| WorldLog | `InterestFeeds.OnWorldLogMessage` | catalog / EventReason | A if CreatesInterest | +| Happiness | `IngestHappiness` | catalog + EventReason | Signal = A; see [event-prose-audit.md](event-prose-audit.md) | +| Status | `OnStatusChange` | EventReason.Status | Gain A if CreatesInterest; egg **loss** → hatch A | +| Scanner combat | WorldActivityScanner | EventReason.Fight / Battle | `EventCatalog.Combat` | +| War / Plot / Relationship / Trait / Building / Boat / Book / Era | Feeds + patches | EventReason / catalog | A via CreatesInterest | +| Libraries | Deferred feeds | EventReason.Library | Mostly B; spectacle ids A | | Character fill | InterestDirector.TryCharacterFill | empty Label | Only when no pending EventLed | -## Ambient statuses (intentional off) +## Layer A demotion (this pass) -`festive_spirit`, `laughing`, `singing`, `sleeping`, `handsome_migrant` - CreatesInterest=false. +Happiness Signal → Ambient (B ticker only): gifts, cry, inspired, enchanted, divine, magnetised, strange_urge, tantrum, forced_power, lost_fight, got_robbed, house find/lose, made friend, injured. -## Same-moment ownership - -Prefer typed feeds over WorldLog when both fire (relationship / status / combat). -Registry Upsert merges same key and refreshes `LastSeenAt` for sticky combat/status/grief. +Status CreatesInterest → false (Activity remains): surprised, confused, motivated, inspired, magnetized, starving, ash_fever, powerup, voices_in_my_head, angry. ## Interrupt / hold / grace -1. Instant cut when `next.TotalScore >= current + cutInMargin` (no settle; ignores min dwell). -2. Ambient fill: any EventLed may take the camera immediately (no `minCameraDwell`). -3. Else hold while `InterestCompletion.IsActive` and under MaxWatch (moments floor to `minCameraDwell`; combat uses class 60s). -4. Soft peer rotates on EventLed only after `minCameraDwell` (15s). +1. Instant cut when `next.TotalScore >= current + cutInMargin` (sticky: `stickyCutInMargin`). +2. Ambient fill: any A EventLed may take the camera immediately. +3. Else hold while `InterestCompletion.IsActive` and under MaxWatch. +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`. ## Mutation gap triage `mutation_discovery` refreshes `.harness/mutation_gaps.tsv`. Open rows are unexplained Wire candidates only. -False positives (clear/load/UI/FX/getters) and covered-by-primary paths are filtered or listed in `AlreadyWired`. -Library fields are inventory-complete via catalogs / activity / WorldLog (`mutation_libraries.tsv` notes). diff --git a/docs/event-prose-audit.md b/docs/event-prose-audit.md index cd4c03a..f8fd858 100644 --- a/docs/event-prose-audit.md +++ b/docs/event-prose-audit.md @@ -39,6 +39,14 @@ Scope: Happiness (all authored), Relationship (all), Status/WorldLog/Plot/etc. s Grief deaths, `fallen_in_love`, gifts, house find/lose, `just_got_out_of_egg`, `just_became_adult`, WorldLog `king_new` template roles, Status `egg` / `pregnant` / `drowning` prose. +## A/B camera demotion (2026-07-16) + +Happiness Signal → Ambient (ticker only): gifts, cry, inspired, enchanted, divine, magnetised, strange_urge, tantrum happiness, forced_power, lost_fight, got_robbed, house find/lose, made friend, injured. + +Status CreatesInterest → false (Activity remains): surprised, confused, motivated, inspired, magnetized, starving, ash_fever, powerup, voices_in_my_head, angry. + +Gate helper: `EventCatalog.IsCameraWorthy`. Drops: `InterestDropLog`. + ## Hatch camera path (fixed) Hatch was Activity-only for many units: diff --git a/docs/event-reason.md b/docs/event-reason.md index 8f8080f..f40c5f8 100644 --- a/docs/event-reason.md +++ b/docs/event-reason.md @@ -1,14 +1,23 @@ # Event reasons (camera + orange dossier) -## Contract +## A/B layers + +| Layer | What | Gate | +|-------|------|------| +| **B ticker** | Activity / Life / chronicle prose for live ids | Always may write when the game fires | +| **A story camera** | Interest candidates, orange reason, camera cuts | `EventCatalog.IsCameraWorthy` only | + +Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only. +Discrete / status / WorldLog: `CreatesInterest` = A gate. +`InterestDirector` only ranks A candidates. It does not author inventory. -Registered **events** own the idle camera. -`EventStrength` / `TotalScore` ranks who wins. Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only). -Orange dossier reason = the owning event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`). +Orange dossier reason = the owning A event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`). Quiet grace / inactive dwell clears the reason even if focus has not switched yet. +Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`. + ## EventReason [`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates. @@ -19,21 +28,20 @@ Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply | Path | Role | |------|------| -| `Events/EventCatalog.cs` (+ `Catalogs/EventCatalog.*.cs`) | **One inventory** - nested domains (`Status`, `Combat`, …) | -| `Events/Feeds/` | Build + register candidates from catalog dials | -| `Events/Patches/` | Harmony hooks → feeds | +| `Events/EventCatalog.cs` (+ `Catalogs/EventCatalog.*.cs`) | **One inventory** - nested domains + `IsCameraWorthy` | +| `Events/Feeds/` | Build + register **A** candidates from catalog dials | +| `Events/Patches/` | Harmony hooks → feeds / Activity | | `Events/EventReason.cs` | Orange reason sentences | | `Events/EventFeedUtil.cs` | Shared Register path | +| `Events/InterestDropLog.cs` | Why a beat stayed B or missed A | | `Events/DiscreteEventEntry.cs` | Shared catalog row shape | -`InterestDirector` only ranks registered candidates for the camera - it does not author event inventory. - Rules: - Sentence form with names: `{a} is fighting {b}`, `{a} is eating a beehive`, `{a} is seeking a lover`. - Set `RelatedUnit` when the sentence names a second party. - Never invent stranger subjects for unit-led events. -- Never use engine jargon that misstates the action (`Consumes a building` → eating/foraging via `BehConsumeTargetBuilding`). +- Never use engine jargon that misstates the action. ## Removed crumbs @@ -44,19 +52,20 @@ Do not author these as orange reasons: - identity titles (`King of X`, `Leader of X`, `Lone beetle`) - `Consumes a building` / `Damages a building: {a}` crumb form -## Strength dial (no ownership enum) +## Strength dial (Layer A only) -`InterestOwnership` / `OwnsCamera` were removed. -Every registration is EventLed and may own the camera. -`CreatesInterest` gates whether an id registers; `EventStrength` ranks. -Instant cut-in when `next.TotalScore >= current + cutInMargin` (see `scoring-model.json`). +`CreatesInterest` / Signal gates whether an id registers for the camera; `EventStrength` ranks among A. +Instant cut-in when `next.TotalScore >= current + cutInMargin` (sticky scenes use `stickyCutInMargin`). +See `scoring-model.json`. ## Dossier display -`UnitDossier.EventLabelBeat` keeps the authored sentence. -It only rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`). +`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. ## See also -- [`event-audit.md`](event-audit.md) - full source inventory, MaxWatch classes, drowning policy +- [`event-audit.md`](event-audit.md) - sources, MaxWatch, A demotion set +- [`event-prose-audit.md`](event-prose-audit.md) - prose vs game call sites - [`scoring-model.md`](scoring-model.md) - score weights