worldbox-observer-mod/IdleSpectator/Events/Feeds/InterestFeeds.cs
DazedAnon 366ae45f13 Implement combat isolation and improve combat event handling.
- Add new command for isolating combat pairs to prevent ambient escalation
- Enhance combat event logging and participant tracking
- Optimize combat focus handling based on participant count
- Update harness scenarios to validate new combat interactions
- Increment version to 0.26.7 in mod.json
2026-07-17 14:40:32 -05:00

1371 lines
43 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Converts live streams into <see cref="InterestRegistry"/> candidates.
/// Sole path for WorldLog / happiness / scanner / activity / status / chronicle → registry.
/// </summary>
public static class InterestFeeds
{
private static ulong _happinessCursor;
private static readonly List<HappinessOccurrence> HappinessDrain = new List<HappinessOccurrence>(64);
private static float _lastScannerAt = -999f;
private const float ScannerInterval = 0.75f;
// Civic aggregate boosts keyed by kingdom+effect.
private static readonly Dictionary<string, float> CivicBoostUntil = new Dictionary<string, float>(32);
public static void Reset()
{
_happinessCursor = HappinessEventRouter.CurrentSequence;
HappinessDrain.Clear();
_lastScannerAt = -999f;
CivicBoostUntil.Clear();
InterestDropLog.Clear();
}
/// <summary>Keep drain cursor coherent when the happiness router sequence resets.</summary>
public static void OnHappinessRouterCleared()
{
_happinessCursor = HappinessEventRouter.CurrentSequence;
HappinessDrain.Clear();
}
/// <summary>
/// Harness: if the router was cleared without syncing the feed cursor, rewind so
/// <see cref="HarnessDrainOnce"/> can see the new sequence.
/// </summary>
public static void HarnessEnsureCursorBefore(ulong sequence)
{
if (sequence == 0)
{
return;
}
if (_happinessCursor >= sequence)
{
_happinessCursor = sequence - 1;
}
}
public static ulong HappinessCursor => _happinessCursor;
public static int CivicBoostCount
{
get
{
int n = 0;
foreach (KeyValuePair<string, float> kv in CivicBoostUntil)
{
if (kv.Key != null && !kv.Key.EndsWith("|amt") && kv.Value > Time.unscaledTime)
{
n++;
}
}
return n;
}
}
public static bool HasCivicBoostContaining(string effectId)
{
if (string.IsNullOrEmpty(effectId))
{
return CivicBoostCount > 0;
}
foreach (KeyValuePair<string, float> kv in CivicBoostUntil)
{
if (kv.Key != null
&& !kv.Key.EndsWith("|amt")
&& kv.Key.IndexOf(effectId, System.StringComparison.OrdinalIgnoreCase) >= 0
&& kv.Value > Time.unscaledTime)
{
return true;
}
}
return false;
}
/// <summary>Harness: drain happiness once; returns how many occurrences were ingested.</summary>
public static int HarnessDrainOnce()
{
DrainHappiness();
return HappinessDrain.Count;
}
/// <summary>Harness: stamp a civic boost as AggregateSummary would.</summary>
public static void HarnessStampCivicBoost(string kingdom, string effectId, float amount)
{
StampCivicBoost(kingdom ?? "harness", effectId ?? "", amount);
}
public static void Tick()
{
DrainHappiness();
TickScanner();
PruneCivicBoosts();
}
public static void OnWorldLogMessage(WorldLogMessage message)
{
if (message == null || !AgentHarness.LiveFeedsAllowed)
{
return;
}
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(message.asset_id);
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record(
"createsInterest=false",
"worldlog:" + (message.asset_id ?? ""));
return;
}
float evtSeed = entry.EventStrength;
Vector3 position = message.getLocation();
Actor unit = null;
if (message.hasFollowLocation())
{
unit = message.unit;
}
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
string kingdom = message.special1 ?? "";
string key = "worldlog:" + assetId + ":" + kingdom;
float boost = PeekCivicBoost(kingdom, assetId);
string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category;
string label = entry.MakeLabel(message);
if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos))
{
EventFeedUtil.Register(
key,
"worldlog",
category,
label,
assetId,
evtSeed + boost,
pos,
follow,
minWatch: entry.MinWatch,
maxWatch: entry.MaxWatch);
return;
}
// Civic / disaster without a real unit: location-only (no stranger subject).
if (position != Vector3.zero && !float.IsNaN(position.x))
{
EventFeedUtil.Register(
key,
"worldlog",
category,
label,
assetId,
evtSeed + boost,
position,
follow: null,
locationOnly: true,
minWatch: entry.MinWatch,
maxWatch: entry.MaxWatch);
}
}
/// <summary>Legacy / discovery / harness enqueue path → registry.</summary>
public static InterestCandidate RegisterDirect(InterestEvent interest)
{
if (interest == null || !interest.HasValidPosition)
{
return null;
}
Actor unit = interest.FollowUnit;
long subjectId = SafeId(unit);
string asset = interest.AssetId ?? "";
float score = interest.Score;
bool hot = score >= InterestScoringConfig.W.noticeScoreMin || asset == "live_battle";
string key = "direct:" + subjectId + ":" + (interest.Label ?? "") + ":" + asset;
InterestLeadKind lead = hot ? InterestLeadKind.EventLed : InterestLeadKind.CharacterLed;
InterestCompletionKind completion = hot
? InterestCompletionKind.FixedDwell
: InterestCompletionKind.CharacterVignette;
if (asset == "live_battle")
{
key = "battle:hot:" + Mathf.FloorToInt(interest.Position.x) + ":" + Mathf.FloorToInt(interest.Position.y);
lead = InterestLeadKind.EventLed;
completion = InterestCompletionKind.CombatActive;
}
var candidate = new InterestCandidate
{
Key = key,
LeadKind = lead,
Category = asset == "live_battle"
? "Combat"
: CategoryForLead(lead, completion, asset),
Source = "direct",
EventStrength = score > 0f ? score : 40f,
CharacterSignificance = interest.CharacterSignificance > 0f
? interest.CharacterSignificance
: (lead == InterestLeadKind.CharacterLed ? score * 0.5f : 0f),
VisualConfidence = unit != null ? 0.75f : 0.4f,
Position = interest.Position,
FollowUnit = unit,
SubjectId = subjectId,
Label = interest.Label ?? "",
AssetId = asset,
SpeciesId = unit?.asset != null ? unit.asset.id : asset,
ParticipantCount = interest.ParticipantCount,
NotableParticipantCount = interest.NotableParticipantCount,
Verb = asset == "live_battle" ? "fighting" : "",
CreatedAt = interest.CreatedAt > 0f ? interest.CreatedAt : Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 30f,
MinWatch = InterestScoring.IsFillScore(score) ? 1.2f : 1.5f,
MaxWatch = InterestScoring.IsHotScore(score) ? 40f : 25f,
Completion = completion
};
InterestScoring.ScoreCheap(candidate);
return EventFeedUtil.RegisterCandidate(candidate);
}
public static InterestCandidate RegisterHarness(
Actor follow,
string label,
string keySuffix,
InterestLeadKind lead,
InterestCompletionKind completion,
bool forceActive,
float eventStrength,
float maxWatch)
{
return RegisterHarness(
follow,
label,
keySuffix,
lead,
completion,
forceActive,
eventStrength,
characterSignificance: lead == InterestLeadKind.CharacterLed
? UnityEngine.Mathf.Min(40f, eventStrength * 0.4f)
: 10f,
maxWatch,
ttlSeconds: 60f,
related: null,
resumable: true);
}
public static InterestCandidate RegisterHarness(
Actor follow,
string label,
string keySuffix,
InterestLeadKind lead,
InterestCompletionKind completion,
bool forceActive,
float eventStrength,
float characterSignificance,
float maxWatch,
float ttlSeconds,
Actor related,
bool resumable)
{
if (follow == null || !follow.isAlive())
{
return null;
}
long id = SafeId(follow);
string key = "harness:" + (keySuffix ?? "scene") + ":" + id;
float now = Time.unscaledTime;
var candidate = new InterestCandidate
{
Key = key,
LeadKind = lead,
Category = CategoryForLead(lead, completion, ""),
Source = "harness",
EventStrength = eventStrength,
CharacterSignificance = characterSignificance,
VisualConfidence = 0.9f,
Position = follow.current_position,
FollowUnit = follow,
RelatedUnit = related,
SubjectId = id,
Label = label ?? "Harness",
AssetId = follow.asset != null ? follow.asset.id : "harness",
SpeciesId = follow.asset != null ? follow.asset.id : "",
CreatedAt = now,
LastSeenAt = now,
ExpiresAt = now + (ttlSeconds > 0f ? ttlSeconds : 60f),
MinWatch = 1f,
MaxWatch = maxWatch > 0f ? maxWatch : 20f,
Completion = completion,
ForceActive = forceActive,
Resumable = resumable
};
if (related != null)
{
try
{
candidate.RelatedId = related.getID();
}
catch
{
candidate.RelatedId = 0;
}
}
InterestScoring.ScoreCheap(candidate);
return EventFeedUtil.RegisterCandidate(candidate);
}
public static void OnActivityNote(Actor actor, string taskOrBeat, bool hot)
{
if (actor == null || !actor.isAlive() || !AgentHarness.LiveFeedsAllowed || !hot)
{
return;
}
// Non-combat hot activity is not an event reason - task chip covers live job.
if (!actor.has_attack_target)
{
return;
}
Actor foe = null;
try
{
if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor())
{
foe = actor.attack_target.a;
}
}
catch
{
foe = null;
}
string verb = taskOrBeat ?? "fighting";
WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
Vector3 pos = follow != null ? follow.current_position : actor.current_position;
LiveEnsemble.TryBuildCombat(pos, 10f, out LiveEnsemble ensemble);
if (ensemble != null && ensemble.HasFocus)
{
// Multi camps may reframe focus; NamedPair keeps the first claimant.
if (ensemble.ParticipantCount > 2
|| LiveEnsemble.HasOpposingCollectiveSides(ensemble)
|| ensemble.Scale > EnsembleScale.Pair)
{
follow = ensemble.Focus;
related = ensemble.Related ?? related;
}
else
{
related = related ?? ensemble.Related;
}
}
Actor followActor = follow ?? actor;
long followId = SafeId(followActor);
string key = EventCatalog.Combat.KeyFor(followActor, related);
string label = ensemble != null
? EventReason.Combat(ensemble)
: EventReason.Fight(followActor, related);
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = "Combat",
Source = "activity",
EventStrength = EventCatalog.Combat.ActivityStrength,
VisualConfidence = 0.8f,
Position = followActor != null ? followActor.current_position : actor.current_position,
FollowUnit = followActor,
RelatedUnit = related,
SubjectId = followId,
RelatedId = related != null ? SafeId(related) : 0,
Label = label,
AssetId = followActor.asset != null ? followActor.asset.id : "",
SpeciesId = followActor.asset != null ? followActor.asset.id : "",
Verb = verb,
CityKey = followActor.city != null ? (followActor.city.name ?? "") : "",
KingdomKey = followActor.kingdom != null ? (followActor.kingdom.name ?? "") : "",
ParticipantCount = ensemble != null ? Mathf.Max(1, ensemble.ParticipantCount) : (related != null ? 2 : 1),
NotableParticipantCount = ensemble != null ? ensemble.NotableParticipantCount : 0,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
MinWatch = EventCatalog.Combat.MinWatch,
MaxWatch = EventCatalog.Combat.MaxWatch,
Completion = InterestCompletionKind.CombatActive
};
candidate.StampCombatPair(followActor, related);
if (ensemble != null)
{
WorldActivityScanner.StampParticipantIds(candidate, ensemble);
if (ensemble.Scale >= EnsembleScale.Skirmish)
{
candidate.AssetId = "live_battle";
}
if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
&& ensemble.SideA != null
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
{
candidate.KingdomKey = ensemble.SideA.Key;
}
}
else
{
candidate.ParticipantIds.Add(followId);
if (related != null)
{
long rid = SafeId(related);
if (rid != 0)
{
candidate.ParticipantIds.Add(rid);
}
}
}
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
public static void OnStatusChange(Actor actor, string statusId, bool gained)
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(statusId))
{
return;
}
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
// Egg timer end is the reliable hatch beat. Happiness just_got_out_of_egg is often
// suppressed (no emotions), so status loss owns the camera reason.
if (!gained && string.Equals(statusId.Trim(), "egg", StringComparison.OrdinalIgnoreCase))
{
EmitHatch(actor, "status_egg_loss");
return;
}
if (!gained)
{
InterestDropLog.Record("status_loss_ignored", statusId);
return;
}
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId);
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", "status:" + statusId);
return;
}
long id = SafeId(actor);
if (entry.ExtendsGrief || statusId == "crying")
{
string griefPrefix = "happiness:death_";
if (ExtendMatching(id, griefPrefix, statusId))
{
return;
}
// Standalone crying with no grief scene still creates interest.
if (statusId != "crying" && entry.ExtendsGrief)
{
return;
}
}
string phrase = ActivityStatusProse.Phrase(statusId, gained: true, out _);
string key = "status:" + statusId + ":" + id;
InterestCandidate registered = EventFeedUtil.Register(
key,
"status",
string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category,
EventReason.Status(actor, phrase),
statusId,
entry.EventStrength,
actor.current_position,
actor,
minWatch: 2f,
maxWatch: InterestScoringConfig.W.maxWatchStatus,
completion: InterestCompletionKind.StatusPhase);
if (registered != null)
{
registered.StatusId = statusId;
registered.LastSeenAt = Time.unscaledTime;
// Cluster of carriers → sticky outbreak tip instead of thin single-unit prose.
StatusOutbreakFeed.TryUpgradeFromEmit(actor, statusId, registered);
}
}
/// <summary>
/// Hatch camera beat. Shared key with happiness <c>just_got_out_of_egg</c> so both paths merge.
/// </summary>
public static void EmitHatch(Actor actor, string source)
{
if (actor == null || !actor.isAlive())
{
return;
}
long id = SafeId(actor);
if (id == 0)
{
return;
}
float strength = Mathf.Max(
InterestScoring.EventStrengthForHappiness("just_got_out_of_egg"),
88f);
var candidate = new InterestCandidate
{
Key = HatchKey(id),
LeadKind = InterestLeadKind.EventLed,
Category = "LifeChapter",
Source = string.IsNullOrEmpty(source) ? "hatch" : source,
EventStrength = strength,
VisualConfidence = 0.8f,
Position = actor.current_position,
FollowUnit = actor,
SubjectId = id,
Label = EventReason.Hatch(actor),
AssetId = SafeAsset(actor),
SpeciesId = SafeAsset(actor),
HappinessEffectId = "just_got_out_of_egg",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 7f,
MinWatch = 4f,
MaxWatch = 15f,
Completion = InterestCompletionKind.FixedDwell
};
candidate.ParticipantIds.Add(id);
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
private static string HatchKey(long subjectId) => "hatch:" + subjectId;
private static string AlphaKey(long subjectId) => "alpha:" + subjectId;
/// <summary>
/// Family alpha succession. Shared key with happiness <c>become_alpha</c> so emotion and
/// emotion-less paths merge into one camera beat.
/// </summary>
public static void EmitAlpha(Actor actor, string source)
{
if (actor == null || !actor.isAlive())
{
return;
}
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
long id = SafeId(actor);
if (id == 0)
{
return;
}
DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback("become_alpha");
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", "become_alpha");
return;
}
float strength = Mathf.Max(
entry.EventStrength,
InterestScoring.EventStrengthForHappiness("become_alpha"));
var candidate = new InterestCandidate
{
Key = AlphaKey(id),
LeadKind = InterestLeadKind.EventLed,
Category = string.IsNullOrEmpty(entry.Category) ? "LifeChapter" : entry.Category,
Source = string.IsNullOrEmpty(source) ? "alpha" : source,
EventStrength = strength,
VisualConfidence = 0.8f,
Position = actor.current_position,
FollowUnit = actor,
SubjectId = id,
Label = EventReason.BecomeAlpha(actor),
AssetId = SafeAsset(actor),
SpeciesId = SafeAsset(actor),
HappinessEffectId = "become_alpha",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 25f,
MinWatch = 5f,
MaxWatch = 22f,
Completion = InterestCompletionKind.FixedDwell
};
candidate.ParticipantIds.Add(id);
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
public static void OnChronicleMilestone(Actor subject, string milestoneKey, Actor related, string label)
{
if (subject == null || !subject.isAlive() || string.IsNullOrEmpty(milestoneKey))
{
return;
}
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
long sid = SafeId(subject);
long rid = SafeId(related);
// Shared key space with happiness canonical merges.
string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid;
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = "Relationship",
Source = "chronicle",
EventStrength = 60f,
VisualConfidence = 0.7f,
Position = subject.current_position,
FollowUnit = subject,
RelatedUnit = related,
SubjectId = sid,
RelatedId = rid,
Label = label ?? milestoneKey,
AssetId = subject.asset != null ? subject.asset.id : "",
SpeciesId = subject.asset != null ? subject.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 25f,
MinWatch = 3f,
MaxWatch = 20f,
Completion = InterestCompletionKind.FixedDwell
};
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
private static void DrainHappiness()
{
ulong next = HappinessEventRouter.DrainSince(_happinessCursor, HappinessDrain);
if (HappinessDrain.Count == 0)
{
_happinessCursor = next;
return;
}
for (int i = 0; i < HappinessDrain.Count; i++)
{
IngestHappiness(HappinessDrain[i]);
}
_happinessCursor = next;
}
private static void IngestHappiness(HappinessOccurrence occ)
{
if (occ == null)
{
return;
}
if (!occ.Applied || occ.SuppressedByPsychopath)
{
InterestDropLog.Record(
occ.SuppressedByPsychopath ? "suppressed" : "not_applied",
occ.EffectId ?? "");
return;
}
if (!AgentHarness.LiveFeedsAllowed && occ.SourceHook != HappinessSourceHook.Harness)
{
InterestDropLog.Record("busy", occ.EffectId ?? "");
return;
}
if (occ.Presentation == HappinessPresentationTier.Aggregate)
{
if (!occ.Merged || occ.SourceHook == HappinessSourceHook.AggregateSummary)
{
string kingdom = "";
try
{
// Prefer civ key fragment.
string civ = occ.CivEventKey ?? "";
int pipe = civ.IndexOf('|');
if (pipe >= 0 && pipe + 1 < civ.Length)
{
int pipe2 = civ.IndexOf('|', pipe + 1);
kingdom = pipe2 > pipe
? civ.Substring(pipe + 1, pipe2 - pipe - 1)
: civ.Substring(pipe + 1);
}
}
catch
{
kingdom = "";
}
StampCivicBoost(kingdom, occ.EffectId, 25f + Mathf.Min(40f, occ.TotalAffectedCount));
}
InterestDropLog.Record("aggregate", occ.EffectId ?? "");
return;
}
if (!EventCatalog.IsCameraWorthy(occ.Presentation))
{
// Layer B only - never owns the camera.
InterestDropLog.Record("ambient", occ.EffectId ?? "");
return;
}
// Signal: skip merged duplicates (canonical / continuation already covered).
if (occ.Merged)
{
return;
}
Actor subject = FindUnitById(occ.SubjectId);
if (subject == null || !subject.isAlive())
{
return;
}
Actor related = occ.RelatedId != 0 ? FindUnitById(occ.RelatedId) : null;
string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory;
bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_");
float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId);
bool hatchMoment = IsFreshLifeMoment(occ.EffectId);
bool alphaMoment = IsAlphaMoment(occ.EffectId);
// Hatch / alpha share keys with status-loss / Family.setAlpha so paths merge.
string key;
if (hatchMoment)
{
key = HatchKey(occ.SubjectId);
}
else if (alphaMoment)
{
key = AlphaKey(occ.SubjectId);
}
else
{
key = "happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId
+ ":" + CorrBucket(occ.CorrelationKey);
}
// Hatch: short queue TTL, but on-camera floor comes from minCameraDwell.
float ttl = hatchMoment ? 7f : 35f;
float minWatch = hatchMoment ? 4f : 8f;
float maxWatch = hatchMoment ? 15f : 28f;
if (hatchMoment)
{
strength = Mathf.Max(strength, 88f);
}
if (alphaMoment)
{
strength = Mathf.Max(strength, EventCatalog.Relationship.GetOrFallback("become_alpha").EventStrength);
ttl = 25f;
minWatch = 5f;
maxWatch = 22f;
}
string label;
if (hatchMoment)
{
label = EventReason.Hatch(subject);
}
else if (alphaMoment)
{
label = EventReason.BecomeAlpha(subject);
}
else if (!string.IsNullOrEmpty(occ.PlainClause))
{
label = occ.SubjectName + " " + occ.PlainClause;
}
else
{
label = occ.SubjectName + " · " + occ.EffectId;
}
InterestCompletionKind completion = grief
? InterestCompletionKind.HappinessGrief
: InterestCompletionKind.FixedDwell;
string statusId = grief ? "crying" : "";
if (!grief
&& !hatchMoment
&& !alphaMoment
&& !TryBindStatusOverlapCamera(
occ,
subject,
ref key,
ref minWatch,
ref maxWatch,
ref strength,
ref completion,
ref statusId))
{
return;
}
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = grief ? "FamilyEmotion" : MapHappinessCategory(cat),
Source = hatchMoment
? "happiness_hatch"
: (alphaMoment ? "happiness_alpha" : "happiness"),
EventStrength = strength,
VisualConfidence = 0.75f,
Position = occ.Position != Vector3.zero ? occ.Position : subject.current_position,
FollowUnit = subject,
RelatedUnit = related,
SubjectId = occ.SubjectId,
RelatedId = occ.RelatedId,
Label = label,
AssetId = occ.SubjectSpecies,
SpeciesId = occ.SubjectSpecies,
HappinessEffectId = occ.EffectId,
StatusId = statusId,
CorrelationKey = occ.CorrelationKey,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + ttl,
MinWatch = minWatch,
MaxWatch = maxWatch,
Completion = completion
};
candidate.ParticipantIds.Add(occ.SubjectId);
if (occ.RelatedId != 0)
{
candidate.ParticipantIds.Add(occ.RelatedId);
}
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
/// <summary>Harness / push path: ingest one occurrence into the registry now.</summary>
public static void IngestForHarness(HappinessOccurrence occ)
{
IngestHappiness(occ);
if (occ != null && occ.Sequence > _happinessCursor)
{
_happinessCursor = occ.Sequence;
}
}
/// <summary>
/// Camera-worthy status-overlap happiness must not FixedDwell-claim a status the unit lacks.
/// Hold (onset): StatusPhase while live, else drop. Offset (end-ping): FixedDwell without status.
/// </summary>
private static bool TryBindStatusOverlapCamera(
HappinessOccurrence occ,
Actor subject,
ref string key,
ref float minWatch,
ref float maxWatch,
ref float strength,
ref InterestCompletionKind completion,
ref string statusId)
{
if (occ == null || string.IsNullOrEmpty(occ.StatusOverlapId))
{
return true;
}
HappinessStatusOverlapKind kind = ResolveStatusOverlapKind(occ);
if (kind == HappinessStatusOverlapKind.None)
{
return true;
}
string overlapId = occ.StatusOverlapId.Trim();
StatusInterestEntry statusEntry = EventCatalog.Status.GetOrFallback(overlapId);
if (!EventCatalog.IsCameraWorthy(statusEntry))
{
return true;
}
bool hasStatus = StatusGameApi.HasStatus(subject, overlapId)
|| InterestCompletionHasStatus(subject, overlapId);
if (hasStatus)
{
key = "status:" + overlapId + ":" + occ.SubjectId;
statusId = overlapId;
completion = InterestCompletionKind.StatusPhase;
minWatch = Mathf.Max(minWatch, 2f);
maxWatch = Mathf.Max(maxWatch, InterestScoringConfig.W.maxWatchStatus);
strength = Mathf.Max(strength, statusEntry.EventStrength);
return true;
}
if (kind == HappinessStatusOverlapKind.Offset)
{
// End-ping after the status cleared - short FixedDwell, keep happiness key.
maxWatch = Mathf.Min(maxWatch, 12f);
return true;
}
InterestDropLog.Record("status_overlap_absent", occ.EffectId ?? overlapId);
return false;
}
private static HappinessStatusOverlapKind ResolveStatusOverlapKind(HappinessOccurrence occ)
{
if (occ.StatusOverlapKind != HappinessStatusOverlapKind.None)
{
return occ.StatusOverlapKind;
}
if (string.IsNullOrEmpty(occ.StatusOverlapId))
{
return HappinessStatusOverlapKind.None;
}
string id = (occ.EffectId ?? "").Trim().ToLowerInvariant();
switch (id)
{
case "just_laughed":
case "just_sang":
case "just_swore":
case "just_cried":
case "just_had_tantrum":
case "just_inspired":
return HappinessStatusOverlapKind.Offset;
default:
break;
}
string clause = (occ.PlainClause ?? "").Trim();
if (clause.StartsWith("finishes", StringComparison.OrdinalIgnoreCase)
|| clause.StartsWith("comes down", StringComparison.OrdinalIgnoreCase)
|| clause.StartsWith("calms after", StringComparison.OrdinalIgnoreCase)
|| clause.StartsWith("lets inspiration", StringComparison.OrdinalIgnoreCase))
{
return HappinessStatusOverlapKind.Offset;
}
return HappinessStatusOverlapKind.Hold;
}
private static bool InterestCompletionHasStatus(Actor actor, string statusId)
{
if (actor == null || string.IsNullOrEmpty(statusId))
{
return false;
}
try
{
return actor.hasStatus(statusId);
}
catch
{
return false;
}
}
/// <summary>Direct registry insert for harness happiness signals (skips drain filters).</summary>
public static InterestCandidate RegisterHappinessSignal(
Actor subject,
string effectId,
string label,
string relatedName = "")
{
if (subject == null || string.IsNullOrEmpty(effectId))
{
return null;
}
long id = SafeId(subject);
Vector3 pos = Vector3.zero;
try
{
pos = subject.current_position;
}
catch
{
pos = Vector3.zero;
}
bool grief = effectId.StartsWith("death_");
float strength = InterestScoring.EventStrengthForHappiness(effectId);
string key = "happiness:" + effectId + ":" + id + ":0:harness";
bool alive = false;
try
{
alive = subject.isAlive();
}
catch
{
alive = false;
}
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = grief ? "FamilyEmotion" : "LifeChapter",
Source = "happiness_harness",
EventStrength = strength,
VisualConfidence = 0.9f,
Position = pos != Vector3.zero ? pos : new Vector3(1f, 1f, 0f),
FollowUnit = alive ? subject : null,
SubjectId = id,
Label = !string.IsNullOrEmpty(label)
? label
: (SafeName(subject) + " · " + effectId),
AssetId = subject.asset != null ? subject.asset.id : "",
SpeciesId = subject.asset != null ? subject.asset.id : "",
HappinessEffectId = effectId,
StatusId = grief ? "crying" : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 60f,
MinWatch = 4f,
MaxWatch = 28f,
Completion = grief
? InterestCompletionKind.HappinessGrief
: InterestCompletionKind.FixedDwell
};
if (!string.IsNullOrEmpty(relatedName))
{
candidate.Label = SafeName(subject) + " mourns " + relatedName;
}
InterestScoring.ScoreCheap(candidate);
return EventFeedUtil.RegisterCandidate(candidate);
}
private static void TickScanner()
{
float now = Time.unscaledTime;
if (now - _lastScannerAt < ScannerInterval)
{
return;
}
_lastScannerAt = now;
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
InterestCandidate current = InterestDirector.CurrentCandidate;
bool stickyCombat = current != null
&& current.Completion == InterestCompletionKind.CombatActive
&& InterestDirector.CurrentIsActive
&& current.ParticipantCount >= 2;
InterestEvent battle;
if (stickyCombat)
{
// Sticky owns the camera - never CollectAllCombatFighters. Chunk/BattleKeeper only.
Vector3 anchor = current.Position;
try
{
if (current.FollowUnit != null && current.FollowUnit.isAlive())
{
anchor = current.FollowUnit.current_position;
}
}
catch
{
// keep Position
}
battle = WorldActivityScanner.FindHottestBattleLight(anchor);
}
else
{
// Quiet / non-sticky: full ranking is OK (one fighter pass per ScanInterval).
battle = WorldActivityScanner.FindHottestBattle();
WorldActivityScanner.RegisterTopCharacterCandidates(max: 4);
}
if (battle != null)
{
if (string.Equals(battle.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
&& InterestDirector.TryAbsorbCombatBattle(battle))
{
// Active combat scene refreshed in place.
}
else if (!stickyCombat)
{
// Do not let a far-away light tip steal sticky ownership via RegisterDirect.
RegisterDirect(battle);
}
}
// Ongoing wars from WarManager (complements WorldLog war start/end).
WarInterestFeed.Tick();
// Active plots from PlotManager / World.plots.
PlotInterestFeed.Tick();
// Active family packs (2+ members) from World.families.
FamilyInterestFeed.Tick();
// Nearby multi-carrier status clusters.
StatusOutbreakFeed.Tick();
}
private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId)
{
var pending = new List<InterestCandidate>(32);
InterestRegistry.CopyPending(pending);
for (int i = 0; i < pending.Count; i++)
{
InterestCandidate c = pending[i];
if (c == null || c.SubjectId != subjectId)
{
continue;
}
if (c.Key != null && c.Key.StartsWith(keyPrefix))
{
c.StatusId = statusId;
c.LastSeenAt = Time.unscaledTime;
c.ExpiresAt = Time.unscaledTime + 20f;
EventFeedUtil.RegisterCandidate(c);
return true;
}
}
return false;
}
private static void StampCivicBoost(string kingdom, string effectId, float amount)
{
string key = (kingdom ?? "") + "|" + (effectId ?? "");
CivicBoostUntil[key] = Time.unscaledTime + 12f;
// Store amount in a parallel way via Expires encoding isn't ideal; keep simple additive peek.
CivicBoostUntil[key + "|amt"] = amount;
}
private static float PeekCivicBoost(string kingdom, string assetId)
{
// Map worldlog war assets to happiness war effects loosely.
string effect = "";
if (assetId != null && assetId.Contains("war"))
{
effect = "just_started_war";
}
else if (assetId != null && (assetId.Contains("city") || assetId.Contains("kingdom")))
{
effect = "conquered_city";
}
string key = (kingdom ?? "") + "|" + effect;
if (!CivicBoostUntil.TryGetValue(key, out float until) || Time.unscaledTime > until)
{
return 0f;
}
return CivicBoostUntil.TryGetValue(key + "|amt", out float amt) ? amt : 15f;
}
private static void PruneCivicBoosts()
{
if (CivicBoostUntil.Count < 64)
{
return;
}
float now = Time.unscaledTime;
var remove = new List<string>();
foreach (KeyValuePair<string, float> kv in CivicBoostUntil)
{
if (!kv.Key.EndsWith("|amt") && now > kv.Value)
{
remove.Add(kv.Key);
remove.Add(kv.Key + "|amt");
}
}
for (int i = 0; i < remove.Count; i++)
{
CivicBoostUntil.Remove(remove[i]);
}
}
private static bool IsFreshLifeMoment(string effectId)
{
if (string.IsNullOrEmpty(effectId))
{
return false;
}
// Hatch moments only - just_born is spawn bookkeeping, not a birth/hatch beat.
string id = effectId.Trim();
return string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase);
}
private static bool IsAlphaMoment(string effectId)
{
return !string.IsNullOrEmpty(effectId)
&& string.Equals(effectId.Trim(), "become_alpha", StringComparison.OrdinalIgnoreCase);
}
private static string MapHappinessCategory(string cat)
{
switch ((cat ?? "").ToLowerInvariant())
{
case "grief":
return "FamilyEmotion";
case "social":
return "Relationship";
case "lifechapter":
return "LifeChapter";
case "civic":
return "Politics";
default:
return "FamilyEmotion";
}
}
private static string CategoryForLead(
InterestLeadKind lead,
InterestCompletionKind completion,
string asset)
{
if (asset == "live_battle" || completion == InterestCompletionKind.CombatActive)
{
return "Combat";
}
if (completion == InterestCompletionKind.CharacterVignette
|| lead == InterestLeadKind.CharacterLed)
{
return "CharacterVignette";
}
if (completion == InterestCompletionKind.HappinessGrief)
{
return "FamilyEmotion";
}
if (completion == InterestCompletionKind.ActivityActive)
{
return "Work";
}
return "Settlement";
}
private static string CorrBucket(string corr)
{
if (string.IsNullOrEmpty(corr))
{
return "0";
}
int hash = corr.GetHashCode();
return (hash & 0xFFFF).ToString("x");
}
private static Actor FindUnitById(long id)
{
if (id == 0)
{
return null;
}
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
try
{
if (actor != null && actor.getID() == id)
{
return actor;
}
}
catch
{
// continue
}
}
return null;
}
private static long SafeId(Actor actor)
{
if (actor == null)
{
return 0;
}
try
{
return actor.getID();
}
catch
{
return 0;
}
}
private static string SafeName(Actor actor)
{
if (actor == null)
{
return "Someone";
}
try
{
string n = actor.getName();
if (!string.IsNullOrEmpty(n))
{
return n;
}
}
catch
{
// ignore
}
return actor.asset != null ? actor.asset.id : "creature";
}
private static string SafeAsset(Actor actor)
{
if (actor == null)
{
return "";
}
try
{
return actor.asset != null ? actor.asset.id : "";
}
catch
{
return "";
}
}
}