worldbox-observer-mod/IdleSpectator/EventFeedUtil.cs
2026-07-15 22:16:24 -05:00

278 lines
7.7 KiB
C#

using UnityEngine;
namespace IdleSpectator;
/// <summary>Whether an interest candidate may steal the idle camera.</summary>
public enum InterestOwnership
{
/// <summary>May cut in / own EventLed camera focus.</summary>
Signal,
/// <summary>Tracked for fill/enrichment; fill-capped so it does not preempt Signal drama.</summary>
Ambient
}
/// <summary>
/// Sole unit-led publish API for the idle director.
/// Ownership rule: 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,
InterestOwnership ownership,
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;
}
}
bool ambient = ownership == InterestOwnership.Ambient;
float strength = eventStrength;
if (ambient)
{
strength = Mathf.Min(strength, InterestScoringConfig.W.fillScoreMax - 1f);
if (strength < 8f)
{
strength = 8f;
}
}
var candidate = new InterestCandidate
{
Key = key,
LeadKind = ambient ? InterestLeadKind.CharacterLed : InterestLeadKind.EventLed,
Category = string.IsNullOrEmpty(category) ? "Event" : category,
Source = source ?? "event",
EventStrength = strength,
CharacterSignificance = ambient ? strength * 0.5f : 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 + (ambient ? 18f : 35f),
MinWatch = minWatch,
MaxWatch = maxWatch,
Completion = completion
};
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);
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
public static long SafeId(Actor actor)
{
if (actor == null)
{
return 0;
}
try
{
return actor.getID();
}
catch
{
return 0;
}
}
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 Signal 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);
}
}