worldbox-observer-mod/IdleSpectator/Events/EventFeedUtil.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

309 lines
8.5 KiB
C#

using System;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Sole unit-led publish API for the idle director.
/// Every registration is EventLed and may own the camera (score ranks).
/// FollowUnit owns the reason; RelatedUnit required when the label claims a second party;
/// location-only civic events may omit FollowUnit but never invent a stranger subject.
/// </summary>
public static class EventFeedUtil
{
public static InterestCandidate Register(
string key,
string source,
string category,
string label,
string assetId,
float eventStrength,
Vector3 position,
Actor follow,
Actor related = null,
bool locationOnly = false,
float minWatch = 4f,
float maxWatch = 22f,
InterestCompletionKind completion = InterestCompletionKind.FixedDwell)
{
if (string.IsNullOrEmpty(key))
{
return null;
}
Actor subject = follow;
if (subject != null && !subject.isAlive())
{
subject = null;
}
Actor relatedAlive = related;
if (relatedAlive != null && !relatedAlive.isAlive())
{
relatedAlive = null;
}
Vector3 pos = position;
if (subject != null)
{
pos = subject.current_position;
}
if (locationOnly)
{
if (pos == Vector3.zero || float.IsNaN(pos.x))
{
return null;
}
}
else
{
// Unit-led: require a living subject. Never invent a stranger.
if (subject == null)
{
return null;
}
if (pos == Vector3.zero || float.IsNaN(pos.x))
{
return null;
}
}
float strength = eventStrength;
if (strength < 8f)
{
strength = 8f;
}
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = string.IsNullOrEmpty(category) ? "Event" : category,
Source = source ?? "event",
EventStrength = strength,
CharacterSignificance = 0f,
VisualConfidence = subject != null ? 0.7f : 0.35f,
Position = pos,
FollowUnit = subject,
RelatedUnit = relatedAlive,
SubjectId = subject != null ? SafeId(subject) : 0,
RelatedId = relatedAlive != null ? SafeId(relatedAlive) : 0,
Label = label ?? assetId ?? key,
AssetId = assetId ?? "",
SpeciesId = subject?.asset != null ? subject.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 35f,
MinWatch = minWatch,
MaxWatch = maxWatch,
Completion = completion
};
if (!EventPresentability.WouldShow(candidate))
{
InterestDropLog.Record("unpresentable", candidate.Label ?? key);
return null;
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
/// <summary>
/// Publish a fully built candidate through the registry (still the sole intake).
/// Prefer the typed Register overload for new feeds.
/// </summary>
public static InterestCandidate RegisterCandidate(InterestCandidate candidate)
{
if (candidate == null || string.IsNullOrEmpty(candidate.Key) || !candidate.HasValidPosition)
{
return null;
}
// Reject stranger subjects: dead follow with no position is already invalid via HasValidPosition.
if (candidate.FollowUnit != null && !candidate.FollowUnit.isAlive())
{
candidate.FollowUnit = null;
candidate.SubjectId = 0;
if (!candidate.HasValidPosition)
{
return null;
}
}
if (candidate.RelatedUnit != null && !candidate.RelatedUnit.isAlive())
{
candidate.RelatedUnit = null;
candidate.RelatedId = 0;
}
if (candidate.FollowUnit != null && candidate.SubjectId == 0)
{
candidate.SubjectId = SafeId(candidate.FollowUnit);
}
if (candidate.RelatedUnit != null && candidate.RelatedId == 0)
{
candidate.RelatedId = SafeId(candidate.RelatedUnit);
}
// Harness injects synthetic tip labels for director tests; dossier still blanks them.
// Production feeds (Source != harness) must be presentable to enter the registry.
if (!IsHarnessSource(candidate.Source) && !EventPresentability.WouldShow(candidate))
{
InterestDropLog.Record("unpresentable", candidate.Label ?? candidate.Key);
return null;
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
private static bool IsHarnessSource(string source) =>
string.Equals(source, "harness", StringComparison.OrdinalIgnoreCase);
public static long SafeId(Actor actor)
{
if (actor == null)
{
return 0;
}
try
{
return actor.getID();
}
catch
{
return 0;
}
}
/// <summary>
/// O(1) living actor lookup via <c>ActorManager.get</c>. Prefer this over scanning
/// <see cref="WorldActivityScanner.EnumerateAliveUnitsPublic"/>.
/// </summary>
public static Actor FindAliveById(long id)
{
if (id == 0 || World.world?.units == null)
{
return null;
}
try
{
Actor actor = World.world.units.get(id);
if (actor != null && actor.isAlive())
{
return actor;
}
}
catch
{
// ignore lookup failures
}
return null;
}
public static string SafeName(Actor actor)
{
if (actor == null)
{
return "";
}
try
{
return actor.getName() ?? "";
}
catch
{
return "";
}
}
public static Actor NearestUnit(Vector3 near, float radius = 200f)
{
try
{
return WorldActivityScanner.FindNearestAliveUnit(near, radius);
}
catch
{
return null;
}
}
/// <summary>
/// Harness / diagnostics only. Must not be used as a silent camera subject for EventLed events.
/// </summary>
public static Actor AnyAliveUnit()
{
try
{
if (World.world?.units == null)
{
return null;
}
foreach (Actor actor in World.world.units)
{
if (actor != null && actor.isAlive())
{
return actor;
}
}
}
catch
{
// ignore
}
return null;
}
/// <summary>
/// Resolve a unit-led anchor without stranger fallback.
/// Preferred unit if alive; else nearest at position; else fail (caller may location-only).
/// </summary>
public static bool TryResolveOwnedAnchor(
Actor preferred,
Vector3 position,
out Actor follow,
out Vector3 pos,
bool allowNearestAtPosition = true)
{
follow = null;
pos = position;
if (preferred != null && preferred.isAlive())
{
follow = preferred;
pos = preferred.current_position;
return pos != Vector3.zero && !float.IsNaN(pos.x);
}
if (allowNearestAtPosition && position != Vector3.zero && !float.IsNaN(position.x))
{
follow = NearestUnit(position, 48f);
if (follow != null && follow.isAlive())
{
pos = follow.current_position;
return true;
}
}
follow = null;
pos = position;
return false;
}
/// <summary>Obsolete stranger path. Prefer TryResolveOwnedAnchor or location-only Register.</summary>
[System.Obsolete("Use TryResolveOwnedAnchor or location-only Register. Do not invent subjects.")]
public static bool TryResolveAnchor(Actor preferred, Vector3 position, out Actor follow, out Vector3 pos)
{
return TryResolveOwnedAnchor(preferred, position, out follow, out pos, allowNearestAtPosition: true);
}
}