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 } /// /// 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. /// 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 Slots = new List(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 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; } }