From 6c6a7cc286df4397827465051547ef59394c78c0 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 15 Jul 2026 23:03:57 -0500 Subject: [PATCH] Refactor event handling in IdleSpectator to improve reason clarity and ownership management. Update label templates across various event catalogs to provide more descriptive outputs. Enhance event registration logic to ensure accurate representation of actions and relationships. Increment version in mod.json to 0.19.2 to reflect these changes. --- .cursor/skills/idle-spectator-e2e/SKILL.md | 1 + IdleSpectator/AgentHarness.cs | 36 +- IdleSpectator/BoatEventPatches.cs | 17 +- IdleSpectator/BookInterestCatalog.cs | 26 +- IdleSpectator/BookInterestFeed.cs | 7 +- IdleSpectator/BuildingEventPatches.cs | 63 ++-- IdleSpectator/DeferredEventPatches.cs | 4 +- IdleSpectator/DeferredInterestFeed.cs | 21 +- IdleSpectator/EventFeedUtil.cs | 27 +- IdleSpectator/EventReason.cs | 363 +++++++++++++++++++++ IdleSpectator/HarnessScenarios.cs | 20 +- IdleSpectator/InterestDirector.cs | 7 +- IdleSpectator/InterestFeeds.cs | 36 +- IdleSpectator/InterestRegistry.cs | 23 ++ IdleSpectator/MetaEventPatches.cs | 39 +-- IdleSpectator/PlotInterestCatalog.cs | 58 ++-- IdleSpectator/PlotInterestFeed.cs | 5 +- IdleSpectator/RelationshipEventCatalog.cs | 40 +-- IdleSpectator/RelationshipInterestFeed.cs | 82 +++-- IdleSpectator/TraitEventPatches.cs | 13 +- IdleSpectator/UnitDossier.cs | 114 ++++--- IdleSpectator/WarInterestFeed.cs | 27 ++ IdleSpectator/WorldActivityScanner.cs | 109 +++---- IdleSpectator/mod.json | 2 +- README.md | 13 +- docs/event-reason.md | 42 +++ 26 files changed, 865 insertions(+), 330 deletions(-) create mode 100644 IdleSpectator/EventReason.cs create mode 100644 docs/event-reason.md diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md index 76db354..8d2cc72 100644 --- a/.cursor/skills/idle-spectator-e2e/SKILL.md +++ b/.cursor/skills/idle-spectator-e2e/SKILL.md @@ -125,6 +125,7 @@ A loaded world is still required. | Name | Purpose | |------|---------| +| `watch_reason_clarity` | Orange reason = event sentence (`is fighting`, `is seeking a lover`); no Signal gate; fill has empty reason | | `critical_smoke` | PR gate: settings gate, idle health, tip match, retarget, manual exit | | `settings_full` | Disable-while-active, show_watch_reasons | | `ghost_guard` | Fake new-species tip must not steal focus onto wrong unit | diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index eee9aa4..374cc99 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -3111,18 +3111,48 @@ public static class AgentHarness float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f); InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed); + ParseInterestInjectOptions( + cmd.value, + out bool forceIgnored, + out InterestLeadKind? leadOpt, + out float eventStrength, + out float charSigIgnored, + out float maxWatchIgnored, + out float ttlIgnored, + out bool resumableIgnored, + out int participantsIgnored, + out int notablesIgnored); + _ = forceIgnored; + _ = charSigIgnored; + _ = maxWatchIgnored; + _ = ttlIgnored; + _ = resumableIgnored; + _ = participantsIgnored; + _ = notablesIgnored; + InterestLeadKind lead = leadOpt ?? tierLead; + if (eventStrength < 0f) + { + eventStrength = tierEvt; + } + string label = string.IsNullOrEmpty(cmd.label) ? ("Force " + (cmd.tier ?? "scene")) : cmd.label; + string focusName = SafeName(follow); + if (!string.IsNullOrEmpty(focusName)) + { + label = label.Replace("{a}", focusName); + } + string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "force_" + label.Replace(" ", "_") : cmd.expect; InterestCandidate c = InterestFeeds.RegisterHarness( follow, label, keySuffix, - lead: tierLead, + lead: lead, completion: InterestCompletionKind.Manual, forceActive: true, - eventStrength: tierEvt > 0f ? tierEvt : 70f, + eventStrength: eventStrength > 0f ? eventStrength : 70f, maxWatch: 60f); - if (c != null && tierLead == InterestLeadKind.CharacterLed) + if (c != null && lead == InterestLeadKind.CharacterLed) { c.Category = "CharacterVignette"; InterestScoring.ScoreCheap(c); diff --git a/IdleSpectator/BoatEventPatches.cs b/IdleSpectator/BoatEventPatches.cs index 788a58a..3c16a6d 100644 --- a/IdleSpectator/BoatEventPatches.cs +++ b/IdleSpectator/BoatEventPatches.cs @@ -1,6 +1,5 @@ using ai.behaviours; using HarmonyLib; -using UnityEngine; namespace IdleSpectator; @@ -17,7 +16,7 @@ public static class BoatEventPatches return; } - EmitBoat("boat_unload", 72f, InterestOwnership.Signal, pActor, "Boat unloads: {a}"); + EmitBoat("boat_unload", 72f, pActor, EventReason.Boat(pActor, "unload")); } [HarmonyPatch(typeof(BehBoatMakeTrade), nameof(BehBoatMakeTrade.execute))] @@ -29,7 +28,7 @@ public static class BoatEventPatches return; } - EmitBoat("boat_trade", 68f, InterestOwnership.Signal, pActor, "Boat trade: {a}"); + EmitBoat("boat_trade", 68f, pActor, EventReason.Boat(pActor, "trade")); } [HarmonyPatch(typeof(BehBoatTransportDoLoading), nameof(BehBoatTransportDoLoading.execute))] @@ -41,22 +40,16 @@ public static class BoatEventPatches return; } - EmitBoat("boat_load", 55f, InterestOwnership.Ambient, pActor, "Boat loads: {a}"); + EmitBoat("boat_load", 55f, pActor, EventReason.Boat(pActor, "load")); } - private static void EmitBoat( - string eventId, - float strength, - InterestOwnership owns, - Actor actor, - string labelTemplate) + private static void EmitBoat(string eventId, float strength, Actor actor, string label) { if (AgentHarness.Busy) { return; } - string label = (labelTemplate ?? "{a}").Replace("{a}", EventFeedUtil.SafeName(actor)); string key = "boat:" + eventId + ":" + EventFeedUtil.SafeId(actor); EventFeedUtil.Register( key, @@ -65,7 +58,7 @@ public static class BoatEventPatches label, eventId, strength, - owns, + InterestOwnership.Signal, actor.current_position, actor); } diff --git a/IdleSpectator/BookInterestCatalog.cs b/IdleSpectator/BookInterestCatalog.cs index 5692ffd..589cf93 100644 --- a/IdleSpectator/BookInterestCatalog.cs +++ b/IdleSpectator/BookInterestCatalog.cs @@ -11,18 +11,18 @@ public static class BookInterestCatalog static BookInterestCatalog() { - Add("family_story", 52f, "Culture", InterestOwnership.Ambient, "Family story: {a}"); - Add("love_story", 58f, "Culture", InterestOwnership.Ambient, "Love story: {a}"); - Add("friendship_story", 50f, "Culture", InterestOwnership.Ambient, "Friendship story: {a}"); - Add("bad_story_about_king", 62f, "Culture", InterestOwnership.Signal, "Scandalous book: {a}"); - Add("fable", 48f, "Culture", InterestOwnership.Ambient, "Fable: {a}"); - Add("warfare_manual", 60f, "Culture", InterestOwnership.Ambient, "Warfare manual: {a}"); - Add("economy_manual", 48f, "Culture", InterestOwnership.Ambient, "Economy manual: {a}"); - Add("stewardship_manual", 48f, "Culture", InterestOwnership.Ambient, "Stewardship manual: {a}"); - Add("diplomacy_manual", 55f, "Culture", InterestOwnership.Ambient, "Diplomacy manual: {a}"); - Add("mathbook", 45f, "Culture", InterestOwnership.Ambient, "Math book: {a}"); - Add("biology_book", 45f, "Culture", InterestOwnership.Ambient, "Biology book: {a}"); - Add("history_book", 55f, "Culture", InterestOwnership.Ambient, "History book: {a}"); + Add("family_story", 52f, "Culture", InterestOwnership.Ambient, "{a} writes a family story"); + Add("love_story", 58f, "Culture", InterestOwnership.Ambient, "{a} writes a love story"); + Add("friendship_story", 50f, "Culture", InterestOwnership.Ambient, "{a} writes a friendship story"); + Add("bad_story_about_king", 62f, "Culture", InterestOwnership.Signal, "{a} writes a scandalous book"); + Add("fable", 48f, "Culture", InterestOwnership.Ambient, "{a} writes a fable"); + Add("warfare_manual", 60f, "Culture", InterestOwnership.Ambient, "{a} writes a warfare manual"); + Add("economy_manual", 48f, "Culture", InterestOwnership.Ambient, "{a} writes an economy manual"); + Add("stewardship_manual", 48f, "Culture", InterestOwnership.Ambient, "{a} writes a stewardship manual"); + Add("diplomacy_manual", 55f, "Culture", InterestOwnership.Ambient, "{a} writes a diplomacy manual"); + Add("mathbook", 45f, "Culture", InterestOwnership.Ambient, "{a} writes a math book"); + Add("biology_book", 45f, "Culture", InterestOwnership.Ambient, "{a} writes a biology book"); + Add("history_book", 55f, "Culture", InterestOwnership.Ambient, "{a} writes a history book"); } private static void Add( @@ -62,7 +62,7 @@ public static class BookInterestCatalog EventStrength = 45f, Category = "Culture", OwnsCamera = InterestOwnership.Ambient, - LabelTemplate = "Book ({id}): {a}", + LabelTemplate = "{a} writes a book", IsFallback = true }; } diff --git a/IdleSpectator/BookInterestFeed.cs b/IdleSpectator/BookInterestFeed.cs index 20f68b5..dc262af 100644 --- a/IdleSpectator/BookInterestFeed.cs +++ b/IdleSpectator/BookInterestFeed.cs @@ -25,14 +25,13 @@ public static class BookInterestFeed return; } - string label = entry.MakeLabel(EventFeedUtil.SafeName(follow), phase ?? ""); + string label = EventReason.Apply(entry.LabelTemplate, follow, id: entry.Id); if (!string.IsNullOrEmpty(phase) && phase == "burn") { - label = "Book burned: " + EventFeedUtil.SafeName(follow); + label = EventFeedUtil.SafeName(follow) + " burns a book"; } string key = "book:" + entry.Id + ":" + (phase ?? "new") + ":" + EventFeedUtil.SafeId(follow); - InterestOwnership owns = phase == "burn" ? InterestOwnership.Signal : entry.OwnsCamera; float strength = phase == "burn" ? Mathf.Max(entry.EventStrength, 70f) : entry.EventStrength; EventFeedUtil.Register( key, @@ -41,7 +40,7 @@ public static class BookInterestFeed label, entry.Id, strength, - owns, + InterestOwnership.Signal, follow.current_position, follow); } diff --git a/IdleSpectator/BuildingEventPatches.cs b/IdleSpectator/BuildingEventPatches.cs index de2f999..95dbe52 100644 --- a/IdleSpectator/BuildingEventPatches.cs +++ b/IdleSpectator/BuildingEventPatches.cs @@ -4,7 +4,7 @@ using UnityEngine; namespace IdleSpectator; -/// Building damage/destroy interest patches from mutation discovery. +/// Building damage/destroy/eat interest patches from mutation discovery. [HarmonyPatch] public static class BuildingEventPatches { @@ -17,7 +17,18 @@ public static class BuildingEventPatches return; } - Emit("building_damage", 70f, InterestOwnership.Signal, pActor, "Damages a building: {a}"); + Building target = null; + try + { + target = pActor.beh_building_target; + } + catch + { + target = null; + } + + string label = EventReason.BuildingDamage(pActor, target); + Emit("building_damage", 70f, pActor, label); } [HarmonyPatch(typeof(BehConsumeTargetBuilding), nameof(BehConsumeTargetBuilding.execute))] @@ -29,7 +40,18 @@ public static class BuildingEventPatches return; } - Emit("building_consume", 78f, InterestOwnership.Signal, pActor, "Consumes a building: {a}"); + Building food = null; + try + { + food = pActor.beh_building_target; + } + catch + { + food = null; + } + + string label = EventReason.BuildingEat(pActor, food); + Emit("building_consume", 78f, pActor, label); } [HarmonyPatch(typeof(Building), "startDestroyBuilding")] @@ -52,35 +74,35 @@ public static class BuildingEventPatches } string buildingName = ""; - object asset = __instance.GetType().GetField("asset")?.GetValue(__instance) - ?? __instance.GetType().GetProperty("asset")?.GetValue(__instance, null); - if (asset != null) + try { - object id = asset.GetType().GetField("id")?.GetValue(asset) - ?? asset.GetType().GetProperty("id")?.GetValue(asset, null); - buildingName = id?.ToString() ?? ""; + if (__instance.asset != null) + { + buildingName = __instance.asset.id ?? ""; + } + } + catch + { + buildingName = ""; } Actor near = pos != Vector3.zero ? EventFeedUtil.NearestUnit(pos, 48f) : null; + string label = EventReason.BuildingFalls(buildingName, near); if (near == null) { - // Location-only building fall - no stranger subject. if (pos == Vector3.zero) { return; } - string locLabel = string.IsNullOrEmpty(buildingName) - ? "Building destroyed" - : "Building falls: " + buildingName; string locKey = "building:destroy:" + buildingName + ":loc"; EventFeedUtil.Register( locKey, "building", "Spectacle", - locLabel, + label, string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName, 88f, InterestOwnership.Signal, @@ -90,9 +112,6 @@ public static class BuildingEventPatches return; } - string label = string.IsNullOrEmpty(buildingName) - ? "Building destroyed near " + EventFeedUtil.SafeName(near) - : "Building falls: " + buildingName; string key = "building:destroy:" + buildingName + ":" + EventFeedUtil.SafeId(near); EventFeedUtil.Register( key, @@ -111,19 +130,13 @@ public static class BuildingEventPatches } } - private static void Emit( - string eventId, - float strength, - InterestOwnership owns, - Actor actor, - string labelTemplate) + private static void Emit(string eventId, float strength, Actor actor, string label) { if (AgentHarness.Busy) { return; } - string label = (labelTemplate ?? "{a}").Replace("{a}", EventFeedUtil.SafeName(actor)); string key = "building:" + eventId + ":" + EventFeedUtil.SafeId(actor); EventFeedUtil.Register( key, @@ -132,7 +145,7 @@ public static class BuildingEventPatches label, eventId, strength, - owns, + InterestOwnership.Signal, actor.current_position, actor); } diff --git a/IdleSpectator/DeferredEventPatches.cs b/IdleSpectator/DeferredEventPatches.cs index 6ee4b34..8f4b9dc 100644 --- a/IdleSpectator/DeferredEventPatches.cs +++ b/IdleSpectator/DeferredEventPatches.cs @@ -82,8 +82,8 @@ public static class DeferredEventPatches } DiscreteEventEntry entry = DecisionInterestCatalog.GetOrFallback(id); - // Only emit Signal decisions from this high-frequency hook; Ambient would flood. - if (entry.OwnsCamera != InterestOwnership.Signal) + // High-frequency hook: only emit stronger decisions (strength dial, not Signal gate). + if (entry.EventStrength < InterestScoringConfig.W.noticeScoreMin) { return; } diff --git a/IdleSpectator/DeferredInterestFeed.cs b/IdleSpectator/DeferredInterestFeed.cs index b4dae4a..c9f7b02 100644 --- a/IdleSpectator/DeferredInterestFeed.cs +++ b/IdleSpectator/DeferredInterestFeed.cs @@ -61,10 +61,10 @@ public static class DeferredInterestFeed "power:" + entry.Id, "power", entry.Category, - entry.MakeLabel("", ""), + EventReason.HumanizeId(entry.Id), entry.Id, entry.EventStrength, - entry.OwnsCamera, + InterestOwnership.Signal, position, follow: null, locationOnly: true); @@ -120,10 +120,10 @@ public static class DeferredInterestFeed "worldlaw:" + entry.Id, "worldlaw", entry.Category, - entry.MakeLabel("", ""), + EventReason.HumanizeId(entry.Id), entry.Id, entry.EventStrength, - entry.OwnsCamera, + InterestOwnership.Signal, position, follow: null, locationOnly: true); @@ -146,10 +146,10 @@ public static class DeferredInterestFeed "biome:" + entry.Id, "biome", entry.Category, - entry.MakeLabel("", ""), + EventReason.HumanizeId(entry.Id), entry.Id, entry.EventStrength, - entry.OwnsCamera, + InterestOwnership.Signal, position, follow: null, locationOnly: true); @@ -180,7 +180,12 @@ public static class DeferredInterestFeed return; } - string label = entry.MakeLabel(EventFeedUtil.SafeName(follow), ""); + string label = EventReason.Library(follow, source, entry.Id); + if (follow == null) + { + label = EventReason.HumanizeId(entry.Id); + } + string key = source + ":" + entry.Id + ":" + EventFeedUtil.SafeId(follow); EventFeedUtil.Register( key, @@ -189,7 +194,7 @@ public static class DeferredInterestFeed label, entry.Id, entry.EventStrength, - entry.OwnsCamera, + InterestOwnership.Signal, follow != null ? follow.current_position : Vector3.zero, follow, related: related, diff --git a/IdleSpectator/EventFeedUtil.cs b/IdleSpectator/EventFeedUtil.cs index 1f338a3..5bed2dc 100644 --- a/IdleSpectator/EventFeedUtil.cs +++ b/IdleSpectator/EventFeedUtil.cs @@ -2,18 +2,20 @@ using UnityEngine; namespace IdleSpectator; -/// Whether an interest candidate may steal the idle camera. +/// +/// Legacy catalog tag. Ignored by - +/// every registered event is EventLed and may own the camera; strength ranks. +/// public enum InterestOwnership { - /// May cut in / own EventLed camera focus. Signal, - /// Tracked for fill/enrichment; fill-capped so it does not preempt Signal drama. Ambient } /// /// Sole unit-led publish API for the idle director. -/// Ownership rule: FollowUnit owns the reason; RelatedUnit required when the label claims a second party; +/// Every registration is EventLed and may own the camera (score ranks). +/// FollowUnit owns the reason; RelatedUnit required when the label claims a second party; /// location-only civic events may omit FollowUnit but never invent a stranger subject. /// public static class EventFeedUtil @@ -34,6 +36,8 @@ public static class EventFeedUtil float maxWatch = 22f, InterestCompletionKind completion = InterestCompletionKind.FixedDwell) { + // ownership retained for call-site compatibility; camera gate removed. + _ = ownership; if (string.IsNullOrEmpty(key)) { return null; @@ -78,25 +82,20 @@ public static class EventFeedUtil } } - bool ambient = ownership == InterestOwnership.Ambient; float strength = eventStrength; - if (ambient) + if (strength < 8f) { - strength = Mathf.Min(strength, InterestScoringConfig.W.fillScoreMax - 1f); - if (strength < 8f) - { - strength = 8f; - } + strength = 8f; } var candidate = new InterestCandidate { Key = key, - LeadKind = ambient ? InterestLeadKind.CharacterLed : InterestLeadKind.EventLed, + LeadKind = InterestLeadKind.EventLed, Category = string.IsNullOrEmpty(category) ? "Event" : category, Source = source ?? "event", EventStrength = strength, - CharacterSignificance = ambient ? strength * 0.5f : 0f, + CharacterSignificance = 0f, VisualConfidence = subject != null ? 0.7f : 0.35f, Position = pos, FollowUnit = subject, @@ -108,7 +107,7 @@ public static class EventFeedUtil SpeciesId = subject?.asset != null ? subject.asset.id : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, - ExpiresAt = Time.unscaledTime + (ambient ? 18f : 35f), + ExpiresAt = Time.unscaledTime + 35f, MinWatch = minWatch, MaxWatch = maxWatch, Completion = completion diff --git a/IdleSpectator/EventReason.cs b/IdleSpectator/EventReason.cs new file mode 100644 index 0000000..ab6e7aa --- /dev/null +++ b/IdleSpectator/EventReason.cs @@ -0,0 +1,363 @@ +using System; +using System.Globalization; +using System.Text; + +namespace IdleSpectator; + +/// +/// Sole factory for interest Labels shown as the orange dossier reason. +/// Feeds must call these helpers (or Apply) instead of concatenating crumbs. +/// +public static class EventReason +{ + public static string Fight(Actor a, Actor b) + { + string name = Name(a); + if (string.IsNullOrEmpty(name)) + { + name = "Someone"; + } + + string foe = Name(b); + if (string.IsNullOrEmpty(foe)) + { + return name + " is fighting"; + } + + return name + " is fighting " + foe; + } + + public static string Battle(Actor focus, int fighters) + { + string name = Name(focus); + if (string.IsNullOrEmpty(name)) + { + name = "Someone"; + } + + int n = Math.Max(1, fighters); + if (n <= 2) + { + return name + " is in a duel"; + } + + return name + " is in a fight of " + n.ToString(CultureInfo.InvariantCulture); + } + + public static string SeekingLover(Actor a) + { + return NameOrSomeone(a) + " is seeking a lover"; + } + + public static string Lovers(Actor a, Actor b) + { + string an = NameOrSomeone(a); + string bn = Name(b); + if (string.IsNullOrEmpty(bn)) + { + return an + " found a lover"; + } + + return an + " became lovers with " + bn; + } + + public static string LoverParted(Actor a) + { + return NameOrSomeone(a) + " parted from a lover"; + } + + public static string FamilyPack(Actor a, Actor peer, string phase) + { + string an = NameOrSomeone(a); + string bn = Name(peer); + string p = (phase ?? "").Trim().ToLowerInvariant(); + switch (p) + { + case "join": + case "family_group_join": + return string.IsNullOrEmpty(bn) + ? an + " joins a family pack" + : an + " joins a family pack with " + bn; + case "leave": + case "family_group_leave": + return string.IsNullOrEmpty(bn) + ? an + " leaves a family pack" + : an + " leaves a family pack with " + bn; + case "form": + case "new": + case "family_group_new": + return string.IsNullOrEmpty(bn) + ? an + " forms a family pack" + : an + " forms a family pack with " + bn; + default: + return an + " gathers with kin"; + } + } + + public static string NewChild(Actor a, Actor b) + { + string an = NameOrSomeone(a); + string bn = Name(b); + if (string.IsNullOrEmpty(bn)) + { + return an + " has a new child"; + } + + return an + " and " + bn + " have a new child"; + } + + public static string BabyBorn(Actor a) + { + return NameOrSomeone(a) + " welcomes a baby"; + } + + public static string NewFamily(Actor a) + { + return NameOrSomeone(a) + " starts a new family"; + } + + public static string FamilyEnded(Actor a) + { + return NameOrSomeone(a) + "'s family ends"; + } + + public static string BuildingEat(Actor a, Building food) + { + string an = NameOrSomeone(a); + string buildingId = BuildingId(food); + string buildingType = BuildingType(food); + + if (string.Equals(buildingType, "type_fruits", StringComparison.OrdinalIgnoreCase)) + { + return string.IsNullOrEmpty(buildingId) + ? an + " is foraging fruit" + : an + " is foraging " + HumanizeId(buildingId); + } + + if (!string.IsNullOrEmpty(buildingId)) + { + return an + " is eating a " + HumanizeId(buildingId); + } + + return an + " is eating"; + } + + public static string BuildingDamage(Actor a, Building building) + { + string an = NameOrSomeone(a); + string buildingId = BuildingId(building); + if (!string.IsNullOrEmpty(buildingId)) + { + return an + " is damaging a " + HumanizeId(buildingId); + } + + return an + " is damaging a building"; + } + + public static string BuildingFalls(string buildingId, Actor near) + { + string id = HumanizeId(buildingId); + if (near != null && near.isAlive()) + { + string n = Name(near); + if (!string.IsNullOrEmpty(n)) + { + return string.IsNullOrEmpty(id) + ? "A building falls near " + n + : "A " + id + " falls near " + n; + } + } + + return string.IsNullOrEmpty(id) ? "A building falls" : "A " + id + " falls"; + } + + public static string Status(Actor a, string phrase) + { + string an = NameOrSomeone(a); + string p = (phrase ?? "").Trim(); + if (string.IsNullOrEmpty(p)) + { + return an + " changes status"; + } + + // Prose often already includes a verb phrase ("is burning"). + if (p.StartsWith("is ", StringComparison.OrdinalIgnoreCase) + || p.StartsWith("are ", StringComparison.OrdinalIgnoreCase)) + { + return an + " " + p; + } + + return an + " · " + p; + } + + public static string Trait(Actor a, string traitId, bool gained) + { + string an = NameOrSomeone(a); + string id = HumanizeId(traitId); + if (string.IsNullOrEmpty(id)) + { + id = "a trait"; + } + + return gained ? an + " gains " + id : an + " loses " + id; + } + + public static string Boat(Actor a, string phase) + { + string an = NameOrSomeone(a); + switch ((phase ?? "").Trim().ToLowerInvariant()) + { + case "unload": + case "boat_unload": + return an + " is unloading passengers"; + case "load": + case "boat_load": + return an + " is loading passengers"; + case "trade": + case "boat_trade": + return an + " is trading by boat"; + default: + return an + " is on a boat"; + } + } + + public static string MetaNew(Actor a, string kind) + { + string an = NameOrSomeone(a); + string k = HumanizeId(kind); + if (string.IsNullOrEmpty(k)) + { + k = "something new"; + } + + return an + " founds a " + k; + } + + public static string Library(Actor a, string kind, string assetId) + { + string an = NameOrSomeone(a); + string id = HumanizeId(assetId); + string k = HumanizeId(kind); + if (string.IsNullOrEmpty(id)) + { + return an + " triggers " + (string.IsNullOrEmpty(k) ? "an event" : k); + } + + switch ((kind ?? "").Trim().ToLowerInvariant()) + { + case "spell": + return an + " casts " + id; + case "item": + return an + " forges " + id; + case "decision": + return an + " decides " + id; + case "gene": + return an + " gains gene " + id; + case "phenotype": + return an + " shows phenotype " + id; + case "power": + return an + " channels " + id; + case "subspecies_trait": + return an + " gains " + id; + default: + return an + " · " + id; + } + } + + /// + /// Apply a sentence template with {a}/{b}/{id}. Prefer typed helpers for new copy. + /// + public static string Apply(string template, Actor a, Actor b = null, string id = "") + { + string t = string.IsNullOrEmpty(template) ? "{a}" : template; + return t + .Replace("{id}", id ?? "") + .Replace("{a}", Name(a) ?? "") + .Replace("{b}", Name(b) ?? ""); + } + + public static string HumanizeId(string id) + { + if (string.IsNullOrEmpty(id)) + { + return ""; + } + + string raw = id.Trim().Replace('-', '_'); + if (raw.StartsWith("type_", StringComparison.OrdinalIgnoreCase)) + { + raw = raw.Substring(5); + } + + string[] parts = raw.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + return raw; + } + + var sb = new StringBuilder(raw.Length + 4); + for (int i = 0; i < parts.Length; i++) + { + if (i > 0) + { + sb.Append(' '); + } + + string p = parts[i]; + if (p.Length == 0) + { + continue; + } + + sb.Append(char.ToUpperInvariant(p[0])); + if (p.Length > 1) + { + sb.Append(p.Substring(1).ToLowerInvariant()); + } + } + + return sb.ToString(); + } + + private static string NameOrSomeone(Actor a) + { + string n = Name(a); + return string.IsNullOrEmpty(n) ? "Someone" : n; + } + + private static string Name(Actor a) => EventFeedUtil.SafeName(a); + + private static string BuildingId(Building building) + { + if (building?.asset == null) + { + return ""; + } + + try + { + return building.asset.id ?? ""; + } + catch + { + return ""; + } + } + + private static string BuildingType(Building building) + { + if (building?.asset == null) + { + return ""; + } + + try + { + return building.asset.type ?? ""; + } + catch + { + return ""; + } + } +} diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 68e77fd..1d27f25 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -885,12 +885,12 @@ internal static class HarnessScenarios Step("wr8", "focus", asset: "human"), Step("wr9", "dossier_clear_job"), - // Fighting label → story beat "Fighting" (no tier badge, no identity crumb). - Step("wr10", "interest_force_session", asset: "human", label: "Fighting: Isemward", tier: "Action", expect: "fight_reason"), - Step("wr11", "assert", expect: "dossier_contains", value: "Fighting"), + // EventReason sentence with subject name kept. + Step("wr10", "interest_force_session", asset: "human", label: "{a} is fighting Waaaf", tier: "Action", expect: "fight_reason"), + Step("wr11", "assert", expect: "dossier_contains", value: "is fighting"), Step("wr12", "assert", expect: "dossier_not_contains", value: "Action ·"), Step("wr12b", "assert", expect: "dossier_not_contains", value: "Live ·"), - Step("wr13", "assert", expect: "tip_contains", value: "Fighting"), + Step("wr13", "assert", expect: "tip_contains", value: "is fighting"), Step("wr14", "screenshot", value: "hud-watch-reason-fighting.png"), Step("wr15", "wait", wait: 0.55f), @@ -910,7 +910,7 @@ internal static class HarnessScenarios Step("wr33", "screenshot", value: "hud-watch-reason-war.png"), Step("wr34", "wait", wait: 0.55f), - // Weak singleton crumb → catch-all or Last of its kind (never "Lone beetle" / tier badges). + // Weak singleton crumb → never "Lone beetle" / tier badges. Step("wr40", "interest_force_session", asset: "human", label: "Lone beetle", tier: "Curiosity", expect: "lone_crumb"), Step("wr41", "assert", expect: "dossier_not_contains", value: "Lone beetle"), Step("wr42", "assert", expect: "dossier_not_contains", value: "Live ·"), @@ -934,10 +934,18 @@ internal static class HarnessScenarios Step("wr62", "assert", expect: "reason_owns_focus"), Step("wr63", "assert", expect: "dossier_not_contains", value: "Cabo"), Step("wr64", "interest_force_session", asset: "human", - label: "Lovers: Alpha + Beta", tier: "Action", expect: "lovers_owned"), + label: "{a} became lovers with a partner", tier: "Action", expect: "lovers_owned"), Step("wr65", "assert", expect: "reason_owns_focus"), + Step("wr65b", "assert", expect: "dossier_contains", value: "became lovers"), Step("wr66", "wait", wait: 0.35f), + // Low-strength former-Ambient event still owns camera + shows sentence. + Step("wr67", "interest_force_session", asset: "human", + label: "{a} is seeking a lover", tier: "Action", expect: "seek_lover_low", + value: "lead=event;evt=45"), + Step("wr68", "assert", expect: "dossier_contains", value: "is seeking a lover"), + Step("wr69", "assert", expect: "reason_owns_focus"), + // Label-sourced clash (not inventing Clash from fighter counts). Step("wr50", "interest_expire_pending", value: ""), Step("wr51", "interest_inject", asset: "human", label: "Clash · 8 fighting", tier: "Action", expect: "mass_clash", diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 8b54b98..3eb5b2e 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -368,9 +368,8 @@ public static class InterestDirector sinceSwitch = 0f; } - // Queued discovery tips beat ambient fill, but never stall under Action/Story. - if (InterestRegistry.HasPendingScoreAtLeast(InterestScoringConfig.W.fillScoreMax) - && (_current == null || InterestScoring.IsFillScore(_current.TotalScore))) + // Pending events own the camera - never Character-fill over them. + if (InterestRegistry.HasPendingEvent()) { return; } @@ -815,6 +814,8 @@ public static class InterestDirector candidate.LeadKind = InterestLeadKind.CharacterLed; candidate.Category = "CharacterVignette"; candidate.Completion = InterestCompletionKind.CharacterVignette; + // Fill is not an event - empty orange reason; task chip shows live job. + candidate.Label = ""; if (AgentHarness.Busy) { candidate.ForceActive = false; diff --git a/IdleSpectator/InterestFeeds.cs b/IdleSpectator/InterestFeeds.cs index b0dff22..3a4772d 100644 --- a/IdleSpectator/InterestFeeds.cs +++ b/IdleSpectator/InterestFeeds.cs @@ -305,22 +305,42 @@ public static class InterestFeeds return; } + // Non-combat hot activity is not an event reason - task chip covers live job. + if (!actor.has_attack_target) + { + return; + } + long id = SafeId(actor); - string verb = taskOrBeat ?? "activity"; + Actor foe = null; + try + { + if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor()) + { + foe = actor.attack_target.a; + } + } + catch + { + foe = null; + } + + string verb = taskOrBeat ?? "fighting"; string key = "activity:" + id + ":" + verb; - bool combat = actor.has_attack_target; var candidate = new InterestCandidate { Key = key, LeadKind = InterestLeadKind.EventLed, - Category = combat ? "Combat" : "Work", + Category = "Combat", Source = "activity", - EventStrength = combat ? 75f : 50f, + EventStrength = 75f, VisualConfidence = 0.8f, Position = actor.current_position, FollowUnit = actor, + RelatedUnit = foe, SubjectId = id, - Label = combat ? ("Fighting: " + SafeName(actor)) : (SafeName(actor) + " · " + verb), + RelatedId = foe != null ? SafeId(foe) : 0, + Label = EventReason.Fight(actor, foe), AssetId = actor.asset != null ? actor.asset.id : "", SpeciesId = actor.asset != null ? actor.asset.id : "", Verb = verb, @@ -331,7 +351,7 @@ public static class InterestFeeds ExpiresAt = Time.unscaledTime + 20f, MinWatch = 3f, MaxWatch = 30f, - Completion = combat ? InterestCompletionKind.CombatActive : InterestCompletionKind.ActivityActive + Completion = InterestCompletionKind.CombatActive }; candidate.ParticipantIds.Add(id); InterestScoring.ScoreCheap(candidate); @@ -378,10 +398,10 @@ public static class InterestFeeds key, "status", string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category, - SafeName(actor) + " · " + phrase, + EventReason.Status(actor, phrase), statusId, entry.EventStrength, - entry.OwnsCamera, + InterestOwnership.Signal, actor.current_position, actor, minWatch: 2f, diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs index 0a74c06..0b500c7 100644 --- a/IdleSpectator/InterestRegistry.cs +++ b/IdleSpectator/InterestRegistry.cs @@ -324,6 +324,29 @@ public static class InterestRegistry } } + /// True when any unselected EventLed candidate is waiting for the camera. + public static bool HasPendingEvent() + { + lock (Gate) + { + foreach (KeyValuePair kv in ByKey) + { + InterestCandidate c = kv.Value; + if (c == null || c.Selected || !c.HasValidPosition) + { + continue; + } + + if (c.LeadKind == InterestLeadKind.EventLed) + { + return true; + } + } + + return false; + } + } + private static void TrimLocked(float now) { if (ByKey.Count <= MaxCandidates) diff --git a/IdleSpectator/MetaEventPatches.cs b/IdleSpectator/MetaEventPatches.cs index efedfed..015e38e 100644 --- a/IdleSpectator/MetaEventPatches.cs +++ b/IdleSpectator/MetaEventPatches.cs @@ -17,85 +17,66 @@ public static class MetaEventPatches [HarmonyPostfix] public static void PostfixNewCulture(Culture __result) { - EmitNew("new_culture", "Culture", 68f, InterestOwnership.Signal, __result, "New culture: {a}"); + EmitNew("new_culture", "Culture", 68f, __result, "culture"); } [HarmonyPatch(typeof(ReligionManager), nameof(ReligionManager.newReligion))] [HarmonyPostfix] public static void PostfixNewReligion(Religion __result) { - EmitNew("new_religion", "Culture", 72f, InterestOwnership.Signal, __result, "New religion: {a}"); + EmitNew("new_religion", "Culture", 72f, __result, "religion"); } [HarmonyPatch(typeof(LanguageManager), nameof(LanguageManager.newLanguage))] [HarmonyPostfix] public static void PostfixNewLanguage(Language __result) { - EmitNew("new_language", "Culture", 60f, InterestOwnership.Ambient, __result, "New language: {a}"); + EmitNew("new_language", "Culture", 60f, __result, "language"); } [HarmonyPatch(typeof(ClanManager), nameof(ClanManager.newClan))] [HarmonyPostfix] public static void PostfixNewClan(Clan __result) { - EmitNew("new_clan", "Politics", 70f, InterestOwnership.Signal, __result, "New clan: {a}"); + EmitNew("new_clan", "Politics", 70f, __result, "clan"); } [HarmonyPatch(typeof(ArmyManager), nameof(ArmyManager.newArmy))] [HarmonyPostfix] public static void PostfixNewArmy(Army __result) { - EmitNew("new_army", "Politics", 66f, InterestOwnership.Ambient, __result, "New army: {a}"); + EmitNew("new_army", "Politics", 66f, __result, "army"); } [HarmonyPatch(typeof(AllianceManager), nameof(AllianceManager.newAlliance))] [HarmonyPostfix] public static void PostfixNewAlliance(Alliance __result) { - EmitNew("new_alliance", "Politics", 78f, InterestOwnership.Signal, __result, "New alliance: {a}"); + EmitNew("new_alliance", "Politics", 78f, __result, "alliance"); } [HarmonyPatch(typeof(CityManager), nameof(CityManager.newCity))] [HarmonyPostfix] public static void PostfixNewCity(City __result) { - EmitNew("new_city", "Politics", 74f, InterestOwnership.Signal, __result, "New city: {a}"); + EmitNew("new_city", "Politics", 74f, __result, "city"); } private static void EmitNew( string eventId, string category, float strength, - InterestOwnership owns, object meta, - string labelTemplate) + string kind) { Actor anchor = TryReadActor(meta); - string name = EventFeedUtil.SafeName(anchor); - if (string.IsNullOrEmpty(name) && meta != null) - { - try - { - name = meta.GetType().GetMethod("getName")?.Invoke(meta, null) as string - ?? meta.GetType().GetProperty("name")?.GetValue(meta, null) as string - ?? ""; - } - catch - { - name = ""; - } - } - if (anchor == null) { - // Meta without a living founder/leader: skip (no stranger subject). return; } - string label = (labelTemplate ?? "{a}") - .Replace("{a}", name ?? "") - .Replace("{id}", eventId); - MetaInterestFeed.EmitMeta(eventId, category, strength, owns, anchor, label); + string label = EventReason.MetaNew(anchor, kind); + MetaInterestFeed.EmitMeta(eventId, category, strength, InterestOwnership.Signal, anchor, label); } private static Actor TryReadActor(object meta) diff --git a/IdleSpectator/PlotInterestCatalog.cs b/IdleSpectator/PlotInterestCatalog.cs index 8e9c221..559269e 100644 --- a/IdleSpectator/PlotInterestCatalog.cs +++ b/IdleSpectator/PlotInterestCatalog.cs @@ -11,34 +11,34 @@ public static class PlotInterestCatalog static PlotInterestCatalog() { - Add("rebellion", 92f, "Politics", InterestOwnership.Signal, "Rebellion plot: {a}"); - Add("new_war", 94f, "Politics", InterestOwnership.Signal, "War plot: {a}"); - Add("alliance_create", 80f, "Politics", InterestOwnership.Signal, "Alliance plot: {a}"); - Add("alliance_join", 72f, "Politics", InterestOwnership.Signal, "Join alliance: {a}"); - Add("alliance_destroy", 86f, "Politics", InterestOwnership.Signal, "Break alliance: {a}"); - Add("attacker_stop_war", 78f, "Politics", InterestOwnership.Signal, "End war plot: {a}"); - Add("new_book", 55f, "Culture", InterestOwnership.Ambient, "Book plot: {a}"); - Add("new_language", 62f, "Culture", InterestOwnership.Ambient, "Language plot: {a}"); - Add("new_religion", 70f, "Culture", InterestOwnership.Signal, "Religion plot: {a}"); - Add("new_culture", 68f, "Culture", InterestOwnership.Signal, "Culture plot: {a}"); - Add("clan_ascension", 82f, "Politics", InterestOwnership.Signal, "Clan ascension: {a}"); - Add("culture_divide", 75f, "Culture", InterestOwnership.Signal, "Culture divides: {a}"); - Add("religion_schism", 84f, "Culture", InterestOwnership.Signal, "Religion schism: {a}"); - Add("language_divergence", 60f, "Culture", InterestOwnership.Ambient, "Language splits: {a}"); - Add("summon_meteor_rain", 96f, "Spectacle", InterestOwnership.Signal, "Meteor plot: {a}"); - Add("summon_earthquake", 95f, "Spectacle", InterestOwnership.Signal, "Earthquake plot: {a}"); - Add("summon_thunderstorm", 93f, "Spectacle", InterestOwnership.Signal, "Thunderstorm plot: {a}"); - Add("summon_stormfront", 93f, "Spectacle", InterestOwnership.Signal, "Stormfront plot: {a}"); - Add("summon_hellstorm", 97f, "Spectacle", InterestOwnership.Signal, "Hellstorm plot: {a}"); - Add("summon_demons", 96f, "Spectacle", InterestOwnership.Signal, "Summon demons: {a}"); - Add("summon_angles", 96f, "Spectacle", InterestOwnership.Signal, "Summon angels: {a}"); - Add("summon_skeletons", 92f, "Spectacle", InterestOwnership.Signal, "Summon skeletons: {a}"); - Add("summon_living_plants", 90f, "Spectacle", InterestOwnership.Signal, "Summon plants: {a}"); - Add("big_cast_coffee", 58f, "Spectacle", InterestOwnership.Ambient, "Coffee ritual: {a}"); - Add("big_cast_bubble_shield", 64f, "Spectacle", InterestOwnership.Ambient, "Bubble shield: {a}"); - Add("big_cast_madness", 80f, "Spectacle", InterestOwnership.Signal, "Madness cast: {a}"); - Add("big_cast_slowness", 70f, "Spectacle", InterestOwnership.Ambient, "Slowness cast: {a}"); - Add("cause_rebellion", 90f, "Politics", InterestOwnership.Signal, "Cause rebellion: {a}"); + Add("rebellion", 92f, "Politics", InterestOwnership.Signal, "{a} is plotting a rebellion"); + Add("new_war", 94f, "Politics", InterestOwnership.Signal, "{a} is plotting a war"); + Add("alliance_create", 80f, "Politics", InterestOwnership.Signal, "{a} is plotting an alliance"); + Add("alliance_join", 72f, "Politics", InterestOwnership.Signal, "{a} is joining an alliance"); + Add("alliance_destroy", 86f, "Politics", InterestOwnership.Signal, "{a} is breaking an alliance"); + Add("attacker_stop_war", 78f, "Politics", InterestOwnership.Signal, "{a} is ending a war"); + Add("new_book", 55f, "Culture", InterestOwnership.Ambient, "{a} is writing a book"); + Add("new_language", 62f, "Culture", InterestOwnership.Ambient, "{a} is forging a language"); + Add("new_religion", 70f, "Culture", InterestOwnership.Signal, "{a} is founding a religion"); + Add("new_culture", 68f, "Culture", InterestOwnership.Signal, "{a} is founding a culture"); + Add("clan_ascension", 82f, "Politics", InterestOwnership.Signal, "{a} is ascending a clan"); + Add("culture_divide", 75f, "Culture", InterestOwnership.Signal, "{a} splits a culture"); + Add("religion_schism", 84f, "Culture", InterestOwnership.Signal, "{a} causes a religion schism"); + Add("language_divergence", 60f, "Culture", InterestOwnership.Ambient, "{a} splits a language"); + Add("summon_meteor_rain", 96f, "Spectacle", InterestOwnership.Signal, "{a} summons a meteor rain"); + Add("summon_earthquake", 95f, "Spectacle", InterestOwnership.Signal, "{a} summons an earthquake"); + Add("summon_thunderstorm", 93f, "Spectacle", InterestOwnership.Signal, "{a} summons a thunderstorm"); + Add("summon_stormfront", 93f, "Spectacle", InterestOwnership.Signal, "{a} summons a stormfront"); + Add("summon_hellstorm", 97f, "Spectacle", InterestOwnership.Signal, "{a} summons a hellstorm"); + Add("summon_demons", 96f, "Spectacle", InterestOwnership.Signal, "{a} summons demons"); + Add("summon_angles", 96f, "Spectacle", InterestOwnership.Signal, "{a} summons angels"); + Add("summon_skeletons", 92f, "Spectacle", InterestOwnership.Signal, "{a} summons skeletons"); + Add("summon_living_plants", 90f, "Spectacle", InterestOwnership.Signal, "{a} summons living plants"); + Add("big_cast_coffee", 58f, "Spectacle", InterestOwnership.Ambient, "{a} performs a coffee ritual"); + Add("big_cast_bubble_shield", 64f, "Spectacle", InterestOwnership.Ambient, "{a} casts a bubble shield"); + Add("big_cast_madness", 80f, "Spectacle", InterestOwnership.Signal, "{a} casts madness"); + Add("big_cast_slowness", 70f, "Spectacle", InterestOwnership.Ambient, "{a} casts slowness"); + Add("cause_rebellion", 90f, "Politics", InterestOwnership.Signal, "{a} causes a rebellion"); } private static void Add( @@ -78,7 +78,7 @@ public static class PlotInterestCatalog EventStrength = 55f, Category = "Plot", OwnsCamera = InterestOwnership.Ambient, - LabelTemplate = "Plot ({id}): {a}", + LabelTemplate = "{a} · {id}", IsFallback = true }; } diff --git a/IdleSpectator/PlotInterestFeed.cs b/IdleSpectator/PlotInterestFeed.cs index 1155452..ea964be 100644 --- a/IdleSpectator/PlotInterestFeed.cs +++ b/IdleSpectator/PlotInterestFeed.cs @@ -27,8 +27,7 @@ public static class PlotInterestFeed return; } - string name = EventFeedUtil.SafeName(follow); - string label = entry.MakeLabel(name, phase ?? ""); + string label = EventReason.Apply(entry.LabelTemplate, follow, id: entry.Id); string key = "plot:" + entry.Id + ":" + (phase ?? "active") + ":" + EventFeedUtil.SafeId(follow); EventFeedUtil.Register( key, @@ -37,7 +36,7 @@ public static class PlotInterestFeed label, entry.Id, entry.EventStrength, - entry.OwnsCamera, + InterestOwnership.Signal, follow.current_position, follow); } diff --git a/IdleSpectator/RelationshipEventCatalog.cs b/IdleSpectator/RelationshipEventCatalog.cs index 5d62762..ccf82d9 100644 --- a/IdleSpectator/RelationshipEventCatalog.cs +++ b/IdleSpectator/RelationshipEventCatalog.cs @@ -10,9 +10,14 @@ public sealed class DiscreteEventEntry public string Category = "Event"; public InterestOwnership OwnsCamera = InterestOwnership.Signal; public bool CreatesInterest = true; - public string LabelTemplate = "{id}"; + public string LabelTemplate = "{a}"; public bool IsFallback; + public string MakeLabel(Actor a, Actor b = null) + { + return EventReason.Apply(LabelTemplate, a, b, Id); + } + public string MakeLabel(string a, string b) { string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate; @@ -31,31 +36,26 @@ public static class RelationshipEventCatalog static RelationshipEventCatalog() { - Add("set_lover", 72f, "Relationship", InterestOwnership.Signal, "Lovers: {a} + {b}"); - Add("clear_lover", 55f, "Relationship", InterestOwnership.Signal, "Lover parted: {a}"); - Add("add_child", 68f, "Relationship", InterestOwnership.Signal, "New child: {a} + {b}"); - Add("new_family", 70f, "Relationship", InterestOwnership.Signal, "New family: {a}"); - Add("family_removed", 50f, "Relationship", InterestOwnership.Ambient, "Family ended: {a}"); - Add("find_lover", 45f, "Relationship", InterestOwnership.Ambient, "Seeking a lover: {a}"); - Add("family_group_new", 60f, "Relationship", InterestOwnership.Signal, "Family pack forms: {a}"); - Add("family_group_join", 48f, "Relationship", InterestOwnership.Ambient, "Joins family pack: {a}"); - Add("family_group_leave", 48f, "Relationship", InterestOwnership.Ambient, "Leaves family pack: {a}"); - Add("baby_created", 70f, "Relationship", InterestOwnership.Signal, "Baby born: {a}"); + Add("set_lover", 72f, "Relationship", "{a} became lovers with {b}"); + Add("clear_lover", 55f, "Relationship", "{a} parted from a lover"); + Add("add_child", 68f, "Relationship", "{a} and {b} have a new child"); + Add("new_family", 70f, "Relationship", "{a} starts a new family"); + Add("family_removed", 50f, "Relationship", "{a}'s family ends"); + Add("find_lover", 45f, "Relationship", "{a} is seeking a lover"); + Add("family_group_new", 60f, "Relationship", "{a} forms a family pack"); + Add("family_group_join", 48f, "Relationship", "{a} joins a family pack"); + Add("family_group_leave", 48f, "Relationship", "{a} leaves a family pack"); + Add("baby_created", 70f, "Relationship", "{a} welcomes a baby"); } - private static void Add( - string id, - float strength, - string category, - InterestOwnership owns, - string label) + private static void Add(string id, float strength, string category, string label) { Entries[id] = new DiscreteEventEntry { Id = id, EventStrength = strength, Category = category, - OwnsCamera = owns, + OwnsCamera = InterestOwnership.Signal, CreatesInterest = true, LabelTemplate = label }; @@ -79,8 +79,8 @@ public static class RelationshipEventCatalog Id = key, EventStrength = 40f, Category = "Relationship", - OwnsCamera = InterestOwnership.Ambient, - LabelTemplate = "{id}: {a}", + OwnsCamera = InterestOwnership.Signal, + LabelTemplate = "{a} · {id}", IsFallback = true }; } diff --git a/IdleSpectator/RelationshipInterestFeed.cs b/IdleSpectator/RelationshipInterestFeed.cs index 6760793..a6a2044 100644 --- a/IdleSpectator/RelationshipInterestFeed.cs +++ b/IdleSpectator/RelationshipInterestFeed.cs @@ -20,38 +20,36 @@ public static class RelationshipInterestFeed Actor relatedAlive = related != null && related.isAlive() ? related : null; - // Pack / join labels claim a second party - resolve live related or drop the dishonest claim. - bool claimsPack = eventId == "family_group_new" + bool claimsPeer = eventId == "family_group_new" || eventId == "family_group_join" || eventId == "family_group_leave" || eventId == "set_lover" || eventId == "add_child"; - if (claimsPack && relatedAlive == null) + if (claimsPeer && relatedAlive == null) { relatedAlive = ActorRelation.ResolvePackRelated(subject); } - if (claimsPack && relatedAlive == null) + string label = !string.IsNullOrEmpty(labelOverride) + ? labelOverride + : TypedLabel(eventId, subject, relatedAlive); + + if (claimsPeer && relatedAlive == null) { - // Honest Ambient: subject only, no pack/lover claim in the label. - string soloLabel = SoloLabel(eventId, EventFeedUtil.SafeName(subject)); string soloKey = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":solo"; EventFeedUtil.Register( soloKey, "relationship", entry.Category, - soloLabel, + label, entry.Id, Mathf.Min(entry.EventStrength, 40f), - InterestOwnership.Ambient, + InterestOwnership.Signal, subject.current_position, subject); return; } - string a = EventFeedUtil.SafeName(subject); - string b = EventFeedUtil.SafeName(relatedAlive); - string label = string.IsNullOrEmpty(labelOverride) ? entry.MakeLabel(a, b) : labelOverride; string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":" + EventFeedUtil.SafeId(relatedAlive); EventFeedUtil.Register( key, @@ -60,7 +58,7 @@ public static class RelationshipInterestFeed label, entry.Id, entry.EventStrength, - entry.OwnsCamera, + InterestOwnership.Signal, subject.current_position, subject, related: relatedAlive); @@ -82,58 +80,55 @@ public static class RelationshipInterestFeed if (follow == null) { - // No owned member - skip rather than invent a stranger. return; } Actor related = ActorRelation.FirstFamilyPeer(follow); - string name = ""; - try - { - name = family?.GetType().GetMethod("getName")?.Invoke(family, null) as string - ?? family?.GetType().GetProperty("name")?.GetValue(family, null) as string - ?? ""; - } - catch - { - // ignore - } - - if (string.IsNullOrEmpty(name)) - { - name = EventFeedUtil.SafeName(follow); - } - - string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(follow) + ":" + (name ?? "family"); + string label = TypedLabel(eventId, follow, related); + string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(follow); EventFeedUtil.Register( key, "relationship", entry.Category, - entry.MakeLabel(name, EventFeedUtil.SafeName(related)), + label, entry.Id, entry.EventStrength, - entry.OwnsCamera, + InterestOwnership.Signal, follow.current_position, follow, related: related); } - private static string SoloLabel(string eventId, string name) + private static string TypedLabel(string eventId, Actor subject, Actor related) { switch (eventId) { - case "family_group_join": - return "Gathers with kin: " + name; - case "family_group_new": - return "Starts a kin group: " + name; - case "family_group_leave": - return "Leaves kin: " + name; case "set_lover": - return "Seeking a bond: " + name; + return EventReason.Lovers(subject, related); + case "clear_lover": + return EventReason.LoverParted(subject); + case "find_lover": + return EventReason.SeekingLover(subject); case "add_child": - return "Family grows: " + name; + return EventReason.NewChild(subject, related); + case "baby_created": + return EventReason.BabyBorn(subject); + case "new_family": + return EventReason.NewFamily(subject); + case "family_removed": + return EventReason.FamilyEnded(subject); + case "family_group_new": + return EventReason.FamilyPack(subject, related, "form"); + case "family_group_join": + return EventReason.FamilyPack(subject, related, "join"); + case "family_group_leave": + return EventReason.FamilyPack(subject, related, "leave"); default: - return name; + return EventReason.Apply( + RelationshipEventCatalog.GetOrFallback(eventId).LabelTemplate, + subject, + related, + eventId); } } @@ -161,7 +156,6 @@ public static class RelationshipInterestFeed // fall through } - // Direct units list on the family meta object. try { object list = family.GetType().GetField("units")?.GetValue(family) diff --git a/IdleSpectator/TraitEventPatches.cs b/IdleSpectator/TraitEventPatches.cs index 0877d6e..77d2a93 100644 --- a/IdleSpectator/TraitEventPatches.cs +++ b/IdleSpectator/TraitEventPatches.cs @@ -1,5 +1,6 @@ using System; using HarmonyLib; +using UnityEngine; namespace IdleSpectator; @@ -98,14 +99,14 @@ public static class TraitEventPatches return; } - InterestOwnership owns = entry.OwnsCamera; - if (!gained && owns == InterestOwnership.Signal) + float strength = entry.EventStrength; + if (!gained) { - owns = InterestOwnership.Ambient; + strength = Mathf.Min(strength, 45f); } + string label = EventReason.Trait(actor, entry.Id, gained); string phase = gained ? "gains" : "loses"; - string label = EventFeedUtil.SafeName(actor) + " " + phase + " " + entry.Id; string key = "trait:" + entry.Id + ":" + phase + ":" + EventFeedUtil.SafeId(actor); EventFeedUtil.Register( key, @@ -113,8 +114,8 @@ public static class TraitEventPatches entry.Category, label, entry.Id, - entry.EventStrength, - owns, + strength, + InterestOwnership.Signal, actor.current_position, actor); } diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index ce61bba..b223599 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -242,8 +242,9 @@ public sealed class UnitDossier return ""; } - // Character-fill ambient: hide reason (task chip already shows live activity). - if (InterestScoring.IsFillScore(scene.TotalScore) && !InterestScoring.IsCombatAction(scene)) + // Character-fill vignettes: hide reason (task chip shows live activity). + if (scene.LeadKind == InterestLeadKind.CharacterLed + && !InterestScoring.IsCombatAction(scene)) { return ""; } @@ -430,7 +431,7 @@ public sealed class UnitDossier continue; } - if (reason.IndexOf(name, System.StringComparison.OrdinalIgnoreCase) >= 0) + if (ContainsWholeName(reason, name)) { return true; } @@ -444,6 +445,37 @@ public sealed class UnitDossier return false; } + /// True when appears as a whole token in . + private static bool ContainsWholeName(string reason, string name) + { + if (string.IsNullOrEmpty(reason) || string.IsNullOrEmpty(name)) + { + return false; + } + + int start = 0; + while (start <= reason.Length - name.Length) + { + int idx = reason.IndexOf(name, start, System.StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + return false; + } + + bool leftOk = idx == 0 || !char.IsLetterOrDigit(reason[idx - 1]); + int end = idx + name.Length; + bool rightOk = end >= reason.Length || !char.IsLetterOrDigit(reason[end]); + if (leftOk && rightOk) + { + return true; + } + + start = idx + 1; + } + + return false; + } + private static string EventLabelBeat(string label, UnitDossier d) { if (string.IsNullOrEmpty(label)) @@ -461,44 +493,13 @@ public sealed class UnitDossier } } - // Keep war / fight story text with context (do not strip after first colon). - if (s.StartsWith("War", System.StringComparison.OrdinalIgnoreCase) - || s.StartsWith("Fighting", System.StringComparison.OrdinalIgnoreCase) - || s.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase) - || s.StartsWith("Clash", System.StringComparison.OrdinalIgnoreCase) - || s.StartsWith("Grief", System.StringComparison.OrdinalIgnoreCase) - || s.StartsWith("Mourn", System.StringComparison.OrdinalIgnoreCase) - || s.StartsWith("Slain", System.StringComparison.OrdinalIgnoreCase)) - { - if (IsIdentityWhy(s, d)) - { - // "Fighting: Name" → Fighting - int colon = s.IndexOf(':'); - if (colon > 0) - { - string head = s.Substring(0, colon).Trim(); - return string.IsNullOrEmpty(head) ? "Fighting" : head; - } - - return ""; - } - - return CleanBeat(s); - } - - string shortened = ShortenWatchLabel(s, d); - if (string.IsNullOrEmpty(shortened) || IsIdentityWhy(shortened, d)) + // EventReason sentences are dossier-ready - keep names; only drop identity crumbs. + if (IsIdentityWhy(s, d) || IsWeakFlavorBeat(s, d)) { return ""; } - // Reject weak singleton/species crumbs ("Lone beetle"). - if (IsWeakFlavorBeat(shortened, d)) - { - return ""; - } - - return shortened; + return CleanBeat(s); } private static bool IsWeakFlavorBeat(string text, UnitDossier d) @@ -566,6 +567,12 @@ public sealed class UnitDossier && t.IndexOf("mourn", System.StringComparison.OrdinalIgnoreCase) < 0 && t.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) < 0) { + // EventReason sentences lead with the subject name - keep them. + if (LooksLikeEventSentence(t)) + { + return false; + } + // "Isemward (human, 12 kills)" style still useful via kills - keep if kills mentioned. if (t.IndexOf("kill", System.StringComparison.OrdinalIgnoreCase) >= 0) { @@ -578,6 +585,39 @@ public sealed class UnitDossier return IsRedundantWithHeadline(t, d); } + private static bool LooksLikeEventSentence(string text) + { + if (string.IsNullOrEmpty(text)) + { + return false; + } + + string t = text; + return t.IndexOf(" is ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" became ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" joins ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" leaves ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" forms ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" gains ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" loses ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" writes ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" founds ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" summons ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" begins ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" ends ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" burns ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" welcomes ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" parted ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" decides ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" casts ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" forges ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" eating ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" foraging ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" damaging ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" plotting ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" lovers ", System.StringComparison.OrdinalIgnoreCase) >= 0; + } + private static bool IsShownTraitName(UnitDossier d, string text) { if (d == null || string.IsNullOrEmpty(text)) diff --git a/IdleSpectator/WarInterestFeed.cs b/IdleSpectator/WarInterestFeed.cs index 487ab37..e5b5cb2 100644 --- a/IdleSpectator/WarInterestFeed.cs +++ b/IdleSpectator/WarInterestFeed.cs @@ -95,6 +95,33 @@ public static class WarInterestFeed { label = "War ends: " + labelPair; } + else if (!string.IsNullOrEmpty(labelPair) && !label.Contains(labelPair)) + { + label = label + ": " + labelPair; + } + + // Prefer sentence form when we have an owned follow unit. + if (follow != null) + { + if (phaseKey == "new") + { + label = EventFeedUtil.SafeName(follow) + " begins a war"; + if (!string.IsNullOrEmpty(defenderName)) + { + label = EventFeedUtil.SafeName(follow) + " begins a war against " + defenderName; + } + } + else if (phaseKey == "end") + { + label = EventFeedUtil.SafeName(follow) + " ends a war"; + } + else + { + label = string.IsNullOrEmpty(defenderName) + ? EventFeedUtil.SafeName(follow) + " is at war" + : EventFeedUtil.SafeName(follow) + " is at war with " + defenderName; + } + } float strength = entry.EventStrength; if (phaseKey == "end") diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index de125b6..f54d5e3 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -87,23 +87,11 @@ public static class WorldActivityScanner for (int i = 0; i < n; i++) { Actor actor = alive[i]; - ActivityBand band = ActivityInterestTable.Classify(actor); - InterestLeadKind lead = InterestLeadKind.CharacterLed; - // Actions outrank characters: combat/hot work are events; kings walking stay vignettes. - if (band == ActivityBand.Hot || actor.has_attack_target) + if (actor == null || !actor.isAlive() || !actor.has_attack_target) { - lead = InterestLeadKind.EventLed; - } - else if (band >= ActivityBand.Warm) - { - lead = InterestLeadKind.EventLed; - } - else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts)) - { - lead = InterestLeadKind.CharacterLed; + continue; } - SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore); long id = 0; try { @@ -114,63 +102,56 @@ public static class WorldActivityScanner continue; } - string key = lead == InterestLeadKind.CharacterLed - ? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit") - : "scan:evt:" + id; - - string label = FormatUnitLabel(actor, speciesCounts); - if (actor.has_attack_target) + Actor foe = null; + try { - label = "Fighting: " + SafeName(actor); + if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor()) + { + foe = actor.attack_target.a; + if (foe != null && !foe.isAlive()) + { + foe = null; + } + } } - else if (band == ActivityBand.Hot) + catch { - label = "In action"; + foe = null; } - bool combat = actor.has_attack_target || band == ActivityBand.Hot; - int fighters = 1; - int notables = InterestScoring.IsNotable(actor) ? 1 : 0; - if (combat) - { - CountFightCluster(actor.current_position, 10f, out fighters, out notables, out _); - fighters = Mathf.Max(1, fighters); - } + SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore); + CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _); + fighters = Mathf.Max(1, fighters); + string label = EventReason.Fight(actor, foe); var candidate = new InterestCandidate { - Key = key, - LeadKind = lead, - Category = combat - ? "Combat" - : (lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Work"), + Key = "scan:evt:" + id, + LeadKind = InterestLeadKind.EventLed, + Category = "Combat", Source = "scanner", - EventStrength = lead == InterestLeadKind.EventLed - ? actionScore - : actionScore * InterestScoringConfig.W.characterLedEventMul, + EventStrength = actionScore, CharacterSignificance = charScore, - VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f, + VisualConfidence = 0.7f, Position = actor.current_position, FollowUnit = actor, + RelatedUnit = foe, SubjectId = id, + RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0, Label = label, - Verb = actor.has_attack_target ? "fighting" : (band == ActivityBand.Hot ? "busy" : ""), + Verb = "fighting", AssetId = actor.asset != null ? actor.asset.id : "scored_unit", SpeciesId = actor.asset != null ? actor.asset.id : "", CityKey = actor.city != null ? (actor.city.name ?? "") : "", KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "", - ParticipantCount = combat ? fighters : 0, - NotableParticipantCount = combat ? notables : 0, + ParticipantCount = fighters, + NotableParticipantCount = notables, CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 18f, MinWatch = 2f, MaxWatch = 22f, - Completion = combat - ? InterestCompletionKind.CombatActive - : (lead == InterestLeadKind.EventLed - ? InterestCompletionKind.ActivityActive - : InterestCompletionKind.CharacterVignette) + Completion = InterestCompletionKind.CombatActive }; InterestScoring.ScoreCheap(candidate); EventFeedUtil.RegisterCandidate(candidate); @@ -258,11 +239,7 @@ public static class WorldActivityScanner if (score > bestScore) { bestScore = score; - string label = fighters >= 5 || deaths >= 6 - ? $"Battle ({fighters} fighting, {deaths} fallen)" - : fighters <= 2 - ? $"Duel ({deaths} fallen)" - : $"Skirmish ({fighters} fighting)"; + string label = EventReason.Battle(follow ?? FindNearestAliveUnit(pos, 14f), fighters); best = new InterestEvent { Score = score, @@ -322,12 +299,13 @@ public static class WorldActivityScanner if (score > bestScore) { bestScore = score; + Actor focus = follow ?? actor; best = new InterestEvent { Score = score, Position = actor.current_position, - FollowUnit = follow ?? actor, - Label = fighters <= 2 ? "Duel" : $"Fight ({fighters})", + FollowUnit = focus, + Label = EventReason.Battle(focus, fighters), CreatedAt = Time.unscaledTime, AssetId = "live_battle", ParticipantCount = fighters, @@ -531,12 +509,31 @@ public static class WorldActivityScanner finalScore = Mathf.Max(finalScore, actionScore + charScore * rankChar); } + string label = ""; + if (best.has_attack_target) + { + Actor foe = null; + try + { + if (best.attack_target != null && best.attack_target.isAlive() && best.attack_target.isActor()) + { + foe = best.attack_target.a; + } + } + catch + { + foe = null; + } + + label = EventReason.Fight(best, foe); + } + return new InterestEvent { Score = finalScore, Position = best.current_position, FollowUnit = best, - Label = FormatUnitLabel(best, speciesCounts), + Label = label, CreatedAt = Time.unscaledTime, AssetId = best.asset != null ? best.asset.id : "scored_unit", ParticipantCount = fighters, diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index aab4cf8..d9e022b 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.18.2", + "version": "0.19.2", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" } diff --git a/README.md b/README.md index 9c5f003..0fb8269 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,20 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director. ## What it follows -Priority (wiki-aligned): +The camera follows **ranked events** from live feeds (wars, fights, relationships, buildings, statuses, plots, …). +`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending. +Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job). -1. **Epic** - wars, kingdom fall/shatter, city destroyed, disasters -2. **Story** - kings, clans, alliances, favorites, new kingdoms/cities -3. **Action** - live battles/skirmishes (civ or wild), fighting units -4. **Curiosity** - new subspecies (low priority; will not interrupt fights) -5. **Ambient** - highest-scoring alive creature on the map (works on fresh animal-only worlds) +While watching, a **dossier caption** shows who they are and an orange **event sentence** (why the camera is here), e.g. `Kaszus is fighting Waaaf`. +See [`docs/event-reason.md`](docs/event-reason.md). -While watching, a **dossier caption** shows who they are (species, kills, age, roles). Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps the camera. ## Layout - `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML - `scripts/verify-nml.sh` - load verification helper +- `docs/event-reason.md` - events-own-camera + EventReason contract ## Deploy diff --git a/docs/event-reason.md b/docs/event-reason.md new file mode 100644 index 0000000..c0d8396 --- /dev/null +++ b/docs/event-reason.md @@ -0,0 +1,42 @@ +# Event reasons (camera + orange dossier) + +## Contract + +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`). +Quiet grace / inactive dwell clears the reason even if focus has not switched yet. + +## EventReason + +[`IdleSpectator/EventReason.cs`](../IdleSpectator/EventReason.cs) is the sole Label factory for camera candidates. +Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply(template, a, b, id)`. + +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`). + +## Removed crumbs + +Do not author these as orange reasons: + +- `In action` +- bare `Duel` / `Fight (N)` without a named sentence +- identity titles (`King of X`, `Leader of X`, `Lone beetle`) +- `Consumes a building` / `Damages a building: {a}` crumb form + +## Ownership enum + +`InterestOwnership` (Signal/Ambient) is retained on catalogs for compatibility but **ignored** by `EventFeedUtil.Register`. +Every registration is EventLed and may own the camera. +Strength is the dial (war ~94 beats seek-lover ~45). + +## Dossier display + +`UnitDossier.EventLabelBeat` keeps the authored sentence. +It only rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`).