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:
parent
f953a66d77
commit
44371365b3
8 changed files with 372 additions and 64 deletions
67
IdleSpectator/Events/EventObservation.cs
Normal file
67
IdleSpectator/Events/EventObservation.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
156
IdleSpectator/Events/EventOutcome.cs
Normal file
156
IdleSpectator/Events/EventOutcome.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
38
docs/event-observation.md
Normal 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.
|
||||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
Loading…
Reference in a new issue