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