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