- Introduce new probes for combat lifecycle, scheduler bounds, and exposure - Implement narrative scheduler health probe in harness scenarios - Update InterestCandidate to include approved presentation details - Refactor EventPresentability to manage candidate approval and caching - Improve IdleHitchProbe to track performance metrics during narrative events
416 lines
13 KiB
C#
416 lines
13 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,
|
|
NarrativePresentationModel presentation = null)
|
|
{
|
|
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,
|
|
NarrativeDevelopmentId = presentation?.DevelopmentId ?? "",
|
|
NarrativePresentation = presentation?.Clone(),
|
|
Label = label ?? assetId ?? key,
|
|
AssetId = assetId ?? "",
|
|
SpeciesId = subject?.asset != null ? subject.asset.id : "",
|
|
// StatusPhase publishes the status id as AssetId - stamp StatusId before Upsert
|
|
// so NoteSelection / beat cools see the class on first SwitchTo.
|
|
StatusId = completion == InterestCompletionKind.StatusPhase ? (assetId ?? "") : "",
|
|
CreatedAt = Time.unscaledTime,
|
|
LastSeenAt = Time.unscaledTime,
|
|
ExpiresAt = Time.unscaledTime + 35f,
|
|
MinWatch = minWatch,
|
|
MaxWatch = maxWatch,
|
|
Completion = completion
|
|
};
|
|
if (candidate.NarrativePresentation == null)
|
|
{
|
|
candidate.NarrativePresentation = NarrativePresentationFactory.Candidate(
|
|
key, source, assetId, candidate.Label, subject, relatedAlive);
|
|
}
|
|
NarrativeFunctionCatalog.Stamp(candidate);
|
|
NarrativePresentationCoverage.NoteIntake(candidate);
|
|
if (!EventPresentability.WouldShow(candidate))
|
|
{
|
|
InterestDropLog.Record("unpresentable", candidate.Label ?? key);
|
|
return null;
|
|
}
|
|
|
|
EventPresentability.MarkApproved(candidate);
|
|
InterestScoring.ScoreCheap(candidate);
|
|
InterestCandidate registered = InterestRegistry.Upsert(candidate);
|
|
EventPresentability.MarkApproved(registered);
|
|
return registered;
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
if (candidate.NarrativePresentation == null)
|
|
{
|
|
candidate.NarrativePresentation = NarrativePresentationFactory.Candidate(
|
|
candidate.Key,
|
|
candidate.Source,
|
|
candidate.AssetId,
|
|
candidate.Label,
|
|
candidate.FollowUnit,
|
|
candidate.RelatedUnit);
|
|
}
|
|
|
|
NarrativeFunctionCatalog.Stamp(candidate);
|
|
NarrativePresentationCoverage.NoteIntake(candidate);
|
|
|
|
// 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;
|
|
}
|
|
|
|
if (!IsHarnessSource(candidate.Source))
|
|
{
|
|
EventPresentability.MarkApproved(candidate);
|
|
}
|
|
InterestScoring.ScoreCheap(candidate);
|
|
InterestCandidate registered = InterestRegistry.Upsert(candidate);
|
|
if (!IsHarnessSource(candidate.Source))
|
|
{
|
|
EventPresentability.MarkApproved(registered);
|
|
}
|
|
return registered;
|
|
}
|
|
|
|
private static bool IsHarnessSource(string source) =>
|
|
string.Equals(source, "harness", StringComparison.OrdinalIgnoreCase);
|
|
|
|
public static bool HarnessProbePresentationCoverage(Actor subject, out string detail)
|
|
{
|
|
NarrativePresentationCoverage.Clear();
|
|
if (subject == null || !subject.isAlive())
|
|
{
|
|
detail = "missing living subject";
|
|
return false;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
string label = SafeName(subject) + " rests after the harvest";
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = "harness:narrative:presentation-coverage:" + SafeId(subject),
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Category = "Life",
|
|
Source = "harness",
|
|
EventStrength = 30f,
|
|
VisualConfidence = 1f,
|
|
Position = subject.current_position,
|
|
FollowUnit = subject,
|
|
SubjectId = SafeId(subject),
|
|
Label = label,
|
|
AssetId = "harness_presentation_coverage",
|
|
CreatedAt = now,
|
|
LastSeenAt = now,
|
|
ExpiresAt = now + 15f,
|
|
MinWatch = 1f,
|
|
MaxWatch = 4f,
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
|
|
string renderedBeat = "";
|
|
InterestCandidate registered = RegisterCandidate(candidate);
|
|
bool rendered = registered != null
|
|
&& NarrativeProse.TryBuildBeat(
|
|
subject, registered, out _, out renderedBeat, out _);
|
|
|
|
bool ok = registered?.NarrativePresentation != null
|
|
&& rendered
|
|
&& !string.IsNullOrEmpty(renderedBeat)
|
|
&& NarrativePresentationCoverage.IntakeStructuredCount == 1
|
|
&& NarrativePresentationCoverage.IntakeCompatibilityCount == 0
|
|
&& NarrativePresentationCoverage.CompatibilityCount == 0
|
|
&& NarrativePresentationCoverage.RedundancyCount == 0;
|
|
detail = "registered=" + (registered != null)
|
|
+ " typed=" + (registered?.NarrativePresentation != null)
|
|
+ " beat='" + renderedBeat + "' "
|
|
+ NarrativePresentationCoverage.Summary
|
|
+ " pass=" + ok;
|
|
return ok;
|
|
}
|
|
|
|
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)
|
|
{
|
|
Actor actor = FindUnitById(id);
|
|
if (actor == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
return actor.isAlive() ? actor : null;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// O(1) actor lookup including dead units (naming fallen duel partners, etc.).
|
|
/// </summary>
|
|
public static Actor FindUnitById(long id)
|
|
{
|
|
if (id == 0 || World.world?.units == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
return World.world.units.get(id);
|
|
}
|
|
catch
|
|
{
|
|
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);
|
|
}
|
|
}
|