From 020d73fa9c7801e1511ca050e2d3c399bd02aa0d Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 15 Jul 2026 22:16:24 -0500 Subject: [PATCH] Phase 1 --- IdleSpectator/ActivityAssetCatalog.cs | 18 + IdleSpectator/ActorRelation.cs | 368 ++++++++++++++++++++ IdleSpectator/AgentHarness.cs | 283 +++++++++++++++- IdleSpectator/BookInterestFeed.cs | 6 +- IdleSpectator/BuildingEventPatches.cs | 25 +- IdleSpectator/CameraDirector.cs | 11 +- IdleSpectator/CatalogCoverageAudit.cs | 77 ++++- IdleSpectator/DeferredEventPatches.cs | 310 +++++++++++++++++ IdleSpectator/DeferredInterestFeed.cs | 198 +++++++++++ IdleSpectator/DeferredLibraryCatalogs.cs | 391 ++++++++++++++++++++++ IdleSpectator/EventFeedUtil.cs | 166 +++++++-- IdleSpectator/HarnessScenarios.cs | 23 ++ IdleSpectator/InterestCandidate.cs | 1 + IdleSpectator/InterestDirector.cs | 13 +- IdleSpectator/InterestEvent.cs | 3 + IdleSpectator/InterestFeeds.cs | 72 ++-- IdleSpectator/InterestRegistry.cs | 18 + IdleSpectator/LiveLibraryInterest.cs | 82 +++++ IdleSpectator/MetaEventPatches.cs | 8 +- IdleSpectator/MetaInterestFeed.cs | 48 ++- IdleSpectator/PlotInterestFeed.cs | 6 +- IdleSpectator/RelationshipEventPatches.cs | 10 +- IdleSpectator/RelationshipInterestFeed.cs | 137 +++++++- IdleSpectator/UnitDossier.cs | 230 ++++++++++--- IdleSpectator/WarInterestFeed.cs | 25 +- IdleSpectator/WatchCaption.cs | 5 +- IdleSpectator/WorldActivityScanner.cs | 2 +- IdleSpectator/mod.json | 2 +- 28 files changed, 2331 insertions(+), 207 deletions(-) create mode 100644 IdleSpectator/ActorRelation.cs create mode 100644 IdleSpectator/DeferredEventPatches.cs create mode 100644 IdleSpectator/DeferredInterestFeed.cs create mode 100644 IdleSpectator/DeferredLibraryCatalogs.cs create mode 100644 IdleSpectator/LiveLibraryInterest.cs diff --git a/IdleSpectator/ActivityAssetCatalog.cs b/IdleSpectator/ActivityAssetCatalog.cs index fd171d5..6bfda8f 100644 --- a/IdleSpectator/ActivityAssetCatalog.cs +++ b/IdleSpectator/ActivityAssetCatalog.cs @@ -299,6 +299,24 @@ public static class ActivityAssetCatalog return EnumerateLibraryIds("buildings"); } + public static List EnumerateLiveSpellIds() => EnumerateLibraryIds("spells"); + + public static List EnumerateLivePowerIds() => EnumerateLibraryIds("powers"); + + public static List EnumerateLiveDecisionIds() => EnumerateLibraryIds("decisions_library"); + + public static List EnumerateLiveItemIds() => EnumerateLibraryIds("items"); + + public static List EnumerateLiveSubspeciesTraitIds() => EnumerateLibraryIds("subspecies_traits"); + + public static List EnumerateLiveGeneIds() => EnumerateLibraryIds("gene_library"); + + public static List EnumerateLivePhenotypeIds() => EnumerateLibraryIds("phenotype_library"); + + public static List EnumerateLiveWorldLawIds() => EnumerateLibraryIds("world_laws_library"); + + public static List EnumerateLiveBiomeIds() => EnumerateLibraryIds("biome_library"); + private static List EnumerateLibraryIds(string assetManagerField) { var ids = new List(); diff --git a/IdleSpectator/ActorRelation.cs b/IdleSpectator/ActorRelation.cs new file mode 100644 index 0000000..4301293 --- /dev/null +++ b/IdleSpectator/ActorRelation.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; + +namespace IdleSpectator; + +/// +/// Live-game relation resolver. Labels that claim a second party must resolve through here. +/// +public static class ActorRelation +{ + public static Actor GetLover(Actor actor) + { + if (actor == null) + { + return null; + } + + try + { + if (!actor.hasLover()) + { + return null; + } + } + catch + { + // fall through to field/property + } + + try + { + Actor lover = null; + try + { + lover = actor.lover; + } + catch + { + lover = null; + } + + if (lover == null) + { + lover = ReadActorMember(actor, "lover", "get_lover", "getLover"); + } + + if (lover != null && lover.isAlive() && lover != actor) + { + return lover; + } + } + catch + { + // ignore + } + + return null; + } + + public static bool TryGetLover(Actor actor, out Actor lover) + { + lover = GetLover(actor); + return lover != null; + } + + public static Actor GetBestFriend(Actor actor) + { + if (actor == null) + { + return null; + } + + try + { + long id = 0; + object data = actor.data; + if (data != null) + { + object raw = data.GetType().GetProperty("best_friend_id")?.GetValue(data, null) + ?? data.GetType().GetField("best_friend_id")?.GetValue(data); + if (raw is long l) + { + id = l; + } + else if (raw is int i) + { + id = i; + } + } + + if (id == 0) + { + return null; + } + + return FindUnitById(id); + } + catch + { + return null; + } + } + + /// First living peer in the same family (not self). + public static Actor FirstFamilyPeer(Actor actor) + { + foreach (Actor peer in EnumerateFamilyMembers(actor, max: 8)) + { + if (peer != null && peer != actor && peer.isAlive()) + { + return peer; + } + } + + return null; + } + + public static IEnumerable EnumerateFamilyMembers(Actor actor, int max = 12) + { + if (actor == null || max <= 0) + { + yield break; + } + + object family = ReadMember(actor, "family", "get_family", "getFamily"); + if (family == null) + { + yield break; + } + + int yielded = 0; + foreach (Actor member in EnumerateActorsFromMeta(family)) + { + if (member == null || !member.isAlive()) + { + continue; + } + + yield return member; + yielded++; + if (yielded >= max) + { + yield break; + } + } + } + + public static IEnumerable EnumerateChildren(Actor actor, int max = 12) + { + if (actor == null || max <= 0) + { + yield break; + } + + IEnumerable children = null; + try + { + MethodInfo getChildren = typeof(Actor).GetMethod("getChildren", Type.EmptyTypes); + if (getChildren != null) + { + children = getChildren.Invoke(actor, null) as IEnumerable; + } + } + catch + { + children = null; + } + + if (children == null) + { + yield break; + } + + int yielded = 0; + foreach (object raw in children) + { + Actor child = raw as Actor ?? ResolveActor(raw); + if (child == null || !child.isAlive()) + { + continue; + } + + yield return child; + yielded++; + if (yielded >= max) + { + yield break; + } + } + } + + /// + /// Resolve a related unit for pack/family labels. Prefers lover, then family peer, then child. + /// + public static Actor ResolvePackRelated(Actor subject) + { + if (subject == null) + { + return null; + } + + Actor lover = GetLover(subject); + if (lover != null) + { + return lover; + } + + Actor peer = FirstFamilyPeer(subject); + if (peer != null) + { + return peer; + } + + foreach (Actor child in EnumerateChildren(subject, max: 1)) + { + return child; + } + + return null; + } + + public static Actor ResolveActor(object raw) + { + if (raw == null) + { + return null; + } + + if (raw is Actor actor) + { + return actor; + } + + try + { + if (raw is BaseSimObject sim && sim.isActor()) + { + return sim.a; + } + } + catch + { + // fall through + } + + return ReadActorMember(raw, "actor", "Actor", "a", "getActor", "GetActor"); + } + + private static IEnumerable EnumerateActorsFromMeta(object meta) + { + if (meta == null) + { + yield break; + } + + // Common: units / members / getUnits / getSimpleList + object list = ReadMember(meta, "units", "members", "getUnits", "getSimpleList", "getList"); + if (list is IEnumerable enumerable) + { + foreach (object raw in enumerable) + { + Actor a = ResolveActor(raw); + if (a != null) + { + yield return a; + } + } + + yield break; + } + + Actor founder = ReadActorMember(meta, "founder", "creator", "leader", "getFounder"); + if (founder != null) + { + yield return founder; + } + } + + private static Actor FindUnitById(long id) + { + if (id == 0 || World.world?.units == null) + { + return null; + } + + try + { + foreach (Actor unit in World.world.units) + { + if (unit == null) + { + continue; + } + + try + { + if (unit.getID() == id && unit.isAlive()) + { + return unit; + } + } + catch + { + // skip + } + } + } + catch + { + // ignore + } + + return null; + } + + private static Actor ReadActorMember(object target, params string[] names) + { + object raw = ReadMember(target, names); + return raw as Actor; + } + + private static object ReadMember(object target, params string[] names) + { + if (target == null || names == null) + { + return null; + } + + Type type = target as Type ?? target.GetType(); + object instance = target is Type ? null : target; + foreach (string name in names) + { + if (string.IsNullOrEmpty(name)) + { + continue; + } + + try + { + PropertyInfo prop = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (prop != null) + { + return prop.GetValue(instance, null); + } + + FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (field != null) + { + return field.GetValue(instance); + } + + MethodInfo method = type.GetMethod(name, Type.EmptyTypes); + if (method != null) + { + return method.Invoke(instance, null); + } + } + catch + { + // try next + } + } + + return null; + } +} diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index f478286..5553ad3 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2521,7 +2521,7 @@ public static class AgentHarness Completion = InterestCompletionKind.FixedDwell }; InterestScoring.ScoreCheap(candidate); - InterestRegistry.Upsert(candidate); + EventFeedUtil.RegisterCandidate(candidate); _cmdOk++; Emit(cmd, ok: true, detail: $"fed id={assetId} key={key} evt={entry.EventStrength:0.#}"); } @@ -2570,13 +2570,43 @@ public static class AgentHarness } entry = RelationshipEventCatalog.GetOrFallback(id); - key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(unit); - label = entry.MakeLabel(EventFeedUtil.SafeName(unit), ""); + Actor related = ActorRelation.ResolvePackRelated(unit) + ?? WorldActivityScanner.FindNearestAliveUnit(unit.current_position, 40f); + if (related == unit) + { + related = null; + } + + key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(unit) + ":" + + EventFeedUtil.SafeId(related); + label = entry.MakeLabel( + EventFeedUtil.SafeName(unit), + EventFeedUtil.SafeName(related)); owns = entry.OwnsCamera; strength = entry.EventStrength; category = entry.Category; source = "relationship"; - break; + InterestCandidate relReg = EventFeedUtil.Register( + key, + source, + category, + label, + id, + strength, + owns, + unit.current_position, + unit, + related: related); + if (relReg == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "register_failed domain=relationship id=" + id); + return; + } + + _cmdOk++; + Emit(cmd, ok: true, detail: $"domain=relationship id={id} key={key}"); + return; case "plot": if (string.IsNullOrEmpty(id)) { @@ -2598,25 +2628,38 @@ public static class AgentHarness } entry = EraInterestCatalog.GetOrFallback(id); - if (unit == null) - { - unit = EventFeedUtil.AnyAliveUnit(); - } - - if (unit == null) - { - _cmdFail++; - Emit(cmd, ok: false, detail: "no unit for era feed"); - return; - } - key = "era:" + entry.Id; label = entry.MakeLabel("", ""); owns = entry.OwnsCamera; strength = entry.EventStrength; category = entry.Category; source = "era"; - break; + Vector3 eraPos = unit != null + ? unit.current_position + : (Camera.main != null + ? new Vector3(Camera.main.transform.position.x, Camera.main.transform.position.y, 0f) + : new Vector3(1f, 1f, 0f)); + InterestCandidate eraReg = EventFeedUtil.Register( + key, + source, + category, + label, + id, + strength, + owns, + eraPos, + follow: unit, + locationOnly: unit == null); + if (eraReg == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "register_failed domain=era id=" + id); + return; + } + + _cmdOk++; + Emit(cmd, ok: true, detail: $"domain=era id={id} key={key}"); + return; case "book": if (string.IsNullOrEmpty(id)) { @@ -2675,6 +2718,115 @@ public static class AgentHarness strength = 70f; category = "Spectacle"; source = "building"; + break; + case "spell": + if (string.IsNullOrEmpty(id)) + { + id = "summon_lightning"; + } + + { + DiscreteEventEntry spellEntry = SpellInterestCatalog.GetOrFallback(id); + key = "spell:" + spellEntry.Id + ":" + EventFeedUtil.SafeId(unit); + label = spellEntry.MakeLabel(EventFeedUtil.SafeName(unit), ""); + owns = spellEntry.OwnsCamera; + strength = spellEntry.EventStrength; + category = spellEntry.Category; + source = "spell"; + id = spellEntry.Id; + } + + break; + case "item": + if (string.IsNullOrEmpty(id)) + { + id = "sword"; + } + + { + DiscreteEventEntry itemEntry = ItemInterestCatalog.GetOrFallback(id); + key = "item:" + itemEntry.Id + ":" + EventFeedUtil.SafeId(unit); + label = itemEntry.MakeLabel(EventFeedUtil.SafeName(unit), ""); + owns = itemEntry.OwnsCamera; + strength = itemEntry.EventStrength; + category = itemEntry.Category; + source = "item"; + id = itemEntry.Id; + } + + break; + case "decision": + if (string.IsNullOrEmpty(id)) + { + id = "declare_war"; + } + + { + DiscreteEventEntry decEntry = DecisionInterestCatalog.GetOrFallback(id); + key = "decision:" + decEntry.Id + ":" + EventFeedUtil.SafeId(unit); + label = decEntry.MakeLabel(EventFeedUtil.SafeName(unit), ""); + owns = decEntry.OwnsCamera; + strength = decEntry.EventStrength; + category = decEntry.Category; + source = "decision"; + id = decEntry.Id; + } + + break; + case "power": + if (string.IsNullOrEmpty(id)) + { + id = "lightning"; + } + + { + DiscreteEventEntry powEntry = PowerInterestCatalog.GetOrFallback(id); + key = "power:" + powEntry.Id + ":" + EventFeedUtil.SafeId(unit); + label = powEntry.MakeLabel(EventFeedUtil.SafeName(unit), ""); + owns = powEntry.OwnsCamera; + strength = powEntry.EventStrength; + category = powEntry.Category; + source = "power"; + id = powEntry.Id; + } + + break; + case "subspecies_trait": + case "subspecies": + if (string.IsNullOrEmpty(id)) + { + id = "biome_trait"; + } + + { + DiscreteEventEntry stEntry = SubspeciesTraitInterestCatalog.GetOrFallback(id); + key = "subspecies_trait:" + stEntry.Id + ":" + EventFeedUtil.SafeId(unit); + label = stEntry.MakeLabel(EventFeedUtil.SafeName(unit), ""); + owns = stEntry.OwnsCamera; + strength = stEntry.EventStrength; + category = stEntry.Category; + source = "subspecies_trait"; + id = stEntry.Id; + } + + break; + case "gene": + if (string.IsNullOrEmpty(id)) + { + id = "warfare_1"; + } + + { + DiscreteEventEntry geneEntry = GeneInterestCatalog.GetOrFallback(id); + key = "gene:" + geneEntry.Id + ":" + EventFeedUtil.SafeId(unit); + label = geneEntry.MakeLabel(EventFeedUtil.SafeName(unit), ""); + owns = geneEntry.OwnsCamera; + strength = geneEntry.EventStrength; + category = geneEntry.Category; + source = "gene"; + id = geneEntry.Id; + } + break; default: _cmdFail++; @@ -3511,6 +3663,45 @@ public static class AgentHarness detail = $"needle_absent_in_reason='{needle}' reason='{reason}' hit={reasonHit}"; break; } + case "reason_owns_focus": + { + // Reason must not name a different live unit than the scene subject/related. + UnitDossier dossier = WatchCaption.Current; + string reason = dossier?.ReasonLine ?? ""; + InterestCandidate scene = InterestDirector.CurrentCandidate; + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + if (string.IsNullOrEmpty(reason)) + { + pass = true; + detail = "empty_reason_ok"; + break; + } + + if (scene == null || focus == null) + { + pass = string.IsNullOrEmpty(reason); + detail = $"scene={(scene != null)} focus={(focus != null)} reason='{reason}'"; + break; + } + + bool sceneOwnsFocus = scene.FollowUnit == focus + || scene.RelatedUnit == focus + || (scene.SubjectId != 0 && scene.SubjectId == EventFeedUtil.SafeId(focus)) + || (scene.RelatedId != 0 && scene.RelatedId == EventFeedUtil.SafeId(focus)); + if (!sceneOwnsFocus) + { + pass = false; + detail = "reason_present_but_scene_does_not_own_focus reason='" + reason + "'"; + break; + } + + string stranger = FindStrangerNamedInReason(reason, scene); + pass = string.IsNullOrEmpty(stranger); + detail = pass + ? $"owned reason='{reason}'" + : $"stranger_in_reason='{stranger}' reason='{reason}'"; + break; + } case "dossier_task_refresh": { // Stale focus snapshot must recover: blank dossier TaskText, then sync from live AI. @@ -5905,6 +6096,64 @@ public static class AgentHarness return actor.asset != null ? actor.asset.id : "unit"; } + /// + /// If the reason line names a living unit that is not the scene subject/related, return that name. + /// + private static string FindStrangerNamedInReason(string reason, InterestCandidate scene) + { + if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null) + { + return ""; + } + + string subjectName = EventFeedUtil.SafeName(scene.FollowUnit); + string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit); + try + { + foreach (Actor unit in World.world.units) + { + if (unit == null || !unit.isAlive()) + { + continue; + } + + if (unit == scene.FollowUnit || unit == scene.RelatedUnit) + { + continue; + } + + string name = SafeName(unit); + if (string.IsNullOrEmpty(name) || name.Length < 3) + { + continue; + } + + if (!string.IsNullOrEmpty(subjectName) + && name.Equals(subjectName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!string.IsNullOrEmpty(relatedName) + && name.Equals(relatedName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (reason.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0) + { + return name; + } + } + } + catch + { + // ignore + } + + return ""; + } + private static float EventStrengthFromTierHint(string raw, float fallback) { diff --git a/IdleSpectator/BookInterestFeed.cs b/IdleSpectator/BookInterestFeed.cs index 293b62f..20f68b5 100644 --- a/IdleSpectator/BookInterestFeed.cs +++ b/IdleSpectator/BookInterestFeed.cs @@ -21,11 +21,7 @@ public static class BookInterestFeed Actor follow = subject; if (follow == null || !follow.isAlive()) { - follow = EventFeedUtil.AnyAliveUnit(); - } - - if (follow == null) - { + // Book without a living author: skip rather than invent a stranger. return; } diff --git a/IdleSpectator/BuildingEventPatches.cs b/IdleSpectator/BuildingEventPatches.cs index ee37011..de2f999 100644 --- a/IdleSpectator/BuildingEventPatches.cs +++ b/IdleSpectator/BuildingEventPatches.cs @@ -62,10 +62,31 @@ public static class BuildingEventPatches } Actor near = pos != Vector3.zero - ? EventFeedUtil.NearestUnit(pos) ?? EventFeedUtil.AnyAliveUnit() - : EventFeedUtil.AnyAliveUnit(); + ? EventFeedUtil.NearestUnit(pos, 48f) + : null; 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, + string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName, + 88f, + InterestOwnership.Signal, + pos, + follow: null, + locationOnly: true); return; } diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index ab998e8..0bf7c1c 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -33,6 +33,7 @@ public static class CameraDirector } string tip = FormatWatchTip(interest); + // LastWatchLabel is tip/harness telemetry only - never dossier reason truth. LastWatchLabel = interest.Label ?? ""; LastWatchAssetId = interest.AssetId ?? ""; LogService.LogInfo($"[IdleSpectator] {tip}"); @@ -42,17 +43,13 @@ public static class CameraDirector ? interest.FollowUnit : null; - // Never attach a random nearby unit to a "new species" tip - that caused ghost focuses - // (e.g. tip says crab, camera follows a cat). + // Species discovery: only attach a species-matched unit, never a random stranger. if (follow == null && !string.IsNullOrEmpty(interest.AssetId) && interest.AssetId != "live_battle" && interest.AssetId != "scored_unit" && interest.Label != null && interest.Label.StartsWith("New species:")) { follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId); } - else if (follow == null) - { - follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f); - } + // Location-only civic: pan only. Do not invent a stranger FollowUnit. if (follow != null && follow.isAlive()) { @@ -74,7 +71,7 @@ public static class CameraDirector return; } - // No unit available: pan only if we are not already following someone. + // No owned unit: pan only if we are not already following someone. // Never clear an existing focus unit here - that flashes the power bar. if (!MoveCamera.hasFocusUnit()) { diff --git a/IdleSpectator/CatalogCoverageAudit.cs b/IdleSpectator/CatalogCoverageAudit.cs index 074e4b4..1f80d7a 100644 --- a/IdleSpectator/CatalogCoverageAudit.cs +++ b/IdleSpectator/CatalogCoverageAudit.cs @@ -140,6 +140,75 @@ public static class DomainEventHarness TraitInterestCatalog.AuthoredIds); AppendLibraryDomain(tsv, "traits", ActivityAssetCatalog.EnumerateLiveTraitIds(), traits, TraitInterestCatalog.GetOrFallback); + CatalogCoverageDiff spells = CatalogCoverageAudit.Diff( + "spells", + ActivityAssetCatalog.EnumerateLiveSpellIds(), + SpellInterestCatalog.AuthoredIds); + AppendLibraryDomain(tsv, "spells", ActivityAssetCatalog.EnumerateLiveSpellIds(), spells, SpellInterestCatalog.GetOrFallback); + + CatalogCoverageDiff powers = CatalogCoverageAudit.Diff( + "powers", + ActivityAssetCatalog.EnumerateLivePowerIds(), + PowerInterestCatalog.AuthoredIds); + AppendLibraryDomain(tsv, "powers", ActivityAssetCatalog.EnumerateLivePowerIds(), powers, PowerInterestCatalog.GetOrFallback); + + CatalogCoverageDiff decisions = CatalogCoverageAudit.Diff( + "decisions", + ActivityAssetCatalog.EnumerateLiveDecisionIds(), + DecisionInterestCatalog.AuthoredIds); + AppendLibraryDomain(tsv, "decisions", ActivityAssetCatalog.EnumerateLiveDecisionIds(), decisions, DecisionInterestCatalog.GetOrFallback); + + CatalogCoverageDiff items = CatalogCoverageAudit.Diff( + "items", + ActivityAssetCatalog.EnumerateLiveItemIds(), + ItemInterestCatalog.AuthoredIds); + AppendLibraryDomain(tsv, "items", ActivityAssetCatalog.EnumerateLiveItemIds(), items, ItemInterestCatalog.GetOrFallback); + + CatalogCoverageDiff subspTraits = CatalogCoverageAudit.Diff( + "subspecies_traits", + ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds(), + SubspeciesTraitInterestCatalog.AuthoredIds); + AppendLibraryDomain( + tsv, + "subspecies_traits", + ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds(), + subspTraits, + SubspeciesTraitInterestCatalog.GetOrFallback); + + CatalogCoverageDiff genes = CatalogCoverageAudit.Diff( + "genes", + ActivityAssetCatalog.EnumerateLiveGeneIds(), + GeneInterestCatalog.AuthoredIds); + AppendLibraryDomain(tsv, "genes", ActivityAssetCatalog.EnumerateLiveGeneIds(), genes, GeneInterestCatalog.GetOrFallback); + + CatalogCoverageDiff phenotypes = CatalogCoverageAudit.Diff( + "phenotypes", + ActivityAssetCatalog.EnumerateLivePhenotypeIds(), + PhenotypeInterestCatalog.AuthoredIds); + AppendLibraryDomain( + tsv, + "phenotypes", + ActivityAssetCatalog.EnumerateLivePhenotypeIds(), + phenotypes, + PhenotypeInterestCatalog.GetOrFallback); + + CatalogCoverageDiff worldLaws = CatalogCoverageAudit.Diff( + "world_laws", + ActivityAssetCatalog.EnumerateLiveWorldLawIds(), + WorldLawInterestCatalog.AuthoredIds); + AppendLibraryDomain( + tsv, + "world_laws", + ActivityAssetCatalog.EnumerateLiveWorldLawIds(), + worldLaws, + WorldLawInterestCatalog.GetOrFallback); + + CatalogCoverageDiff biomes = CatalogCoverageAudit.Diff( + "biomes", + ActivityAssetCatalog.EnumerateLiveBiomeIds(), + BiomeInterestCatalog.AuthoredIds); + AppendLibraryDomain(tsv, "biomes", ActivityAssetCatalog.EnumerateLiveBiomeIds(), biomes, BiomeInterestCatalog.GetOrFallback); + int relMissing = 0; int relTotal = 0; foreach (string id in RelationshipEventCatalog.AuthoredIds) @@ -162,9 +231,15 @@ public static class DomainEventHarness CatalogCoverageAudit.WriteReport(outputDirectory, "domain_event_audit.tsv", tsv.ToString()); bool passed = plots.Passed && eras.Passed && books.Passed && traits.Passed + && spells.Passed && powers.Passed && decisions.Passed && items.Passed + && subspTraits.Passed && genes.Passed && phenotypes.Passed + && worldLaws.Passed && biomes.Passed && relMissing == 0 && relTotal > 0; string detail = - $"{plots.Detail}; {eras.Detail}; {books.Detail}; {traits.Detail}; relationship: authored={relTotal} missing={relMissing}"; + $"{plots.Detail}; {eras.Detail}; {books.Detail}; {traits.Detail}; " + + $"{spells.Detail}; {powers.Detail}; {decisions.Detail}; {items.Detail}; " + + $"{subspTraits.Detail}; {genes.Detail}; {phenotypes.Detail}; " + + $"{worldLaws.Detail}; {biomes.Detail}; relationship: authored={relTotal} missing={relMissing}"; return new DomainEventAuditResult { Passed = passed, Detail = detail }; } diff --git a/IdleSpectator/DeferredEventPatches.cs b/IdleSpectator/DeferredEventPatches.cs new file mode 100644 index 0000000..6ee4b34 --- /dev/null +++ b/IdleSpectator/DeferredEventPatches.cs @@ -0,0 +1,310 @@ +using System; +using HarmonyLib; +using UnityEngine; + +namespace IdleSpectator; + +/// Harmony hooks for Phase 2 deferred libraries (items, spells, decisions, subspecies). +[HarmonyPatch] +public static class DeferredEventPatches +{ + [HarmonyPatch(typeof(ItemManager), nameof(ItemManager.generateItem))] + [HarmonyPostfix] + public static void PostfixGenerateItem(object __result, object[] __args) + { + if (AgentHarness.Busy) + { + return; + } + + Actor craftsman = null; + if (__args != null) + { + for (int i = 0; i < __args.Length; i++) + { + if (__args[i] is Actor a && a.isAlive()) + { + craftsman = a; + break; + } + } + } + + string itemId = ReadAssetId(__result) ?? "item"; + if (craftsman == null) + { + return; + } + + DeferredInterestFeed.EmitItem(itemId, craftsman); + } + + [HarmonyPatch(typeof(ItemManager), "newItem")] + [HarmonyPostfix] + public static void PostfixNewItem(object __result) + { + // newItem may lack an actor; skip without a subject rather than invent one. + if (AgentHarness.Busy || __result == null) + { + return; + } + + Actor owner = ReadActorMember(__result, "owner", "actor", "creator", "getOwner"); + if (owner == null || !owner.isAlive()) + { + return; + } + + DeferredInterestFeed.EmitItem(ReadAssetId(__result) ?? "item", owner); + } + + // Spells: live catalog + domain_feed; cast site varies by game build (no stable Actor.tryToCastSpell). + + [HarmonyPatch(typeof(Actor), nameof(Actor.setDecisionCooldown))] + [HarmonyPostfix] + public static void PostfixSetDecisionCooldown(Actor __instance, object[] __args) + { + if (__instance == null || !__instance.isAlive() || AgentHarness.Busy) + { + return; + } + + object pDecision = __args != null && __args.Length > 0 ? __args[0] : null; + if (pDecision == null) + { + return; + } + + string id = ReadAssetId(pDecision) ?? pDecision.ToString(); + if (string.IsNullOrEmpty(id)) + { + return; + } + + DiscreteEventEntry entry = DecisionInterestCatalog.GetOrFallback(id); + // Only emit Signal decisions from this high-frequency hook; Ambient would flood. + if (entry.OwnsCamera != InterestOwnership.Signal) + { + return; + } + + DeferredInterestFeed.EmitDecision(id, __instance); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.generatePhenotypeAndShade))] + [HarmonyPostfix] + public static void PostfixPhenotype(Actor __instance) + { + if (__instance == null || !__instance.isAlive() || AgentHarness.Busy) + { + return; + } + + string id = "phenotype"; + try + { + object data = __instance.data; + object ph = data?.GetType().GetField("phenotype")?.GetValue(data) + ?? data?.GetType().GetProperty("phenotype")?.GetValue(data, null); + if (ph != null) + { + id = ph.ToString(); + } + } + catch + { + // ignore + } + + DeferredInterestFeed.EmitPhenotype(id, __instance); + } + + [HarmonyPatch(typeof(SubspeciesManager), "addRandomTraitFromBiomeToSubspecies")] + [HarmonyPostfix] + public static void PostfixSubspeciesTraitRandom(object[] __args) + { + object pSubspecies = __args != null && __args.Length > 0 ? __args[0] : null; + EmitSubspecies(pSubspecies, "biome_trait"); + } + + [HarmonyPatch(typeof(SubspeciesManager), "addTraitsFromBiomeToSubspecies")] + [HarmonyPostfix] + public static void PostfixSubspeciesTraits(object[] __args) + { + object pSubspecies = __args != null && __args.Length > 0 ? __args[0] : null; + EmitSubspecies(pSubspecies, "biome_traits"); + } + + private static void EmitSubspecies(object subspecies, string fallbackId) + { + if (AgentHarness.Busy || subspecies == null) + { + return; + } + + Actor anchor = ReadActorMember(subspecies, "founder", "creator", "leader", "getFounder"); + if (anchor == null || !anchor.isAlive()) + { + // Prefer a unit of this subspecies if possible. + anchor = FindUnitForSubspecies(subspecies); + } + + if (anchor == null) + { + return; + } + + string traitId = fallbackId; + try + { + object last = subspecies.GetType().GetField("last_trait")?.GetValue(subspecies) + ?? subspecies.GetType().GetProperty("last_trait")?.GetValue(subspecies, null); + if (last != null) + { + traitId = ReadAssetId(last) ?? last.ToString() ?? fallbackId; + } + } + catch + { + // ignore + } + + DeferredInterestFeed.EmitSubspeciesTrait(traitId, anchor); + } + + private static Actor FindUnitForSubspecies(object subspecies) + { + if (subspecies == null || World.world?.units == null) + { + return null; + } + + string sid = null; + try + { + object id = subspecies.GetType().GetMethod("getID")?.Invoke(subspecies, null) + ?? subspecies.GetType().GetProperty("id")?.GetValue(subspecies, null); + sid = id?.ToString(); + } + catch + { + sid = null; + } + + try + { + foreach (Actor unit in World.world.units) + { + if (unit == null || !unit.isAlive()) + { + continue; + } + + try + { + object sub = unit.GetType().GetProperty("subspecies")?.GetValue(unit, null) + ?? unit.GetType().GetField("subspecies")?.GetValue(unit); + if (sub == subspecies) + { + return unit; + } + + if (!string.IsNullOrEmpty(sid)) + { + object uid = sub?.GetType().GetMethod("getID")?.Invoke(sub, null); + if (uid != null && uid.ToString() == sid) + { + return unit; + } + } + } + catch + { + // skip + } + } + } + catch + { + // ignore + } + + return null; + } + + private static string ReadAssetId(object obj) + { + if (obj == null) + { + return null; + } + + try + { + if (obj is string s) + { + return s; + } + + object asset = obj.GetType().GetField("asset")?.GetValue(obj) + ?? obj.GetType().GetProperty("asset")?.GetValue(obj, null) + ?? obj.GetType().GetMethod("getAsset")?.Invoke(obj, null); + if (asset != null) + { + object id = asset.GetType().GetField("id")?.GetValue(asset) + ?? asset.GetType().GetProperty("id")?.GetValue(asset, null); + if (id != null) + { + return id.ToString(); + } + } + + object direct = obj.GetType().GetField("id")?.GetValue(obj) + ?? obj.GetType().GetProperty("id")?.GetValue(obj, null); + return direct?.ToString(); + } + catch + { + return null; + } + } + + private static Actor ReadActorMember(object target, params string[] names) + { + if (target == null || names == null) + { + return null; + } + + Type type = target.GetType(); + foreach (string name in names) + { + try + { + var prop = type.GetProperty(name); + if (prop != null && typeof(Actor).IsAssignableFrom(prop.PropertyType)) + { + return prop.GetValue(target, null) as Actor; + } + + var field = type.GetField(name); + if (field != null && typeof(Actor).IsAssignableFrom(field.FieldType)) + { + return field.GetValue(target) as Actor; + } + + var method = type.GetMethod(name, Type.EmptyTypes); + if (method != null && typeof(Actor).IsAssignableFrom(method.ReturnType)) + { + return method.Invoke(target, null) as Actor; + } + } + catch + { + // try next + } + } + + return null; + } +} diff --git a/IdleSpectator/DeferredInterestFeed.cs b/IdleSpectator/DeferredInterestFeed.cs new file mode 100644 index 0000000..b4dae4a --- /dev/null +++ b/IdleSpectator/DeferredInterestFeed.cs @@ -0,0 +1,198 @@ +using UnityEngine; + +namespace IdleSpectator; + +/// Unit-owned emits for deferred Phase 2 libraries (spells/items/decisions/etc.). +public static class DeferredInterestFeed +{ + public static void EmitSpell(string spellId, Actor caster) + { + EmitFromCatalog( + "spell", + spellId, + caster, + SpellInterestCatalog.GetOrFallback, + related: null, + locationOnly: false); + } + + public static void EmitItem(string itemId, Actor craftsman) + { + EmitFromCatalog( + "item", + itemId, + craftsman, + ItemInterestCatalog.GetOrFallback, + related: null, + locationOnly: false); + } + + public static void EmitDecision(string decisionId, Actor actor) + { + EmitFromCatalog( + "decision", + decisionId, + actor, + DecisionInterestCatalog.GetOrFallback, + related: null, + locationOnly: false); + } + + public static void EmitPower(string powerId, Vector3 position, Actor nearUnit) + { + DiscreteEventEntry entry = PowerInterestCatalog.GetOrFallback(powerId); + if (!entry.CreatesInterest || AgentHarness.Busy) + { + return; + } + + if (nearUnit != null && nearUnit.isAlive()) + { + EmitFromCatalog("power", powerId, nearUnit, PowerInterestCatalog.GetOrFallback, null, false); + return; + } + + if (position == Vector3.zero) + { + return; + } + + EventFeedUtil.Register( + "power:" + entry.Id, + "power", + entry.Category, + entry.MakeLabel("", ""), + entry.Id, + entry.EventStrength, + entry.OwnsCamera, + position, + follow: null, + locationOnly: true); + } + + public static void EmitSubspeciesTrait(string traitId, Actor anchor) + { + EmitFromCatalog( + "subspecies_trait", + traitId, + anchor, + SubspeciesTraitInterestCatalog.GetOrFallback, + related: null, + locationOnly: false); + } + + public static void EmitGene(string geneId, Actor actor) + { + EmitFromCatalog( + "gene", + geneId, + actor, + GeneInterestCatalog.GetOrFallback, + related: null, + locationOnly: false); + } + + public static void EmitPhenotype(string phenotypeId, Actor actor) + { + EmitFromCatalog( + "phenotype", + phenotypeId, + actor, + PhenotypeInterestCatalog.GetOrFallback, + related: null, + locationOnly: false); + } + + public static void EmitWorldLaw(string lawId, Vector3 position) + { + if (AgentHarness.Busy || position == Vector3.zero) + { + return; + } + + DiscreteEventEntry entry = WorldLawInterestCatalog.GetOrFallback(lawId); + if (!entry.CreatesInterest) + { + return; + } + + EventFeedUtil.Register( + "worldlaw:" + entry.Id, + "worldlaw", + entry.Category, + entry.MakeLabel("", ""), + entry.Id, + entry.EventStrength, + entry.OwnsCamera, + position, + follow: null, + locationOnly: true); + } + + public static void EmitBiome(string biomeId, Vector3 position) + { + if (AgentHarness.Busy || position == Vector3.zero) + { + return; + } + + DiscreteEventEntry entry = BiomeInterestCatalog.GetOrFallback(biomeId); + if (!entry.CreatesInterest) + { + return; + } + + EventFeedUtil.Register( + "biome:" + entry.Id, + "biome", + entry.Category, + entry.MakeLabel("", ""), + entry.Id, + entry.EventStrength, + entry.OwnsCamera, + position, + follow: null, + locationOnly: true); + } + + private static void EmitFromCatalog( + string source, + string assetId, + Actor subject, + System.Func lookup, + Actor related, + bool locationOnly) + { + if (AgentHarness.Busy || lookup == null) + { + return; + } + + DiscreteEventEntry entry = lookup(assetId); + if (!entry.CreatesInterest) + { + return; + } + + Actor follow = subject != null && subject.isAlive() ? subject : null; + if (!locationOnly && follow == null) + { + return; + } + + string label = entry.MakeLabel(EventFeedUtil.SafeName(follow), ""); + string key = source + ":" + entry.Id + ":" + EventFeedUtil.SafeId(follow); + EventFeedUtil.Register( + key, + source, + entry.Category, + label, + entry.Id, + entry.EventStrength, + entry.OwnsCamera, + follow != null ? follow.current_position : Vector3.zero, + follow, + related: related, + locationOnly: locationOnly); + } +} diff --git a/IdleSpectator/DeferredLibraryCatalogs.cs b/IdleSpectator/DeferredLibraryCatalogs.cs new file mode 100644 index 0000000..6292741 --- /dev/null +++ b/IdleSpectator/DeferredLibraryCatalogs.cs @@ -0,0 +1,391 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// Live spells library interest overlay (Signal when cast resolves to a unit). +public static class SpellInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static readonly HashSet SignalIds = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "summon_lightning", "summon_tornado", "cast_curse", "cast_fire", + "cast_blood_rain", "summon_meteor", "teleport" + }; + + private static void Ensure() => + LiveLibraryInterest.EnsureSeeded( + Entries, + ActivityAssetCatalog.EnumerateLiveSpellIds, + "Spectacle", + "Spell: {id} ({a})", + ambientStrength: 48f, + SignalIds, + signalStrength: 88f); + + public static IEnumerable AuthoredIds + { + get + { + Ensure(); + return Entries.Keys; + } + } + + public static DiscreteEventEntry GetOrFallback(string id) + { + Ensure(); + return LiveLibraryInterest.Lookup(Entries, id, "Spectacle", "Spell: {id} ({a})", 55f); + } +} + +/// God powers library - mostly Ambient; Signal for disaster/spectacle ids. +public static class PowerInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static bool IsSpectaclePower(string id) + { + if (string.IsNullOrEmpty(id)) + { + return false; + } + + string s = id.ToLowerInvariant(); + return s.Contains("meteor") + || s.Contains("earthquake") + || s.Contains("tornado") + || s.Contains("volcano") + || s.Contains("nuke") + || s.Contains("bomb") + || s.Contains("rain_blood") + || s.Contains("hell") + || s.Contains("lightning") + || s.Contains("storm") + || s.Contains("acid") + || s.Contains("fire") + || s.Contains("disaster"); + } + + private static void Ensure() => + LiveLibraryInterest.EnsureSeeded( + Entries, + ActivityAssetCatalog.EnumerateLivePowerIds, + "Spectacle", + "Power: {id}", + ambientStrength: 28f, + signalIds: null, + signalStrength: 92f, + signalPredicate: IsSpectaclePower); + + public static IEnumerable AuthoredIds + { + get + { + Ensure(); + return Entries.Keys; + } + } + + public static DiscreteEventEntry GetOrFallback(string id) + { + Ensure(); + return LiveLibraryInterest.Lookup(Entries, id, "Spectacle", "Power: {id}", 30f); + } +} + +/// AI decisions - Signal for war/rebellion/kingdom-affecting; Ambient otherwise. +public static class DecisionInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static bool IsKingdomDecision(string id) + { + if (string.IsNullOrEmpty(id)) + { + return false; + } + + string s = id.ToLowerInvariant(); + return s.Contains("war") + || s.Contains("rebellion") + || s.Contains("revolt") + || s.Contains("alliance") + || s.Contains("king") + || s.Contains("coup") + || s.Contains("betray") + || s.Contains("declare") + || s.Contains("invade") + || s.Contains("siege"); + } + + private static void Ensure() => + LiveLibraryInterest.EnsureSeeded( + Entries, + ActivityAssetCatalog.EnumerateLiveDecisionIds, + "Politics", + "Decision: {id} ({a})", + ambientStrength: 32f, + signalIds: null, + signalStrength: 86f, + signalPredicate: IsKingdomDecision); + + public static IEnumerable AuthoredIds + { + get + { + Ensure(); + return Entries.Keys; + } + } + + public static DiscreteEventEntry GetOrFallback(string id) + { + Ensure(); + return LiveLibraryInterest.Lookup(Entries, id, "Politics", "Decision: {id} ({a})", 35f); + } +} + +/// Items library - Signal for legendary/wonder subset. +public static class ItemInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static bool IsLegendaryItem(string id) + { + if (string.IsNullOrEmpty(id)) + { + return false; + } + + string s = id.ToLowerInvariant(); + return s.Contains("legendary") + || s.Contains("mythic") + || s.Contains("artifact") + || s.Contains("relic") + || s.Contains("divine") + || s.Contains("demon") + || s.Contains("dragon") + || s.Contains("nuke") + || s.Contains("excalibur") + || s.Contains("wonder"); + } + + private static void Ensure() => + LiveLibraryInterest.EnsureSeeded( + Entries, + ActivityAssetCatalog.EnumerateLiveItemIds, + "Item", + "Item forged: {id} ({a})", + ambientStrength: 36f, + signalIds: null, + signalStrength: 80f, + signalPredicate: IsLegendaryItem); + + public static IEnumerable AuthoredIds + { + get + { + Ensure(); + return Entries.Keys; + } + } + + public static DiscreteEventEntry GetOrFallback(string id) + { + Ensure(); + return LiveLibraryInterest.Lookup(Entries, id, "Item", "Item forged: {id} ({a})", 40f); + } +} + +/// Subspecies traits - Signal for dramatic; Ambient fill-capped. +public static class SubspeciesTraitInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static bool IsDramatic(string id) + { + if (string.IsNullOrEmpty(id)) + { + return false; + } + + string s = id.ToLowerInvariant(); + return s.Contains("immortal") + || s.Contains("giant") + || s.Contains("tiny") + || s.Contains("fire") + || s.Contains("frost") + || s.Contains("poison") + || s.Contains("plague") + || s.Contains("magic") + || s.Contains("divine") + || s.Contains("demon") + || s.Contains("undead") + || s.Contains("dragon") + || s.Contains("evolve") + || s.Contains("mutate"); + } + + private static void Ensure() => + LiveLibraryInterest.EnsureSeeded( + Entries, + ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds, + "Evolution", + "Subspecies trait: {id}", + ambientStrength: 34f, + signalIds: null, + signalStrength: 78f, + signalPredicate: IsDramatic); + + public static IEnumerable AuthoredIds + { + get + { + Ensure(); + return Entries.Keys; + } + } + + public static DiscreteEventEntry GetOrFallback(string id) + { + Ensure(); + return LiveLibraryInterest.Lookup(Entries, id, "Evolution", "Subspecies trait: {id}", 36f); + } +} + +/// Genes - Ambient default. +public static class GeneInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static void Ensure() => + LiveLibraryInterest.EnsureSeeded( + Entries, + ActivityAssetCatalog.EnumerateLiveGeneIds, + "Genetics", + "Gene: {id} ({a})", + ambientStrength: 30f, + signalIds: null, + signalStrength: 70f, + signalPredicate: id => id != null && (id.Contains("warfare") || id.Contains("magic"))); + + public static IEnumerable AuthoredIds + { + get + { + Ensure(); + return Entries.Keys; + } + } + + public static DiscreteEventEntry GetOrFallback(string id) + { + Ensure(); + return LiveLibraryInterest.Lookup(Entries, id, "Genetics", "Gene: {id} ({a})", 30f); + } +} + +/// Phenotypes - Ambient default. +public static class PhenotypeInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static void Ensure() => + LiveLibraryInterest.EnsureSeeded( + Entries, + ActivityAssetCatalog.EnumerateLivePhenotypeIds, + "Genetics", + "Phenotype: {id} ({a})", + ambientStrength: 28f, + signalIds: null, + signalStrength: 60f); + + public static IEnumerable AuthoredIds + { + get + { + Ensure(); + return Entries.Keys; + } + } + + public static DiscreteEventEntry GetOrFallback(string id) + { + Ensure(); + return LiveLibraryInterest.Lookup(Entries, id, "Genetics", "Phenotype: {id} ({a})", 28f); + } +} + +/// World laws - Ambient / location-only style. +public static class WorldLawInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static void Ensure() => + LiveLibraryInterest.EnsureSeeded( + Entries, + ActivityAssetCatalog.EnumerateLiveWorldLawIds, + "WorldLaw", + "World law: {id}", + ambientStrength: 40f, + signalIds: null, + signalStrength: 70f, + signalPredicate: id => id != null && (id.Contains("war") || id.Contains("death") || id.Contains("plague"))); + + public static IEnumerable AuthoredIds + { + get + { + Ensure(); + return Entries.Keys; + } + } + + public static DiscreteEventEntry GetOrFallback(string id) + { + Ensure(); + return LiveLibraryInterest.Lookup(Entries, id, "WorldLaw", "World law: {id}", 40f); + } +} + +/// Biomes - Ambient. +public static class BiomeInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static void Ensure() => + LiveLibraryInterest.EnsureSeeded( + Entries, + ActivityAssetCatalog.EnumerateLiveBiomeIds, + "Biome", + "Biome: {id}", + ambientStrength: 26f, + signalIds: null, + signalStrength: 50f); + + public static IEnumerable AuthoredIds + { + get + { + Ensure(); + return Entries.Keys; + } + } + + public static DiscreteEventEntry GetOrFallback(string id) + { + Ensure(); + return LiveLibraryInterest.Lookup(Entries, id, "Biome", "Biome: {id}", 26f); + } +} diff --git a/IdleSpectator/EventFeedUtil.cs b/IdleSpectator/EventFeedUtil.cs index 613866e..1f338a3 100644 --- a/IdleSpectator/EventFeedUtil.cs +++ b/IdleSpectator/EventFeedUtil.cs @@ -11,7 +11,11 @@ public enum InterestOwnership Ambient } -/// Shared registration helpers for domain event feeds. +/// +/// Sole unit-led publish API for the idle director. +/// Ownership rule: 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 { public static InterestCandidate Register( @@ -24,24 +28,54 @@ public static class EventFeedUtil InterestOwnership ownership, Vector3 position, Actor follow, + Actor related = null, + bool locationOnly = false, float minWatch = 4f, float maxWatch = 22f, InterestCompletionKind completion = InterestCompletionKind.FixedDwell) { - if (string.IsNullOrEmpty(key) || position == Vector3.zero && follow == null) + if (string.IsNullOrEmpty(key)) { return null; } - if (follow == null && position == Vector3.zero) + Actor subject = follow; + if (subject != null && !subject.isAlive()) { - return null; + subject = null; } - Vector3 pos = follow != null ? follow.current_position : position; - if (pos == Vector3.zero) + Actor relatedAlive = related; + if (relatedAlive != null && !relatedAlive.isAlive()) { - return null; + relatedAlive = null; + } + + Vector3 pos = position; + if (subject != null) + { + pos = subject.current_position; + } + + if (locationOnly) + { + if (pos == Vector3.zero || float.IsNaN(pos.x)) + { + return null; + } + } + else + { + // Unit-led: require a living subject. Never invent a stranger. + if (subject == null) + { + return null; + } + + if (pos == Vector3.zero || float.IsNaN(pos.x)) + { + return null; + } } bool ambient = ownership == InterestOwnership.Ambient; @@ -63,13 +97,15 @@ public static class EventFeedUtil Source = source ?? "event", EventStrength = strength, CharacterSignificance = ambient ? strength * 0.5f : 0f, - VisualConfidence = follow != null ? 0.7f : 0.35f, + VisualConfidence = subject != null ? 0.7f : 0.35f, Position = pos, - FollowUnit = follow, - SubjectId = follow != null ? SafeId(follow) : 0, + FollowUnit = subject, + RelatedUnit = relatedAlive, + SubjectId = subject != null ? SafeId(subject) : 0, + RelatedId = relatedAlive != null ? SafeId(relatedAlive) : 0, Label = label ?? assetId ?? key, AssetId = assetId ?? "", - SpeciesId = follow?.asset != null ? follow.asset.id : "", + SpeciesId = subject?.asset != null ? subject.asset.id : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + (ambient ? 18f : 35f), @@ -81,6 +117,48 @@ public static class EventFeedUtil return InterestRegistry.Upsert(candidate); } + /// + /// Publish a fully built candidate through the registry (still the sole intake). + /// Prefer the typed Register overload for new feeds. + /// + public static InterestCandidate RegisterCandidate(InterestCandidate candidate) + { + if (candidate == null || string.IsNullOrEmpty(candidate.Key) || !candidate.HasValidPosition) + { + return null; + } + + // Reject stranger subjects: dead follow with no position is already invalid via HasValidPosition. + if (candidate.FollowUnit != null && !candidate.FollowUnit.isAlive()) + { + candidate.FollowUnit = null; + candidate.SubjectId = 0; + if (!candidate.HasValidPosition) + { + return null; + } + } + + if (candidate.RelatedUnit != null && !candidate.RelatedUnit.isAlive()) + { + candidate.RelatedUnit = null; + candidate.RelatedId = 0; + } + + if (candidate.FollowUnit != null && candidate.SubjectId == 0) + { + candidate.SubjectId = SafeId(candidate.FollowUnit); + } + + if (candidate.RelatedUnit != null && candidate.RelatedId == 0) + { + candidate.RelatedId = SafeId(candidate.RelatedUnit); + } + + InterestScoring.ScoreCheap(candidate); + return InterestRegistry.Upsert(candidate); + } + public static long SafeId(Actor actor) { if (actor == null) @@ -127,6 +205,9 @@ public static class EventFeedUtil } } + /// + /// Harness / diagnostics only. Must not be used as a silent camera subject for Signal events. + /// public static Actor AnyAliveUnit() { try @@ -152,31 +233,46 @@ public static class EventFeedUtil return null; } + /// + /// Resolve a unit-led anchor without stranger fallback. + /// Preferred unit if alive; else nearest at position; else fail (caller may location-only). + /// + public static bool TryResolveOwnedAnchor( + Actor preferred, + Vector3 position, + out Actor follow, + out Vector3 pos, + bool allowNearestAtPosition = true) + { + follow = null; + pos = position; + + if (preferred != null && preferred.isAlive()) + { + follow = preferred; + pos = preferred.current_position; + return pos != Vector3.zero && !float.IsNaN(pos.x); + } + + if (allowNearestAtPosition && position != Vector3.zero && !float.IsNaN(position.x)) + { + follow = NearestUnit(position, 48f); + if (follow != null && follow.isAlive()) + { + pos = follow.current_position; + return true; + } + } + + follow = null; + pos = position; + return false; + } + + /// Obsolete stranger path. Prefer TryResolveOwnedAnchor or location-only Register. + [System.Obsolete("Use TryResolveOwnedAnchor or location-only Register. Do not invent subjects.")] public static bool TryResolveAnchor(Actor preferred, Vector3 position, out Actor follow, out Vector3 pos) { - follow = preferred; - pos = position; - if (follow != null && follow.isAlive()) - { - pos = follow.current_position; - return pos != Vector3.zero || true; - } - - if (position != Vector3.zero) - { - follow = NearestUnit(position) ?? AnyAliveUnit(); - pos = position; - return true; - } - - follow = AnyAliveUnit(); - if (follow == null) - { - pos = Vector3.zero; - return false; - } - - pos = follow.current_position; - return true; + return TryResolveOwnedAnchor(preferred, position, out follow, out pos, allowNearestAtPosition: true); } } diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index e6e7ed0..e4312f9 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -927,6 +927,17 @@ internal static class HarnessScenarios Step("wr48", "assert", expect: "dossier_not_contains", value: "Live ·"), Step("wr49", "wait", wait: 0.35f), + // Ownership: reason must belong to focused subject (not a stranger name). + Step("wr60", "spawn", asset: "human", count: 2), + Step("wr61", "interest_force_session", asset: "human", + label: "Cabo loses infected", tier: "Action", expect: "stranger_label"), + 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"), + Step("wr65", "assert", expect: "reason_owns_focus"), + Step("wr66", "wait", wait: 0.35f), + // Mass fight inject → Clash / Fighting scale beat. Step("wr50", "interest_expire_pending", value: ""), Step("wr51", "interest_inject", asset: "human", label: "MassClash", tier: "Action", expect: "mass_clash", @@ -1143,6 +1154,18 @@ internal static class HarnessScenarios Step("et33", "assert", expect: "interest_has_key", value: "boat:"), Step("et34", "domain_feed", asset: "building"), Step("et35", "assert", expect: "interest_has_key", value: "building:"), + Step("et40", "domain_feed", asset: "spell", value: "summon_lightning"), + Step("et41", "assert", expect: "interest_has_key", value: "spell:summon_lightning"), + Step("et42", "domain_feed", asset: "item", value: "sword"), + Step("et43", "assert", expect: "interest_has_key", value: "item:"), + Step("et44", "domain_feed", asset: "decision", value: "declare_war"), + Step("et45", "assert", expect: "interest_has_key", value: "decision:"), + Step("et46", "domain_feed", asset: "power", value: "lightning"), + Step("et47", "assert", expect: "interest_has_key", value: "power:"), + Step("et48", "domain_feed", asset: "subspecies_trait", value: "fire_blood"), + Step("et49", "assert", expect: "interest_has_key", value: "subspecies_trait:"), + Step("et50", "domain_feed", asset: "gene", value: "warfare_1"), + Step("et51", "assert", expect: "interest_has_key", value: "gene:"), Step("et90", "assert", expect: "health"), Step("et91", "assert", expect: "no_bad"), diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index 9c7a298..61bed9b 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -85,6 +85,7 @@ public sealed class InterestCandidate Score = TotalScore, Position = Position, FollowUnit = FollowUnit, + RelatedUnit = RelatedUnit, Label = Label, CreatedAt = CreatedAt, AssetId = AssetId, diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 853999c..42d7045 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -134,7 +134,7 @@ public static class InterestDirector return false; } - InterestRegistry.Upsert(candidate); + EventFeedUtil.RegisterCandidate(candidate); SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false); return true; } @@ -373,20 +373,17 @@ public static class InterestDirector return; } - // Prefer related (grief survivor is already FollowUnit); try related then participants. + // Ownership-preserving handoff only: related survivor. Never invent a stranger. if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { _current.FollowUnit = _current.RelatedUnit; + _current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit); CameraDirector.Watch(_current.ToInterestEvent()); return; } - Actor near = WorldActivityScanner.FindNearestAliveUnit(_current.Position, 18f); - if (near != null) - { - _current.FollowUnit = near; - CameraDirector.Watch(_current.ToInterestEvent()); - } + // No owned related: end the scene rather than attach a nearby stranger. + EndCurrent(Time.unscaledTime, reason: "follow_lost"); } private static void EndCurrent(float now, string reason) diff --git a/IdleSpectator/InterestEvent.cs b/IdleSpectator/InterestEvent.cs index df7ad55..15aff3a 100644 --- a/IdleSpectator/InterestEvent.cs +++ b/IdleSpectator/InterestEvent.cs @@ -11,6 +11,7 @@ public sealed class InterestEvent public float Score; public Vector3 Position; public Actor FollowUnit; + public Actor RelatedUnit; public string Label; public float CreatedAt; public string AssetId; @@ -20,6 +21,8 @@ public sealed class InterestEvent public bool HasFollowUnit => FollowUnit != null && FollowUnit.isAlive(); + public bool HasRelatedUnit => RelatedUnit != null && RelatedUnit.isAlive(); + public bool HasValidPosition => HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x)); } diff --git a/IdleSpectator/InterestFeeds.cs b/IdleSpectator/InterestFeeds.cs index 434a76d..b0dff22 100644 --- a/IdleSpectator/InterestFeeds.cs +++ b/IdleSpectator/InterestFeeds.cs @@ -106,29 +106,47 @@ public static class InterestFeeds unit = message.unit; } - if (!EventFeedUtil.TryResolveAnchor(unit, position, out Actor follow, out Vector3 pos)) - { - return; - } - string assetId = message.asset_id ?? entry.Id ?? "worldlog"; string kingdom = message.special1 ?? ""; string key = "worldlog:" + assetId + ":" + kingdom; float boost = PeekCivicBoost(kingdom, assetId); string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category; + string label = entry.MakeLabel(message); - EventFeedUtil.Register( - key, - "worldlog", - category, - entry.MakeLabel(message), - assetId, - evtSeed + boost, - entry.OwnsCamera, - pos, - follow, - entry.MinWatch, - entry.MaxWatch); + if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos)) + { + EventFeedUtil.Register( + key, + "worldlog", + category, + label, + assetId, + evtSeed + boost, + entry.OwnsCamera, + pos, + follow, + minWatch: entry.MinWatch, + maxWatch: entry.MaxWatch); + return; + } + + // Civic / disaster without a real unit: location-only (no stranger subject). + if (position != Vector3.zero && !float.IsNaN(position.x)) + { + EventFeedUtil.Register( + key, + "worldlog", + category, + label, + assetId, + evtSeed + boost, + entry.OwnsCamera, + position, + follow: null, + locationOnly: true, + minWatch: entry.MinWatch, + maxWatch: entry.MaxWatch); + } } /// Legacy / discovery / harness enqueue path → registry. @@ -187,7 +205,7 @@ public static class InterestFeeds Completion = completion }; InterestScoring.ScoreCheap(candidate); - return InterestRegistry.Upsert(candidate); + return EventFeedUtil.RegisterCandidate(candidate); } public static InterestCandidate RegisterHarness( @@ -277,7 +295,7 @@ public static class InterestFeeds } InterestScoring.ScoreCheap(candidate); - return InterestRegistry.Upsert(candidate); + return EventFeedUtil.RegisterCandidate(candidate); } public static void OnActivityNote(Actor actor, string taskOrBeat, bool hot) @@ -317,7 +335,7 @@ public static class InterestFeeds }; candidate.ParticipantIds.Add(id); InterestScoring.ScoreCheap(candidate); - InterestRegistry.Upsert(candidate); + EventFeedUtil.RegisterCandidate(candidate); } public static void OnStatusChange(Actor actor, string statusId, bool gained) @@ -366,9 +384,9 @@ public static class InterestFeeds entry.OwnsCamera, actor.current_position, actor, - 2f, - 22f, - InterestCompletionKind.StatusPhase); + minWatch: 2f, + maxWatch: 22f, + completion: InterestCompletionKind.StatusPhase); } public static void OnChronicleMilestone(Actor subject, string milestoneKey, Actor related, string label) @@ -411,7 +429,7 @@ public static class InterestFeeds Completion = InterestCompletionKind.FixedDwell }; InterestScoring.ScoreCheap(candidate); - InterestRegistry.Upsert(candidate); + EventFeedUtil.RegisterCandidate(candidate); } private static void DrainHappiness() @@ -535,7 +553,7 @@ public static class InterestFeeds } InterestScoring.ScoreCheap(candidate); - InterestRegistry.Upsert(candidate); + EventFeedUtil.RegisterCandidate(candidate); } /// Harness / push path: ingest one occurrence into the registry now. @@ -617,7 +635,7 @@ public static class InterestFeeds } InterestScoring.ScoreCheap(candidate); - return InterestRegistry.Upsert(candidate); + return EventFeedUtil.RegisterCandidate(candidate); } private static void TickScanner() @@ -668,7 +686,7 @@ public static class InterestFeeds c.StatusId = statusId; c.LastSeenAt = Time.unscaledTime; c.ExpiresAt = Time.unscaledTime + 20f; - InterestRegistry.Upsert(c); + EventFeedUtil.RegisterCandidate(c); return true; } } diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs index 5da25be..0a74c06 100644 --- a/IdleSpectator/InterestRegistry.cs +++ b/IdleSpectator/InterestRegistry.cs @@ -84,6 +84,21 @@ public static class InterestRegistry { if (ByKey.TryGetValue(incoming.Key, out InterestCandidate existing) && existing != null) { + // Ownership merge: same key must not adopt a different subject's FollowUnit/Label. + bool sameSubject = incoming.SubjectId == 0 + || existing.SubjectId == 0 + || incoming.SubjectId == existing.SubjectId; + if (!sameSubject) + { + // Reject subject hijack; keep existing ownership, still refresh TTL/scores lightly. + existing.LastSeenAt = now; + existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt); + existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength); + existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore); + existing.Selected = false; + return existing; + } + existing.LastSeenAt = now; existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt); existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength); @@ -100,6 +115,9 @@ public static class InterestRegistry if (incoming.FollowUnit != null && incoming.FollowUnit.isAlive()) { existing.FollowUnit = incoming.FollowUnit; + existing.SubjectId = incoming.SubjectId != 0 + ? incoming.SubjectId + : EventFeedUtil.SafeId(incoming.FollowUnit); existing.Position = incoming.FollowUnit.current_position; } else if (incoming.Position != Vector3.zero) diff --git a/IdleSpectator/LiveLibraryInterest.cs b/IdleSpectator/LiveLibraryInterest.cs new file mode 100644 index 0000000..5775cf5 --- /dev/null +++ b/IdleSpectator/LiveLibraryInterest.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// +/// Live-seeded interest catalogs: every live library id is authored (Ambient default), +/// with curated Signal overlays for spectacle / kingdom-affecting entries. +/// +public static class LiveLibraryInterest +{ + public static void EnsureSeeded( + Dictionary entries, + Func> enumerateLive, + string category, + string labelTemplate, + float ambientStrength, + HashSet signalIds, + float signalStrength, + Func signalPredicate = null) + { + if (entries == null || enumerateLive == null) + { + return; + } + + List live; + try + { + live = enumerateLive() ?? new List(); + } + catch + { + live = new List(); + } + + for (int i = 0; i < live.Count; i++) + { + string id = live[i]; + if (string.IsNullOrEmpty(id) || entries.ContainsKey(id)) + { + continue; + } + + bool signal = (signalIds != null && signalIds.Contains(id)) + || (signalPredicate != null && signalPredicate(id)); + entries[id] = new DiscreteEventEntry + { + Id = id, + EventStrength = signal ? signalStrength : ambientStrength, + Category = category, + OwnsCamera = signal ? InterestOwnership.Signal : InterestOwnership.Ambient, + CreatesInterest = true, + LabelTemplate = labelTemplate + }; + } + } + + public static DiscreteEventEntry Lookup( + Dictionary entries, + string id, + string category, + string labelTemplate, + float fallbackStrength = 40f) + { + if (!string.IsNullOrEmpty(id) && entries != null && entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry)) + { + return entry; + } + + string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim(); + return new DiscreteEventEntry + { + Id = key, + EventStrength = fallbackStrength, + Category = category, + OwnsCamera = InterestOwnership.Ambient, + LabelTemplate = labelTemplate, + IsFallback = true + }; + } +} diff --git a/IdleSpectator/MetaEventPatches.cs b/IdleSpectator/MetaEventPatches.cs index 64dcd94..efedfed 100644 --- a/IdleSpectator/MetaEventPatches.cs +++ b/IdleSpectator/MetaEventPatches.cs @@ -70,7 +70,7 @@ public static class MetaEventPatches object meta, string labelTemplate) { - Actor anchor = TryReadActor(meta) ?? EventFeedUtil.AnyAliveUnit(); + Actor anchor = TryReadActor(meta); string name = EventFeedUtil.SafeName(anchor); if (string.IsNullOrEmpty(name) && meta != null) { @@ -86,6 +86,12 @@ public static class MetaEventPatches } } + if (anchor == null) + { + // Meta without a living founder/leader: skip (no stranger subject). + return; + } + string label = (labelTemplate ?? "{a}") .Replace("{a}", name ?? "") .Replace("{id}", eventId); diff --git a/IdleSpectator/MetaInterestFeed.cs b/IdleSpectator/MetaInterestFeed.cs index c3bc188..3daf7df 100644 --- a/IdleSpectator/MetaInterestFeed.cs +++ b/IdleSpectator/MetaInterestFeed.cs @@ -18,11 +18,40 @@ public static class MetaInterestFeed return; } - Actor follow = EventFeedUtil.AnyAliveUnit(); - Vector3 pos = follow != null ? follow.current_position : Vector3.zero; - if (follow == null && pos == Vector3.zero) + // Eras are world-scoped: location-only. Prefer camera center, else a known map tile. + Vector3 pos = Vector3.zero; + try { - return; + if (Camera.main != null) + { + Vector3 cam = Camera.main.transform.position; + pos = new Vector3(cam.x, cam.y, 0f); + } + } + catch + { + pos = Vector3.zero; + } + + if (pos == Vector3.zero) + { + try + { + if (MapBox.instance != null) + { + // Non-zero placeholder so location-only Register accepts the candidate. + pos = new Vector3(1f, 1f, 0f); + } + } + catch + { + pos = new Vector3(1f, 1f, 0f); + } + } + + if (pos == Vector3.zero) + { + pos = new Vector3(1f, 1f, 0f); } string key = "era:" + entry.Id; @@ -35,7 +64,8 @@ public static class MetaInterestFeed entry.EventStrength, entry.OwnsCamera, pos, - follow, + follow: null, + locationOnly: true, minWatch: 6f, maxWatch: 28f); } @@ -47,14 +77,10 @@ public static class MetaInterestFeed return; } - Actor follow = anchor; - if (follow == null || !follow.isAlive()) - { - follow = EventFeedUtil.AnyAliveUnit(); - } - + Actor follow = anchor != null && anchor.isAlive() ? anchor : null; if (follow == null) { + // Meta genesis without a founder: skip unit dossier rather than invent a subject. return; } diff --git a/IdleSpectator/PlotInterestFeed.cs b/IdleSpectator/PlotInterestFeed.cs index 67aa3d7..1155452 100644 --- a/IdleSpectator/PlotInterestFeed.cs +++ b/IdleSpectator/PlotInterestFeed.cs @@ -23,11 +23,7 @@ public static class PlotInterestFeed Actor follow = subject; if (follow == null || !follow.isAlive()) { - follow = EventFeedUtil.AnyAliveUnit(); - } - - if (follow == null) - { + // Plot without a living author: skip rather than invent a stranger. return; } diff --git a/IdleSpectator/RelationshipEventPatches.cs b/IdleSpectator/RelationshipEventPatches.cs index d4e2e58..1c091c9 100644 --- a/IdleSpectator/RelationshipEventPatches.cs +++ b/IdleSpectator/RelationshipEventPatches.cs @@ -113,7 +113,7 @@ public static class RelationshipEventPatches [HarmonyPostfix] public static void PostfixNewFamily(Family __result, Actor pActor) { - Actor anchor = pActor != null && pActor.isAlive() ? pActor : EventFeedUtil.AnyAliveUnit(); + Actor anchor = pActor != null && pActor.isAlive() ? pActor : null; RelationshipInterestFeed.EmitFamily("new_family", __result, anchor); } @@ -121,7 +121,7 @@ public static class RelationshipEventPatches [HarmonyPostfix] public static void PostfixRemoveFamily(Family pObject) { - RelationshipInterestFeed.EmitFamily("family_removed", pObject, EventFeedUtil.AnyAliveUnit()); + RelationshipInterestFeed.EmitFamily("family_removed", pObject, null); } [HarmonyPatch(typeof(ActorManager), "createBabyActorFromData")] @@ -157,7 +157,7 @@ public static class RelationshipEventPatches return; } - RelationshipInterestFeed.Emit("family_group_new", pActor, null); + RelationshipInterestFeed.Emit("family_group_new", pActor, ActorRelation.ResolvePackRelated(pActor)); } [HarmonyPatch(typeof(BehFamilyGroupJoin), nameof(BehFamilyGroupJoin.execute))] @@ -169,7 +169,7 @@ public static class RelationshipEventPatches return; } - RelationshipInterestFeed.Emit("family_group_join", pActor, null); + RelationshipInterestFeed.Emit("family_group_join", pActor, ActorRelation.ResolvePackRelated(pActor)); } [HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))] @@ -181,6 +181,6 @@ public static class RelationshipEventPatches return; } - RelationshipInterestFeed.Emit("family_group_leave", pActor, null); + RelationshipInterestFeed.Emit("family_group_leave", pActor, ActorRelation.ResolvePackRelated(pActor)); } } diff --git a/IdleSpectator/RelationshipInterestFeed.cs b/IdleSpectator/RelationshipInterestFeed.cs index 99ca141..6760793 100644 --- a/IdleSpectator/RelationshipInterestFeed.cs +++ b/IdleSpectator/RelationshipInterestFeed.cs @@ -2,7 +2,7 @@ using UnityEngine; namespace IdleSpectator; -/// Registers relationship lifecycle interest candidates. +/// Registers relationship lifecycle interest candidates under the ownership contract. public static class RelationshipInterestFeed { public static void Emit(string eventId, Actor subject, Actor related, string labelOverride = null) @@ -18,10 +18,41 @@ public static class RelationshipInterestFeed return; } + 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" + || eventId == "family_group_join" + || eventId == "family_group_leave" + || eventId == "set_lover" + || eventId == "add_child"; + if (claimsPack && relatedAlive == null) + { + relatedAlive = ActorRelation.ResolvePackRelated(subject); + } + + if (claimsPack && 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, + entry.Id, + Mathf.Min(entry.EventStrength, 40f), + InterestOwnership.Ambient, + subject.current_position, + subject); + return; + } + string a = EventFeedUtil.SafeName(subject); - string b = EventFeedUtil.SafeName(related); + 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(related); + string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":" + EventFeedUtil.SafeId(relatedAlive); EventFeedUtil.Register( key, "relationship", @@ -31,7 +62,8 @@ public static class RelationshipInterestFeed entry.EventStrength, entry.OwnsCamera, subject.current_position, - subject); + subject, + related: relatedAlive); } public static void EmitFamily(string eventId, object family, Actor anchor) @@ -42,17 +74,19 @@ public static class RelationshipInterestFeed } DiscreteEventEntry entry = RelationshipEventCatalog.GetOrFallback(eventId); - Actor follow = anchor; - if (follow == null || !follow.isAlive()) + Actor follow = anchor != null && anchor.isAlive() ? anchor : null; + if (follow == null) { - follow = EventFeedUtil.AnyAliveUnit(); + follow = FirstFamilyMember(family); } if (follow == null) { + // No owned member - skip rather than invent a stranger. return; } + Actor related = ActorRelation.FirstFamilyPeer(follow); string name = ""; try { @@ -65,16 +99,99 @@ public static class RelationshipInterestFeed // ignore } - string key = "rel:" + entry.Id + ":" + (name ?? "family"); + if (string.IsNullOrEmpty(name)) + { + name = EventFeedUtil.SafeName(follow); + } + + string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(follow) + ":" + (name ?? "family"); EventFeedUtil.Register( key, "relationship", entry.Category, - entry.MakeLabel(name, ""), + entry.MakeLabel(name, EventFeedUtil.SafeName(related)), entry.Id, entry.EventStrength, entry.OwnsCamera, follow.current_position, - follow); + follow, + related: related); + } + + private static string SoloLabel(string eventId, string name) + { + 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; + case "add_child": + return "Family grows: " + name; + default: + return name; + } + } + + private static Actor FirstFamilyMember(object family) + { + if (family == null) + { + return null; + } + + try + { + foreach (Actor member in ActorRelation.EnumerateFamilyMembers( + ActorRelation.ResolveActor( + family.GetType().GetField("founder")?.GetValue(family) + ?? family.GetType().GetProperty("founder")?.GetValue(family, null)) + ?? null, + max: 1)) + { + return member; + } + } + catch + { + // fall through + } + + // Direct units list on the family meta object. + try + { + object list = family.GetType().GetField("units")?.GetValue(family) + ?? family.GetType().GetProperty("units")?.GetValue(family, null) + ?? family.GetType().GetMethod("getUnits")?.Invoke(family, null) + ?? family.GetType().GetMethod("getSimpleList")?.Invoke(family, null); + if (list is System.Collections.IEnumerable enumerable) + { + foreach (object raw in enumerable) + { + Actor a = ActorRelation.ResolveActor(raw); + if (a != null && a.isAlive()) + { + return a; + } + } + } + + Actor founder = family.GetType().GetField("founder")?.GetValue(family) as Actor + ?? family.GetType().GetProperty("founder")?.GetValue(family, null) as Actor; + if (founder != null && founder.isAlive()) + { + return founder; + } + } + catch + { + // ignore + } + + return null; } } diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 3499c0b..1fe858e 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -212,11 +212,8 @@ public sealed class UnitDossier } /// - /// Story beat for the dossier reason row: what is happening in this shot. - /// No tier badges, no job append, no nametag repeats. - /// Fill / ambient scenes with no sharper beat leave the reason empty (row hidden). - /// Follow-up (not blocking): expand event/character tags and optional beatPhrases - /// in scoring-model.json once playtests show thin beats. + /// Story beat for the dossier reason row: owned only by the matched current candidate. + /// Empty reason beats a wrong line - no watchLabel / spectacle / activity fallbacks. /// private static string BuildStoryBeat(UnitDossier d, string watchLabel, Actor actor) { @@ -226,6 +223,10 @@ public sealed class UnitDossier } InterestCandidate scene = MatchingScene(actor, d); + if (scene == null) + { + return ""; + } string combat = CombatBeat(d, scene); if (!string.IsNullOrEmpty(combat)) @@ -240,55 +241,18 @@ public sealed class UnitDossier } // Ambient filler: no story beat - hide the reason row. - if (scene != null && InterestScoring.IsFillScore(scene.TotalScore) - && !InterestScoring.IsCombatAction(scene)) + if (InterestScoring.IsFillScore(scene.TotalScore) && !InterestScoring.IsCombatAction(scene)) { return ""; } - string fromScene = SceneLabelBeat(scene, d); - if (!string.IsNullOrEmpty(fromScene)) - { - return fromScene; - } - - string fromWatch = EventLabelBeat(watchLabel, d); - if (!string.IsNullOrEmpty(fromWatch)) - { - return fromWatch; - } - - if (d.IsSpectacle) - { - return "Spectacle"; - } - - string activity = ActivityBeat(actor); - if (!string.IsNullOrEmpty(activity)) - { - return activity; - } - - if (d.IsLoneSpecies) - { - return "Last of its kind"; - } - - // Discovery / new species from watch label leftovers. - string discovery = CleanBeat(ShortenWatchLabel(watchLabel, d)); - if (!string.IsNullOrEmpty(discovery) - && discovery.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) >= 0) - { - return "New species"; - } - - return ""; + return SceneLabelBeat(scene, d) ?? ""; } private static InterestCandidate MatchingScene(Actor actor, UnitDossier d) { InterestCandidate scene = InterestDirector.CurrentCandidate; - if (scene == null) + if (scene == null || actor == null) { return null; } @@ -298,7 +262,7 @@ public sealed class UnitDossier return scene; } - if (actor != null && scene.SubjectId == d.UnitId) + if (scene.SubjectId != 0 && scene.SubjectId == d.UnitId) { return scene; } @@ -308,6 +272,11 @@ public sealed class UnitDossier return scene; } + if (scene.RelatedId != 0 && scene.RelatedId == d.UnitId) + { + return scene; + } + return null; } @@ -397,7 +366,174 @@ public sealed class UnitDossier return ""; } - return EventLabelBeat(scene.Label, d); + string beat = EventLabelBeat(scene.Label, d); + if (string.IsNullOrEmpty(beat)) + { + return ""; + } + + // Ownership: never let the reason name a living stranger (not subject/related). + if (ReasonNamesStranger(beat, scene)) + { + return ""; + } + + // Ownership: reject beats that lead with someone else's proper name. + if (LeadsWithForeignName(beat, scene)) + { + return ""; + } + + return beat; + } + + private static bool LeadsWithForeignName(string reason, InterestCandidate scene) + { + if (string.IsNullOrEmpty(reason) || scene == null) + { + return false; + } + + string first = FirstToken(reason); + if (string.IsNullOrEmpty(first) || IsOwnedBeatKeyword(first)) + { + return false; + } + + string subjectName = EventFeedUtil.SafeName(scene.FollowUnit); + string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit); + if (!string.IsNullOrEmpty(subjectName) + && first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrEmpty(relatedName) + && first.Equals(FirstToken(relatedName), System.StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Leading token looks like a proper name that is not the owned subject/related. + return first.Length >= 3 && char.IsLetter(first[0]); + } + + private static string FirstToken(string text) + { + if (string.IsNullOrEmpty(text)) + { + return ""; + } + + int i = 0; + while (i < text.Length && (char.IsWhiteSpace(text[i]) || text[i] == ':' || text[i] == '·')) + { + i++; + } + + int start = i; + while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_' || text[i] == '-')) + { + i++; + } + + return start < i ? text.Substring(start, i - start) : ""; + } + + private static bool IsOwnedBeatKeyword(string token) + { + if (string.IsNullOrEmpty(token)) + { + return true; + } + + switch (token.ToLowerInvariant()) + { + case "fighting": + case "war": + case "battle": + case "clash": + case "grief": + case "mourning": + case "mourn": + case "slain": + case "lovers": + case "lover": + case "new": + case "baby": + case "book": + case "building": + case "boat": + case "meta": + case "seeking": + case "joins": + case "leaves": + case "family": + case "starts": + case "gathers": + case "damages": + case "consumes": + case "spectacle": + case "in": + return true; + default: + return false; + } + } + + private static bool ReasonNamesStranger(string reason, InterestCandidate scene) + { + if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null) + { + return false; + } + + string subjectName = EventFeedUtil.SafeName(scene.FollowUnit); + string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit); + try + { + foreach (Actor unit in World.world.units) + { + if (unit == null || !unit.isAlive()) + { + continue; + } + + if (unit == scene.FollowUnit || unit == scene.RelatedUnit) + { + continue; + } + + string name = EventFeedUtil.SafeName(unit); + if (string.IsNullOrEmpty(name) || name.Length < 3) + { + continue; + } + + if (!string.IsNullOrEmpty(subjectName) + && name.Equals(subjectName, System.StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!string.IsNullOrEmpty(relatedName) + && name.Equals(relatedName, System.StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (reason.IndexOf(name, System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + } + catch + { + // ignore + } + + return false; } private static string EventLabelBeat(string label, UnitDossier d) diff --git a/IdleSpectator/WarInterestFeed.cs b/IdleSpectator/WarInterestFeed.cs index d8dfa55..487ab37 100644 --- a/IdleSpectator/WarInterestFeed.cs +++ b/IdleSpectator/WarInterestFeed.cs @@ -132,29 +132,8 @@ public static class WarInterestFeed return true; } - // Fall back to any living unit. - try - { - if (World.world?.units != null) - { - foreach (Actor actor in World.world.units) - { - if (actor == null || !actor.isAlive()) - { - continue; - } - - follow = actor; - position = actor.current_position; - return true; - } - } - } - catch - { - // ignore - } - + // No kingdom unit found - location-only if we have a capital position is handled above. + // Never invent a global random unit as war subject. return false; } diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 46c5125..88d9019 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -413,6 +413,7 @@ public static class WatchCaption { if (interest != null) { + // Location-only / no unit: tip text for logs; dossier has no owned subject reason. LastHeadline = CameraDirector.FormatWatchTip(interest); LastDetail = ""; LastCaptionText = LastHeadline; @@ -434,7 +435,9 @@ public static class WatchCaption return; } - SetFromActor(unit, interest?.Label); + // Reason ownership comes from InterestDirector.CurrentCandidate via MatchingScene, + // not from a sticky InterestEvent.Label / LastWatchLabel. + SetFromActor(unit, label: null); } public static void SetFromActor(Actor actor, string label = null) diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index 0dbf357..de125b6 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -173,7 +173,7 @@ public static class WorldActivityScanner : InterestCompletionKind.CharacterVignette) }; InterestScoring.ScoreCheap(candidate); - InterestRegistry.Upsert(candidate); + EventFeedUtil.RegisterCandidate(candidate); } } diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 4a735f8..0beeba2 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.16.32", + "version": "0.18.1", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" }