diff --git a/IdleSpectator/ActivityAssetCatalog.cs b/IdleSpectator/ActivityAssetCatalog.cs index 276d5ba..fd171d5 100644 --- a/IdleSpectator/ActivityAssetCatalog.cs +++ b/IdleSpectator/ActivityAssetCatalog.cs @@ -274,6 +274,31 @@ public static class ActivityAssetCatalog } } + public static List EnumerateLivePlotIds() + { + return EnumerateLibraryIds("plots_library"); + } + + public static List EnumerateLiveEraIds() + { + return EnumerateLibraryIds("era_library"); + } + + public static List EnumerateLiveBookTypeIds() + { + return EnumerateLibraryIds("book_types"); + } + + public static List EnumerateLiveTraitIds() + { + return EnumerateLibraryIds("traits"); + } + + public static List EnumerateLiveBuildingIds() + { + return EnumerateLibraryIds("buildings"); + } + private static List EnumerateLibraryIds(string assetManagerField) { var ids = new List(); diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index c0334b4..f478286 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -1801,6 +1801,10 @@ public static class AgentHarness DoWorldLogFeed(cmd); break; + case "domain_feed": + DoDomainFeed(cmd); + break; + case "interest_inject": DoInterestInject(cmd); break; @@ -2522,6 +2526,189 @@ public static class AgentHarness Emit(cmd, ok: true, detail: $"fed id={assetId} key={key} evt={entry.EventStrength:0.#}"); } + /// + /// Injects a Signal from a domain catalog while the harness is Busy + /// (domain feeds themselves early-out on Busy, so register here). + /// asset = domain, value = event/asset id. + /// + private static void DoDomainFeed(HarnessCommand cmd) + { + if (!WorldReady()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "world_not_ready"); + return; + } + + string domain = (cmd.asset ?? "").Trim().ToLowerInvariant(); + string id = (cmd.value ?? cmd.expect ?? "").Trim(); + Actor unit = ResolveUnit(null) ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + if (unit == null && domain != "era") + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no unit for domain_feed"); + return; + } + + try + { + DiscreteEventEntry entry; + string key; + string label; + InterestOwnership owns; + float strength; + string category; + string source; + + switch (domain) + { + case "relationship": + case "rel": + if (string.IsNullOrEmpty(id)) + { + id = "set_lover"; + } + + entry = RelationshipEventCatalog.GetOrFallback(id); + key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(unit); + label = entry.MakeLabel(EventFeedUtil.SafeName(unit), ""); + owns = entry.OwnsCamera; + strength = entry.EventStrength; + category = entry.Category; + source = "relationship"; + break; + case "plot": + if (string.IsNullOrEmpty(id)) + { + id = "new_war"; + } + + entry = PlotInterestCatalog.GetOrFallback(id); + key = "plot:" + entry.Id + ":new:" + EventFeedUtil.SafeId(unit); + label = entry.MakeLabel(EventFeedUtil.SafeName(unit), "new"); + owns = entry.OwnsCamera; + strength = entry.EventStrength; + category = entry.Category; + source = "plot"; + break; + case "era": + if (string.IsNullOrEmpty(id)) + { + id = "age_chaos"; + } + + 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; + case "book": + if (string.IsNullOrEmpty(id)) + { + id = "history_book"; + } + + entry = BookInterestCatalog.GetOrFallback(id); + key = "book:" + entry.Id + ":new:" + EventFeedUtil.SafeId(unit); + label = entry.MakeLabel(EventFeedUtil.SafeName(unit), "new"); + owns = entry.OwnsCamera; + strength = entry.EventStrength; + category = entry.Category; + source = "book"; + break; + case "trait": + if (string.IsNullOrEmpty(id)) + { + id = "immortal"; + } + + entry = TraitInterestCatalog.GetOrFallback(id); + key = "trait:" + entry.Id + ":gains:" + EventFeedUtil.SafeId(unit); + label = EventFeedUtil.SafeName(unit) + " gains " + entry.Id; + owns = entry.OwnsCamera; + strength = entry.EventStrength; + category = entry.Category; + source = "trait"; + break; + case "meta": + if (string.IsNullOrEmpty(id)) + { + id = "new_city"; + } + + key = "meta:" + id + ":" + EventFeedUtil.SafeId(unit); + label = "Meta: " + id + " (" + EventFeedUtil.SafeName(unit) + ")"; + owns = InterestOwnership.Signal; + strength = 74f; + category = "Politics"; + source = "meta"; + break; + case "boat": + id = "boat_unload"; + key = "boat:boat_unload:" + EventFeedUtil.SafeId(unit); + label = "Boat unloads: " + EventFeedUtil.SafeName(unit); + owns = InterestOwnership.Signal; + strength = 72f; + category = "Travel"; + source = "boat"; + break; + case "building": + id = "building_damage"; + key = "building:building_damage:" + EventFeedUtil.SafeId(unit); + label = "Damages a building: " + EventFeedUtil.SafeName(unit); + owns = InterestOwnership.Signal; + strength = 70f; + category = "Spectacle"; + source = "building"; + break; + default: + _cmdFail++; + Emit(cmd, ok: false, detail: "unknown domain=" + domain); + return; + } + + InterestCandidate registered = EventFeedUtil.Register( + key, + source, + category, + label, + id, + strength, + owns, + unit.current_position, + unit); + if (registered == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "register_failed domain=" + domain + " id=" + id); + return; + } + + _cmdOk++; + Emit(cmd, ok: true, detail: $"domain={domain} id={id} key={key}"); + } + catch (System.Exception ex) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "domain_feed error: " + ex.Message); + } + } + private static void DoInterestInject(HarnessCommand cmd) { if (!WorldReady()) @@ -4061,6 +4248,13 @@ public static class AgentHarness detail = audit.Detail; break; } + case "domain_event_audit": + { + DomainEventAuditResult audit = DomainEventHarness.RunAudit(HarnessDir()); + pass = audit.Passed; + detail = audit.Detail; + break; + } case "mutation_discovery": { MutationDiscoveryResult discovery = MutationDiscoveryHarness.Run(HarnessDir()); diff --git a/IdleSpectator/BoatEventPatches.cs b/IdleSpectator/BoatEventPatches.cs new file mode 100644 index 0000000..788a58a --- /dev/null +++ b/IdleSpectator/BoatEventPatches.cs @@ -0,0 +1,72 @@ +using ai.behaviours; +using HarmonyLib; +using UnityEngine; + +namespace IdleSpectator; + +/// Boat transport/trade interest patches from mutation discovery. +[HarmonyPatch] +public static class BoatEventPatches +{ + [HarmonyPatch(typeof(BehBoatTransportUnloadUnits), nameof(BehBoatTransportUnloadUnits.execute))] + [HarmonyPostfix] + public static void PostfixUnload(Actor pActor, BehResult __result) + { + if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) + { + return; + } + + EmitBoat("boat_unload", 72f, InterestOwnership.Signal, pActor, "Boat unloads: {a}"); + } + + [HarmonyPatch(typeof(BehBoatMakeTrade), nameof(BehBoatMakeTrade.execute))] + [HarmonyPostfix] + public static void PostfixTrade(Actor pActor, BehResult __result) + { + if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) + { + return; + } + + EmitBoat("boat_trade", 68f, InterestOwnership.Signal, pActor, "Boat trade: {a}"); + } + + [HarmonyPatch(typeof(BehBoatTransportDoLoading), nameof(BehBoatTransportDoLoading.execute))] + [HarmonyPostfix] + public static void PostfixLoad(Actor pActor, BehResult __result) + { + if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) + { + return; + } + + EmitBoat("boat_load", 55f, InterestOwnership.Ambient, pActor, "Boat loads: {a}"); + } + + private static void EmitBoat( + string eventId, + float strength, + InterestOwnership owns, + Actor actor, + string labelTemplate) + { + if (AgentHarness.Busy) + { + return; + } + + string label = (labelTemplate ?? "{a}").Replace("{a}", EventFeedUtil.SafeName(actor)); + string key = "boat:" + eventId + ":" + EventFeedUtil.SafeId(actor); + EventFeedUtil.Register( + key, + "boat", + "Travel", + label, + eventId, + strength, + owns, + actor.current_position, + actor); + } +} diff --git a/IdleSpectator/BookEventPatches.cs b/IdleSpectator/BookEventPatches.cs new file mode 100644 index 0000000..bd6dc4d --- /dev/null +++ b/IdleSpectator/BookEventPatches.cs @@ -0,0 +1,50 @@ +using HarmonyLib; + +namespace IdleSpectator; + +/// Book lifecycle patches from mutation discovery. +[HarmonyPatch] +public static class BookEventPatches +{ + [HarmonyPatch(typeof(BookManager), nameof(BookManager.newBook))] + [HarmonyPostfix] + public static void PostfixNewBook(Book __result) + { + if (__result == null) + { + return; + } + + BookInterestFeed.EmitFromBook(__result, "new"); + } + + [HarmonyPatch(typeof(BookManager), nameof(BookManager.generateNewBook))] + [HarmonyPostfix] + public static void PostfixGenerateNewBook(Book __result) + { + if (__result == null) + { + return; + } + + BookInterestFeed.EmitFromBook(__result, "generate"); + } + + [HarmonyPatch(typeof(BookManager), nameof(BookManager.burnBook))] + [HarmonyPostfix] + public static void PostfixBurnBook(object[] __args) + { + Book book = null; + if (__args != null && __args.Length > 0) + { + book = __args[0] as Book; + } + + if (book == null) + { + return; + } + + BookInterestFeed.EmitFromBook(book, "burn"); + } +} diff --git a/IdleSpectator/BookInterestCatalog.cs b/IdleSpectator/BookInterestCatalog.cs new file mode 100644 index 0000000..5692ffd --- /dev/null +++ b/IdleSpectator/BookInterestCatalog.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// Authored interest overlay for every live book_types asset. +public static class BookInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + 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}"); + } + + private static void Add( + string id, + float strength, + string category, + InterestOwnership owns, + string label) + { + Entries[id] = new DiscreteEventEntry + { + Id = id, + EventStrength = strength, + Category = category, + OwnsCamera = owns, + CreatesInterest = true, + LabelTemplate = label + }; + } + + public static IEnumerable AuthoredIds => Entries.Keys; + + public static bool HasAuthored(string id) => + !string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim()); + + public static DiscreteEventEntry GetOrFallback(string id) + { + if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry)) + { + return entry; + } + + string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim(); + return new DiscreteEventEntry + { + Id = key, + EventStrength = 45f, + Category = "Culture", + OwnsCamera = InterestOwnership.Ambient, + LabelTemplate = "Book ({id}): {a}", + IsFallback = true + }; + } +} diff --git a/IdleSpectator/BookInterestFeed.cs b/IdleSpectator/BookInterestFeed.cs new file mode 100644 index 0000000..293b62f --- /dev/null +++ b/IdleSpectator/BookInterestFeed.cs @@ -0,0 +1,88 @@ +using UnityEngine; + +namespace IdleSpectator; + +/// Registers book creation/burn interest candidates. +public static class BookInterestFeed +{ + public static void Emit(string bookTypeId, Actor subject, string phase) + { + if (AgentHarness.Busy) + { + return; + } + + DiscreteEventEntry entry = BookInterestCatalog.GetOrFallback(bookTypeId); + if (!entry.CreatesInterest) + { + return; + } + + Actor follow = subject; + if (follow == null || !follow.isAlive()) + { + follow = EventFeedUtil.AnyAliveUnit(); + } + + if (follow == null) + { + return; + } + + string label = entry.MakeLabel(EventFeedUtil.SafeName(follow), phase ?? ""); + if (!string.IsNullOrEmpty(phase) && phase == "burn") + { + label = "Book burned: " + EventFeedUtil.SafeName(follow); + } + + 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, + "book", + entry.Category, + label, + entry.Id, + strength, + owns, + follow.current_position, + follow); + } + + public static void EmitFromBook(object book, string phase) + { + if (book == null) + { + return; + } + + string typeId = "unknown"; + Actor author = null; + try + { + object asset = book.GetType().GetField("asset")?.GetValue(book) + ?? book.GetType().GetProperty("asset")?.GetValue(book, null) + ?? book.GetType().GetMethod("getAsset")?.Invoke(book, null); + if (asset != null) + { + object id = asset.GetType().GetField("id")?.GetValue(asset) + ?? asset.GetType().GetProperty("id")?.GetValue(asset, null); + if (id != null) + { + typeId = id.ToString(); + } + } + + author = book.GetType().GetField("author")?.GetValue(book) as Actor + ?? book.GetType().GetProperty("author")?.GetValue(book, null) as Actor + ?? book.GetType().GetMethod("getAuthor")?.Invoke(book, null) as Actor; + } + catch + { + // ignore + } + + Emit(typeId, author, phase); + } +} diff --git a/IdleSpectator/BuildingEventPatches.cs b/IdleSpectator/BuildingEventPatches.cs new file mode 100644 index 0000000..ee37011 --- /dev/null +++ b/IdleSpectator/BuildingEventPatches.cs @@ -0,0 +1,118 @@ +using ai.behaviours; +using HarmonyLib; +using UnityEngine; + +namespace IdleSpectator; + +/// Building damage/destroy interest patches from mutation discovery. +[HarmonyPatch] +public static class BuildingEventPatches +{ + [HarmonyPatch(typeof(BehDealDamageToTargetBuilding), nameof(BehDealDamageToTargetBuilding.execute))] + [HarmonyPostfix] + public static void PostfixDamageBuilding(Actor pActor, BehResult __result) + { + if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) + { + return; + } + + Emit("building_damage", 70f, InterestOwnership.Signal, pActor, "Damages a building: {a}"); + } + + [HarmonyPatch(typeof(BehConsumeTargetBuilding), nameof(BehConsumeTargetBuilding.execute))] + [HarmonyPostfix] + public static void PostfixConsumeBuilding(Actor pActor, BehResult __result) + { + if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) + { + return; + } + + Emit("building_consume", 78f, InterestOwnership.Signal, pActor, "Consumes a building: {a}"); + } + + [HarmonyPatch(typeof(Building), "startDestroyBuilding")] + [HarmonyPostfix] + public static void PostfixStartDestroy(Building __instance) + { + if (__instance == null || AgentHarness.Busy) + { + return; + } + + try + { + Vector3 pos = Vector3.zero; + object posObj = __instance.GetType().GetField("current_position")?.GetValue(__instance) + ?? __instance.GetType().GetProperty("current_position")?.GetValue(__instance, null); + if (posObj is Vector3 v) + { + pos = v; + } + + string buildingName = ""; + object asset = __instance.GetType().GetField("asset")?.GetValue(__instance) + ?? __instance.GetType().GetProperty("asset")?.GetValue(__instance, null); + if (asset != null) + { + object id = asset.GetType().GetField("id")?.GetValue(asset) + ?? asset.GetType().GetProperty("id")?.GetValue(asset, null); + buildingName = id?.ToString() ?? ""; + } + + Actor near = pos != Vector3.zero + ? EventFeedUtil.NearestUnit(pos) ?? EventFeedUtil.AnyAliveUnit() + : EventFeedUtil.AnyAliveUnit(); + if (near == null) + { + 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, + "building", + "Spectacle", + label, + string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName, + 88f, + InterestOwnership.Signal, + near.current_position, + near); + } + catch + { + // ignore API mismatches + } + } + + private static void Emit( + string eventId, + float strength, + InterestOwnership owns, + Actor actor, + string labelTemplate) + { + if (AgentHarness.Busy) + { + return; + } + + string label = (labelTemplate ?? "{a}").Replace("{a}", EventFeedUtil.SafeName(actor)); + string key = "building:" + eventId + ":" + EventFeedUtil.SafeId(actor); + EventFeedUtil.Register( + key, + "building", + "Spectacle", + label, + eventId, + strength, + owns, + actor.current_position, + actor); + } +} diff --git a/IdleSpectator/CatalogCoverageAudit.cs b/IdleSpectator/CatalogCoverageAudit.cs new file mode 100644 index 0000000..074e4b4 --- /dev/null +++ b/IdleSpectator/CatalogCoverageAudit.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace IdleSpectator; + +public sealed class CatalogCoverageDiff +{ + public string Domain = ""; + public int LiveTotal; + public int AuthoredTotal; + public int Missing; + public int Orphan; + public readonly List MissingIds = new List(); + public readonly List OrphanIds = new List(); + + public bool Passed => Missing == 0 && Orphan == 0; + + public string Detail => + $"{Domain}: live={LiveTotal} authored={AuthoredTotal} missing={Missing} orphan={Orphan}"; +} + +/// Shared live-vs-authored coverage audit used by domain event catalogs. +public static class CatalogCoverageAudit +{ + public static CatalogCoverageDiff Diff( + string domain, + IEnumerable liveIds, + IEnumerable authoredIds) + { + var live = new HashSet(StringComparer.OrdinalIgnoreCase); + var authored = new HashSet(StringComparer.OrdinalIgnoreCase); + var diff = new CatalogCoverageDiff { Domain = domain ?? "domain" }; + + if (liveIds != null) + { + foreach (string id in liveIds) + { + if (string.IsNullOrEmpty(id)) + { + continue; + } + + live.Add(id.Trim()); + } + } + + if (authoredIds != null) + { + foreach (string id in authoredIds) + { + if (string.IsNullOrEmpty(id)) + { + continue; + } + + authored.Add(id.Trim()); + } + } + + diff.LiveTotal = live.Count; + diff.AuthoredTotal = authored.Count; + + foreach (string id in live) + { + if (!authored.Contains(id)) + { + diff.Missing++; + diff.MissingIds.Add(id); + } + } + + foreach (string id in authored) + { + if (!live.Contains(id)) + { + diff.Orphan++; + diff.OrphanIds.Add(id); + } + } + + return diff; + } + + public static void WriteReport(string outputDirectory, string fileName, string body) + { + if (string.IsNullOrEmpty(outputDirectory) || string.IsNullOrEmpty(fileName)) + { + return; + } + + try + { + Directory.CreateDirectory(outputDirectory); + File.WriteAllText(Path.Combine(outputDirectory, fileName), body ?? ""); + } + catch + { + // ignore IO + } + } +} + +public sealed class DomainEventAuditResult +{ + public bool Passed; + public string Detail = ""; +} + +/// Coverage audits for relationship discrete events + live plot/era/book libraries. +public static class DomainEventHarness +{ + public static DomainEventAuditResult RunAudit(string outputDirectory) + { + var tsv = new StringBuilder(); + tsv.AppendLine("domain\tid\tauthored\towns_camera\tevent_strength\tnotes"); + + CatalogCoverageDiff plots = CatalogCoverageAudit.Diff( + "plots", + ActivityAssetCatalog.EnumerateLivePlotIds(), + PlotInterestCatalog.AuthoredIds); + AppendLibraryDomain(tsv, "plots", ActivityAssetCatalog.EnumerateLivePlotIds(), plots, PlotInterestCatalog.GetOrFallback); + + CatalogCoverageDiff eras = CatalogCoverageAudit.Diff( + "eras", + ActivityAssetCatalog.EnumerateLiveEraIds(), + EraInterestCatalog.AuthoredIds); + AppendLibraryDomain(tsv, "eras", ActivityAssetCatalog.EnumerateLiveEraIds(), eras, EraInterestCatalog.GetOrFallback); + + CatalogCoverageDiff books = CatalogCoverageAudit.Diff( + "books", + ActivityAssetCatalog.EnumerateLiveBookTypeIds(), + BookInterestCatalog.AuthoredIds); + AppendLibraryDomain(tsv, "books", ActivityAssetCatalog.EnumerateLiveBookTypeIds(), books, BookInterestCatalog.GetOrFallback); + + CatalogCoverageDiff traits = CatalogCoverageAudit.Diff( + "traits", + ActivityAssetCatalog.EnumerateLiveTraitIds(), + TraitInterestCatalog.AuthoredIds); + AppendLibraryDomain(tsv, "traits", ActivityAssetCatalog.EnumerateLiveTraitIds(), traits, TraitInterestCatalog.GetOrFallback); + + int relMissing = 0; + int relTotal = 0; + foreach (string id in RelationshipEventCatalog.AuthoredIds) + { + relTotal++; + DiscreteEventEntry entry = RelationshipEventCatalog.GetOrFallback(id); + bool ok = RelationshipEventCatalog.HasAuthored(id) && !entry.IsFallback; + if (!ok) + { + relMissing++; + } + + tsv.Append("relationship\t").Append(id).Append('\t') + .Append(ok ? "1" : "0").Append('\t') + .Append(entry.OwnsCamera).Append('\t') + .Append(entry.EventStrength.ToString("0.#")).Append('\t') + .Append(ok ? "ok" : "missing_authored").AppendLine(); + } + + CatalogCoverageAudit.WriteReport(outputDirectory, "domain_event_audit.tsv", tsv.ToString()); + + bool passed = plots.Passed && eras.Passed && books.Passed && traits.Passed + && relMissing == 0 && relTotal > 0; + string detail = + $"{plots.Detail}; {eras.Detail}; {books.Detail}; {traits.Detail}; relationship: authored={relTotal} missing={relMissing}"; + return new DomainEventAuditResult { Passed = passed, Detail = detail }; + } + + private static void AppendLibraryDomain( + StringBuilder tsv, + string domain, + IEnumerable liveIds, + CatalogCoverageDiff diff, + Func lookup) + { + foreach (string id in liveIds) + { + DiscreteEventEntry entry = lookup(id); + bool has = !entry.IsFallback; + tsv.Append(domain).Append('\t').Append(id).Append('\t') + .Append(has ? "1" : "0").Append('\t') + .Append(entry.OwnsCamera).Append('\t') + .Append(entry.EventStrength.ToString("0.#")).Append('\t') + .Append(has ? "ok" : "missing_authored").AppendLine(); + } + + foreach (string id in diff.OrphanIds) + { + tsv.Append(domain).Append('\t').Append(id).Append("\t1\t\t0\torphan_authored").AppendLine(); + } + } +} diff --git a/IdleSpectator/EraInterestCatalog.cs b/IdleSpectator/EraInterestCatalog.cs new file mode 100644 index 0000000..40415c8 --- /dev/null +++ b/IdleSpectator/EraInterestCatalog.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// Authored interest overlay for every live era_library asset. +public static class EraInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + static EraInterestCatalog() + { + Add("age_hope", 78f, "World", InterestOwnership.Signal, "Age of Hope"); + Add("age_sun", 80f, "World", InterestOwnership.Signal, "Age of Sun"); + Add("age_dark", 88f, "World", InterestOwnership.Signal, "Age of Dark"); + Add("age_tears", 86f, "World", InterestOwnership.Signal, "Age of Tears"); + Add("age_moon", 82f, "World", InterestOwnership.Signal, "Age of Moon"); + Add("age_chaos", 92f, "World", InterestOwnership.Signal, "Age of Chaos"); + Add("age_wonders", 84f, "World", InterestOwnership.Signal, "Age of Wonders"); + Add("age_ice", 87f, "World", InterestOwnership.Signal, "Age of Ice"); + Add("age_ash", 90f, "World", InterestOwnership.Signal, "Age of Ash"); + Add("age_despair", 91f, "World", InterestOwnership.Signal, "Age of Despair"); + Add("age_unknown", 70f, "World", InterestOwnership.Ambient, "Unknown age"); + } + + private static void Add( + string id, + float strength, + string category, + InterestOwnership owns, + string label) + { + Entries[id] = new DiscreteEventEntry + { + Id = id, + EventStrength = strength, + Category = category, + OwnsCamera = owns, + CreatesInterest = true, + LabelTemplate = label + }; + } + + public static IEnumerable AuthoredIds => Entries.Keys; + + public static bool HasAuthored(string id) => + !string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim()); + + public static DiscreteEventEntry GetOrFallback(string id) + { + if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry)) + { + return entry; + } + + string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim(); + return new DiscreteEventEntry + { + Id = key, + EventStrength = 70f, + Category = "World", + OwnsCamera = InterestOwnership.Signal, + LabelTemplate = "Era ({id})", + IsFallback = true + }; + } +} diff --git a/IdleSpectator/EventFeedUtil.cs b/IdleSpectator/EventFeedUtil.cs new file mode 100644 index 0000000..613866e --- /dev/null +++ b/IdleSpectator/EventFeedUtil.cs @@ -0,0 +1,182 @@ +using UnityEngine; + +namespace IdleSpectator; + +/// Whether an interest candidate may steal the idle camera. +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 +} + +/// Shared registration helpers for domain event feeds. +public static class EventFeedUtil +{ + public static InterestCandidate Register( + string key, + string source, + string category, + string label, + string assetId, + float eventStrength, + InterestOwnership ownership, + Vector3 position, + Actor follow, + float minWatch = 4f, + float maxWatch = 22f, + InterestCompletionKind completion = InterestCompletionKind.FixedDwell) + { + if (string.IsNullOrEmpty(key) || position == Vector3.zero && follow == null) + { + return null; + } + + if (follow == null && position == Vector3.zero) + { + return null; + } + + Vector3 pos = follow != null ? follow.current_position : position; + if (pos == Vector3.zero) + { + return null; + } + + bool ambient = ownership == InterestOwnership.Ambient; + float strength = eventStrength; + if (ambient) + { + strength = Mathf.Min(strength, InterestScoringConfig.W.fillScoreMax - 1f); + if (strength < 8f) + { + strength = 8f; + } + } + + var candidate = new InterestCandidate + { + Key = key, + LeadKind = ambient ? InterestLeadKind.CharacterLed : InterestLeadKind.EventLed, + Category = string.IsNullOrEmpty(category) ? "Event" : category, + Source = source ?? "event", + EventStrength = strength, + CharacterSignificance = ambient ? strength * 0.5f : 0f, + VisualConfidence = follow != null ? 0.7f : 0.35f, + Position = pos, + FollowUnit = follow, + SubjectId = follow != null ? SafeId(follow) : 0, + Label = label ?? assetId ?? key, + AssetId = assetId ?? "", + SpeciesId = follow?.asset != null ? follow.asset.id : "", + CreatedAt = Time.unscaledTime, + LastSeenAt = Time.unscaledTime, + ExpiresAt = Time.unscaledTime + (ambient ? 18f : 35f), + MinWatch = minWatch, + MaxWatch = maxWatch, + Completion = completion + }; + InterestScoring.ScoreCheap(candidate); + return InterestRegistry.Upsert(candidate); + } + + public static long SafeId(Actor actor) + { + if (actor == null) + { + return 0; + } + + try + { + return actor.getID(); + } + catch + { + return 0; + } + } + + public static string SafeName(Actor actor) + { + if (actor == null) + { + return ""; + } + + try + { + return actor.getName() ?? ""; + } + catch + { + return ""; + } + } + + public static Actor NearestUnit(Vector3 near, float radius = 200f) + { + try + { + return WorldActivityScanner.FindNearestAliveUnit(near, radius); + } + catch + { + return null; + } + } + + public static Actor AnyAliveUnit() + { + try + { + if (World.world?.units == null) + { + return null; + } + + foreach (Actor actor in World.world.units) + { + if (actor != null && actor.isAlive()) + { + return actor; + } + } + } + catch + { + // ignore + } + + return null; + } + + 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; + } +} diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 4fd71a8..e6e7ed0 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -55,6 +55,10 @@ internal static class HarnessScenarios return DisasterWarAudit(); case "event_coverage": return EventCoverage(); + case "domain_event_audit": + return DomainEventAudit(); + case "event_tracking": + return EventTracking(); case "mutation_discovery": return MutationDiscovery(); case "chronicle": @@ -1099,6 +1103,53 @@ internal static class HarnessScenarios }; } + private static List DomainEventAudit() + { + return new List + { + Step("dea0", "wait_world"), + Step("dea1", "assert", expect: "domain_event_audit"), + Step("dea2", "snapshot") + }; + } + + private static List EventTracking() + { + return new List + { + Step("et0", "dismiss_windows"), + Step("et1", "wait_world"), + Step("et2", "set_setting", expect: "enabled", value: "true"), + Step("et3", "pick_unit", asset: "auto"), + Step("et4", "spectator", value: "off"), + Step("et5", "spectator", value: "on"), + Step("et6", "focus", asset: "auto"), + + Step("et10", "assert", expect: "domain_event_audit"), + + Step("et20", "domain_feed", asset: "relationship", value: "set_lover"), + Step("et21", "assert", expect: "interest_has_key", value: "rel:set_lover"), + Step("et22", "domain_feed", asset: "plot", value: "summon_meteor_rain"), + Step("et23", "assert", expect: "interest_has_key", value: "plot:summon_meteor_rain"), + Step("et24", "domain_feed", asset: "era", value: "age_chaos"), + Step("et25", "assert", expect: "interest_has_key", value: "era:age_chaos"), + Step("et26", "domain_feed", asset: "meta", value: "new_city"), + Step("et27", "assert", expect: "interest_has_key", value: "meta:new_city"), + Step("et28", "domain_feed", asset: "book", value: "bad_story_about_king"), + Step("et29", "assert", expect: "interest_has_key", value: "book:bad_story_about_king"), + Step("et30", "domain_feed", asset: "trait", value: "immortal"), + Step("et31", "assert", expect: "interest_has_key", value: "trait:immortal"), + Step("et32", "domain_feed", asset: "boat"), + 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("et90", "assert", expect: "health"), + Step("et91", "assert", expect: "no_bad"), + Step("et99", "snapshot") + }; + } + private static List EventCoverage() { return new List @@ -1116,6 +1167,7 @@ internal static class HarnessScenarios Step("ec11", "assert", expect: "disaster_war_audit"), Step("ec12", "assert", expect: "activity_status_audit"), Step("ec13", "assert", expect: "activity_happiness_audit"), + Step("ec14", "assert", expect: "domain_event_audit"), // Previously silent-dropped WorldLog disaster id must enter the registry Step("ec20", "world_log_feed", asset: "disaster_tornado"), diff --git a/IdleSpectator/InterestFeeds.cs b/IdleSpectator/InterestFeeds.cs index d8c4c8e..434a76d 100644 --- a/IdleSpectator/InterestFeeds.cs +++ b/IdleSpectator/InterestFeeds.cs @@ -106,7 +106,7 @@ public static class InterestFeeds unit = message.unit; } - if (unit == null && position == Vector3.zero) + if (!EventFeedUtil.TryResolveAnchor(unit, position, out Actor follow, out Vector3 pos)) { return; } @@ -117,30 +117,18 @@ public static class InterestFeeds float boost = PeekCivicBoost(kingdom, assetId); string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category; - var candidate = new InterestCandidate - { - Key = key, - LeadKind = InterestLeadKind.EventLed, - Category = category, - Source = "worldlog", - EventStrength = evtSeed + boost, - VisualConfidence = unit != null ? 0.7f : 0.4f, - Position = unit != null ? unit.current_position : position, - FollowUnit = unit, - SubjectId = SafeId(unit), - Label = entry.MakeLabel(message), - AssetId = assetId, - KingdomKey = kingdom, - SpeciesId = unit?.asset != null ? unit.asset.id : "", - CreatedAt = Time.unscaledTime, - LastSeenAt = Time.unscaledTime, - ExpiresAt = Time.unscaledTime + 40f, - MinWatch = entry.MinWatch, - MaxWatch = entry.MaxWatch, - Completion = InterestCompletionKind.FixedDwell - }; - InterestScoring.ScoreCheap(candidate); - InterestRegistry.Upsert(candidate); + EventFeedUtil.Register( + key, + "worldlog", + category, + entry.MakeLabel(message), + assetId, + evtSeed + boost, + entry.OwnsCamera, + pos, + follow, + entry.MinWatch, + entry.MaxWatch); } /// Legacy / discovery / harness enqueue path → registry. @@ -353,38 +341,34 @@ public static class InterestFeeds long id = SafeId(actor); if (entry.ExtendsGrief || statusId == "crying") { - // Extend existing grief candidate rather than spawning a second scene. string griefPrefix = "happiness:death_"; - ExtendMatching(id, griefPrefix, statusId); - return; + if (ExtendMatching(id, griefPrefix, statusId)) + { + return; + } + + // Standalone crying with no grief scene still creates interest. + if (statusId != "crying" && entry.ExtendsGrief) + { + return; + } } string phrase = ActivityStatusProse.Phrase(statusId, gained: true, out _); string key = "status:" + statusId + ":" + id; - var candidate = new InterestCandidate - { - Key = key, - LeadKind = InterestLeadKind.EventLed, - Category = string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category, - Source = "status", - EventStrength = entry.EventStrength, - VisualConfidence = 0.85f, - Position = actor.current_position, - FollowUnit = actor, - SubjectId = id, - StatusId = statusId, - Label = SafeName(actor) + " · " + phrase, - AssetId = actor.asset != null ? actor.asset.id : "", - SpeciesId = actor.asset != null ? actor.asset.id : "", - CreatedAt = Time.unscaledTime, - LastSeenAt = Time.unscaledTime, - ExpiresAt = Time.unscaledTime + 18f, - MinWatch = 2f, - MaxWatch = 22f, - Completion = InterestCompletionKind.StatusPhase - }; - InterestScoring.ScoreCheap(candidate); - InterestRegistry.Upsert(candidate); + EventFeedUtil.Register( + key, + "status", + string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category, + SafeName(actor) + " · " + phrase, + statusId, + entry.EventStrength, + entry.OwnsCamera, + actor.current_position, + actor, + 2f, + 22f, + InterestCompletionKind.StatusPhase); } public static void OnChronicleMilestone(Actor subject, string milestoneKey, Actor related, string label) @@ -660,11 +644,14 @@ public static class InterestFeeds // Ongoing wars from WarManager (complements WorldLog war start/end). WarInterestFeed.Tick(); + // Active plots from PlotManager / World.plots. + PlotInterestFeed.Tick(); + // Character-led vignette seeds (not sole one-best monopoly: register best + a few notables). WorldActivityScanner.RegisterTopCharacterCandidates(max: 4); } - private static void ExtendMatching(long subjectId, string keyPrefix, string statusId) + private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId) { var pending = new List(32); InterestRegistry.CopyPending(pending); @@ -682,9 +669,11 @@ public static class InterestFeeds c.LastSeenAt = Time.unscaledTime; c.ExpiresAt = Time.unscaledTime + 20f; InterestRegistry.Upsert(c); - return; + return true; } } + + return false; } private static void StampCivicBoost(string kingdom, string effectId, float amount) diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs index e6284b3..25cf7de 100644 --- a/IdleSpectator/Main.cs +++ b/IdleSpectator/Main.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using HarmonyLib; using NeoModLoader.api; @@ -56,12 +57,42 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable InterestScoringConfig.LoadFromModFolder(ModFolder); _harmony = new Harmony(pModDecl.UID); - _harmony.PatchAll(typeof(ModClass).Assembly); + ApplyHarmonyPatches(pModDecl.Name); LogService.LogInfo($"[{pModDecl.Name}]: Harmony patches applied"); LogService.LogInfo($"[{pModDecl.Name}]: Press I for Idle Spectator, L for Lore"); Chronicle.EnsureWindow(); } + private void ApplyHarmonyPatches(string modName) + { + Exception firstFailure = null; + foreach (Type type in typeof(ModClass).Assembly.GetTypes()) + { + try + { + new PatchClassProcessor(_harmony, type).Patch(); + } + catch (Exception ex) + { + LogService.LogError($"[{modName}]: Harmony failed on {type.FullName}: {ex}"); + if (ex.InnerException != null) + { + LogService.LogError($"[{modName}]: Inner: {ex.InnerException}"); + } + + if (firstFailure == null) + { + firstFailure = ex; + } + } + } + + if (firstFailure != null) + { + throw firstFailure; + } + } + private void Update() { AgentHarness.Update(); diff --git a/IdleSpectator/MetaEventPatches.cs b/IdleSpectator/MetaEventPatches.cs new file mode 100644 index 0000000..64dcd94 --- /dev/null +++ b/IdleSpectator/MetaEventPatches.cs @@ -0,0 +1,156 @@ +using HarmonyLib; + +namespace IdleSpectator; + +/// Era + meta genesis patches from mutation discovery. +[HarmonyPatch] +public static class MetaEventPatches +{ + [HarmonyPatch(typeof(WorldAgeManager), nameof(WorldAgeManager.startNextAge))] + [HarmonyPostfix] + public static void PostfixStartNextAge() + { + MetaInterestFeed.EmitEra(ReadCurrentEraId()); + } + + [HarmonyPatch(typeof(CultureManager), nameof(CultureManager.newCulture))] + [HarmonyPostfix] + public static void PostfixNewCulture(Culture __result) + { + EmitNew("new_culture", "Culture", 68f, InterestOwnership.Signal, __result, "New culture: {a}"); + } + + [HarmonyPatch(typeof(ReligionManager), nameof(ReligionManager.newReligion))] + [HarmonyPostfix] + public static void PostfixNewReligion(Religion __result) + { + EmitNew("new_religion", "Culture", 72f, InterestOwnership.Signal, __result, "New religion: {a}"); + } + + [HarmonyPatch(typeof(LanguageManager), nameof(LanguageManager.newLanguage))] + [HarmonyPostfix] + public static void PostfixNewLanguage(Language __result) + { + EmitNew("new_language", "Culture", 60f, InterestOwnership.Ambient, __result, "New language: {a}"); + } + + [HarmonyPatch(typeof(ClanManager), nameof(ClanManager.newClan))] + [HarmonyPostfix] + public static void PostfixNewClan(Clan __result) + { + EmitNew("new_clan", "Politics", 70f, InterestOwnership.Signal, __result, "New clan: {a}"); + } + + [HarmonyPatch(typeof(ArmyManager), nameof(ArmyManager.newArmy))] + [HarmonyPostfix] + public static void PostfixNewArmy(Army __result) + { + EmitNew("new_army", "Politics", 66f, InterestOwnership.Ambient, __result, "New army: {a}"); + } + + [HarmonyPatch(typeof(AllianceManager), nameof(AllianceManager.newAlliance))] + [HarmonyPostfix] + public static void PostfixNewAlliance(Alliance __result) + { + EmitNew("new_alliance", "Politics", 78f, InterestOwnership.Signal, __result, "New alliance: {a}"); + } + + [HarmonyPatch(typeof(CityManager), nameof(CityManager.newCity))] + [HarmonyPostfix] + public static void PostfixNewCity(City __result) + { + EmitNew("new_city", "Politics", 74f, InterestOwnership.Signal, __result, "New city: {a}"); + } + + private static void EmitNew( + string eventId, + string category, + float strength, + InterestOwnership owns, + object meta, + string labelTemplate) + { + Actor anchor = TryReadActor(meta) ?? EventFeedUtil.AnyAliveUnit(); + 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 = ""; + } + } + + string label = (labelTemplate ?? "{a}") + .Replace("{a}", name ?? "") + .Replace("{id}", eventId); + MetaInterestFeed.EmitMeta(eventId, category, strength, owns, anchor, label); + } + + private static Actor TryReadActor(object meta) + { + if (meta == null) + { + return null; + } + + try + { + foreach (string name in new[] { "founder", "creator", "leader", "king", "actor", "unit" }) + { + object value = meta.GetType().GetField(name)?.GetValue(meta) + ?? meta.GetType().GetProperty(name)?.GetValue(meta, null); + if (value is Actor actor && actor.isAlive()) + { + return actor; + } + } + } + catch + { + // ignore + } + + return null; + } + + private static string ReadCurrentEraId() + { + try + { + object world = World.world; + object ageManager = world?.GetType().GetField("era_manager")?.GetValue(world) + ?? world?.GetType().GetProperty("era_manager")?.GetValue(world, null) + ?? world?.GetType().GetField("age_manager")?.GetValue(world) + ?? MapBox.instance?.GetType().GetField("era_manager")?.GetValue(MapBox.instance); + if (ageManager == null) + { + return "age_unknown"; + } + + object asset = ageManager.GetType().GetMethod("getCurrentAge")?.Invoke(ageManager, null) + ?? ageManager.GetType().GetProperty("current_age")?.GetValue(ageManager, null) + ?? ageManager.GetType().GetField("current_age")?.GetValue(ageManager); + if (asset != null) + { + object id = asset.GetType().GetField("id")?.GetValue(asset) + ?? asset.GetType().GetProperty("id")?.GetValue(asset, null); + if (id != null) + { + return id.ToString(); + } + } + } + catch + { + // ignore + } + + return "age_unknown"; + } +} diff --git a/IdleSpectator/MetaInterestFeed.cs b/IdleSpectator/MetaInterestFeed.cs new file mode 100644 index 0000000..c3bc188 --- /dev/null +++ b/IdleSpectator/MetaInterestFeed.cs @@ -0,0 +1,73 @@ +using UnityEngine; + +namespace IdleSpectator; + +/// Registers era and meta-genesis interest candidates. +public static class MetaInterestFeed +{ + public static void EmitEra(string eraId) + { + if (AgentHarness.Busy) + { + return; + } + + DiscreteEventEntry entry = EraInterestCatalog.GetOrFallback(eraId); + if (!entry.CreatesInterest) + { + return; + } + + Actor follow = EventFeedUtil.AnyAliveUnit(); + Vector3 pos = follow != null ? follow.current_position : Vector3.zero; + if (follow == null && pos == Vector3.zero) + { + return; + } + + string key = "era:" + entry.Id; + EventFeedUtil.Register( + key, + "era", + entry.Category, + entry.MakeLabel("", ""), + entry.Id, + entry.EventStrength, + entry.OwnsCamera, + pos, + follow, + minWatch: 6f, + maxWatch: 28f); + } + + public static void EmitMeta(string eventId, string category, float strength, InterestOwnership owns, Actor anchor, string label) + { + if (AgentHarness.Busy) + { + return; + } + + Actor follow = anchor; + if (follow == null || !follow.isAlive()) + { + follow = EventFeedUtil.AnyAliveUnit(); + } + + if (follow == null) + { + return; + } + + string key = "meta:" + eventId + ":" + EventFeedUtil.SafeId(follow); + EventFeedUtil.Register( + key, + "meta", + category, + label, + eventId, + strength, + owns, + follow.current_position, + follow); + } +} diff --git a/IdleSpectator/MutationDiscoveryHarness.cs b/IdleSpectator/MutationDiscoveryHarness.cs index da5ca62..c84699d 100644 --- a/IdleSpectator/MutationDiscoveryHarness.cs +++ b/IdleSpectator/MutationDiscoveryHarness.cs @@ -292,6 +292,34 @@ public static class MutationDiscoveryHarness .Append("0").Append('\t') .Append("count=" + ids.Count).AppendLine(); } + + // Full id lists for priority event catalogs. + if (!string.IsNullOrEmpty(outputDirectory) + && (field == "plots_library" + || field == "plot_category_library" + || field == "era_library" + || field == "book_types" + || field == "traits" + || field == "buildings")) + { + try + { + var full = new StringBuilder(); + full.AppendLine("id"); + for (int s = 0; s < ids.Count; s++) + { + full.AppendLine(ids[s]); + } + + File.WriteAllText( + Path.Combine(outputDirectory, "library-" + field + ".tsv"), + full.ToString()); + } + catch + { + // ignore + } + } } // World / MapBox live manager fields diff --git a/IdleSpectator/PlotEventPatches.cs b/IdleSpectator/PlotEventPatches.cs new file mode 100644 index 0000000..933305f --- /dev/null +++ b/IdleSpectator/PlotEventPatches.cs @@ -0,0 +1,102 @@ +using HarmonyLib; + +namespace IdleSpectator; + +/// Plot lifecycle patches from mutation discovery. +[HarmonyPatch] +public static class PlotEventPatches +{ + [HarmonyPatch(typeof(PlotManager), nameof(PlotManager.newPlot))] + [HarmonyPostfix] + public static void PostfixNewPlot(Plot __result) + { + if (__result == null) + { + return; + } + + PlotInterestFeed.EmitFromPlot(__result, "new"); + } + + [HarmonyPatch(typeof(PlotManager), nameof(PlotManager.cancelPlot))] + [HarmonyPostfix] + public static void PostfixCancelPlot(Plot pPlot) + { + if (pPlot == null) + { + return; + } + + PlotInterestFeed.EmitFromPlot(pPlot, "cancel"); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.setPlot))] + [HarmonyPostfix] + public static void PostfixSetPlot(Actor __instance, Plot pObject) + { + if (__instance == null || !__instance.isAlive() || pObject == null) + { + return; + } + + PlotInterestFeed.EmitFromPlot(pObject, "join"); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.leavePlot))] + [HarmonyPrefix] + public static void PrefixLeavePlot(Actor __instance, out string __state) + { + __state = ""; + try + { + object plot = __instance?.GetType().GetField("plot")?.GetValue(__instance) + ?? __instance?.GetType().GetProperty("plot")?.GetValue(__instance, null); + if (plot != null) + { + __state = ReadPlotAssetId(plot); + } + } + catch + { + __state = ""; + } + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.leavePlot))] + [HarmonyPostfix] + public static void PostfixLeavePlot(Actor __instance, string __state) + { + if (__instance == null || !__instance.isAlive() || string.IsNullOrEmpty(__state)) + { + return; + } + + PlotInterestFeed.Emit(__state, __instance, "leave"); + } + + private static string ReadPlotAssetId(object plot) + { + try + { + object asset = plot.GetType().GetField("asset")?.GetValue(plot) + ?? plot.GetType().GetProperty("asset")?.GetValue(plot, null) + ?? plot.GetType().GetMethod("getAsset")?.Invoke(plot, null) + ?? plot.GetType().GetField("plot_asset")?.GetValue(plot); + if (asset != null) + { + object id = asset.GetType().GetField("id")?.GetValue(asset) + ?? asset.GetType().GetProperty("id")?.GetValue(asset, null); + if (id != null) + { + return id.ToString(); + } + } + } + catch + { + // ignore + } + + return ""; + } +} diff --git a/IdleSpectator/PlotInterestCatalog.cs b/IdleSpectator/PlotInterestCatalog.cs new file mode 100644 index 0000000..8e9c221 --- /dev/null +++ b/IdleSpectator/PlotInterestCatalog.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// Authored interest overlay for every live plots_library asset. +public static class PlotInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + 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}"); + } + + private static void Add( + string id, + float strength, + string category, + InterestOwnership owns, + string label) + { + Entries[id] = new DiscreteEventEntry + { + Id = id, + EventStrength = strength, + Category = category, + OwnsCamera = owns, + CreatesInterest = true, + LabelTemplate = label + }; + } + + public static IEnumerable AuthoredIds => Entries.Keys; + + public static bool HasAuthored(string id) => + !string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim()); + + public static DiscreteEventEntry GetOrFallback(string id) + { + if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry)) + { + return entry; + } + + string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim(); + return new DiscreteEventEntry + { + Id = key, + EventStrength = 55f, + Category = "Plot", + OwnsCamera = InterestOwnership.Ambient, + LabelTemplate = "Plot ({id}): {a}", + IsFallback = true + }; + } +} diff --git a/IdleSpectator/PlotInterestFeed.cs b/IdleSpectator/PlotInterestFeed.cs new file mode 100644 index 0000000..67aa3d7 --- /dev/null +++ b/IdleSpectator/PlotInterestFeed.cs @@ -0,0 +1,182 @@ +using System.Collections; +using System.Reflection; +using UnityEngine; + +namespace IdleSpectator; + +/// Registers plot interest from Harmony hooks and World.plots polling. +public static class PlotInterestFeed +{ + public static void Emit(string plotAssetId, Actor subject, string phase) + { + if (AgentHarness.Busy) + { + return; + } + + DiscreteEventEntry entry = PlotInterestCatalog.GetOrFallback(plotAssetId); + if (!entry.CreatesInterest) + { + return; + } + + Actor follow = subject; + if (follow == null || !follow.isAlive()) + { + follow = EventFeedUtil.AnyAliveUnit(); + } + + if (follow == null) + { + return; + } + + string name = EventFeedUtil.SafeName(follow); + string label = entry.MakeLabel(name, phase ?? ""); + string key = "plot:" + entry.Id + ":" + (phase ?? "active") + ":" + EventFeedUtil.SafeId(follow); + EventFeedUtil.Register( + key, + "plot", + entry.Category, + label, + entry.Id, + entry.EventStrength, + entry.OwnsCamera, + follow.current_position, + follow); + } + + public static void EmitFromPlot(object plot, string phase) + { + if (plot == null) + { + return; + } + + string assetId = ReadPlotAssetId(plot); + Actor author = ReadPlotAuthor(plot); + Emit(assetId, author, phase); + } + + public static void Tick() + { + if (AgentHarness.Busy) + { + return; + } + + IEnumerable plots = ResolvePlotsEnumerable(); + if (plots == null) + { + return; + } + + int registered = 0; + foreach (object plot in plots) + { + if (plot == null || registered >= 8) + { + break; + } + + EmitFromPlot(plot, "active"); + registered++; + } + } + + private static IEnumerable ResolvePlotsEnumerable() + { + try + { + object world = World.world; + if (world == null) + { + return null; + } + + object plots = world.GetType().GetField("plots")?.GetValue(world) + ?? world.GetType().GetProperty("plots")?.GetValue(world, null); + if (plots is IEnumerable direct) + { + return direct; + } + + object manager = world.GetType().GetField("plots_manager")?.GetValue(world) + ?? world.GetType().GetProperty("plots_manager")?.GetValue(world, null) + ?? typeof(PlotManager); + if (manager is System.Type) + { + manager = null; + } + + object mapBox = MapBox.instance; + if (manager == null && mapBox != null) + { + manager = mapBox.GetType().GetField("plots")?.GetValue(mapBox) + ?? mapBox.GetType().GetProperty("plots")?.GetValue(mapBox, null); + } + + if (manager == null) + { + return null; + } + + MethodInfo getAll = manager.GetType().GetMethod("getSimpleList") + ?? manager.GetType().GetMethod("getList") + ?? manager.GetType().GetMethod("getPlots"); + if (getAll != null) + { + return getAll.Invoke(manager, null) as IEnumerable; + } + + return manager as IEnumerable; + } + catch + { + return null; + } + } + + private static string ReadPlotAssetId(object plot) + { + try + { + object asset = plot.GetType().GetField("asset")?.GetValue(plot) + ?? plot.GetType().GetProperty("asset")?.GetValue(plot, null) + ?? plot.GetType().GetMethod("getAsset")?.Invoke(plot, 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 typeId = plot.GetType().GetField("type")?.GetValue(plot) + ?? plot.GetType().GetProperty("type")?.GetValue(plot, null); + return typeId?.ToString() ?? "unknown"; + } + catch + { + return "unknown"; + } + } + + private static Actor ReadPlotAuthor(object plot) + { + try + { + object author = plot.GetType().GetField("author")?.GetValue(plot) + ?? plot.GetType().GetProperty("author")?.GetValue(plot, null) + ?? plot.GetType().GetMethod("getAuthor")?.Invoke(plot, null) + ?? plot.GetType().GetField("founder")?.GetValue(plot); + return author as Actor; + } + catch + { + return null; + } + } +} diff --git a/IdleSpectator/RelationshipEventCatalog.cs b/IdleSpectator/RelationshipEventCatalog.cs new file mode 100644 index 0000000..5d62762 --- /dev/null +++ b/IdleSpectator/RelationshipEventCatalog.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +public sealed class DiscreteEventEntry +{ + public string Id = ""; + public float EventStrength = 50f; + public string Category = "Event"; + public InterestOwnership OwnsCamera = InterestOwnership.Signal; + public bool CreatesInterest = true; + public string LabelTemplate = "{id}"; + public bool IsFallback; + + public string MakeLabel(string a, string b) + { + string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate; + return template + .Replace("{id}", Id ?? "") + .Replace("{a}", a ?? "") + .Replace("{b}", b ?? ""); + } +} + +/// Authored discrete relationship lifecycle events (not a live asset library). +public static class RelationshipEventCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + 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}"); + } + + private static void Add( + string id, + float strength, + string category, + InterestOwnership owns, + string label) + { + Entries[id] = new DiscreteEventEntry + { + Id = id, + EventStrength = strength, + Category = category, + OwnsCamera = owns, + CreatesInterest = true, + LabelTemplate = label + }; + } + + public static IEnumerable AuthoredIds => Entries.Keys; + + public static bool HasAuthored(string id) => + !string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim()); + + public static DiscreteEventEntry GetOrFallback(string id) + { + if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry)) + { + return entry; + } + + string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim(); + return new DiscreteEventEntry + { + Id = key, + EventStrength = 40f, + Category = "Relationship", + OwnsCamera = InterestOwnership.Ambient, + LabelTemplate = "{id}: {a}", + IsFallback = true + }; + } +} diff --git a/IdleSpectator/RelationshipEventPatches.cs b/IdleSpectator/RelationshipEventPatches.cs new file mode 100644 index 0000000..d4e2e58 --- /dev/null +++ b/IdleSpectator/RelationshipEventPatches.cs @@ -0,0 +1,186 @@ +using System; +using ai.behaviours; +using HarmonyLib; + +namespace IdleSpectator; + +/// Relationship lifecycle patches from mutation discovery. +[HarmonyPatch] +public static class RelationshipEventPatches +{ + [HarmonyPatch(typeof(Actor), nameof(Actor.setLover))] + [HarmonyPostfix] + public static void PostfixSetLover(Actor __instance, Actor pActor) + { + if (__instance == null || !__instance.isAlive()) + { + return; + } + + if (pActor == null) + { + RelationshipInterestFeed.Emit("clear_lover", __instance, null); + return; + } + + RelationshipInterestFeed.Emit("set_lover", __instance, pActor); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.addChild))] + [HarmonyPostfix] + public static void PostfixAddChild(Actor __instance, object pObject) + { + Actor child = ResolveRelatedActor(pObject); + if (__instance == null || child == null) + { + return; + } + + RelationshipInterestFeed.Emit("add_child", __instance, child); + } + + [HarmonyPatch(typeof(Actor), "addChildSimple")] + [HarmonyPostfix] + public static void PostfixAddChildSimple(Actor __instance, object pObject) + { + Actor child = ResolveRelatedActor(pObject); + if (__instance == null || child == null) + { + return; + } + + RelationshipInterestFeed.Emit("add_child", __instance, child); + } + + private static Actor ResolveRelatedActor(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 + } + + try + { + var type = raw.GetType(); + foreach (string name in new[] { "actor", "Actor", "a", "parent" }) + { + var prop = type.GetProperty(name); + if (prop != null && typeof(Actor).IsAssignableFrom(prop.PropertyType)) + { + return prop.GetValue(raw, null) as Actor; + } + + var field = type.GetField(name); + if (field != null && typeof(Actor).IsAssignableFrom(field.FieldType)) + { + return field.GetValue(raw) as Actor; + } + } + + var getActor = type.GetMethod("getActor", Type.EmptyTypes) + ?? type.GetMethod("GetActor", Type.EmptyTypes); + if (getActor != null && typeof(Actor).IsAssignableFrom(getActor.ReturnType)) + { + return getActor.Invoke(raw, null) as Actor; + } + } + catch + { + // ignore + } + + return null; + } + + [HarmonyPatch(typeof(FamilyManager), nameof(FamilyManager.newFamily))] + [HarmonyPostfix] + public static void PostfixNewFamily(Family __result, Actor pActor) + { + Actor anchor = pActor != null && pActor.isAlive() ? pActor : EventFeedUtil.AnyAliveUnit(); + RelationshipInterestFeed.EmitFamily("new_family", __result, anchor); + } + + [HarmonyPatch(typeof(FamilyManager), "removeObject", new Type[] { typeof(Family) })] + [HarmonyPostfix] + public static void PostfixRemoveFamily(Family pObject) + { + RelationshipInterestFeed.EmitFamily("family_removed", pObject, EventFeedUtil.AnyAliveUnit()); + } + + [HarmonyPatch(typeof(ActorManager), "createBabyActorFromData")] + [HarmonyPostfix] + public static void PostfixCreateBaby(Actor __result) + { + if (__result == null || !__result.isAlive()) + { + return; + } + + RelationshipInterestFeed.Emit("baby_created", __result, null); + } + + [HarmonyPatch(typeof(BehFindLover), nameof(BehFindLover.execute))] + [HarmonyPostfix] + public static void PostfixFindLover(Actor pActor, BehResult __result) + { + if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) + { + return; + } + + RelationshipInterestFeed.Emit("find_lover", pActor, null); + } + + [HarmonyPatch(typeof(BehFamilyGroupNew), nameof(BehFamilyGroupNew.execute))] + [HarmonyPostfix] + public static void PostfixFamilyNew(Actor pActor, BehResult __result) + { + if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) + { + return; + } + + RelationshipInterestFeed.Emit("family_group_new", pActor, null); + } + + [HarmonyPatch(typeof(BehFamilyGroupJoin), nameof(BehFamilyGroupJoin.execute))] + [HarmonyPostfix] + public static void PostfixFamilyJoin(Actor pActor, BehResult __result) + { + if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) + { + return; + } + + RelationshipInterestFeed.Emit("family_group_join", pActor, null); + } + + [HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))] + [HarmonyPostfix] + public static void PostfixFamilyLeave(Actor pActor, BehResult __result) + { + if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) + { + return; + } + + RelationshipInterestFeed.Emit("family_group_leave", pActor, null); + } +} diff --git a/IdleSpectator/RelationshipInterestFeed.cs b/IdleSpectator/RelationshipInterestFeed.cs new file mode 100644 index 0000000..99ca141 --- /dev/null +++ b/IdleSpectator/RelationshipInterestFeed.cs @@ -0,0 +1,80 @@ +using UnityEngine; + +namespace IdleSpectator; + +/// Registers relationship lifecycle interest candidates. +public static class RelationshipInterestFeed +{ + public static void Emit(string eventId, Actor subject, Actor related, string labelOverride = null) + { + if (AgentHarness.Busy || subject == null || !subject.isAlive()) + { + return; + } + + DiscreteEventEntry entry = RelationshipEventCatalog.GetOrFallback(eventId); + if (!entry.CreatesInterest) + { + return; + } + + string a = EventFeedUtil.SafeName(subject); + string b = EventFeedUtil.SafeName(related); + string label = string.IsNullOrEmpty(labelOverride) ? entry.MakeLabel(a, b) : labelOverride; + string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":" + EventFeedUtil.SafeId(related); + EventFeedUtil.Register( + key, + "relationship", + entry.Category, + label, + entry.Id, + entry.EventStrength, + entry.OwnsCamera, + subject.current_position, + subject); + } + + public static void EmitFamily(string eventId, object family, Actor anchor) + { + if (AgentHarness.Busy) + { + return; + } + + DiscreteEventEntry entry = RelationshipEventCatalog.GetOrFallback(eventId); + Actor follow = anchor; + if (follow == null || !follow.isAlive()) + { + follow = EventFeedUtil.AnyAliveUnit(); + } + + if (follow == null) + { + return; + } + + string name = ""; + try + { + name = family?.GetType().GetMethod("getName")?.Invoke(family, null) as string + ?? family?.GetType().GetProperty("name")?.GetValue(family, null) as string + ?? ""; + } + catch + { + // ignore + } + + string key = "rel:" + entry.Id + ":" + (name ?? "family"); + EventFeedUtil.Register( + key, + "relationship", + entry.Category, + entry.MakeLabel(name, ""), + entry.Id, + entry.EventStrength, + entry.OwnsCamera, + follow.current_position, + follow); + } +} diff --git a/IdleSpectator/StatusInterestCatalog.cs b/IdleSpectator/StatusInterestCatalog.cs index 962705e..8be9c34 100644 --- a/IdleSpectator/StatusInterestCatalog.cs +++ b/IdleSpectator/StatusInterestCatalog.cs @@ -9,6 +9,7 @@ public sealed class StatusInterestEntry public float EventStrength = 40f; public string Category = "Status"; public bool CreatesInterest; + public InterestOwnership OwnsCamera = InterestOwnership.Signal; public bool ExtendsGrief; public bool IsFallback; } @@ -52,7 +53,14 @@ public static class StatusInterestCatalog Add("fell_in_love", 55f, "Relationship", true); Add("pregnant", 58f, "LifeChapter", true); Add("pregnant_parthenogenesis", 58f, "LifeChapter", true); - Add("crying", 50f, "Grief", true, extendsGrief: true); + Add("crying", 50f, "Grief", true, InterestOwnership.Signal, extendsGrief: true); + + // Authored Ambient interest (tracked, fill-only) + Add("festive_spirit", 28f, "StatusAmbient", true, InterestOwnership.Ambient); + Add("laughing", 26f, "StatusAmbient", true, InterestOwnership.Ambient); + Add("singing", 26f, "StatusAmbient", true, InterestOwnership.Ambient); + Add("sleeping", 22f, "StatusAmbient", true, InterestOwnership.Ambient); + Add("handsome_migrant", 30f, "StatusAmbient", true, InterestOwnership.Ambient); // Authored but do not create interest candidates (cooldowns / ambient / brief FX) AddNoInterest("afterglow"); @@ -62,22 +70,17 @@ public static class StatusInterestCatalog AddNoInterest("cough"); AddNoInterest("dash"); AddNoInterest("dodge"); - AddNoInterest("festive_spirit"); AddNoInterest("flicked"); AddNoInterest("had_bad_dream"); AddNoInterest("had_good_dream"); AddNoInterest("had_nightmare"); - AddNoInterest("handsome_migrant"); AddNoInterest("just_ate"); - AddNoInterest("laughing"); AddNoInterest("on_guard"); AddNoInterest("recovery_combat_action"); AddNoInterest("recovery_plot"); AddNoInterest("recovery_social"); AddNoInterest("recovery_spell"); AddNoInterest("shield"); - AddNoInterest("singing"); - AddNoInterest("sleeping"); AddNoInterest("slowness"); AddNoInterest("spell_boost"); AddNoInterest("spell_silence"); @@ -92,6 +95,17 @@ public static class StatusInterestCatalog string category, bool createsInterest, bool extendsGrief = false) + { + Add(id, strength, category, createsInterest, InterestOwnership.Signal, extendsGrief); + } + + private static void Add( + string id, + float strength, + string category, + bool createsInterest, + InterestOwnership ownsCamera, + bool extendsGrief = false) { Entries[id] = new StatusInterestEntry { @@ -99,13 +113,14 @@ public static class StatusInterestCatalog EventStrength = strength, Category = category, CreatesInterest = createsInterest, + OwnsCamera = ownsCamera, ExtendsGrief = extendsGrief }; } private static void AddNoInterest(string id) { - Add(id, 20f, "StatusAmbient", false); + Add(id, 20f, "StatusAmbient", false, InterestOwnership.Ambient); } public static IEnumerable AuthoredIds => Entries.Keys; diff --git a/IdleSpectator/TraitEventPatches.cs b/IdleSpectator/TraitEventPatches.cs new file mode 100644 index 0000000..0877d6e --- /dev/null +++ b/IdleSpectator/TraitEventPatches.cs @@ -0,0 +1,121 @@ +using System; +using HarmonyLib; + +namespace IdleSpectator; + +/// Actor trait add/remove interest patches from mutation discovery. +[HarmonyPatch] +public static class TraitEventPatches +{ + [HarmonyPatch(typeof(Actor), nameof(Actor.addTrait), new Type[] { typeof(string), typeof(bool) })] + [HarmonyPostfix] + public static void PostfixAddTraitString(Actor __instance, string pTraitID, bool __result) + { + if (!__result || __instance == null || !__instance.isAlive() || string.IsNullOrEmpty(pTraitID)) + { + return; + } + + Emit(pTraitID, __instance, gained: true); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.addTrait), new Type[] { typeof(ActorTrait), typeof(bool) })] + [HarmonyPostfix] + public static void PostfixAddTraitAsset(Actor __instance, ActorTrait pTrait, bool __result) + { + if (!__result || __instance == null || !__instance.isAlive() || pTrait == null) + { + return; + } + + string id = ""; + try + { + id = pTrait.id ?? ""; + } + catch + { + id = ""; + } + + if (string.IsNullOrEmpty(id)) + { + return; + } + + Emit(id, __instance, gained: true); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.removeTrait), new Type[] { typeof(string) })] + [HarmonyPostfix] + public static void PostfixRemoveTraitString(Actor __instance, string pTraitID) + { + if (__instance == null || !__instance.isAlive() || string.IsNullOrEmpty(pTraitID)) + { + return; + } + + Emit(pTraitID, __instance, gained: false); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.removeTrait), new Type[] { typeof(ActorTrait) })] + [HarmonyPostfix] + public static void PostfixRemoveTraitAsset(Actor __instance, ActorTrait pTrait, bool __result) + { + if (!__result || __instance == null || !__instance.isAlive() || pTrait == null) + { + return; + } + + string id = ""; + try + { + id = pTrait.id ?? ""; + } + catch + { + id = ""; + } + + if (string.IsNullOrEmpty(id)) + { + return; + } + + Emit(id, __instance, gained: false); + } + + private static void Emit(string traitId, Actor actor, bool gained) + { + if (AgentHarness.Busy) + { + return; + } + + DiscreteEventEntry entry = TraitInterestCatalog.GetOrFallback(traitId); + if (!entry.CreatesInterest) + { + return; + } + + InterestOwnership owns = entry.OwnsCamera; + if (!gained && owns == InterestOwnership.Signal) + { + owns = InterestOwnership.Ambient; + } + + 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, + "trait", + entry.Category, + label, + entry.Id, + entry.EventStrength, + owns, + actor.current_position, + actor); + } +} diff --git a/IdleSpectator/TraitInterestCatalog.cs b/IdleSpectator/TraitInterestCatalog.cs new file mode 100644 index 0000000..07d7987 --- /dev/null +++ b/IdleSpectator/TraitInterestCatalog.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// Authored interest overlay for every live traits library asset. +public static class TraitInterestCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + static TraitInterestCatalog() + { + Add("zombie", 82f, InterestOwnership.Signal); + Add("wise", 42f, InterestOwnership.Ambient); + Add("whirlwind", 82f, InterestOwnership.Signal); + Add("weightless", 42f, InterestOwnership.Ambient); + Add("weak", 42f, InterestOwnership.Ambient); + Add("veteran", 42f, InterestOwnership.Ambient); + Add("venomous", 82f, InterestOwnership.Signal); + Add("unlucky", 42f, InterestOwnership.Ambient); + Add("ugly", 42f, InterestOwnership.Ambient); + Add("tumor_infection", 82f, InterestOwnership.Signal); + Add("tough", 42f, InterestOwnership.Ambient); + Add("titan_lungs", 42f, InterestOwnership.Ambient); + Add("tiny", 42f, InterestOwnership.Ambient); + Add("thorns", 42f, InterestOwnership.Ambient); + Add("thief", 42f, InterestOwnership.Ambient); + Add("super_health", 42f, InterestOwnership.Ambient); + Add("sunblessed", 42f, InterestOwnership.Ambient); + Add("stupid", 42f, InterestOwnership.Ambient); + Add("strong_minded", 42f, InterestOwnership.Ambient); + Add("strong", 42f, InterestOwnership.Ambient); + Add("soft_skin", 42f, InterestOwnership.Ambient); + Add("slow", 42f, InterestOwnership.Ambient); + Add("skin_burns", 42f, InterestOwnership.Ambient); + Add("short_sighted", 42f, InterestOwnership.Ambient); + Add("shiny", 42f, InterestOwnership.Ambient); + Add("scar_of_divinity", 82f, InterestOwnership.Signal); + Add("savage", 82f, InterestOwnership.Signal); + Add("regeneration", 42f, InterestOwnership.Ambient); + Add("pyromaniac", 82f, InterestOwnership.Signal); + Add("psychopath", 82f, InterestOwnership.Signal); + Add("poisonous", 82f, InterestOwnership.Signal); + Add("poison_immune", 42f, InterestOwnership.Ambient); + Add("plague", 82f, InterestOwnership.Signal); + Add("peaceful", 42f, InterestOwnership.Ambient); + Add("paranoid", 42f, InterestOwnership.Ambient); + Add("pacifist", 42f, InterestOwnership.Ambient); + Add("nightchild", 42f, InterestOwnership.Ambient); + Add("mute", 42f, InterestOwnership.Ambient); + Add("mush_spores", 82f, InterestOwnership.Signal); + Add("moonchild", 42f, InterestOwnership.Ambient); + Add("miracle_born", 82f, InterestOwnership.Signal); + Add("miracle_bearer", 82f, InterestOwnership.Signal); + Add("miner", 42f, InterestOwnership.Ambient); + Add("metamorphed", 42f, InterestOwnership.Ambient); + Add("mega_heartbeat", 42f, InterestOwnership.Ambient); + Add("mageslayer", 82f, InterestOwnership.Signal); + Add("madness", 82f, InterestOwnership.Signal); + Add("lustful", 42f, InterestOwnership.Ambient); + Add("lucky", 42f, InterestOwnership.Ambient); + Add("long_liver", 42f, InterestOwnership.Ambient); + Add("light_lamp", 42f, InterestOwnership.Ambient); + Add("kingslayer", 82f, InterestOwnership.Signal); + Add("infertile", 42f, InterestOwnership.Ambient); + Add("infected", 82f, InterestOwnership.Signal); + Add("immune", 42f, InterestOwnership.Ambient); + Add("immortal", 82f, InterestOwnership.Signal); + Add("hotheaded", 42f, InterestOwnership.Ambient); + Add("honest", 42f, InterestOwnership.Ambient); + Add("heliophobia", 42f, InterestOwnership.Ambient); + Add("heart_of_wizard", 42f, InterestOwnership.Ambient); + Add("healing_aura", 42f, InterestOwnership.Ambient); + Add("hard_skin", 42f, InterestOwnership.Ambient); + Add("greedy", 42f, InterestOwnership.Ambient); + Add("golden_tooth", 42f, InterestOwnership.Ambient); + Add("gluttonous", 42f, InterestOwnership.Ambient); + Add("giant", 82f, InterestOwnership.Signal); + Add("genius", 42f, InterestOwnership.Ambient); + Add("freeze_proof", 42f, InterestOwnership.Ambient); + Add("fragile_health", 42f, InterestOwnership.Ambient); + Add("flower_prints", 42f, InterestOwnership.Ambient); + Add("flesh_eater", 82f, InterestOwnership.Signal); + Add("fire_proof", 42f, InterestOwnership.Ambient); + Add("fire_blood", 82f, InterestOwnership.Signal); + Add("fertile", 42f, InterestOwnership.Ambient); + Add("fat", 42f, InterestOwnership.Ambient); + Add("fast", 42f, InterestOwnership.Ambient); + Add("eyepatch", 42f, InterestOwnership.Ambient); + Add("evil", 82f, InterestOwnership.Signal); + Add("energized", 42f, InterestOwnership.Ambient); + Add("eagle_eyed", 42f, InterestOwnership.Ambient); + Add("dragonslayer", 82f, InterestOwnership.Signal); + Add("dodge", 42f, InterestOwnership.Ambient); + Add("desire_harp", 42f, InterestOwnership.Ambient); + Add("desire_golden_egg", 42f, InterestOwnership.Ambient); + Add("desire_computer", 42f, InterestOwnership.Ambient); + Add("desire_alien_mold", 42f, InterestOwnership.Ambient); + Add("deflect_projectile", 42f, InterestOwnership.Ambient); + Add("deceitful", 42f, InterestOwnership.Ambient); + Add("death_nuke", 82f, InterestOwnership.Signal); + Add("death_mark", 82f, InterestOwnership.Signal); + Add("death_bomb", 82f, InterestOwnership.Signal); + Add("dash", 42f, InterestOwnership.Ambient); + Add("crippled", 42f, InterestOwnership.Ambient); + Add("content", 42f, InterestOwnership.Ambient); + Add("contagious", 82f, InterestOwnership.Signal); + Add("cold_aura", 82f, InterestOwnership.Signal); + Add("clumsy", 42f, InterestOwnership.Ambient); + Add("clone", 42f, InterestOwnership.Ambient); + Add("chosen_one", 82f, InterestOwnership.Signal); + Add("burning_feet", 82f, InterestOwnership.Signal); + Add("bubble_defense", 42f, InterestOwnership.Ambient); + Add("boosted_vitality", 42f, InterestOwnership.Ambient); + Add("bomberman", 82f, InterestOwnership.Signal); + Add("boat", 42f, InterestOwnership.Ambient); + Add("bloodlust", 82f, InterestOwnership.Signal); + Add("block", 42f, InterestOwnership.Ambient); + Add("blessed", 42f, InterestOwnership.Ambient); + Add("battle_reflexes", 42f, InterestOwnership.Ambient); + Add("backstep", 42f, InterestOwnership.Ambient); + Add("attractive", 42f, InterestOwnership.Ambient); + Add("arcane_reflexes", 42f, InterestOwnership.Ambient); + Add("ambitious", 42f, InterestOwnership.Ambient); + Add("agile", 42f, InterestOwnership.Ambient); + Add("acid_touch", 82f, InterestOwnership.Signal); + Add("acid_proof", 42f, InterestOwnership.Ambient); + Add("acid_blood", 42f, InterestOwnership.Ambient); + } + + private static void Add(string id, float strength, InterestOwnership owns) + { + Entries[id] = new DiscreteEventEntry + { + Id = id, + EventStrength = strength, + Category = "Trait", + OwnsCamera = owns, + CreatesInterest = true, + LabelTemplate = "Trait: {id} ({a})" + }; + } + + public static IEnumerable AuthoredIds => Entries.Keys; + + public static bool HasAuthored(string id) => + !string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim()); + + public static DiscreteEventEntry GetOrFallback(string id) + { + if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry)) + { + return entry; + } + + string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim(); + return new DiscreteEventEntry + { + Id = key, + EventStrength = 40f, + Category = "Trait", + OwnsCamera = InterestOwnership.Ambient, + LabelTemplate = "Trait: {id} ({a})", + IsFallback = true + }; + } +} diff --git a/IdleSpectator/WarEventPatches.cs b/IdleSpectator/WarEventPatches.cs new file mode 100644 index 0000000..22324ac --- /dev/null +++ b/IdleSpectator/WarEventPatches.cs @@ -0,0 +1,46 @@ +using HarmonyLib; + +namespace IdleSpectator; + +/// War genesis/end patches from mutation discovery. +[HarmonyPatch] +public static class WarEventPatches +{ + [HarmonyPatch(typeof(WarManager), nameof(WarManager.newWar))] + [HarmonyPostfix] + public static void PostfixNewWar(War __result) + { + if (__result == null || AgentHarness.Busy) + { + return; + } + + try + { + WarInterestFeed.EmitFromWarObject(__result, phase: "new"); + } + catch + { + // Fallback: poll path still covers ongoing wars. + } + } + + [HarmonyPatch(typeof(WarManager), nameof(WarManager.endWar))] + [HarmonyPostfix] + public static void PostfixEndWar(object[] __args) + { + if (AgentHarness.Busy || __args == null || __args.Length == 0 || __args[0] == null) + { + return; + } + + try + { + WarInterestFeed.EmitFromWarObject(__args[0], phase: "end"); + } + catch + { + // ignore + } + } +} diff --git a/IdleSpectator/WarInterestFeed.cs b/IdleSpectator/WarInterestFeed.cs index fafe87e..d8dfa55 100644 --- a/IdleSpectator/WarInterestFeed.cs +++ b/IdleSpectator/WarInterestFeed.cs @@ -11,6 +11,16 @@ namespace IdleSpectator; /// public static class WarInterestFeed { + public static void EmitFromWarObject(object war, string phase = "active") + { + if (war == null || AgentHarness.Busy) + { + return; + } + + TryRegisterWar(war, phase); + } + public static void Tick() { if (AgentHarness.Busy) @@ -39,14 +49,14 @@ public static class WarInterestFeed break; } - if (TryRegisterWar(war)) + if (TryRegisterWar(war, "active")) { registered++; } } } - private static bool TryRegisterWar(object war) + private static bool TryRegisterWar(object war, string phase) { string warTypeId = ReadWarTypeId(war); WarTypeInterestEntry entry = WarTypeInterestCatalog.GetOrFallback(warTypeId); @@ -74,31 +84,37 @@ public static class WarInterestFeed return false; } - string key = "war:" + (ReadString(war, "id") ?? labelPair) + ":" + (warTypeId ?? "normal"); + string phaseKey = string.IsNullOrEmpty(phase) ? "active" : phase; + string key = "war:" + phaseKey + ":" + (ReadString(war, "id") ?? labelPair) + ":" + (warTypeId ?? "normal"); string label = WarTypeInterestCatalog.MakeLabel(entry, labelPair); - var candidate = new InterestCandidate + if (phaseKey == "new") { - Key = key, - LeadKind = InterestLeadKind.EventLed, - Category = entry.Category, - Source = "war", - EventStrength = entry.EventStrength, - VisualConfidence = follow != null ? 0.65f : 0.35f, - Position = position, - FollowUnit = follow, - SubjectId = follow != null ? SafeActorId(follow) : 0, - Label = label, - AssetId = warTypeId ?? "war", - KingdomKey = attackerName, - CreatedAt = Time.unscaledTime, - LastSeenAt = Time.unscaledTime, - ExpiresAt = Time.unscaledTime + 25f, - MinWatch = 6f, - MaxWatch = 30f, - Completion = InterestCompletionKind.FixedDwell - }; - InterestScoring.ScoreCheap(candidate); - InterestRegistry.Upsert(candidate); + label = "War begins: " + labelPair; + } + else if (phaseKey == "end") + { + label = "War ends: " + labelPair; + } + + float strength = entry.EventStrength; + if (phaseKey == "end") + { + strength = Mathf.Max(60f, strength - 10f); + } + + InterestOwnership owns = InterestOwnership.Signal; + EventFeedUtil.Register( + key, + "war", + entry.Category, + label, + warTypeId ?? "war", + strength, + owns, + position, + follow, + minWatch: 6f, + maxWatch: 30f); return true; } diff --git a/IdleSpectator/WorldLogEventCatalog.cs b/IdleSpectator/WorldLogEventCatalog.cs index 756e7e7..ea6f5aa 100644 --- a/IdleSpectator/WorldLogEventCatalog.cs +++ b/IdleSpectator/WorldLogEventCatalog.cs @@ -9,6 +9,7 @@ public sealed class WorldLogEventEntry public float EventStrength = 40f; public string Category = "WorldLog"; public bool CreatesInterest = true; + public InterestOwnership OwnsCamera = InterestOwnership.Signal; public bool ChronicleEligible; public float MinWatch = 6f; public float MaxWatch = 28f; @@ -98,7 +99,7 @@ public static class WorldLogEventCatalog Add("disaster_mad_thoughts", 90f, "Disaster", true, "Mad thoughts: {a}", 8f, 35f); // Harness / internal - authored but never owns camera interest - Add("auto_tester", 10f, "Harness", false, "Auto tester: {a}", 2f, 6f); + Add("auto_tester", 10f, "Harness", false, InterestOwnership.Ambient, "Auto tester: {a}", 2f, 6f); } private static void Add( @@ -109,6 +110,19 @@ public static class WorldLogEventCatalog string labelTemplate, float minWatch, float maxWatch) + { + Add(id, strength, category, createsInterest, InterestOwnership.Signal, labelTemplate, minWatch, maxWatch); + } + + private static void Add( + string id, + float strength, + string category, + bool createsInterest, + InterestOwnership ownsCamera, + string labelTemplate, + float minWatch, + float maxWatch) { Entries[id] = new WorldLogEventEntry { @@ -116,6 +130,7 @@ public static class WorldLogEventCatalog EventStrength = strength, Category = category, CreatesInterest = createsInterest, + OwnsCamera = ownsCamera, ChronicleEligible = createsInterest && strength >= 65f, LabelTemplate = labelTemplate, MinWatch = minWatch, @@ -163,6 +178,7 @@ public static class WorldLogEventCatalog EventStrength = strength, Category = category, CreatesInterest = true, + OwnsCamera = InterestOwnership.Signal, ChronicleEligible = strength >= 65f, LabelTemplate = "{id}: {a}", MinWatch = strength >= 90f ? 8f : 6f, diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 900a41f..4a735f8 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.16.11", + "version": "0.16.32", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" } diff --git a/scripts/worldbox-ctl.sh b/scripts/worldbox-ctl.sh index 1f3462e..36c208a 100755 --- a/scripts/worldbox-ctl.sh +++ b/scripts/worldbox-ctl.sh @@ -30,10 +30,16 @@ worldbox_running() { mod_ready() { [[ -f "$LOG" ]] || return 1 + # Compile errors (NML prints error CS during Compile Mod IdleSpectator). if rg -q "error CS" "$LOG" 2>/dev/null \ && rg -q "Compile Mod IdleSpectator|IdleSpectator.*error CS|error CS.*IdleSpectator" "$LOG" 2>/dev/null; then return 2 fi + # Harmony PatchAll failures disable the mod without a CS error. + if rg -q "IdleSpectator has been disabled due to an error" "$LOG" 2>/dev/null \ + || rg -q "\[IdleSpectator\]: Harmony failed" "$LOG" 2>/dev/null; then + return 3 + fi rg -q "\[IdleSpectator\]: Harmony patches applied" "$LOG" 2>/dev/null } @@ -103,6 +109,11 @@ cmd_start() { rg -n "error CS|Compile Mod IdleSpectator" "$LOG" 2>/dev/null | tail -n 40 >&2 || true return 1 fi + if (( ready_rc == 3 )); then + echo "IdleSpectator Harmony failed - see $LOG" >&2 + rg -n "Harmony failed|Parameter \"|IdleSpectator has been disabled|IL Compile Error" "$LOG" 2>/dev/null | tail -n 40 >&2 || true + return 1 + fi printf '.' else printf 'o'