diff --git a/IdleSpectator/Events/EventObservation.cs b/IdleSpectator/Events/EventObservation.cs new file mode 100644 index 0000000..55a73f1 --- /dev/null +++ b/IdleSpectator/Events/EventObservation.cs @@ -0,0 +1,67 @@ +using System; +using ai.behaviours; + +namespace IdleSpectator; + +/// +/// Observe → confirm → emit bridge. Patches stay thin sensors; feeds only run after +/// confirms the catalog claim matches game state. +/// +public static class EventObservation +{ + /// + /// If , run ; otherwise record a drop. + /// + public static bool Confirm( + string source, + string eventId, + bool confirmed, + Action emit) + { + string id = string.IsNullOrEmpty(eventId) ? "unknown" : eventId.Trim(); + string src = string.IsNullOrEmpty(source) ? "observe" : source.Trim(); + if (!confirmed) + { + InterestDropLog.Record("outcome_unconfirmed", src + ":" + id); + return false; + } + + if (emit == null) + { + return false; + } + + emit(); + return true; + } + + /// + /// Prefix/Postfix helper: snapshot before a behaviour, confirm with a predicate after. + /// + public static bool ConfirmAfter( + string source, + string eventId, + EventOutcome.ActorFlags before, + Actor actor, + BehResult result, + Func outcome, + Action emit) + { + if (outcome == null) + { + return false; + } + + bool ok = false; + try + { + ok = outcome(before, actor, result); + } + catch + { + ok = false; + } + + return Confirm(source, eventId, ok, emit); + } +} diff --git a/IdleSpectator/Events/EventOutcome.cs b/IdleSpectator/Events/EventOutcome.cs new file mode 100644 index 0000000..da9d111 --- /dev/null +++ b/IdleSpectator/Events/EventOutcome.cs @@ -0,0 +1,156 @@ +using System; +using ai.behaviours; + +namespace IdleSpectator; + +/// +/// Shared outcome checks for Layer A/B event confirmation. +/// Patches observe; this layer answers "did the semantic event actually happen?" +/// before any feed Emit. Do not treat as success. +/// +public static class EventOutcome +{ + /// Cheap before-snapshot for Prefix → Postfix confirmation. + public struct ActorFlags + { + public bool Valid; + public bool Alive; + public bool HasFamily; + public bool HasLover; + } + + public static ActorFlags Snapshot(Actor actor) + { + var flags = new ActorFlags(); + if (actor == null) + { + return flags; + } + + flags.Valid = true; + try + { + flags.Alive = actor.isAlive(); + if (!flags.Alive) + { + return flags; + } + + flags.HasFamily = actor.hasFamily(); + flags.HasLover = actor.hasLover(); + } + catch + { + flags.Valid = false; + } + + return flags; + } + + public static bool IsLiving(Actor actor) + { + try + { + return actor != null && actor.isAlive(); + } + catch + { + return false; + } + } + + /// Behaviour node finished without Stop. Never implies semantic success. + public static bool BehaviourRan(BehResult result) + { + return result != BehResult.Stop; + } + + public static bool GainedFamily(ActorFlags before, Actor actor) + { + if (!before.Valid || !before.Alive || !IsLiving(actor)) + { + return false; + } + + try + { + return !before.HasFamily && actor.hasFamily(); + } + catch + { + return false; + } + } + + public static bool LostFamily(ActorFlags before, Actor actor) + { + if (!before.Valid || !before.Alive || !IsLiving(actor)) + { + return false; + } + + try + { + return before.HasFamily && !actor.hasFamily(); + } + catch + { + return false; + } + } + + public static bool GainedLover(ActorFlags before, Actor actor) + { + if (!before.Valid || !before.Alive || !IsLiving(actor)) + { + return false; + } + + try + { + return !before.HasLover && actor.hasLover(); + } + catch + { + return false; + } + } + + public static bool LostLover(ActorFlags before, Actor actor) + { + if (!before.Valid || !before.Alive || !IsLiving(actor)) + { + return false; + } + + try + { + return before.HasLover && !actor.hasLover(); + } + catch + { + return false; + } + } + + /// + /// Still seeking: behaviour continued and the unit did not acquire a lover + /// (success path is set_lover / love status). + /// + public static bool StillSeekingLover(Actor actor, BehResult result) + { + if (!BehaviourRan(result) || !IsLiving(actor)) + { + return false; + } + + try + { + return !actor.hasLover(); + } + catch + { + return false; + } + } +} diff --git a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs index 6567d9d..adc31f9 100644 --- a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs +++ b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs @@ -4,7 +4,11 @@ using HarmonyLib; namespace IdleSpectator; -/// Relationship lifecycle patches from mutation discovery. +/// +/// Relationship lifecycle sensors. Thin Harmony only - outcomes confirmed via +/// / before Emit. +/// Prefer state setters (setLover, setParent*, newFamily) over Beh Continue. +/// [HarmonyPatch] public static class RelationshipEventPatches { @@ -12,11 +16,12 @@ public static class RelationshipEventPatches [HarmonyPostfix] public static void PostfixSetLover(Actor __instance, Actor pActor) { - if (__instance == null || !__instance.isAlive()) + if (!EventOutcome.IsLiving(__instance)) { return; } + // setLover is already the confirmed state transition. if (pActor == null) { RelationshipInterestFeed.Emit("clear_lover", __instance, null); @@ -105,24 +110,20 @@ public static class RelationshipEventPatches private static void EmitAddChild(Actor parent, Actor child, bool increaseChildren) { - if (!increaseChildren || parent == null || child == null) - { - return; - } - - if (!parent.isAlive() || !child.isAlive()) - { - return; - } - - RelationshipInterestFeed.Emit("add_child", parent, child); + EventObservation.Confirm( + "setParent", + "add_child", + increaseChildren + && EventOutcome.IsLiving(parent) + && EventOutcome.IsLiving(child), + () => RelationshipInterestFeed.Emit("add_child", parent, child)); } [HarmonyPatch(typeof(FamilyManager), nameof(FamilyManager.newFamily))] [HarmonyPostfix] public static void PostfixNewFamily(Family __result, Actor pActor) { - Actor anchor = pActor != null && pActor.isAlive() ? pActor : null; + Actor anchor = EventOutcome.IsLiving(pActor) ? pActor : null; RelationshipInterestFeed.EmitFamily("new_family", __result, anchor); } @@ -137,70 +138,103 @@ public static class RelationshipEventPatches [HarmonyPostfix] public static void PostfixCreateBaby(Actor __result) { - if (__result == null || !__result.isAlive()) - { - return; - } - - RelationshipInterestFeed.Emit("baby_created", __result, null); + EventObservation.Confirm( + "createBaby", + "baby_created", + EventOutcome.IsLiving(__result), + () => RelationshipInterestFeed.Emit("baby_created", __result, null)); } /// - /// Seeking only: BehFindLover returns Continue both when still searching and after - /// . Success is covered by set_lover / love status. + /// Seeking only. Beh Continue is not success - confirm still !hasLover. + /// Success is covered by set_lover / love status. /// [HarmonyPatch(typeof(BehFindLover), nameof(BehFindLover.execute))] [HarmonyPostfix] public static void PostfixFindLover(Actor pActor, BehResult __result) { - if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) - { - return; - } + EventObservation.Confirm( + "BehFindLover", + "find_lover", + EventOutcome.StillSeekingLover(pActor, __result), + () => RelationshipInterestFeed.Emit("find_lover", pActor, null)); + } - if (pActor.hasLover()) - { - return; - } - - RelationshipInterestFeed.Emit("find_lover", pActor, null); + [HarmonyPatch(typeof(BehFamilyGroupNew), nameof(BehFamilyGroupNew.execute))] + [HarmonyPrefix] + public static void PrefixFamilyNew(Actor pActor, out EventOutcome.ActorFlags __state) + { + __state = EventOutcome.Snapshot(pActor); } [HarmonyPatch(typeof(BehFamilyGroupNew), nameof(BehFamilyGroupNew.execute))] [HarmonyPostfix] - public static void PostfixFamilyNew(Actor pActor, BehResult __result) + public static void PostfixFamilyNew(Actor pActor, BehResult __result, EventOutcome.ActorFlags __state) { - if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) - { - return; - } + // Shares founding key with new_family when both fire. + EventObservation.ConfirmAfter( + "BehFamilyGroupNew", + "family_group_new", + __state, + pActor, + __result, + (before, actor, result) => + EventOutcome.BehaviourRan(result) && EventOutcome.GainedFamily(before, actor), + () => RelationshipInterestFeed.Emit( + "family_group_new", + pActor, + ActorRelation.ResolvePackRelated(pActor))); + } - // BehFamilyGroupNew always calls FamilyManager.newFamily first - share that founding key. - RelationshipInterestFeed.Emit("family_group_new", pActor, ActorRelation.ResolvePackRelated(pActor)); + [HarmonyPatch(typeof(BehFamilyGroupJoin), nameof(BehFamilyGroupJoin.execute))] + [HarmonyPrefix] + public static void PrefixFamilyJoin(Actor pActor, out EventOutcome.ActorFlags __state) + { + __state = EventOutcome.Snapshot(pActor); } [HarmonyPatch(typeof(BehFamilyGroupJoin), nameof(BehFamilyGroupJoin.execute))] [HarmonyPostfix] - public static void PostfixFamilyJoin(Actor pActor, BehResult __result) + public static void PostfixFamilyJoin(Actor pActor, BehResult __result, EventOutcome.ActorFlags __state) { - if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) - { - return; - } + // Vanilla returns Continue even when getNearbyFamily was null - require GainedFamily. + EventObservation.ConfirmAfter( + "BehFamilyGroupJoin", + "family_group_join", + __state, + pActor, + __result, + (before, actor, result) => + EventOutcome.BehaviourRan(result) && EventOutcome.GainedFamily(before, actor), + () => RelationshipInterestFeed.Emit( + "family_group_join", + pActor, + ActorRelation.ResolvePackRelated(pActor))); + } - RelationshipInterestFeed.Emit("family_group_join", pActor, ActorRelation.ResolvePackRelated(pActor)); + [HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))] + [HarmonyPrefix] + public static void PrefixFamilyLeave(Actor pActor, out EventOutcome.ActorFlags __state) + { + __state = EventOutcome.Snapshot(pActor); } [HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))] [HarmonyPostfix] - public static void PostfixFamilyLeave(Actor pActor, BehResult __result) + public static void PostfixFamilyLeave(Actor pActor, BehResult __result, EventOutcome.ActorFlags __state) { - if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop) - { - return; - } - - RelationshipInterestFeed.Emit("family_group_leave", pActor, ActorRelation.ResolvePackRelated(pActor)); + EventObservation.ConfirmAfter( + "BehFamilyGroupLeave", + "family_group_leave", + __state, + pActor, + __result, + (before, actor, result) => + EventOutcome.BehaviourRan(result) && EventOutcome.LostFamily(before, actor), + () => RelationshipInterestFeed.Emit( + "family_group_leave", + pActor, + ActorRelation.ResolvePackRelated(pActor))); } /// @@ -211,11 +245,10 @@ public static class RelationshipEventPatches [HarmonyPostfix] public static void PostfixSetAlpha(Actor pActor, bool pNew) { - if (!pNew || pActor == null || !pActor.isAlive()) - { - return; - } - - InterestFeeds.EmitAlpha(pActor, "family_set_alpha"); + EventObservation.Confirm( + "Family.setAlpha", + "become_alpha", + pNew && EventOutcome.IsLiving(pActor), + () => InterestFeeds.EmitAlpha(pActor, "family_set_alpha")); } } diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 4e33f40..6d95842 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.25.12", - "description": "AFK Idle Spectator (I) + Lore (L). Activity History peek live-relevance.", + "version": "0.25.14", + "description": "AFK Idle Spectator (I) + Lore (L). EventOutcome observe→confirm→emit.", "GUID": "com.dazed.idlespectator" } diff --git a/docs/event-observation.md b/docs/event-observation.md new file mode 100644 index 0000000..4c0da53 --- /dev/null +++ b/docs/event-observation.md @@ -0,0 +1,38 @@ +# Event observation (observe → confirm → emit) + +IdleSpectator learns about the game through Harmony sensors. +Those sensors must not treat AI noise as catalog truth. + +## Pipeline + +```text +Patch (Observe) → EventOutcome (Confirm) → Feed Emit (EventReason + catalog) +``` + +| Layer | Responsibility | Lives in | +|-------|----------------|----------| +| Observe | Thin Harmony Prefix/Postfix; snapshot before when needed | `Events/Patches/*` | +| Confirm | Did the semantic claim happen? | `Events/EventOutcome.cs` | +| Bridge | Drop unconfirmed with `InterestDropLog`; else run emit | `Events/EventObservation.cs` | +| Emit | Catalog dials, keys, `EventReason` labels | `Events/Feeds/*` | + +## Rules + +1. **Never** treat `BehResult.Continue` as success by itself. +2. Prefer **state setters** when they exist (`setLover`, `setParent*`, `newFamily`, `addNewStatusEffect`). +3. Beh hooks need **before/after** confirmation (`EventOutcome.GainedFamily`, `StillSeekingLover`, …). +4. Unconfirmed attempts log `outcome_unconfirmed` via `InterestDropLog` (not silent). +5. New catalog events must name their confirm predicate (or setter) in the audit TSV `game_call_site` / `fix_action`. + +## Example (family join) + +Vanilla `BehFamilyGroupJoin` returns Continue even when no nearby family exists. +Confirm with `GainedFamily(before, actor)` before Emit `family_group_join`. + +## Adding an event + +1. Find the real game call site (setter or Beh). +2. Add/extend an `EventOutcome` predicate if needed. +3. Patch calls `EventObservation.Confirm` / `ConfirmAfter` then the domain feed. +4. Mark the audit row with the confirm rule. +5. Harness: force or live path that asserts key + absence when outcome fails. diff --git a/docs/event-prose-audit.md b/docs/event-prose-audit.md index 343dc2a..3ead52e 100644 --- a/docs/event-prose-audit.md +++ b/docs/event-prose-audit.md @@ -117,3 +117,16 @@ Phases 4–7 worksheet complete (all audit rows `done`). Worksheet: `docs/event-trigger-audit.tsv` (all domains done). +## Family pack join false positive (2026-07-16) + +`BehFamilyGroupJoin` returns `Continue` even when `getNearbyFamily` is null (no `setFamily`). +IdleSpectator used to Emit on any Continue → orange “joins a family pack” with no Family meta. +Gate: Emit only when `hasFamily()` after execute. + +## Observe → confirm → emit (2026-07-16) + +Shared pipeline: `EventOutcome` predicates + `EventObservation.Confirm*` before feeds. +Relationship Beh hooks (find_lover, family group new/join/leave) migrated first. +Rule: never treat `BehResult.Continue` as semantic success. +Docs: `docs/event-observation.md`. + diff --git a/docs/event-reason.md b/docs/event-reason.md index abe437f..f44aba9 100644 --- a/docs/event-reason.md +++ b/docs/event-reason.md @@ -82,6 +82,7 @@ Active EventLed A scenes should not silently blank a valid Label. ## See also +- [`event-observation.md`](event-observation.md) - observe → confirm → emit (Beh Continue is not success) - [`event-audit.md`](event-audit.md) - sources, MaxWatch, A demotion set - [`event-prose-audit.md`](event-prose-audit.md) - prose vs game call sites - [`scoring-model.md`](scoring-model.md) - score weights diff --git a/docs/event-trigger-audit.tsv b/docs/event-trigger-audit.tsv index 34b2ec1..3b65172 100644 --- a/docs/event-trigger-audit.tsv +++ b/docs/event-trigger-audit.tsv @@ -212,11 +212,11 @@ relationship clear_lover 60 True setLover(null)+MapBox dead-lover cleanup hook_r relationship add_child 74 True Actor.setParent1/setParent2 hook_retarget done relationship new_family 72 True FamilyManager.newFamily accurate done relationship family_removed 58 True FamilyManager.removeObject accurate Confirmed patch + EventReason prose done -relationship find_lover 52 True BehFindLover Continue && !hasLover gate done +relationship find_lover 52 True BehFindLover + EventOutcome.StillSeekingLover gate EventObservation; success via set_lover done relationship become_alpha 74 True Family.setAlpha accurate done -relationship family_group_new 58 True BehFamilyGroupNew; shares new_family key merge done -relationship family_group_join 36 True BehFamilyGroupJoin accurate Confirmed patch + EventReason prose done -relationship family_group_leave 36 True BehFamilyGroupLeave accurate Confirmed patch + EventReason prose done +relationship family_group_new 58 True BehFamilyGroupNew + EventOutcome.GainedFamily accurate EventObservation; shares new_family key done +relationship family_group_join 36 True BehFamilyGroupJoin + EventOutcome.GainedFamily gate EventObservation; Continue≠join done +relationship family_group_leave 36 True BehFamilyGroupLeave + EventOutcome.LostFamily accurate EventObservation done relationship baby_created 72 True ActorManager.createBabyActorFromData accurate done traits zombie 72 True Actor.addTrait/removeTrait → EventReason.Trait prose_fix Catalog label aligned to gains/loses; spawn-window gate done traits wise 38 True Actor.addTrait/removeTrait → EventReason.Trait prose_fix Catalog label aligned to gains/loses; spawn-window gate done