- Implement checks for civilized terminology in camera focus events - Update event handling to suppress inappropriate pack language for civilized characters - Enhance narrative presentation to differentiate between family and pack language - Introduce new scenarios to validate significance of beats and relationships - Improve documentation to clarify narrative structure and event significance
538 lines
19 KiB
C#
538 lines
19 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 (WorldCriticalPolicy.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 IsConfirmedDisasterImpact(
|
|
InterestCandidate c,
|
|
NarrativeFunction function)
|
|
{
|
|
bool disaster = c.Completion == InterestCompletionKind.EarthquakeActive
|
|
|| c.Completion == InterestCompletionKind.StatusOutbreak
|
|
|| WorldCriticalPolicy.IsTypedDisaster(c);
|
|
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"
|
|
};
|
|
var loudGrief = new InterestCandidate
|
|
{
|
|
Key = "harness:loud_grief",
|
|
AssetId = "death_friend",
|
|
Category = "FamilyEmotion",
|
|
Completion = InterestCompletionKind.HappinessGrief,
|
|
SubjectId = 991003,
|
|
EventStrength = 99f,
|
|
Novelty = 1f,
|
|
HasNarrativeFunction = true,
|
|
NarrativeFunction = NarrativeFunction.TurningPoint
|
|
};
|
|
var loudDuel = new InterestCandidate
|
|
{
|
|
Key = "harness:loud_duel",
|
|
AssetId = "live_combat",
|
|
Category = "Combat",
|
|
Completion = InterestCompletionKind.CombatActive,
|
|
SubjectId = 991004,
|
|
EventStrength = 99f,
|
|
Novelty = 1f,
|
|
HasNarrativeFunction = true,
|
|
NarrativeFunction = NarrativeFunction.Escalation
|
|
};
|
|
var garden = new InterestCandidate
|
|
{
|
|
Key = "harness:garden",
|
|
AssetId = "disaster_garden_surprise",
|
|
Category = "Disaster",
|
|
EventStrength = 90f
|
|
};
|
|
var meteor = new InterestCandidate
|
|
{
|
|
Key = "harness:meteor",
|
|
AssetId = "disaster_meteorite",
|
|
Category = "Disaster",
|
|
EventStrength = 95f
|
|
};
|
|
var war = new InterestCandidate
|
|
{
|
|
Key = "harness:war",
|
|
AssetId = "live_war",
|
|
Category = "War",
|
|
Completion = InterestCompletionKind.WarFront,
|
|
ParticipantCount = 10,
|
|
CombatSideACount = 5,
|
|
CombatSideBCount = 5,
|
|
EventStrength = 65f
|
|
};
|
|
|
|
CameraEligibilityModel hatchModel = Classify(hatch);
|
|
CameraEligibilityModel deathModel = Classify(death);
|
|
CameraEligibilityModel griefModel = Classify(loudGrief);
|
|
CameraEligibilityModel duelModel = Classify(loudDuel);
|
|
CameraEligibilityModel gardenModel = Classify(garden);
|
|
CameraEligibilityModel meteorModel = Classify(meteor);
|
|
CameraEligibilityModel warModel = Classify(war);
|
|
bool pass = hatchModel.Kind == CameraEligibilityKind.DiscoveryOnly
|
|
&& deathModel.Kind == CameraEligibilityKind.ExceptionalBackground
|
|
&& griefModel.Kind != CameraEligibilityKind.WorldCritical
|
|
&& duelModel.Kind != CameraEligibilityKind.WorldCritical
|
|
&& gardenModel.Kind != CameraEligibilityKind.WorldCritical
|
|
&& meteorModel.Kind == CameraEligibilityKind.WorldCritical
|
|
&& warModel.Kind == CameraEligibilityKind.WorldCritical;
|
|
detail = "hatch=" + hatchModel.Kind + ":" + hatchModel.Evidence
|
|
+ " death=" + deathModel.Kind + ":" + deathModel.Evidence;
|
|
detail += " grief=" + griefModel.Kind
|
|
+ " duel=" + duelModel.Kind
|
|
+ " garden=" + gardenModel.Kind
|
|
+ " meteor=" + meteorModel.Kind
|
|
+ " war=" + warModel.Kind;
|
|
return pass;
|
|
}
|
|
}
|