worldbox-observer-mod/IdleSpectator/Narrative/CameraEligibility.cs
DazedAnon 188d091166 feat(spectator): focus camera on a five-character story ensemble
- Prioritize MCs, related cast, meaningful disasters, and exceptional events
- Show MC connections in the dossier when following related characters
- Promote rare creatures and recurring antagonists through bounded scoring
- Reduce roster churn and repetitive love/combat coverage
- Add performance telemetry, soak auditing, documentation, and harness coverage
2026-07-24 12:54:06 -05:00

483 lines
17 KiB
C#

using System;
using System.Collections.Generic;
namespace IdleSpectator;
public enum CameraEligibilityKind
{
DiscoveryOnly = 0,
GroundingFill = 1,
ExceptionalBackground = 2,
DisasterPersonalImpact = 3,
McThreadContext = 4,
McCast = 5,
McDirect = 6,
WorldCritical = 7
}
/// <summary>
/// Typed explanation for why a candidate may own the main camera. This is evaluated from
/// structured ids/evidence before selection; presentation consumes the same result.
/// </summary>
public sealed class CameraEligibilityModel
{
public CameraEligibilityKind Kind;
public long AnchorMcId;
public string AnchorMcName = "";
public string ConnectionKind = "";
public string ConnectionLine = "";
public string Evidence = "";
public bool IsMcOrbit =>
Kind == CameraEligibilityKind.McDirect
|| Kind == CameraEligibilityKind.McCast
|| Kind == CameraEligibilityKind.McThreadContext;
public bool MayUseCamera => Kind != CameraEligibilityKind.DiscoveryOnly;
}
public static class CameraEligibility
{
private static readonly List<LifeSagaSlot> Slots = new List<LifeSagaSlot>(LifeSagaRoster.Cap);
public static CameraEligibilityModel Classify(InterestCandidate candidate)
{
var model = new CameraEligibilityModel();
if (candidate == null)
{
return model;
}
long subjectId = candidate.SubjectId != 0
? candidate.SubjectId
: EventFeedUtil.SafeId(candidate.FollowUnit);
if (IsWorldCritical(candidate))
{
model.Kind = CameraEligibilityKind.WorldCritical;
model.Evidence = "world_critical";
if (subjectId != 0 && !LifeSagaRoster.IsMc(subjectId))
{
long criticalAnchor = FirstTouchedMc(candidate);
if (criticalAnchor != 0)
{
StampAnchor(model, criticalAnchor, "event", "world_critical_mc_context");
model.ConnectionLine = EventConnection(candidate, model.AnchorMcName);
}
else if (LifeSagaRoster.IsMcCast(subjectId)
&& TryResolveCastConnection(
subjectId,
out long castAnchor,
out string castKind,
out string castLine))
{
StampAnchor(model, castAnchor, castKind, "world_critical_cast_relation");
model.ConnectionLine = castLine;
}
}
return model;
}
if (LifeSagaRoster.IsMc(subjectId))
{
model.Kind = CameraEligibilityKind.McDirect;
StampAnchor(model, subjectId, "self", "mc_subject");
return model;
}
long touchedMc = FirstTouchedMc(candidate);
if (touchedMc != 0)
{
model.Kind = CameraEligibilityKind.McThreadContext;
StampAnchor(model, touchedMc, "event", "exact_event_participant");
model.ConnectionLine = EventConnection(candidate, model.AnchorMcName);
return model;
}
if (subjectId != 0 && LifeSagaRoster.IsMcCast(subjectId)
&& TryResolveCastConnection(subjectId, out long anchorId, out string kind, out string line))
{
model.Kind = CameraEligibilityKind.McCast;
StampAnchor(model, anchorId, kind, "live_cast_relation");
model.ConnectionLine = line;
return model;
}
EpisodePlan episode = StoryScheduler.ActiveEpisode;
if (episode != null && LifeSagaRoster.IsMc(episode.ProtagonistId))
{
if (TouchesExactEpisodeParticipant(candidate, episode))
{
model.Kind = CameraEligibilityKind.McThreadContext;
StampAnchor(model, episode.ProtagonistId, "story", "active_thread");
model.ConnectionLine = "In " + model.AnchorMcName + "'s story";
return model;
}
}
NarrativeFunction function = NarrativeFunctionPolicy.Classify(candidate);
if (StoryReason.IsStoryAsset(candidate.AssetId)
|| (candidate.Source ?? "").IndexOf("story_planner", StringComparison.OrdinalIgnoreCase) >= 0)
{
model.Kind = CameraEligibilityKind.ExceptionalBackground;
model.Evidence = "confirmed_story_payoff";
return model;
}
if (IsConfirmedDisasterImpact(candidate, function))
{
model.Kind = CameraEligibilityKind.DisasterPersonalImpact;
model.Evidence = "confirmed_disaster_impact";
return model;
}
if (IsExceptionalBackground(candidate, function))
{
model.Kind = CameraEligibilityKind.ExceptionalBackground;
model.Evidence = "exceptional_" + function.ToString().ToLowerInvariant();
return model;
}
if (candidate.LeadKind == InterestLeadKind.CharacterLed
&& subjectId != 0
&& (LifeSagaRoster.IsMc(subjectId) || LifeSagaRoster.IsMcCast(subjectId)))
{
model.Kind = CameraEligibilityKind.GroundingFill;
model.Evidence = "mc_orbit_fill";
}
return model;
}
public static bool MayUseCamera(InterestCandidate candidate)
{
// Before an ensemble has emerged, retain normal world coverage so candidates can
// accumulate evidence and the first five can be discovered.
if (LifeSagaRoster.Count == 0)
{
return true;
}
return Classify(candidate).MayUseCamera;
}
public static string ConnectionLine(InterestCandidate candidate, long focusId)
{
if (candidate == null || focusId == 0 || LifeSagaRoster.IsMc(focusId))
{
return "";
}
CameraEligibilityModel model = Classify(candidate);
return model.AnchorMcId != 0 ? model.ConnectionLine ?? "" : "";
}
private static bool IsWorldCritical(InterestCandidate c)
{
if (c.EventStrength >= 95f) return true;
return c.Completion == InterestCompletionKind.EarthquakeActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool IsConfirmedDisasterImpact(
InterestCandidate c,
NarrativeFunction function)
{
bool disaster = c.Completion == InterestCompletionKind.EarthquakeActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0
|| (c.Source ?? "").IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0;
if (!disaster || c.SubjectId == 0)
{
return false;
}
// A personal typed change/outcome is required. WorldContext alone is theater coverage.
return NarrativeFunctionPolicy.ChangesStoryState(function)
&& (c.NarrativePresentation != null
|| !string.IsNullOrEmpty(c.NarrativeDevelopmentId));
}
private static bool IsExceptionalBackground(
InterestCandidate c,
NarrativeFunction function)
{
if (!NarrativeFunctionPolicy.ChangesStoryState(function))
{
return false;
}
bool typed = c.NarrativePresentation != null
|| !string.IsNullOrEmpty(c.NarrativeDevelopmentId);
if (!typed || c.EventStrength < 82f || c.Novelty < 0.35f)
{
return false;
}
string text = ((c.AssetId ?? "") + " "
+ (c.Source ?? "") + " "
+ (c.Verb ?? "") + " "
+ (c.NarrativeDevelopmentId ?? "")).ToLowerInvariant();
bool routineLife = ContainsAny(
text,
"just_born",
"born",
"hatch",
"egg",
"became_adult",
"sleep",
"caffeine",
"find_lover",
"reproduction");
if (routineLife)
{
Actor subject = EventFeedUtil.FindAliveById(c.SubjectId);
return subject != null
&& LifeSagaRoster.IsSpecialCreature(subject)
&& LifeSagaRoster.IsSpeciesSingleton(subject);
}
bool decisive = function == NarrativeFunction.TurningPoint
|| function == NarrativeFunction.Resolution
|| function == NarrativeFunction.Consequence;
if (!decisive)
{
return false;
}
// Exceptional background is an explicit outcome lane, not a numeric escape hatch.
return ContainsAny(
text,
"death",
"died",
"killed",
"destroy",
"lost_home",
"become_king",
"new_king",
"founded",
"war_ended",
"plot_ended",
"disaster",
"earthquake",
"meteor",
"tornado",
"volcano");
}
private static bool TouchesExactEpisodeParticipant(InterestCandidate c, EpisodePlan episode)
{
if (c == null || episode == null)
{
return false;
}
long[] ids =
{
c.SubjectId,
c.RelatedId,
c.PairOwnerId,
c.PairPartnerId,
c.TheaterLeadId,
EventFeedUtil.SafeId(c.FollowUnit)
};
for (int i = 0; i < ids.Length; i++)
{
long id = ids[i];
if (id == 0) continue;
if (id == episode.ProtagonistId) return true;
for (int j = 0; j < episode.EligibleCastIds.Count; j++)
{
if (episode.EligibleCastIds[j] == id) return true;
}
}
return false;
}
private static bool ContainsAny(string value, params string[] needles)
{
for (int i = 0; i < needles.Length; i++)
{
if (value.IndexOf(needles[i], StringComparison.OrdinalIgnoreCase) >= 0) return true;
}
return false;
}
private static long FirstTouchedMc(InterestCandidate c)
{
long[] direct =
{
c.RelatedId,
c.PairOwnerId,
c.PairPartnerId,
c.TheaterLeadId,
EventFeedUtil.SafeId(c.RelatedUnit)
};
for (int i = 0; i < direct.Length; i++)
{
if (LifeSagaRoster.IsMc(direct[i])) return direct[i];
}
// ParticipantIds are accepted only when the MC is exact and the event is a semantic
// state change; broad live clusters do not manufacture connections.
if (NarrativeFunctionPolicy.ChangesStoryState(NarrativeFunctionPolicy.Classify(c)))
{
for (int i = 0; i < c.ParticipantIds.Count; i++)
{
if (LifeSagaRoster.IsMc(c.ParticipantIds[i])) return c.ParticipantIds[i];
}
}
return 0;
}
private static void StampAnchor(
CameraEligibilityModel model,
long anchorId,
string connectionKind,
string evidence)
{
model.AnchorMcId = anchorId;
model.ConnectionKind = connectionKind ?? "";
model.Evidence = evidence ?? "";
LifeSagaSlot slot = LifeSagaRoster.Get(anchorId);
model.AnchorMcName = slot?.DisplayName ?? "";
if (string.IsNullOrEmpty(model.AnchorMcName))
{
Actor actor = EventFeedUtil.FindAliveById(anchorId);
model.AnchorMcName = EventFeedUtil.SafeName(actor);
}
if (string.IsNullOrEmpty(model.AnchorMcName)) model.AnchorMcName = "the main character";
}
private static string EventConnection(InterestCandidate c, string mcName)
{
if (c.Completion == InterestCompletionKind.WarFront)
return "Fights in " + mcName + "'s war";
if (c.Completion == InterestCompletionKind.PlotActive)
return "Caught in " + mcName + "'s plot";
if (!string.IsNullOrEmpty(c.CityKey))
return "Caught in " + mcName + "'s city";
return "In " + mcName + "'s story";
}
private static bool TryResolveCastConnection(
long subjectId,
out long anchorId,
out string kind,
out string line)
{
anchorId = 0;
kind = "";
line = "";
Actor subject = EventFeedUtil.FindAliveById(subjectId);
if (subject == null) return false;
Slots.Clear();
LifeSagaRoster.CopySlots(Slots);
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
Actor mc = slot != null ? EventFeedUtil.FindAliveById(slot.UnitId) : null;
if (mc == null) continue;
string name = string.IsNullOrEmpty(slot.DisplayName)
? EventFeedUtil.SafeName(mc)
: slot.DisplayName;
if (Same(ActorRelation.GetLover(subject), mc)
|| Same(ActorRelation.GetLover(mc), subject))
return Set(slot.UnitId, "partner", name + "'s partner", out anchorId, out kind, out line);
if (Contains(ActorRelation.EnumerateChildren(mc, 12), subject))
return Set(slot.UnitId, "child", name + "'s child", out anchorId, out kind, out line);
if (Contains(ActorRelation.EnumerateParents(mc, 2), subject))
return Set(slot.UnitId, "parent", name + "'s parent", out anchorId, out kind, out line);
if (Same(ActorRelation.GetBestFriend(subject), mc)
|| Same(ActorRelation.GetBestFriend(mc), subject))
return Set(slot.UnitId, "friend", name + "'s best friend", out anchorId, out kind, out line);
if (Contains(ActorRelation.EnumerateFamilyMembers(mc, 12), subject))
return Set(slot.UnitId, "kin", name + "'s kin", out anchorId, out kind, out line);
if (LifeSagaRoster.TryGetRecurringAntagonist(
subject,
out long rivalAnchor,
out LifeSagaFact rivalEvidence)
&& rivalAnchor == slot.UnitId)
{
string evidence = rivalEvidence?.Note ?? "";
string label = evidence.IndexOf("killed_kin", StringComparison.OrdinalIgnoreCase) >= 0
|| evidence.IndexOf("kin_slain", StringComparison.OrdinalIgnoreCase) >= 0
? "Killed one of " + name + "'s kin"
: "Recurring antagonist of " + name;
return Set(slot.UnitId, "antagonist", label, out anchorId, out kind, out line);
}
if (Same(ActorRelation.GetCombatOpponent(subject), mc)
|| Same(ActorRelation.GetCombatOpponent(mc), subject))
return Set(slot.UnitId, "foe", "Fighting " + name, out anchorId, out kind, out line);
}
return false;
}
private static bool Same(Actor a, Actor b) =>
a != null && b != null && EventFeedUtil.SafeId(a) == EventFeedUtil.SafeId(b);
private static bool Contains(IEnumerable<Actor> actors, Actor target)
{
long id = EventFeedUtil.SafeId(target);
foreach (Actor actor in actors)
{
if (EventFeedUtil.SafeId(actor) == id) return true;
}
return false;
}
private static bool Set(
long id,
string relation,
string text,
out long anchorId,
out string kind,
out string line)
{
anchorId = id;
kind = relation;
line = text;
return true;
}
public static bool HarnessProbeExceptionalPolicy(out string detail)
{
var hatch = new InterestCandidate
{
Key = "harness:hatch",
AssetId = "just_got_out_of_egg",
Verb = "hatches from an egg",
SubjectId = 991001,
EventStrength = 90f,
Novelty = 1f,
HasNarrativeFunction = true,
NarrativeFunction = NarrativeFunction.Consequence,
NarrativeDevelopmentId = "harness_hatch"
};
var death = new InterestCandidate
{
Key = "harness:death",
AssetId = "death",
Verb = "died in battle",
SubjectId = 991002,
EventStrength = 90f,
Novelty = 1f,
HasNarrativeFunction = true,
NarrativeFunction = NarrativeFunction.Consequence,
NarrativeDevelopmentId = "harness_death"
};
CameraEligibilityModel hatchModel = Classify(hatch);
CameraEligibilityModel deathModel = Classify(death);
bool pass = hatchModel.Kind == CameraEligibilityKind.DiscoveryOnly
&& deathModel.Kind == CameraEligibilityKind.ExceptionalBackground;
detail = "hatch=" + hatchModel.Kind + ":" + hatchModel.Evidence
+ " death=" + deathModel.Kind + ":" + deathModel.Evidence;
return pass;
}
}