From 2c1f80cdb4c18dc11b01d3cbff4d745feac04faa Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 16 Jul 2026 01:25:42 -0500 Subject: [PATCH] Enhance IdleSpectator event handling by introducing new logic for prose regressions and camera demotion criteria. Update event catalogs to adjust presentation tiers for various happiness events, ensuring accurate representation of event ownership. Refine scoring logic in InterestDirector to incorporate sticky scene management and improve camera cut-in conditions. Increment version in mod.json to 0.22.0 to reflect these changes. --- IdleSpectator/ActivityHappinessHarness.cs | 67 +++++++++- IdleSpectator/ActivityStatusHarness.cs | 35 ++++- IdleSpectator/AgentHarness.cs | 8 +- .../Events/Catalogs/EventCatalog.Happiness.cs | 32 ++--- .../Events/Catalogs/EventCatalog.Status.cs | 22 ++-- IdleSpectator/Events/EventCatalog.cs | 29 ++++- .../Events/Feeds/BookInterestFeed.cs | 3 +- .../Events/Feeds/DeferredInterestFeed.cs | 18 ++- IdleSpectator/Events/Feeds/InterestFeeds.cs | 38 ++++-- .../Events/Feeds/MetaInterestFeed.cs | 3 +- .../Events/Feeds/PlotInterestFeed.cs | 3 +- .../Events/Feeds/RelationshipInterestFeed.cs | 3 +- IdleSpectator/Events/Feeds/WarInterestFeed.cs | 3 +- IdleSpectator/Events/InterestDropLog.cs | 108 ++++++++++++++++ .../Events/Patches/TraitEventPatches.cs | 3 +- IdleSpectator/HappinessEventRouter.cs | 1 + IdleSpectator/InterestDirector.cs | 121 +++++++++++++++++- IdleSpectator/InterestScoringConfig.cs | 2 + IdleSpectator/UnitDossier.cs | 17 ++- IdleSpectator/mod.json | 4 +- IdleSpectator/scoring-model.json | 1 + docs/event-audit.md | 76 +++++------ docs/event-prose-audit.md | 8 ++ docs/event-reason.md | 45 ++++--- 24 files changed, 521 insertions(+), 129 deletions(-) create mode 100644 IdleSpectator/Events/InterestDropLog.cs 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