Update IdleSpectator to version 0.25.14, enhancing event handling by implementing a confirm-emit pipeline for relationship lifecycle events. Refactor event hooks to ensure accurate state transitions and improve clarity in event emissions. Document changes in event-prose and event-reason files to reflect the new observe-confirm-emit logic.

This commit is contained in:
DazedAnon 2026-07-16 16:07:48 -05:00
parent f953a66d77
commit 44371365b3
8 changed files with 372 additions and 64 deletions

View file

@ -0,0 +1,67 @@
using System;
using ai.behaviours;
namespace IdleSpectator;
/// <summary>
/// Observe → confirm → emit bridge. Patches stay thin sensors; feeds only run after
/// <see cref="EventOutcome"/> confirms the catalog claim matches game state.
/// </summary>
public static class EventObservation
{
/// <summary>
/// If <paramref name="confirmed"/>, run <paramref name="emit"/>; otherwise record a drop.
/// </summary>
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;
}
/// <summary>
/// Prefix/Postfix helper: snapshot before a behaviour, confirm with a predicate after.
/// </summary>
public static bool ConfirmAfter(
string source,
string eventId,
EventOutcome.ActorFlags before,
Actor actor,
BehResult result,
Func<EventOutcome.ActorFlags, Actor, BehResult, bool> 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);
}
}

View file

@ -0,0 +1,156 @@
using System;
using ai.behaviours;
namespace IdleSpectator;
/// <summary>
/// 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 <see cref="BehResult.Continue"/> as success.
/// </summary>
public static class EventOutcome
{
/// <summary>Cheap before-snapshot for Prefix → Postfix confirmation.</summary>
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;
}
}
/// <summary>Behaviour node finished without Stop. Never implies semantic success.</summary>
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;
}
}
/// <summary>
/// Still seeking: behaviour continued and the unit did not acquire a lover
/// (success path is <c>set_lover</c> / love status).
/// </summary>
public static bool StillSeekingLover(Actor actor, BehResult result)
{
if (!BehaviourRan(result) || !IsLiving(actor))
{
return false;
}
try
{
return !actor.hasLover();
}
catch
{
return false;
}
}
}

View file

@ -4,7 +4,11 @@ using HarmonyLib;
namespace IdleSpectator;
/// <summary>Relationship lifecycle patches from mutation discovery.</summary>
/// <summary>
/// Relationship lifecycle sensors. Thin Harmony only - outcomes confirmed via
/// <see cref="EventOutcome"/> / <see cref="EventObservation"/> before Emit.
/// Prefer state setters (<c>setLover</c>, <c>setParent*</c>, <c>newFamily</c>) over Beh Continue.
/// </summary>
[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));
}
/// <summary>
/// Seeking only: BehFindLover returns Continue both when still searching and after
/// <see cref="Actor.becomeLoversWith"/>. 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.
/// </summary>
[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)));
}
/// <summary>
@ -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"));
}
}

View file

@ -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"
}

38
docs/event-observation.md Normal file
View file

@ -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.

View file

@ -117,3 +117,16 @@ Phases 47 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`.

View file

@ -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

View file

@ -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

1 domain id strength camera game_call_site verdict fix_action done
212 relationship add_child 74 True Actor.setParent1/setParent2 hook_retarget done
213 relationship new_family 72 True FamilyManager.newFamily accurate done
214 relationship family_removed 58 True FamilyManager.removeObject accurate Confirmed patch + EventReason prose done
215 relationship find_lover 52 True BehFindLover Continue && !hasLover BehFindLover + EventOutcome.StillSeekingLover gate EventObservation; success via set_lover done
216 relationship become_alpha 74 True Family.setAlpha accurate done
217 relationship family_group_new 58 True BehFamilyGroupNew; shares new_family key BehFamilyGroupNew + EventOutcome.GainedFamily merge accurate EventObservation; shares new_family key done
218 relationship family_group_join 36 True BehFamilyGroupJoin BehFamilyGroupJoin + EventOutcome.GainedFamily accurate gate Confirmed patch + EventReason prose EventObservation; Continue≠join done
219 relationship family_group_leave 36 True BehFamilyGroupLeave BehFamilyGroupLeave + EventOutcome.LostFamily accurate Confirmed patch + EventReason prose EventObservation done
220 relationship baby_created 72 True ActorManager.createBabyActorFromData accurate done
221 traits zombie 72 True Actor.addTrait/removeTrait → EventReason.Trait prose_fix Catalog label aligned to gains/loses; spawn-window gate done
222 traits wise 38 True Actor.addTrait/removeTrait → EventReason.Trait prose_fix Catalog label aligned to gains/loses; spawn-window gate done