Compare commits
2 commits
04731c2c06
...
44371365b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 44371365b3 | |||
| f953a66d77 |
13 changed files with 845 additions and 71 deletions
|
|
@ -208,6 +208,74 @@ public static class ActivityLog
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Newest-first peek filtered by <see cref="ActivityRelevance"/> against live actor state.
|
||||
/// Full ring is unchanged (Lore Activity / <see cref="LatestForSubject"/>).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ActivityEntry> LatestRelevantForSubject(
|
||||
Actor actor,
|
||||
long subjectId,
|
||||
int max,
|
||||
int scanMax = 0)
|
||||
{
|
||||
if (subjectId == 0 || max <= 0)
|
||||
{
|
||||
return System.Array.Empty<ActivityEntry>();
|
||||
}
|
||||
|
||||
if (!BySubject.TryGetValue(subjectId, out List<ActivityEntry> list) || list.Count == 0)
|
||||
{
|
||||
return System.Array.Empty<ActivityEntry>();
|
||||
}
|
||||
|
||||
int scan = scanMax > 0
|
||||
? Mathf.Min(scanMax, list.Count)
|
||||
: Mathf.Min(ActivityRelevance.PeekScanMax, list.Count);
|
||||
float now = Time.unscaledTime;
|
||||
var picked = new List<ActivityEntry>(max);
|
||||
for (int i = 0; i < scan && picked.Count < max; i++)
|
||||
{
|
||||
ActivityEntry entry = list[list.Count - 1 - i];
|
||||
if (ActivityRelevance.IsPeekRelevant(actor, entry, now))
|
||||
{
|
||||
picked.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Never leave the peek empty when the ring has lines - show newest as last resort.
|
||||
if (picked.Count == 0 && list.Count > 0)
|
||||
{
|
||||
picked.Add(list[list.Count - 1]);
|
||||
}
|
||||
|
||||
return picked;
|
||||
}
|
||||
|
||||
/// <summary>Harness: age all lines for a subject so soft-window relevance expires.</summary>
|
||||
public static int HarnessAgeSubject(long subjectId, float secondsAgo)
|
||||
{
|
||||
if (subjectId == 0 || secondsAgo <= 0f
|
||||
|| !BySubject.TryGetValue(subjectId, out List<ActivityEntry> list))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
float stamped = Time.unscaledTime - secondsAgo;
|
||||
int n = 0;
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
if (list[i] == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list[i].CreatedAt = stamped;
|
||||
n++;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
public static void NoteTask(Actor actor, string taskId)
|
||||
{
|
||||
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(taskId))
|
||||
|
|
|
|||
309
IdleSpectator/ActivityRelevance.cs
Normal file
309
IdleSpectator/ActivityRelevance.cs
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
using System;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Live-relevance policy for Activity History <b>peek</b> (dossier).
|
||||
/// The ring stays append-only; Lore Activity still shows the full journal.
|
||||
/// Peek only keeps lines that still match what the unit is doing (or just did).
|
||||
/// </summary>
|
||||
public static class ActivityRelevance
|
||||
{
|
||||
/// <summary>
|
||||
/// Lines newer than this always pass (beat just happened; AI may not have settled).
|
||||
/// </summary>
|
||||
public const float SoftWindowSeconds = 8f;
|
||||
|
||||
/// <summary>How many newest ring lines to scan when filling a peek of size N.</summary>
|
||||
public const int PeekScanMax = 24;
|
||||
|
||||
public enum Family
|
||||
{
|
||||
Ambient,
|
||||
Combat,
|
||||
Haul,
|
||||
Social,
|
||||
Romance,
|
||||
Work,
|
||||
Care,
|
||||
Travel,
|
||||
Milestone,
|
||||
Status,
|
||||
Happiness
|
||||
}
|
||||
|
||||
public static Family FamilyForEntry(ActivityEntry entry)
|
||||
{
|
||||
if (entry == null)
|
||||
{
|
||||
return Family.Ambient;
|
||||
}
|
||||
|
||||
switch (entry.Kind)
|
||||
{
|
||||
case ActivityKind.StatusGain:
|
||||
case ActivityKind.StatusLoss:
|
||||
return Family.Status;
|
||||
case ActivityKind.Happiness:
|
||||
return Family.Happiness;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
string key = (entry.TaskId ?? "").Trim();
|
||||
if (key.StartsWith("milestone_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Family.Milestone;
|
||||
}
|
||||
|
||||
return FamilyForVerb(ActivityVerbMap.Resolve(key));
|
||||
}
|
||||
|
||||
public static Family FamilyForVerb(string verb)
|
||||
{
|
||||
if (string.IsNullOrEmpty(verb))
|
||||
{
|
||||
return Family.Ambient;
|
||||
}
|
||||
|
||||
switch (verb)
|
||||
{
|
||||
case "hunt":
|
||||
case "fight":
|
||||
case "flee":
|
||||
case "fire":
|
||||
case "ignite":
|
||||
case "extinguish":
|
||||
case "warrior":
|
||||
case "loot":
|
||||
case "steal":
|
||||
return Family.Combat;
|
||||
case "haul":
|
||||
return Family.Haul;
|
||||
case "social":
|
||||
case "group":
|
||||
return Family.Social;
|
||||
case "lover":
|
||||
return Family.Romance;
|
||||
case "farm":
|
||||
case "work":
|
||||
case "pollinate":
|
||||
case "fish":
|
||||
case "trade":
|
||||
case "build":
|
||||
case "read":
|
||||
case "heal":
|
||||
return Family.Work;
|
||||
case "eat":
|
||||
case "sleep":
|
||||
case "play":
|
||||
case "relieve":
|
||||
case "laugh":
|
||||
case "sing":
|
||||
case "cry":
|
||||
case "swear":
|
||||
case "wait":
|
||||
case "recharge":
|
||||
case "dream":
|
||||
return Family.Care;
|
||||
case "move":
|
||||
case "settle":
|
||||
case "boat":
|
||||
return Family.Travel;
|
||||
default:
|
||||
return Family.Ambient;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this ring line should appear in the dossier History peek for <paramref name="actor"/>.
|
||||
/// </summary>
|
||||
public static bool IsPeekRelevant(Actor actor, ActivityEntry entry, float now)
|
||||
{
|
||||
if (entry == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (now - entry.CreatedAt <= SoftWindowSeconds)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Family family = FamilyForEntry(entry);
|
||||
if (family == Family.Milestone)
|
||||
{
|
||||
// Life beats stay peek-worthy; Chronicle also covers them.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (family == Family.Status)
|
||||
{
|
||||
return StatusStillMatches(actor, entry);
|
||||
}
|
||||
|
||||
if (family == Family.Happiness)
|
||||
{
|
||||
// Discrete mood beats age out of peek after the soft window.
|
||||
return false;
|
||||
}
|
||||
|
||||
string entryVerb = ActivityVerbMap.Resolve(entry.TaskId ?? "");
|
||||
string liveTaskId = ActivityInterestTable.SafeTaskId(actor);
|
||||
string liveVerb = ActivityVerbMap.Resolve(liveTaskId);
|
||||
|
||||
if (!string.IsNullOrEmpty(entryVerb)
|
||||
&& !string.IsNullOrEmpty(liveVerb)
|
||||
&& (string.Equals(entryVerb, liveVerb, StringComparison.OrdinalIgnoreCase)
|
||||
|| FamilyForVerb(entryVerb) == FamilyForVerb(liveVerb)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return FamilyLivePredicate(actor, family);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint of live AI bits that can change peek without adding ring lines.
|
||||
/// </summary>
|
||||
public static int LiveFingerprint(Actor actor, float now)
|
||||
{
|
||||
int h = 17;
|
||||
h = h * 31 + (int)(now / SoftWindowSeconds);
|
||||
if (actor == null)
|
||||
{
|
||||
return h;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
h = h * 31 + (actor.has_attack_target ? 1 : 0);
|
||||
h = h * 31 + (actor.isCarryingResources() ? 1 : 0);
|
||||
string task = ActivityInterestTable.SafeTaskId(actor) ?? "";
|
||||
h = h * 31 + (task?.GetHashCode() ?? 0);
|
||||
if (actor.hasTask() && actor.ai?.task != null)
|
||||
{
|
||||
h = h * 31 + (actor.ai.task.in_combat ? 1 : 0);
|
||||
h = h * 31 + (actor.ai.task.is_fireman ? 1 : 0);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
private static bool FamilyLivePredicate(Actor actor, Family family)
|
||||
{
|
||||
switch (family)
|
||||
{
|
||||
case Family.Combat:
|
||||
return CombatLive(actor);
|
||||
case Family.Haul:
|
||||
return HaulLive(actor);
|
||||
case Family.Social:
|
||||
case Family.Romance:
|
||||
case Family.Work:
|
||||
case Family.Care:
|
||||
case Family.Travel:
|
||||
case Family.Ambient:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CombatLive(Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (actor.has_attack_target)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actor.hasTask() && actor.ai?.task != null
|
||||
&& (actor.ai.task.in_combat || actor.ai.task.is_fireman))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HaulLive(Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
return actor.isCarryingResources();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool StatusStillMatches(Actor actor, ActivityEntry entry)
|
||||
{
|
||||
string statusId = StatusIdFromEntry(entry);
|
||||
if (string.IsNullOrEmpty(statusId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bool has = actor.hasStatus(statusId);
|
||||
if (entry.Kind == ActivityKind.StatusGain)
|
||||
{
|
||||
return has;
|
||||
}
|
||||
|
||||
if (entry.Kind == ActivityKind.StatusLoss)
|
||||
{
|
||||
return !has;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string StatusIdFromEntry(ActivityEntry entry)
|
||||
{
|
||||
string key = (entry?.TaskId ?? "").Trim();
|
||||
const string gain = "status_gain:";
|
||||
const string loss = "status_loss:";
|
||||
if (key.StartsWith(gain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return key.Substring(gain.Length).Trim();
|
||||
}
|
||||
|
||||
if (key.StartsWith(loss, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return key.Substring(loss.Length).Trim();
|
||||
}
|
||||
|
||||
// NoteStatusChange may store bare status id or ActivityStatusProse.TaskKey.
|
||||
if (key.IndexOf(':') < 0 && !string.IsNullOrEmpty(key))
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
|
@ -540,6 +540,40 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "activity_age":
|
||||
{
|
||||
long id = WatchCaption.CurrentUnitId;
|
||||
if (id == 0 && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
id = MoveCamera._focus_unit.getID();
|
||||
}
|
||||
catch
|
||||
{
|
||||
id = 0;
|
||||
}
|
||||
}
|
||||
|
||||
float seconds = 12f;
|
||||
if (!string.IsNullOrEmpty(cmd.value)
|
||||
&& float.TryParse(
|
||||
cmd.value,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out float parsed)
|
||||
&& parsed > 0f)
|
||||
{
|
||||
seconds = parsed;
|
||||
}
|
||||
|
||||
int n = ActivityLog.HarnessAgeSubject(id, seconds);
|
||||
WatchCaption.ForceRefreshHistory();
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"aged={n} subject={id} seconds={seconds}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "activity_sample_reset":
|
||||
{
|
||||
ActivityLog.ResetSampleCounters();
|
||||
|
|
|
|||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1393,6 +1393,22 @@ internal static class HarnessScenarios
|
|||
Step("act16r", "dossier_clear_job"),
|
||||
Step("act17", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 3, tier: "beat"),
|
||||
Step("act17b", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),
|
||||
// Live-relevance peek: after soft window, aged hunt drops; newest unload remains.
|
||||
// Isolated ring so older move-matching lines cannot crowd the peek.
|
||||
Step("act17c", "activity_clear"),
|
||||
Step("act17d", "activity_force", asset: "BehAttackActorHuntingTarget",
|
||||
label: "Hunts prey", count: 1, tier: "beat", expect: "PreyThing"),
|
||||
Step("act17e", "activity_force", asset: "BehUnloadResources", label: "wheat@Riverhold",
|
||||
count: 1, tier: "beat", value: "human"),
|
||||
Step("act17f", "activity_age", value: "12"),
|
||||
Step("act17g", "assert", expect: "dossier_history_not_contains", value: "Hunts"),
|
||||
Step("act17h", "assert", expect: "dossier_history_contains", value: "wheat"),
|
||||
Step("act17i", "assert", expect: "activity_log_contains", value: "Hunts"),
|
||||
// Rebuild a busy ring for later variety / count asserts.
|
||||
Step("act17j", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 2, tier: "beat"),
|
||||
Step("act17k", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),
|
||||
Step("act17l", "activity_force", asset: "BehSocializeTalk", label: "Talks", count: 1, tier: "beat",
|
||||
expect: "Barkley"),
|
||||
Step("act18", "wait", wait: 0.25f),
|
||||
Step("act19", "assert", expect: "activity_count", value: "4", label: "min"),
|
||||
Step("act20", "assert", expect: "activity_prose_variety", value: "2"),
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ public static class WatchCaption
|
|||
private static float _historyColW = HistoryColMinW;
|
||||
private static int _lastHistoryCount = -1;
|
||||
private static long _lastHistorySubjectId;
|
||||
private static int _lastLiveHistFingerprint = int.MinValue;
|
||||
private const float LifeSepH = 3f;
|
||||
|
||||
private static Button _favoriteBtn;
|
||||
|
|
@ -896,6 +897,7 @@ public static class WatchCaption
|
|||
public static void ForceRefreshHistory()
|
||||
{
|
||||
_lastHistoryCount = -1;
|
||||
_lastLiveHistFingerprint = int.MinValue;
|
||||
if (!_visible || _current == null)
|
||||
{
|
||||
return;
|
||||
|
|
@ -935,7 +937,11 @@ public static class WatchCaption
|
|||
|
||||
long id = _current.UnitId;
|
||||
int count = ActivityLog.CountFor(id) + Chronicle.HistoryCountFor(id);
|
||||
if (id == _lastHistorySubjectId && count == _lastHistoryCount)
|
||||
Actor live = ResolveBoundLiveActor();
|
||||
int fingerprint = ActivityRelevance.LiveFingerprint(live, Time.unscaledTime);
|
||||
if (id == _lastHistorySubjectId
|
||||
&& count == _lastHistoryCount
|
||||
&& fingerprint == _lastLiveHistFingerprint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -1146,8 +1152,30 @@ public static class WatchCaption
|
|||
// does not vanish under a busy activity ring (and chronicle smoke stays valid).
|
||||
int chronicleCount = Chronicle.HistoryCountFor(unitId);
|
||||
int actCap = chronicleCount > 0 ? HistoryPeekMax - 1 : HistoryPeekMax;
|
||||
IReadOnlyList<ActivityEntry> activity =
|
||||
ActivityLog.LatestForSubject(unitId, actCap);
|
||||
Actor live = null;
|
||||
try
|
||||
{
|
||||
live = ResolveBoundLiveActor();
|
||||
if (live != null && live.isAlive() && live.getID() != unitId)
|
||||
{
|
||||
live = null;
|
||||
}
|
||||
|
||||
if (live == null && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null
|
||||
&& MoveCamera._focus_unit.isAlive()
|
||||
&& MoveCamera._focus_unit.getID() == unitId)
|
||||
{
|
||||
live = MoveCamera._focus_unit;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
live = null;
|
||||
}
|
||||
|
||||
IReadOnlyList<ActivityEntry> activity = live != null
|
||||
? ActivityLog.LatestRelevantForSubject(live, unitId, actCap)
|
||||
: ActivityLog.LatestForSubject(unitId, actCap);
|
||||
int lifeNeed = Mathf.Max(0, HistoryPeekMax - (activity?.Count ?? 0));
|
||||
|
||||
IReadOnlyList<ChronicleEntry> life =
|
||||
|
|
@ -1157,6 +1185,7 @@ public static class WatchCaption
|
|||
|
||||
_lastHistorySubjectId = unitId;
|
||||
_lastHistoryCount = ActivityLog.CountFor(unitId) + chronicleCount;
|
||||
_lastLiveHistFingerprint = ActivityRelevance.LiveFingerprint(live, Time.unscaledTime);
|
||||
|
||||
LastHistoryPreview = "";
|
||||
LastHistoryJoined = "";
|
||||
|
|
@ -1181,11 +1210,11 @@ public static class WatchCaption
|
|||
string taskFallback = "";
|
||||
try
|
||||
{
|
||||
Actor live = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
if (live != null && live.isAlive() && live.getID() == unitId && live.hasTask()
|
||||
&& live.ai?.task != null)
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
if (focus != null && focus.isAlive() && focus.getID() == unitId && focus.hasTask()
|
||||
&& focus.ai?.task != null)
|
||||
{
|
||||
taskFallback = live.ai.task.getLocalizedText() ?? "";
|
||||
taskFallback = focus.ai.task.getLocalizedText() ?? "";
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.25.11",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Event trigger audit Phases 4-8 + job reason row.",
|
||||
"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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ Character fill runs only when the pending **EventLed** queue is empty, and uses
|
|||
Orange dossier reason = the owning A event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
|
||||
Quiet grace / inactive dwell clears the reason even if focus has not switched yet.
|
||||
|
||||
## Activity History peek freshness
|
||||
|
||||
Layer B ring stays append-only (Lore Activity = full journal).
|
||||
Dossier History peek uses `ActivityRelevance` / `ActivityLog.LatestRelevantForSubject`:
|
||||
|
||||
- Soft window (~8s): newest beats always show.
|
||||
- After that: same verb/family as live task, or family live predicates (combat ↔ attack target / in_combat, haul ↔ carrying, status ↔ still has/lost status).
|
||||
- Milestones always peek-worthy.
|
||||
- Peek refreshes when live AI fingerprint changes (task, attack target, carrying), not only when the ring grows.
|
||||
|
||||
Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`.
|
||||
|
||||
## EventReason
|
||||
|
|
@ -72,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