From 12e2ba0d01a57c2d3e75e6567c844399f2bb4379 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Fri, 24 Jul 2026 14:06:23 -0500 Subject: [PATCH] feat(narrative): refine camera and narrative handling for civilized terminology - 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 --- IdleSpectator/AgentHarness.cs | 20 + IdleSpectator/CameraDirector.cs | 57 ++- IdleSpectator/Events/EventReason.cs | 23 +- IdleSpectator/HarnessScenarios.cs | 9 + .../InterestDirector.StickyMaintain.cs | 6 + IdleSpectator/InterestDirector.cs | 51 ++- IdleSpectator/Narrative/BeatSignificance.cs | 393 ++++++++++++++++++ IdleSpectator/Narrative/CameraEligibility.cs | 79 +++- IdleSpectator/Narrative/InterruptPolicy.cs | 11 +- .../Narrative/NarrativePersistence.cs | 14 +- .../Narrative/NarrativePresentation.cs | 27 +- .../Narrative/NarrativeStoryStore.cs | 17 +- .../Narrative/WorldCriticalPolicy.cs | 68 +++ IdleSpectator/Story/CaptionComposer.cs | 6 +- IdleSpectator/Story/LifeSagaPresentation.cs | 49 ++- IdleSpectator/Story/LifeSagaRoster.cs | 20 +- IdleSpectator/Story/LifeSagaViewController.cs | 1 + IdleSpectator/Story/SagaProse.cs | 11 +- IdleSpectator/UnitDossier.cs | 6 +- IdleSpectator/WatchCaption.cs | 144 +++++-- README.md | 8 +- docs/five-mc-ensemble-plan.md | 28 +- docs/life-saga.md | 17 +- 23 files changed, 956 insertions(+), 109 deletions(-) create mode 100644 IdleSpectator/Narrative/BeatSignificance.cs create mode 100644 IdleSpectator/Narrative/WorldCriticalPolicy.cs diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index c66222e..b453ec8 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -10420,6 +10420,12 @@ public static class AgentHarness pass = CameraEligibility.HarnessProbeExceptionalPolicy(out detail); break; } + case "saga_civilized_no_pack_language": + { + Actor actor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned; + pass = LifeSagaPresentation.HarnessProbeCivilizedTerminology(actor, out detail); + break; + } case "caption_context_omits": { // Soft tip hitch: Context must not show banned biography fragments. @@ -11007,6 +11013,20 @@ public static class AgentHarness pass = InterestVariety.HarnessProbeStabilityBudgets(out detail); break; } + case "beat_significance_policy": + { + pass = BeatSignificance.HarnessProbePolicy(out detail); + break; + } + case "beat_significance_live_partner": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + pass = BeatSignificance.HarnessProbeLiveRelation( + focus, + _happinessPartner, + out detail); + break; + } case "saga_replacement_evidence": { pass = LifeSagaRoster.HarnessProbeReplacementEvidence(out detail); diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index 9f53592..0bc7236 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -44,7 +44,19 @@ public static class CameraDirector && WasPresentedRecently(incomingLabel, Time.unscaledTime)) { // Sticky maintain paths may reassert a prior label without going through - // candidate selection. Do not turn that refresh into a new camera/caption cut. + // candidate selection. Do not turn that refresh into a new camera cut or log, + // but do commit the label when it belongs to the already-focused active scene. + long incomingId = EventFeedUtil.SafeId(interest.FollowUnit); + long focusedId = EventFeedUtil.SafeId( + MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); + InterestCandidate active = InterestDirector.CurrentCandidate; + if (incomingId != 0 + && incomingId == focusedId + && active != null + && string.Equals(active.Label ?? "", incomingLabel, StringComparison.Ordinal)) + { + RefreshWatchPresentation(interest, suppressReplayLog: true); + } return; } @@ -62,6 +74,10 @@ public static class CameraDirector { WatchCaption.SetFromActor(follow); } + else + { + WatchCaption.RefreshOwnedReasonNow(); + } CommitWatchTelemetry(interest); return; @@ -69,15 +85,21 @@ public static class CameraDirector // No accepted living follow: do not bind a rejected FollowUnit // (dead / species mismatch) as the dossier subject. - if (!interest.HasFollowUnit) + bool unmatchedSpeciesTip = (interest.Label ?? "").StartsWith( + "New species:", + StringComparison.Ordinal); + if (unmatchedSpeciesTip) { + // Discovery may know the species before a matching live unit can be resolved. + // Keep the current living focus; never clear it in favor of a ghost asset id. WatchCaption.SetFromInterest(interest); + CommitWatchTelemetry(interest); + return; } - if (!MoveCamera.hasFocusUnit()) - { - PanCamera(interest.Position); - } + WatchCaption.SetFromLocationInterest(interest); + ClearFollow(); + PanCamera(interest.Position); CommitWatchTelemetry(interest); } @@ -86,7 +108,9 @@ public static class CameraDirector /// Publish reason telemetry only after camera focus and caption ownership have committed. /// This makes Watching/focus/caption one observable transition rather than three frames. /// - private static void CommitWatchTelemetry(InterestEvent interest) + private static void CommitWatchTelemetry( + InterestEvent interest, + bool suppressReplayLog = false) { string tip = FormatWatchTip(interest); string label = interest?.Label ?? ""; @@ -102,13 +126,30 @@ public static class CameraDirector LastWatchLabel = label; LastWatchAssetId = assetId; // Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only. - if (tipChanged && !framingOnly) + if (tipChanged && !framingOnly && !suppressReplayLog) { NotePresentedLabel(label, Time.unscaledTime); LogService.LogInfo($"[IdleSpectator] {tip}"); } } + /// + /// Commit a same-focus sticky reframe without retargeting the camera or rebuilding the + /// dossier. Used for Duel/Battle/Mass and headcount changes that are presentation-only. + /// + public static void RefreshWatchPresentation( + InterestEvent interest, + bool suppressReplayLog = false) + { + if (interest == null) + { + return; + } + + WatchCaption.RefreshOwnedReasonNow(); + CommitWatchTelemetry(interest, suppressReplayLog); + } + private static bool WasPresentedRecently(string label, float now) { string key = EventReason.ReplayStructureKey(label); diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs index a7ce476..617768d 100644 --- a/IdleSpectator/Events/EventReason.cs +++ b/IdleSpectator/Events/EventReason.cs @@ -555,7 +555,9 @@ public static class EventReason public static string BecomeAlpha(Actor a) { - return NameOrSomeone(a) + " becomes the family alpha"; + return LifeSagaRoster.UsesPackLanguage(a) + ? NameOrSomeone(a) + " becomes the pack alpha" + : NameOrSomeone(a) + " becomes head of the family"; } public static string Hatch(Actor a) @@ -585,24 +587,26 @@ public static class EventReason string an = NameOrSomeone(a); string bn = Name(peer); string p = (phase ?? "").Trim().ToLowerInvariant(); + bool pack = LifeSagaRoster.UsesPackLanguage(a); + string group = pack ? "a family pack" : "a family"; switch (p) { case "join": case "family_group_join": return string.IsNullOrEmpty(bn) - ? an + " joins a family pack" - : an + " joins a family pack with " + bn; + ? an + " joins " + group + : an + " joins " + group + " with " + bn; case "leave": case "family_group_leave": return string.IsNullOrEmpty(bn) - ? an + " leaves a family pack" - : an + " leaves a family pack with " + bn; + ? an + " leaves " + group + : an + " leaves " + group + " with " + bn; case "form": case "new": case "family_group_new": return string.IsNullOrEmpty(bn) - ? an + " forms a family pack" - : an + " forms a family pack with " + bn; + ? an + " forms " + group + : an + " forms " + group + " with " + bn; default: return an + " gathers with kin"; } @@ -645,7 +649,10 @@ public static class EventReason label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); int n = Math.Max(0, ensemble.SideA.Count); - return "Pack - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ") · gathering"; + string collective = LifeSagaRoster.UsesPackLanguage(ensemble.Focus) + ? "Pack" + : "Family"; + return collective + " - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ") · gathering"; } public static string NewChild(Actor a, Actor b) diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index aab2c1d..499f741 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -3319,6 +3319,15 @@ internal static class HarnessScenarios Step("ncc6", "assert", expect: "camera_exceptional_policy"), Step("ncc7", "assert", expect: "stability_budgets"), Step("ncc8", "assert", expect: "saga_replacement_evidence"), + Step("ncc9", "assert", expect: "beat_significance_policy"), + Step("ncc10", "happiness_remember_partner"), + Step("ncc11", "pick_unit", asset: "human", value: "other"), + Step("ncc12", "focus", asset: "human"), + Step("ncc13", "interest_saga_clear"), + Step("ncc14", "saga_force_admit_focus"), + Step("ncc15", "saga_memory_record_focus", asset: "child"), + Step("ncc16", "assert", expect: "beat_significance_live_partner"), + Step("ncc17", "assert", expect: "saga_civilized_no_pack_language"), Step("ncc99", "snapshot") }; } diff --git a/IdleSpectator/InterestDirector.StickyMaintain.cs b/IdleSpectator/InterestDirector.StickyMaintain.cs index d98a071..5ec33f4 100644 --- a/IdleSpectator/InterestDirector.StickyMaintain.cs +++ b/IdleSpectator/InterestDirector.StickyMaintain.cs @@ -634,6 +634,12 @@ public static partial class InterestDirector { CameraDirector.Watch(_current.ToInterestEvent()); } + else if (labelChanged) + { + // Keep the visible reason and Watching telemetry coherent without a camera cut + // or full dossier rebuild for same-focus Duel/Battle/Mass reframes. + CameraDirector.RefreshWatchPresentation(_current.ToInterestEvent()); + } // Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo). StoryPlanner.SyncActiveClimax(_current); diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 20794fb..790d5be 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -153,6 +153,14 @@ public static partial class InterestDirector return false; } + // Quiet character grounding never owns an orange Beat. A transient live attack target + // must not promote its generated species presentation into an event; the combat feed + // will publish a separate EventLed candidate when there is an actual fight to show. + if (BeatSignificance.SuppressBeat(scene)) + { + return false; + } + long unitId = EventFeedUtil.SafeId(unit); if (unitId == 0) { @@ -1989,13 +1997,27 @@ public static partial class InterestDirector return; } - if (HasLivingCameraFocus()) + if (HasLivingMcCameraFocus()) { return; } TryCharacterFill(force: true); - if (HasLivingCameraFocus()) + if (HasLivingMcCameraFocus()) + { + return; + } + + Actor mc = LifeSagaRoster.BestLivingMc(); + if (mc != null) + { + CameraDirector.FocusUnit(mc); + return; + } + + // Before the first roster has formed, preserve the old discovery fallback. + // Once MCs exist, never make an arbitrary background unit the quiet landing shot. + if (LifeSagaRoster.Count > 0) { return; } @@ -2009,6 +2031,12 @@ public static partial class InterestDirector _ = now; } + private static bool HasLivingMcCameraFocus() + { + return HasLivingCameraFocus() + && LifeSagaRoster.IsMc(EventFeedUtil.SafeId(MoveCamera._focus_unit)); + } + private static InterestCandidate SelectNext(float now) { bool profile = IdleHitchProbe.Enabled; @@ -2889,7 +2917,8 @@ public static partial class InterestDirector } Actor grounding = LifeSagaRoster.BestLivingMc(); - if (grounding == null) + long groundingId = EventFeedUtil.SafeId(grounding); + if (grounding == null || groundingId == 0 || !LifeSagaRoster.IsMc(groundingId)) { return; } @@ -2931,6 +2960,22 @@ public static partial class InterestDirector candidate.Completion = InterestCompletionKind.CharacterVignette; // Fill is not an event - empty orange reason; task chip shows live job. candidate.Label = ""; + candidate.NarrativePresentation = null; + candidate.NarrativeDevelopmentId = ""; + candidate.HasNarrativeFunction = true; + candidate.NarrativeFunction = NarrativeFunction.CharacterTexture; + candidate.NarrativeFunctionSource = "character_grounding"; + + // Roster refresh can replace an MC between BestLivingMc and registration. + // Revalidate the actual candidate immediately before it is allowed to switch. + if (!grounding.isAlive() + || !LifeSagaRoster.IsMc(groundingId) + || candidate.SubjectId != groundingId + || !CameraEligibility.MayUseCamera(candidate)) + { + InterestRegistry.Remove(candidate.Key); + return; + } if (AgentHarness.Busy) { candidate.ForceActive = false; diff --git a/IdleSpectator/Narrative/BeatSignificance.cs b/IdleSpectator/Narrative/BeatSignificance.cs new file mode 100644 index 0000000..3e3ad39 --- /dev/null +++ b/IdleSpectator/Narrative/BeatSignificance.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// +/// Adds a concise, evidence-backed explanation when an exact event participant matters +/// because of a current MC. Generic identity (species/job/traits) never belongs in the Beat. +/// +public static class BeatSignificance +{ + private static readonly List Slots = + new List(LifeSagaRoster.Cap); + + /// + /// Character grounding is footage, not an event. Its live task already appears in the + /// green task chip, so it must never manufacture an orange Beat. + /// + public static bool SuppressBeat(InterestCandidate candidate) + { + return candidate != null + && (candidate.LeadKind == InterestLeadKind.CharacterLed + || candidate.Completion == InterestCompletionKind.CharacterVignette); + } + + public static string Enrich( + Actor focus, + InterestCandidate candidate, + string beat) + { + if (string.IsNullOrEmpty(beat) || candidate == null || SuppressBeat(candidate)) + { + return SuppressBeat(candidate) ? "" : beat ?? ""; + } + + long focusId = EventFeedUtil.SafeId(focus); + long participantId = ExactOtherParticipant(candidate, focusId); + if (participantId == 0 + || participantId == focusId + || LifeSagaRoster.IsMc(participantId) + || SemanticsAlreadyExplainRelation(candidate, beat)) + { + return beat; + } + + string participantName = ExactOtherName(candidate, participantId); + if (string.IsNullOrEmpty(participantName) + || !ContainsWholeName(StripRichText(beat), participantName)) + { + return beat; + } + + if (!TryDescribeMcRelation( + participantId, + out long anchorMcId, + out string anchorName, + out RelationEvidence relation)) + { + return beat; + } + + string qualifier = FormatQualifier(relation, anchorName, colorName: true); + if (string.IsNullOrEmpty(qualifier) + || StripRichText(beat).IndexOf( + StripRichText(qualifier), + StringComparison.OrdinalIgnoreCase) >= 0) + { + return beat; + } + + // Preserve click ownership on the event's named participant. The qualifier explains + // significance but does not replace the event's subject/other ids. + return beat.TrimEnd() + " (" + qualifier + ")"; + } + + private static long ExactOtherParticipant(InterestCandidate candidate, long focusId) + { + if (candidate?.NarrativePresentation != null) + { + long semanticOther = candidate.NarrativePresentation.OtherId; + if (semanticOther != 0 && semanticOther != focusId) + { + return semanticOther; + } + } + + if (candidate == null) + { + return 0; + } + + long[] exact = + { + candidate.RelatedId, + EventFeedUtil.SafeId(candidate.RelatedUnit), + candidate.PairOwnerId == focusId ? candidate.PairPartnerId : 0, + candidate.PairPartnerId == focusId ? candidate.PairOwnerId : 0 + }; + for (int i = 0; i < exact.Length; i++) + { + if (exact[i] != 0 && exact[i] != focusId) + { + return exact[i]; + } + } + + return 0; + } + + private static string ExactOtherName(InterestCandidate candidate, long participantId) + { + NarrativePresentationModel presentation = candidate?.NarrativePresentation; + if (presentation != null + && presentation.OtherId == participantId + && !string.IsNullOrEmpty(presentation.Other.Name)) + { + return presentation.Other.Name.Trim(); + } + + if (candidate?.RelatedUnit != null + && EventFeedUtil.SafeId(candidate.RelatedUnit) == participantId) + { + string related = EventFeedUtil.SafeName(candidate.RelatedUnit); + if (!string.IsNullOrEmpty(related)) + { + return related; + } + } + + Actor live = EventFeedUtil.FindAliveById(participantId); + return EventFeedUtil.SafeName(live); + } + + private static bool TryDescribeMcRelation( + long participantId, + out long anchorMcId, + out string anchorName, + out RelationEvidence relation) + { + anchorMcId = 0; + anchorName = ""; + relation = RelationEvidence.None; + Actor participant = EventFeedUtil.FindAliveById(participantId); + + Slots.Clear(); + LifeSagaRoster.CopySlots(Slots); + int bestPriority = int.MaxValue; + for (int i = 0; i < Slots.Count; i++) + { + LifeSagaSlot slot = Slots[i]; + if (slot == null || slot.UnitId == 0 || slot.UnitId == participantId) + { + continue; + } + + Actor mc = EventFeedUtil.FindAliveById(slot.UnitId); + RelationEvidence evidence = + SagaProse.ResolveRelation(slot.UnitId, participantId, mc, participant); + string name = !string.IsNullOrEmpty(slot.DisplayName) + ? slot.DisplayName + : EventFeedUtil.SafeName(mc); + string candidateQualifier = FormatQualifier(evidence, name, colorName: false); + if (string.IsNullOrEmpty(candidateQualifier)) + { + continue; + } + + int priority = evidence.Priority > 0 ? evidence.Priority : 9; + if (anchorMcId != 0 && priority >= bestPriority) + { + continue; + } + + anchorMcId = slot.UnitId; + anchorName = name; + relation = evidence; + bestPriority = priority; + } + + return anchorMcId != 0 && !string.IsNullOrEmpty(anchorName); + } + + private static string FormatQualifier( + RelationEvidence relation, + string anchorName, + bool colorName) + { + if (relation == null + || relation.Class == RelationClass.None + || string.IsNullOrEmpty(anchorName)) + { + return ""; + } + + string who = colorName ? ActivityProse.ColorName(anchorName) : anchorName; + switch (relation.Class) + { + case RelationClass.Bond: + return (relation.BondBroken ? "former partner of " : "partner of ") + who; + case RelationClass.Friend: + return (relation.BestFriend ? "best friend of " : "friend of ") + who; + case RelationClass.Kin: + switch (relation.KinKind) + { + case KinKind.Parent: return "parent of " + who; + case KinKind.Child: return "child of " + who; + case KinKind.Heir: return "heir of " + who; + case KinKind.Packmate: return "packmate of " + who; + default: return "kin of " + who; + } + case RelationClass.Foe: + switch (relation.FoeKind) + { + case FoeKind.Blood: return "blood foe of " + who; + case FoeKind.War: return "war enemy of " + who; + case FoeKind.Plot: return "plot enemy of " + who; + default: return "rival of " + who; + } + case RelationClass.PlotPartner: + return "plot ally of " + who; + default: + return ""; + } + } + + private static bool SemanticsAlreadyExplainRelation( + InterestCandidate candidate, + string beat) + { + NarrativePresentationModel presentation = candidate?.NarrativePresentation; + if (presentation != null + && presentation.RelationRole != NarrativeRelationRole.None) + { + return true; + } + + string plain = StripRichText(beat).ToLowerInvariant(); + string[] relationWords = + { + "child", "parent", "lover", "partner", "friend", "kin", + "heir", "rival", "enemy", "foe", "ally" + }; + for (int i = 0; i < relationWords.Length; i++) + { + if (plain.IndexOf(relationWords[i], StringComparison.Ordinal) >= 0) + { + return true; + } + } + + return false; + } + + private static bool ContainsWholeName(string text, string name) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(name)) + { + return false; + } + + int start = 0; + while (start <= text.Length - name.Length) + { + int at = text.IndexOf(name, start, StringComparison.OrdinalIgnoreCase); + if (at < 0) + { + return false; + } + + bool left = at == 0 || !IsNameChar(text[at - 1]); + int end = at + name.Length; + bool right = end >= text.Length || !IsNameChar(text[end]); + if (left && right) + { + return true; + } + + start = at + 1; + } + + return false; + } + + private static bool IsNameChar(char value) + { + return char.IsLetterOrDigit(value) || value == '\'' || value == '-'; + } + + private static string StripRichText(string value) + { + if (string.IsNullOrEmpty(value) || value.IndexOf('<') < 0) + { + return value ?? ""; + } + + var chars = new System.Text.StringBuilder(value.Length); + bool tag = false; + for (int i = 0; i < value.Length; i++) + { + char ch = value[i]; + if (ch == '<') + { + tag = true; + } + else if (ch == '>') + { + tag = false; + } + else if (!tag) + { + chars.Append(ch); + } + } + + return chars.ToString(); + } + + public static bool HarnessProbePolicy(out string detail) + { + var fill = new InterestCandidate + { + LeadKind = InterestLeadKind.CharacterLed, + Completion = InterestCompletionKind.CharacterVignette, + Label = "Elf", + NarrativePresentation = new NarrativePresentationModel + { + Kind = NarrativePresentationKind.AuthoredObservation, + AuthoredClause = "Elf" + } + }; + var child = new RelationEvidence + { + Class = RelationClass.Kin, + KinKind = KinKind.Child, + Priority = 4 + }; + var rival = new RelationEvidence + { + Class = RelationClass.Foe, + FoeKind = FoeKind.Rematch, + Priority = 2 + }; + + bool fillSuppressed = SuppressBeat(fill); + string childQualifier = FormatQualifier(child, "Norron", colorName: false); + string rivalQualifier = FormatQualifier(rival, "Woof", colorName: false); + bool pass = fillSuppressed + && childQualifier == "child of Norron" + && rivalQualifier == "rival of Woof"; + detail = "fillSuppressed=" + fillSuppressed + + " child='" + childQualifier + + "' rival='" + rivalQualifier + + "' pass=" + pass; + return pass; + } + + public static bool HarnessProbeLiveRelation( + Actor focus, + Actor participant, + out string detail) + { + long participantId = EventFeedUtil.SafeId(participant); + string participantName = EventFeedUtil.SafeName(participant); + var candidate = new InterestCandidate + { + LeadKind = InterestLeadKind.EventLed, + Completion = InterestCompletionKind.FixedDwell, + SubjectId = EventFeedUtil.SafeId(focus), + RelatedId = participantId, + RelatedUnit = participant, + NarrativePresentation = new NarrativePresentationModel + { + Kind = NarrativePresentationKind.AuthoredObservation, + PerspectiveId = EventFeedUtil.SafeId(focus), + Perspective = LifeSagaMemory.Snapshot(focus), + OtherId = participantId, + Other = LifeSagaMemory.Snapshot(participant), + AuthoredClause = "Kills " + participantName + } + }; + + string raw = "Kills " + participantName; + string enriched = Enrich(focus, candidate, raw); + string focusName = EventFeedUtil.SafeName(focus); + bool pass = focus != null + && participant != null + && LifeSagaRoster.IsMc(EventFeedUtil.SafeId(focus)) + && enriched.IndexOf("child of", StringComparison.OrdinalIgnoreCase) >= 0 + && enriched.IndexOf(focusName, StringComparison.OrdinalIgnoreCase) >= 0; + detail = "raw='" + raw + "' enriched='" + enriched + "' pass=" + pass; + return pass; + } +} diff --git a/IdleSpectator/Narrative/CameraEligibility.cs b/IdleSpectator/Narrative/CameraEligibility.cs index 90fbd12..775c1b4 100644 --- a/IdleSpectator/Narrative/CameraEligibility.cs +++ b/IdleSpectator/Narrative/CameraEligibility.cs @@ -51,7 +51,7 @@ public static class CameraEligibility long subjectId = candidate.SubjectId != 0 ? candidate.SubjectId : EventFeedUtil.SafeId(candidate.FollowUnit); - if (IsWorldCritical(candidate)) + if (WorldCriticalPolicy.IsWorldCritical(candidate)) { model.Kind = CameraEligibilityKind.WorldCritical; model.Evidence = "world_critical"; @@ -170,22 +170,13 @@ public static class CameraEligibility 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; + || WorldCriticalPolicy.IsTypedDisaster(c); if (!disaster || c.SubjectId == 0) { return false; @@ -471,13 +462,77 @@ public static class CameraEligibility 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; + && 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; } } diff --git a/IdleSpectator/Narrative/InterruptPolicy.cs b/IdleSpectator/Narrative/InterruptPolicy.cs index e1c8b37..52ed547 100644 --- a/IdleSpectator/Narrative/InterruptPolicy.cs +++ b/IdleSpectator/Narrative/InterruptPolicy.cs @@ -31,7 +31,8 @@ public static class InterruptPolicy { return NarrativeInterruptClass.RelatedEscalation; } - if (IsWorldCritical(candidate)) return NarrativeInterruptClass.WorldCritical; + if (WorldCriticalPolicy.IsWorldCritical(candidate)) + return NarrativeInterruptClass.WorldCritical; if (IsStoryPayoff(candidate)) return NarrativeInterruptClass.StoryPayoff; return NarrativeInterruptClass.OrdinaryInteresting; @@ -74,14 +75,6 @@ public static class InterruptPolicy return false; } - 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 IsStoryPayoff(InterestCandidate c) { return StoryReason.IsStoryAsset(c.AssetId) diff --git a/IdleSpectator/Narrative/NarrativePersistence.cs b/IdleSpectator/Narrative/NarrativePersistence.cs index 52a2873..7a49134 100644 --- a/IdleSpectator/Narrative/NarrativePersistence.cs +++ b/IdleSpectator/Narrative/NarrativePersistence.cs @@ -482,7 +482,10 @@ public static class NarrativePersistence /// references must point at retained developments. Active casts and player-selected /// lives are protected from character compaction. /// - private static void CompactGraph(NarrativeSaveState state, int characterLimit) + private static void CompactGraph( + NarrativeSaveState state, + int characterLimit, + bool protectRuntimeRoster = true) { if (state == null) { @@ -611,8 +614,9 @@ public static class NarrativePersistence { CharacterStoryStateDto character = state.characters[i]; if (character == null) continue; - if (LifeSagaRoster.IsMc(character.characterId) - || LifeSagaRoster.IsPrefer(character.characterId)) + if (protectRuntimeRoster + && (LifeSagaRoster.IsMc(character.characterId) + || LifeSagaRoster.IsPrefer(character.characterId))) { protectedCharacters.Add(character.characterId); } @@ -745,7 +749,9 @@ public static class NarrativePersistence storyPotential = 200f }); - CompactGraph(state, 2); + // This probe owns a synthetic graph and must not inherit protection from + // whichever live-world actors happen to share its deliberately small IDs. + CompactGraph(state, 2, protectRuntimeRoster: false); CharacterStoryStateDto protagonist = state.characters.Find(item => item.characterId == 10); bool ok = state.developments.Count == 1 diff --git a/IdleSpectator/Narrative/NarrativePresentation.cs b/IdleSpectator/Narrative/NarrativePresentation.cs index 8cf5d2a..2ebbeb6 100644 --- a/IdleSpectator/Narrative/NarrativePresentation.cs +++ b/IdleSpectator/Narrative/NarrativePresentation.cs @@ -486,17 +486,32 @@ public static class NarrativeRenderer case NarrativePresentationKind.FamilyEnded: return "Their family line ends"; case NarrativePresentationKind.FamilyPackFormed: + { + bool pack = LifeSagaRoster.UsesPackLanguage( + EventFeedUtil.FindAliveById(model.PerspectiveId)); + string group = pack ? "a family pack" : "a family"; return string.IsNullOrEmpty(other) - ? "Forms a family pack" - : "Forms a family pack with " + other; + ? "Forms " + group + : "Forms " + group + " with " + other; + } case NarrativePresentationKind.FamilyPackJoined: + { + bool pack = LifeSagaRoster.UsesPackLanguage( + EventFeedUtil.FindAliveById(model.PerspectiveId)); + string group = pack ? "a family pack" : "a family"; return string.IsNullOrEmpty(other) - ? "Joins a family pack" - : "Joins a family pack with " + other; + ? "Joins " + group + : "Joins " + group + " with " + other; + } case NarrativePresentationKind.FamilyPackLeft: + { + bool pack = LifeSagaRoster.UsesPackLanguage( + EventFeedUtil.FindAliveById(model.PerspectiveId)); + string group = pack ? "a family pack" : "a family"; return string.IsNullOrEmpty(other) - ? "Leaves a family pack" - : "Leaves a family pack shared with " + other; + ? "Leaves " + group + : "Leaves " + group + " shared with " + other; + } case NarrativePresentationKind.GiftGiven: return string.IsNullOrEmpty(other) ? "Gives a gift" : "Gives " + other + " a gift"; case NarrativePresentationKind.GiftReceived: diff --git a/IdleSpectator/Narrative/NarrativeStoryStore.cs b/IdleSpectator/Narrative/NarrativeStoryStore.cs index db9eb48..b136d77 100644 --- a/IdleSpectator/Narrative/NarrativeStoryStore.cs +++ b/IdleSpectator/Narrative/NarrativeStoryStore.cs @@ -1275,8 +1275,13 @@ public static class NarrativeStoryStore const int importedDevelopments = 2300; const int importedThreads = 900; const int importedConsequences = 1300; + // Keep synthetic ids outside normal WorldBox actor-id ranges. Low ids could collide + // with the live five-MC roster, falsely protecting five ambient consequences and + // making this deterministic compaction probe depend on the currently loaded world. + const long importedCharacterBase = 9100000; for (int i = 0; i < importedDevelopments; i++) { + long characterId = importedCharacterBase + i; string developmentId = "probe:compact:development:" + i; ImportDevelopment(new NarrativeDevelopment { @@ -1285,7 +1290,7 @@ public static class NarrativeStoryStore Function = i == importedDevelopments - 1 ? NarrativeFunction.TurningPoint : NarrativeFunction.CharacterTexture, - SubjectId = i + 1, + SubjectId = characterId, OccurredAt = i + 1, Magnitude = i == importedDevelopments - 1 ? 100f : 5f }); @@ -1293,13 +1298,13 @@ public static class NarrativeStoryStore { Id = "event:" + developmentId, DevelopmentId = developmentId, - SubjectId = i + 1, + SubjectId = characterId, ObservedAt = i + 1, WorldTime = i + 1 }); ImportCharacter(new CharacterStoryState { - CharacterId = i + 1, + CharacterId = characterId, LastMeaningfulChangeAt = i + 1, StoryPotential = i % 20 }); @@ -1309,7 +1314,7 @@ public static class NarrativeStoryStore { Id = "probe:compact:thread:" + i, Kind = NarrativeThreadKind.Founding, - ProtagonistId = i + 1, + ProtagonistId = characterId, OpenedByDevelopmentId = developmentId, LatestMeaningfulChangeId = developmentId, Status = NarrativeThreadStatus.Resolved, @@ -1319,7 +1324,7 @@ public static class NarrativeStoryStore Momentum = i / 100f }; thread.DevelopmentIds.Add(developmentId); - thread.AddCast(i + 1, "protagonist"); + thread.AddCast(characterId, "protagonist"); ImportThread(thread); } if (i < importedConsequences) @@ -1327,7 +1332,7 @@ public static class NarrativeStoryStore ImportConsequence(new CharacterConsequence { Id = "probe:compact:consequence:" + i, - CharacterId = i + 1, + CharacterId = characterId, DevelopmentId = developmentId, Kind = CharacterConsequenceKind.FoundHome, OccurredAt = i + 1, diff --git a/IdleSpectator/Narrative/WorldCriticalPolicy.cs b/IdleSpectator/Narrative/WorldCriticalPolicy.cs new file mode 100644 index 0000000..d12d124 --- /dev/null +++ b/IdleSpectator/Narrative/WorldCriticalPolicy.cs @@ -0,0 +1,68 @@ +using System; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Typed camera/interrupt gate for events important enough to leave the MC orbit. +/// A large numeric score is deliberately insufficient: grief, duels, and other personal +/// outcomes can be intense without becoming a world-scale cutaway. +/// +public static class WorldCriticalPolicy +{ + public static bool IsWorldCritical(InterestCandidate candidate) + { + if (candidate == null) + { + return false; + } + + if (candidate.Completion == InterestCompletionKind.EarthquakeActive + || candidate.Completion == InterestCompletionKind.StatusOutbreak) + { + return true; + } + + if (candidate.Completion == InterestCompletionKind.WarFront) + { + int sideA = Mathf.Max(0, candidate.CombatSideACount); + int sideB = Mathf.Max(0, candidate.CombatSideBCount); + int participants = Mathf.Max( + candidate.ParticipantCount, + sideA + sideB); + int minimum = Mathf.Max(2, InterestScoringConfig.Story.crisisWarParticipantMin); + return sideA > 0 && sideB > 0 && participants >= minimum; + } + + if (!IsTypedDisaster(candidate)) + { + return false; + } + + float minimumStrength = Mathf.Max( + 1f, + InterestScoringConfig.Story.crisisDisasterStrengthMin); + return candidate.EventStrength >= minimumStrength; + } + + public static bool IsTypedDisaster(InterestCandidate candidate) + { + if (candidate == null) + { + return false; + } + + if (string.Equals( + candidate.Category, + "Disaster", + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string asset = (candidate.AssetId ?? "").Trim(); + return !string.IsNullOrEmpty(asset) + && (EventCatalog.Disaster.IsLiveOrAuthored(asset) + || EventCatalog.WorldLog.IsDisasterCategory(asset)); + } +} diff --git a/IdleSpectator/Story/CaptionComposer.cs b/IdleSpectator/Story/CaptionComposer.cs index d377faf..f0950b4 100644 --- a/IdleSpectator/Story/CaptionComposer.cs +++ b/IdleSpectator/Story/CaptionComposer.cs @@ -89,10 +89,12 @@ public static class CaptionComposer model.ConnectionLine = CameraEligibility.ConnectionLine(ownedTip, focusId); model.ReasonRelatedId = reasonRelatedId; - string beat = presentableBeat ?? ""; + bool suppressBeat = BeatSignificance.SuppressBeat(ownedTip); + string beat = suppressBeat ? "" : presentableBeat ?? ""; string narrativeContext = ""; bool structuredNarrativeBeat = false; if (!statusBannerOwnsBeat + && !suppressBeat && NarrativeProse.TryBuildBeat( focus, ownedTip, @@ -122,6 +124,8 @@ public static class CaptionComposer { model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine); } + + model.BeatLine = BeatSignificance.Enrich(focus, ownedTip, model.BeatLine); } model.ContextLine = BuildContext( diff --git a/IdleSpectator/Story/LifeSagaPresentation.cs b/IdleSpectator/Story/LifeSagaPresentation.cs index 6241e99..5c8aca4 100644 --- a/IdleSpectator/Story/LifeSagaPresentation.cs +++ b/IdleSpectator/Story/LifeSagaPresentation.cs @@ -61,6 +61,39 @@ public static class LifeSagaPresentation return model; } + public static bool HarnessProbeCivilizedTerminology(Actor actor, out string detail) + { + bool civilized = false; + try + { + civilized = actor != null && actor.asset != null && actor.asset.civ; + } + catch + { + civilized = false; + } + + if (!civilized) + { + detail = "civilized=false"; + return false; + } + + LifeSagaViewModel model = Build(EventFeedUtil.SafeId(actor)); + string panel = model?.ToPlainText() ?? ""; + string relation = EventReason.FamilyPack(actor, null, "join"); + bool packAlpha = LifeSagaRoster.IsPackAlpha(actor); + bool leaked = panel.IndexOf("Packmate", StringComparison.OrdinalIgnoreCase) >= 0 + || panel.IndexOf("Pack Alpha", StringComparison.OrdinalIgnoreCase) >= 0 + || relation.IndexOf("pack", StringComparison.OrdinalIgnoreCase) >= 0; + bool pass = !packAlpha && !leaked; + detail = "alpha=" + packAlpha + + " relation='" + relation + "'" + + " panelPack=" + (panel.IndexOf("pack", StringComparison.OrdinalIgnoreCase) >= 0) + + " pass=" + pass; + return pass; + } + /// /// Pick the strongest unresolved pressure that adds information beyond Identity. /// Current-role/founding facts belong in the title, not a duplicate tooltip stake. @@ -284,7 +317,10 @@ public static class LifeSagaPresentation Add(LifeSagaLens.CrownClan, 8f); break; case LifeSagaAdmissionReason.Alpha: - Add(LifeSagaLens.PackAlpha, 8f); + if (LifeSagaRoster.UsesPackLanguage(actor)) + { + Add(LifeSagaLens.PackAlpha, 8f); + } break; case LifeSagaAdmissionReason.PlotAuthor: Add(LifeSagaLens.Plotter, 8f); @@ -341,7 +377,10 @@ public static class LifeSagaPresentation case LifeSagaFactKind.RoleChange: if ((f.Note ?? "").IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0) { - Add(LifeSagaLens.PackAlpha, 14f); + if (LifeSagaRoster.UsesPackLanguage(actor)) + { + Add(LifeSagaLens.PackAlpha, 14f); + } } else { @@ -350,7 +389,10 @@ public static class LifeSagaPresentation break; case LifeSagaFactKind.ParentChild: - Add(LifeSagaLens.PackAlpha, 6f); + if (LifeSagaRoster.UsesPackLanguage(actor)) + { + Add(LifeSagaLens.PackAlpha, 6f); + } Add(LifeSagaLens.LoveGrief, 4f); break; } @@ -577,6 +619,7 @@ public static class LifeSagaPresentation || primary == LifeSagaLens.Plotter)) { string peerLabel = primary == LifeSagaLens.PackAlpha + && LifeSagaRoster.UsesPackLanguage(actor) ? SagaProse.CastLabelLive("packmate") : SagaProse.CastLabelLive("kin"); foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCastCandidates + 2)) diff --git a/IdleSpectator/Story/LifeSagaRoster.cs b/IdleSpectator/Story/LifeSagaRoster.cs index 535e53a..22529de 100644 --- a/IdleSpectator/Story/LifeSagaRoster.cs +++ b/IdleSpectator/Story/LifeSagaRoster.cs @@ -1944,7 +1944,7 @@ public static class LifeSagaRoster public static bool IsPackAlpha(Actor actor) { - if (actor == null) + if (!UsesPackLanguage(actor)) { return false; } @@ -1966,6 +1966,24 @@ public static class LifeSagaRoster } } + /// + /// WorldBox uses Family/alpha storage for civilized bloodlines as well as creature packs. + /// Only non-civilized species should surface that implementation detail as pack language. + /// + public static bool UsesPackLanguage(Actor actor) + { + try + { + return actor != null + && actor.asset != null + && !actor.asset.civ; + } + catch + { + return false; + } + } + public static bool IsClanChief(Actor actor) { if (actor == null) diff --git a/IdleSpectator/Story/LifeSagaViewController.cs b/IdleSpectator/Story/LifeSagaViewController.cs index d3d6481..2fd91e4 100644 --- a/IdleSpectator/Story/LifeSagaViewController.cs +++ b/IdleSpectator/Story/LifeSagaViewController.cs @@ -90,6 +90,7 @@ public static class LifeSagaViewController _hoverExitAt = -1f; _hoverPreviewId = unitId; + WatchCaption.PrimeSagaHoverHeader(unitId); if (!_pauseHeld) { InterestDirector.AcquireReadPause(ReadPauseOwner); diff --git a/IdleSpectator/Story/SagaProse.cs b/IdleSpectator/Story/SagaProse.cs index 3d6f4f9..057147d 100644 --- a/IdleSpectator/Story/SagaProse.cs +++ b/IdleSpectator/Story/SagaProse.cs @@ -214,7 +214,12 @@ public static class SagaProse if (note.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0) { - return TitleLabel("Pack Alpha"); + if (LifeSagaRoster.UsesPackLanguage(actor)) + { + return TitleLabel("Pack Alpha"); + } + + continue; } if (note.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0 @@ -560,7 +565,9 @@ public static class SagaProse return string.IsNullOrEmpty(k) ? Sentence("Rules a kingdom") : Sentence("Rules " + k); } case LifeSagaAdmissionReason.Alpha: - return Sentence("Leads the pack"); + return LifeSagaRoster.UsesPackLanguage(actor) + ? Sentence("Leads the pack") + : Sentence("Leads a family"); case LifeSagaAdmissionReason.PlotAuthor: return Sentence("Leads a plot"); case LifeSagaAdmissionReason.Singleton: diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index ef14aad..004c0a4 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -448,9 +448,9 @@ public sealed class UnitDossier return ""; } - // Character-fill vignettes: hide reason (task chip shows live activity). - if (scene.LeadKind == InterestLeadKind.CharacterLed - && !InterestScoring.IsCombatAction(scene)) + // Character-fill vignettes: always hide the Beat. Live combat state cannot turn an + // ambient candidate's generated species presentation into an event reason. + if (BeatSignificance.SuppressBeat(scene)) { if (dossier != null) { diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 000a34c..5088ba2 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -617,24 +617,7 @@ public static class WatchCaption { if (interest != null) { - // Location-only / no unit: tip text for logs; dossier has no owned subject reason. - LastHeadline = CameraDirector.FormatWatchTip(interest); - LastDetail = ""; - LastCaptionText = LastHeadline; - LastHistoryPreview = ""; - LastHistoryJoined = ""; - InvalidateOwnedReasonCache(); - _current = null; - _boundActor = null; - EnsureBuilt(); - ApplyVisual(null, null); - if (_nameText != null) - { - _nameText.text = LastHeadline; - } - - Relayout(false, 0, 0, false, false, 0); - SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active); + SetFromLocationInterest(interest); } return; @@ -645,6 +628,36 @@ public static class WatchCaption SetFromActor(unit, label: null); } + /// + /// Present an event as a location shot even when the previous camera focus is still alive. + /// Used when a supplied follow is rejected or a world event has no character subject. + /// + public static void SetFromLocationInterest(InterestEvent interest) + { + if (interest == null) + { + return; + } + + LastHeadline = CameraDirector.FormatWatchTip(interest); + LastDetail = ""; + LastCaptionText = LastHeadline; + LastHistoryPreview = ""; + LastHistoryJoined = ""; + InvalidateOwnedReasonCache(); + _current = null; + _boundActor = null; + EnsureBuilt(); + ApplyVisual(null, null); + if (_nameText != null) + { + _nameText.text = LastHeadline; + } + + Relayout(false, 0, 0, false, false, 0); + SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active); + } + public static void SetFromActor(Actor actor, string label = null) { InvalidateOwnedReasonCache(); @@ -927,6 +940,52 @@ public static class WatchCaption RefreshStorySpine(force: true); } + /// + /// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback + /// immediate even when the deeper saga model has several relationships to resolve. + /// + public static void PrimeSagaHoverHeader(long unitId) + { + if (!_visible || unitId == 0) + { + return; + } + + LifeSagaSlot slot = LifeSagaRoster.Get(unitId); + Actor actor = EventFeedUtil.FindAliveById(unitId); + LifeSagaLifeMemory memory = LifeSagaMemory.Get(unitId); + string name = slot?.DisplayName ?? ""; + if (string.IsNullOrEmpty(name)) + { + name = EventFeedUtil.SafeName(actor); + } + + var model = new LifeSagaViewModel + { + UnitId = unitId, + Name = (slot != null && (slot.Prefer || slot.GameFavorite) ? "★ " : "") + name, + Title = SagaProse.Title(slot, actor, memory), + SpeciesId = slot?.SpeciesId + ?? (actor != null && actor.asset != null ? actor.asset.id : "") + }; + ApplySagaHoverHeader(model); + } + + /// + /// A same-subject camera event can change its orange reason without rebuilding the + /// dossier. Refresh only the owned reason/spine so UI and camera telemetry commit together. + /// + public static void RefreshOwnedReasonNow() + { + if (!_visible || _current == null) + { + return; + } + + InvalidateOwnedReasonCache(); + RefreshOwnedReason(); + } + private static bool HasStatusBanner() { return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil; @@ -1615,12 +1674,15 @@ public static class WatchCaption Sprite sexSprite = null; if (actor != null && actor.isAlive()) { - UnitDossier hovered = UnitDossier.FromActor(actor); - if (hovered != null) + try { - sexSprite = hovered.IsMale + sexSprite = actor.isSexMale() ? HudIcons.SexMale() - : (hovered.IsFemale ? HudIcons.SexFemale() : null); + : (actor.isSexFemale() ? HudIcons.SexFemale() : null); + } + catch + { + sexSprite = null; } } @@ -3165,6 +3227,28 @@ public static class WatchCaption y += ConnectionH + Gap; } + // Narrative header order: Identity → Connection → Beat → Context. The event that + // explains the shot must be readable before portrait, history, status, and traits. + if (hasReason) + { + float reasonH = MeasureReasonHeight(); + PlaceLine( + _reasonText != null ? _reasonText.GetComponent() : null, + y, + reasonH, + PadX); + y += reasonH + Gap; + } + + bool hasStorySpine = _storySpineText != null + && _storySpineText.gameObject.activeSelf + && !string.IsNullOrEmpty(_storySpineText.text); + if (hasStorySpine) + { + PlaceLine(_storySpineText.GetComponent(), y, StorySpineH, PadX); + y += StorySpineH + Gap; + } + // Row visibility is owned by Place*/fill - never leave empty rows active at stale // positions (SetDossierBodyVisible only hides on hover; it must not resurrect them). if (historyCount <= 0 && _historyCol != null) @@ -3216,22 +3300,6 @@ public static class WatchCaption } } - if (hasReason) - { - float reasonH = MeasureReasonHeight(); - PlaceLine(_reasonText != null ? _reasonText.GetComponent() : null, y, reasonH, PadX); - y += reasonH + Gap; - } - - bool hasStorySpine = _storySpineText != null - && _storySpineText.gameObject.activeSelf - && !string.IsNullOrEmpty(_storySpineText.text); - if (hasStorySpine) - { - PlaceLine(_storySpineText.GetComponent(), y, StorySpineH, PadX); - y += StorySpineH + Gap; - } - if (panelMode) { LifeSagaPanel.EnsureBuilt(_root.transform); diff --git a/README.md b/README.md index 3163ab7..ea057df 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,13 @@ Routine camera shifts stay with MCs or their confirmed Cast/story context. When followed, a muted Connection row explains the MC link. Background lives still accumulate story potential off-camera and may earn a slot; only typed turning points, consequences, story payoffs, or critical world/disaster events can give an unrelated background character exceptional screen -time. +time. “World critical” is not inferred from score alone: it requires a typed active quake/outbreak, +a crisis-scale disaster, or a materially contested large war. + +The orange Beat answers “why this shot now?” and is event-only: ambient grounding never falls back +to species or job text. Exact named participants can carry a compact, typed MC-relative qualifier, +such as `Kills Omya (child of Waf)`, when that relationship explains the event's significance. +Civilized family Cast uses `Kin`; pack terminology is reserved for non-civilized creatures. Confirmed MC events, threads, consequences, Legacy, and Prefer choices are stored in a versioned per-world sidecar at the game save boundary. Runtime camera state and episode timing remain diff --git a/docs/five-mc-ensemble-plan.md b/docs/five-mc-ensemble-plan.md index 97828d4..446fa6f 100644 --- a/docs/five-mc-ensemble-plan.md +++ b/docs/five-mc-ensemble-plan.md @@ -2,7 +2,27 @@ ## Status -Core implementation complete; extended organic validation pending. +Core implementation complete; post-soak camera/terminology fixes implemented. Another organic +validation window is pending. + +### Post-soak follow-up (implemented 2026-07-24) + +- World-critical camera access is typed: active quake/outbreak, a disaster at the configured crisis + threshold, or a materially contested large war. Raw `EventStrength >= 95` no longer promotes + grief, ordinary duels, or other unrelated high-score events. +- A sub-threshold disaster spectacle such as Garden Surprise does not take an unrelated character + shot. Confirmed named disaster impact remains significant through the personal-impact lane. +- Scene cleanup and character fill revalidate MC membership immediately before switching. With a + living roster MC available, quiet fallback cannot linger on or select an arbitrary background + character. +- Location events with a rejected/dead follow clear the old dossier/focus and pan to the event, so + Meteorite and similar shots cannot retain a sleeping character's identity. +- Same-character sticky updates refresh the owned orange reason without rebuilding the dossier. +- Civilized families use `Family` / `Kin`; `Pack` / `Packmate` / `Pack Alpha` are reserved for + non-civilized creatures. WorldBox's shared Family/alpha storage is never exposed as human pack + terminology. +- Saga hover primes the new identity immediately and reads sex directly from the hovered actor. + It no longer invokes the full dossier/species-population scan before the visible name swap. ## Product outcome @@ -189,6 +209,12 @@ immediately. - Connection is presentation metadata. It does not become part of the event sentence or replace story Context. +- The orange Beat is strictly event-owned. Character grounding has no Beat; species, job, traits, + city, and other identity fallbacks never occupy this row. +- When an event's exact named participant has a typed relation to a current MC and that relation is + not already stated, Beat may add one compact qualifier: `Kills Omya (child of Waf)`. This explains + significance without turning the focused character's Connection into duplicate prose. +- Render Identity → Connection → Beat → Context before portrait/history and detail chips. - Prefer the clearest confirmed relation: `Waf's partner`, `Waf's daughter`, `Waf's father`, `Waf's best friend`, `Waf's old foe`, `Fights beside Waf`, `Opposes Waf in the war`, or `Caught in Waf's city`. diff --git a/docs/life-saga.md b/docs/life-saga.md index 3e1c44f..7de005a 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -79,10 +79,14 @@ Always-on AFK chrome (no Dossier/Saga tabs): |-----|--------|----|--------| | Identity | `CaptionComposer` | `Name · Title` (real roles only; else species/job) | `Name (Species/Job)` | | Connection | `CameraEligibility` | hidden | MC anchor/relation when this non-MC shot was admitted through MC Cast/thread context | -| Beat | candidate `NarrativePresentationModel` | one complete semantic sentence | one complete semantic sentence | +| Beat | candidate `NarrativePresentationModel` + `BeatSignificance` | one complete event sentence; an exact participant may carry an MC-relative qualifier | one complete semantic sentence | | Context | muted | spine if live short-arc; else unresolved stake hitch (omits kill/role/founding under love/rest tips; RoleChange that echoes Identity always omitted) | spine only | Hard rule: saga titles and stake slogans never enter `InterestCandidate.Label`. +Character-led grounding never owns Beat: species, job, traits, city, and ambient activity remain +Identity/task metadata. An actual event may explain the relevance of its exact named participant, +for example `Kills Omya (child of Waf)`, when the relation to a current MC is backed by live or +durable typed evidence. Broad participants, proximity, and name parsing never create this qualifier. The story scheduler owns selection; `identity_filter` remains a safety gate against presenting identity text as a development. Beat participants come from the exact presentation/event receipt (no latest-similar lookup and no @@ -92,6 +96,8 @@ Outside hover, caption Identity/Beat/Context track camera focus. During Saga hov primary nametag temporarily binds to the hovered MC (species, name/title, sex, favorite), while camera Beat/Context are hidden to prevent false attribution. Camera composition keeps running underneath and restores immediately when hover ends. +Outside hover, the visible order is Identity, Connection, Beat, Context, then portrait/history and +status/trait details, so the reason for the current shot is never stranded below biography chrome. ## Rail UX @@ -122,7 +128,8 @@ running underneath and restores immediately when hover ends. `SagaProse` is the saga Identity/Cast/stake voice owner. `NarrativeRenderer` separately owns the complete orange Beat. Evidence drives both; enum names never appear in UI. -Relation classes: Bond, Friend, Kin (Parent/Child/Heir/Packmate/Kin), Foe (Old Foe/War Enemy/Plot Enemy/Blood Foe), Plot Partner, Grief. +Relation classes: Bond, Friend, Kin (Parent/Child/Heir/Packmate/Kin), Foe (Old Foe/War Enemy/Plot Enemy/Blood Foe), Plot Partner, Grief. `Packmate` and `Pack Alpha` are creature-only language; +civilized actors use `Kin` and family wording even though WorldBox stores both through `Family`. Ban list (player-facing): `earned rival`, `Earned a rival`, `Rivalry with`, `saga hero`, `fellow saga hero`, `War foe`, `Plot ally`, `Likely successor`, `A life in motion` on Identity, mood wallpaper stakes. @@ -156,7 +163,9 @@ Notable only: - Cast shows living credible foes only; weak kill-cycle `repeated_encounters` facts are pruned. - Heir cast label only when a game-authored heir API probe succeeds (`Heir`, not `Likely successor`). - Unresolved `WarJoin` / `PlotJoin` Others fill Cast as `War Enemy` / `Plot Partner` when still living. -- Crown / Combat / Intrigue / Pack / Bond lenses fill remaining Cast slots with live family peers (`Packmate` for Pack, `Kin` otherwise) after lover/friend/parents/children/heir/foe. +- Crown / Combat / Intrigue / Pack / Bond lenses fill remaining Cast slots with live family peers + after lover/friend/parents/children/heir/foe. Non-civilized Pack lenses use `Packmate`; + civilized family peers always use `Kin`. ## Stake @@ -187,6 +196,8 @@ Quiet can refresh briefly when a soft crumb ends (bounded). - World admission walks ~260 units/frame, then BuildSlots only the best ~2× Cap challengers (skips when pins fill Cap). - Dossier `Relayout` runs only when chrome geometry changes; leaving hover restores dossier rows once. - Live portrait tile refresh is ~5 Hz; rail live sprites are only for the watched glyph (others use species icons). +- Hover identity is primed before Cast/Legacy binding and uses direct actor sex metadata; it does + not call the population-counting dossier builder. - Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes. - Plot join memory records the joiner only; full member snapshots stay on create/finish/cancel. - RelationEvidence caches invalidate on memory revision + live lover/friend change.