Refactor decision event handling in IdleSpectator to filter camera-worthy events more effectively. Introduce new logic to exclude routine AI ticks and enhance the identification of significant kingdom-affecting decisions. Update event strength handling for birth moments and adjust status interest catalog to prevent non-essential traits from owning the camera. Increment version in mod.json to 0.19.3 to reflect these changes.

This commit is contained in:
DazedAnon 2026-07-15 23:10:30 -05:00
parent 6c6a7cc286
commit 3fe12d6ff0
7 changed files with 190 additions and 16 deletions

View file

@ -82,8 +82,8 @@ public static class DeferredEventPatches
}
DiscreteEventEntry entry = DecisionInterestCatalog.GetOrFallback(id);
// High-frequency hook: only emit stronger decisions (strength dial, not Signal gate).
if (entry.EventStrength < InterestScoringConfig.W.noticeScoreMin)
// High-frequency hook: only kingdom-affecting decisions, never idle AI ticks.
if (!DecisionInterestCatalog.IsCameraWorthy(id))
{
return;
}

View file

@ -111,18 +111,33 @@ public static class DecisionInterestCatalog
}
string s = id.ToLowerInvariant();
// Routine AI ticks often include "king" in the name - those are not camera events.
if (s.Contains("idle")
|| s.Contains("walking")
|| s.Contains("check")
|| s.Contains("wait")
|| s.Contains("sleep"))
{
return false;
}
return s.Contains("war")
|| s.Contains("rebellion")
|| s.Contains("revolt")
|| s.Contains("alliance")
|| s.Contains("king")
|| s.Contains("coup")
|| s.Contains("betray")
|| s.Contains("declare")
|| s.Contains("invade")
|| s.Contains("siege");
|| s.Contains("siege")
|| s.Contains("found")
|| s.Contains("city_foundation")
|| (s.Contains("king") && (s.Contains("war") || s.Contains("city") || s.Contains("rebellion")));
}
/// <summary>True when a decision id should be allowed to own the idle camera.</summary>
public static bool IsCameraWorthy(string id) => IsKingdomDecision(id);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
@ -486,6 +487,14 @@ public static class InterestDirector
if (c == null || !CanSwitchTo(c, onCurrent, sinceSwitch))
{
PendingScratch.RemoveAt(i);
continue;
}
// Drop stale birth/hatch shots - by then the unit is already running around.
if (IsStaleFreshLifeMoment(c, now))
{
InterestRegistry.Remove(c.Key);
PendingScratch.RemoveAt(i);
}
}
@ -498,6 +507,49 @@ public static class InterestDirector
return InterestVariety.Pick(PendingScratch, _current);
}
private static bool IsStaleFreshLifeMoment(InterestCandidate c, float now)
{
if (c == null || string.IsNullOrEmpty(c.HappinessEffectId))
{
return false;
}
string id = c.HappinessEffectId;
if (!string.Equals(id, "just_born", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Real-time freshness: if the shot waited in queue more than ~5s, skip it.
if (now - c.CreatedAt > 5.5f)
{
return true;
}
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive())
{
return true;
}
try
{
// Already past the newborn beat (playing/moving as a grown unit).
if (!unit.isBaby() && unit.getAge() > 1)
{
return true;
}
}
catch
{
// ignore age API mismatches
}
return false;
}
/// <summary>
/// True when a drained New species tip would be allowed to take the camera.
/// </summary>

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using UnityEngine;
@ -535,6 +536,16 @@ public static class InterestFeeds
+ ":" + CorrBucket(occ.CorrelationKey);
bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_");
float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId);
bool birthMoment = IsFreshLifeMoment(occ.EffectId);
// Birth/hatch only matter while still fresh - do not queue behind a long drama reel.
float ttl = birthMoment ? 7f : 35f;
float minWatch = birthMoment ? 2f : 4f;
float maxWatch = birthMoment ? 5.5f : 28f;
if (birthMoment)
{
strength = Mathf.Max(strength, 88f);
}
var candidate = new InterestCandidate
{
@ -559,9 +570,9 @@ public static class InterestFeeds
CorrelationKey = occ.CorrelationKey,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 35f,
MinWatch = 4f,
MaxWatch = 28f,
ExpiresAt = Time.unscaledTime + ttl,
MinWatch = minWatch,
MaxWatch = maxWatch,
Completion = grief
? InterestCompletionKind.HappinessGrief
: InterestCompletionKind.FixedDwell
@ -768,6 +779,19 @@ public static class InterestFeeds
}
}
private static bool IsFreshLifeMoment(string effectId)
{
if (string.IsNullOrEmpty(effectId))
{
return false;
}
string id = effectId.Trim();
return string.Equals(id, "just_born", StringComparison.OrdinalIgnoreCase)
|| string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase);
}
private static string MapHappinessCategory(string cat)
{
switch ((cat ?? "").ToLowerInvariant())

View file

@ -30,7 +30,7 @@ public static class StatusInterestCatalog
Add("possessed_follower", 60f, "StatusTransformation", true);
Add("frozen", 65f, "StatusTransformation", true);
Add("poisoned", 58f, "StatusTransformation", true);
Add("drowning", 68f, "StatusTransformation", true);
Add("drowning", 40f, "StatusTransformation", false);
Add("stunned", 50f, "StatusTransformation", true);
Add("rage", 62f, "StatusTransformation", true);
Add("angry", 48f, "StatusTransformation", true);
@ -38,7 +38,7 @@ public static class StatusInterestCatalog
Add("soul_harvested", 75f, "StatusTransformation", true);
Add("voices_in_my_head", 55f, "StatusTransformation", true);
Add("tantrum", 52f, "StatusTransformation", true);
Add("egg", 58f, "StatusTransformation", true);
Add("egg", 40f, "StatusTransformation", false);
Add("magnetized", 55f, "StatusTransformation", true);
Add("invincible", 56f, "StatusTransformation", true);
Add("powerup", 54f, "StatusTransformation", true);
@ -55,12 +55,12 @@ public static class StatusInterestCatalog
Add("pregnant_parthenogenesis", 58f, "LifeChapter", true);
Add("crying", 50f, "Grief", true, InterestOwnership.Signal, extendsGrief: true);
// Authored Ambient interest (tracked, fill-only)
Add("festive_spirit", 28f, "StatusAmbient", true, InterestOwnership.Ambient);
Add("laughing", 26f, "StatusAmbient", true, InterestOwnership.Ambient);
Add("singing", 26f, "StatusAmbient", true, InterestOwnership.Ambient);
Add("sleeping", 22f, "StatusAmbient", true, InterestOwnership.Ambient);
Add("handsome_migrant", 30f, "StatusAmbient", true, InterestOwnership.Ambient);
// Authored Ambient - never owns camera (task chip / enrichment only).
Add("festive_spirit", 28f, "StatusAmbient", false, InterestOwnership.Ambient);
Add("laughing", 26f, "StatusAmbient", false, InterestOwnership.Ambient);
Add("singing", 26f, "StatusAmbient", false, InterestOwnership.Ambient);
Add("sleeping", 22f, "StatusAmbient", false, InterestOwnership.Ambient);
Add("handsome_migrant", 30f, "StatusAmbient", false, InterestOwnership.Ambient);
// Authored but do not create interest candidates (cooldowns / ambient / brief FX)
AddNoInterest("afterglow");

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
@ -8,6 +9,24 @@ namespace IdleSpectator;
[HarmonyPatch]
public static class TraitEventPatches
{
/// <summary>
/// Birth/spawn endowment traits that are still worth a camera beat.
/// Routine species defaults (poisonous frog, bloodlust beetle, …) are not.
/// </summary>
private static readonly HashSet<string> LegendaryBirthTraits = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"immortal",
"chosen_one",
"miracle_born",
"miracle_bearer",
"scar_of_divinity",
"giant",
"whirlwind",
"death_nuke",
"death_bomb",
"bomberman"
};
[HarmonyPatch(typeof(Actor), nameof(Actor.addTrait), new Type[] { typeof(string), typeof(bool) })]
[HarmonyPostfix]
public static void PostfixAddTraitString(Actor __instance, string pTraitID, bool __result)
@ -99,10 +118,27 @@ public static class TraitEventPatches
return;
}
// Quiet-world Ambient traits must not steal the camera.
if (entry.EventStrength < InterestScoringConfig.W.noticeScoreMin)
{
return;
}
// Spawn/birth endowment: only legendary rarities.
if (gained && IsSpawnTraitWindow(actor) && !LegendaryBirthTraits.Contains(entry.Id))
{
return;
}
float strength = entry.EventStrength;
if (!gained)
{
strength = Mathf.Min(strength, 45f);
// Trait loss is quieter unless it was dramatic.
if (entry.EventStrength < InterestScoringConfig.W.hotScoreMin)
{
return;
}
}
string label = EventReason.Trait(actor, entry.Id, gained);
@ -119,4 +155,51 @@ public static class TraitEventPatches
actor.current_position,
actor);
}
/// <summary>True while traits are still being stamped onto a brand-new unit.</summary>
private static bool IsSpawnTraitWindow(Actor actor)
{
if (actor == null)
{
return true;
}
try
{
if (actor.isBaby())
{
return true;
}
}
catch
{
// ignore
}
try
{
double created = actor.data != null ? actor.data.created_time : 0;
if (created <= 0)
{
return false;
}
double now = 0;
if (World.world != null)
{
now = World.world.getCurWorldTime();
}
else if (MapBox.instance != null)
{
now = MapBox.instance.getCurWorldTime();
}
// Within ~2 world-years of creation = spawn endowment, not a mid-life gain.
return now > 0 && (now - created) < 2.0;
}
catch
{
return false;
}
}
}

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.19.2",
"version": "0.19.3",
"description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
"GUID": "com.dazed.idlespectator"
}