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
This commit is contained in:
DazedAnon 2026-07-24 14:06:23 -05:00
parent 188d091166
commit 12e2ba0d01
23 changed files with 956 additions and 109 deletions

View file

@ -10420,6 +10420,12 @@ public static class AgentHarness
pass = CameraEligibility.HarnessProbeExceptionalPolicy(out detail); pass = CameraEligibility.HarnessProbeExceptionalPolicy(out detail);
break; 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": case "caption_context_omits":
{ {
// Soft tip hitch: Context must not show banned biography fragments. // Soft tip hitch: Context must not show banned biography fragments.
@ -11007,6 +11013,20 @@ public static class AgentHarness
pass = InterestVariety.HarnessProbeStabilityBudgets(out detail); pass = InterestVariety.HarnessProbeStabilityBudgets(out detail);
break; 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": case "saga_replacement_evidence":
{ {
pass = LifeSagaRoster.HarnessProbeReplacementEvidence(out detail); pass = LifeSagaRoster.HarnessProbeReplacementEvidence(out detail);

View file

@ -44,7 +44,19 @@ public static class CameraDirector
&& WasPresentedRecently(incomingLabel, Time.unscaledTime)) && WasPresentedRecently(incomingLabel, Time.unscaledTime))
{ {
// Sticky maintain paths may reassert a prior label without going through // 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; return;
} }
@ -62,6 +74,10 @@ public static class CameraDirector
{ {
WatchCaption.SetFromActor(follow); WatchCaption.SetFromActor(follow);
} }
else
{
WatchCaption.RefreshOwnedReasonNow();
}
CommitWatchTelemetry(interest); CommitWatchTelemetry(interest);
return; return;
@ -69,15 +85,21 @@ public static class CameraDirector
// No accepted living follow: do not bind a rejected FollowUnit // No accepted living follow: do not bind a rejected FollowUnit
// (dead / species mismatch) as the dossier subject. // (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); WatchCaption.SetFromInterest(interest);
CommitWatchTelemetry(interest);
return;
} }
if (!MoveCamera.hasFocusUnit()) WatchCaption.SetFromLocationInterest(interest);
{ ClearFollow();
PanCamera(interest.Position); PanCamera(interest.Position);
}
CommitWatchTelemetry(interest); CommitWatchTelemetry(interest);
} }
@ -86,7 +108,9 @@ public static class CameraDirector
/// Publish reason telemetry only after camera focus and caption ownership have committed. /// Publish reason telemetry only after camera focus and caption ownership have committed.
/// This makes Watching/focus/caption one observable transition rather than three frames. /// This makes Watching/focus/caption one observable transition rather than three frames.
/// </summary> /// </summary>
private static void CommitWatchTelemetry(InterestEvent interest) private static void CommitWatchTelemetry(
InterestEvent interest,
bool suppressReplayLog = false)
{ {
string tip = FormatWatchTip(interest); string tip = FormatWatchTip(interest);
string label = interest?.Label ?? ""; string label = interest?.Label ?? "";
@ -102,13 +126,30 @@ public static class CameraDirector
LastWatchLabel = label; LastWatchLabel = label;
LastWatchAssetId = assetId; LastWatchAssetId = assetId;
// Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only. // Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only.
if (tipChanged && !framingOnly) if (tipChanged && !framingOnly && !suppressReplayLog)
{ {
NotePresentedLabel(label, Time.unscaledTime); NotePresentedLabel(label, Time.unscaledTime);
LogService.LogInfo($"[IdleSpectator] {tip}"); LogService.LogInfo($"[IdleSpectator] {tip}");
} }
} }
/// <summary>
/// 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.
/// </summary>
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) private static bool WasPresentedRecently(string label, float now)
{ {
string key = EventReason.ReplayStructureKey(label); string key = EventReason.ReplayStructureKey(label);

View file

@ -555,7 +555,9 @@ public static class EventReason
public static string BecomeAlpha(Actor a) 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) public static string Hatch(Actor a)
@ -585,24 +587,26 @@ public static class EventReason
string an = NameOrSomeone(a); string an = NameOrSomeone(a);
string bn = Name(peer); string bn = Name(peer);
string p = (phase ?? "").Trim().ToLowerInvariant(); string p = (phase ?? "").Trim().ToLowerInvariant();
bool pack = LifeSagaRoster.UsesPackLanguage(a);
string group = pack ? "a family pack" : "a family";
switch (p) switch (p)
{ {
case "join": case "join":
case "family_group_join": case "family_group_join":
return string.IsNullOrEmpty(bn) return string.IsNullOrEmpty(bn)
? an + " joins a family pack" ? an + " joins " + group
: an + " joins a family pack with " + bn; : an + " joins " + group + " with " + bn;
case "leave": case "leave":
case "family_group_leave": case "family_group_leave":
return string.IsNullOrEmpty(bn) return string.IsNullOrEmpty(bn)
? an + " leaves a family pack" ? an + " leaves " + group
: an + " leaves a family pack with " + bn; : an + " leaves " + group + " with " + bn;
case "form": case "form":
case "new": case "new":
case "family_group_new": case "family_group_new":
return string.IsNullOrEmpty(bn) return string.IsNullOrEmpty(bn)
? an + " forms a family pack" ? an + " forms " + group
: an + " forms a family pack with " + bn; : an + " forms " + group + " with " + bn;
default: default:
return an + " gathers with kin"; return an + " gathers with kin";
} }
@ -645,7 +649,10 @@ public static class EventReason
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
int n = Math.Max(0, ensemble.SideA.Count); 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) public static string NewChild(Actor a, Actor b)

View file

@ -3319,6 +3319,15 @@ internal static class HarnessScenarios
Step("ncc6", "assert", expect: "camera_exceptional_policy"), Step("ncc6", "assert", expect: "camera_exceptional_policy"),
Step("ncc7", "assert", expect: "stability_budgets"), Step("ncc7", "assert", expect: "stability_budgets"),
Step("ncc8", "assert", expect: "saga_replacement_evidence"), 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") Step("ncc99", "snapshot")
}; };
} }

View file

@ -634,6 +634,12 @@ public static partial class InterestDirector
{ {
CameraDirector.Watch(_current.ToInterestEvent()); 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). // Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo).
StoryPlanner.SyncActiveClimax(_current); StoryPlanner.SyncActiveClimax(_current);

View file

@ -153,6 +153,14 @@ public static partial class InterestDirector
return false; 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); long unitId = EventFeedUtil.SafeId(unit);
if (unitId == 0) if (unitId == 0)
{ {
@ -1989,13 +1997,27 @@ public static partial class InterestDirector
return; return;
} }
if (HasLivingCameraFocus()) if (HasLivingMcCameraFocus())
{ {
return; return;
} }
TryCharacterFill(force: true); 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; return;
} }
@ -2009,6 +2031,12 @@ public static partial class InterestDirector
_ = now; _ = now;
} }
private static bool HasLivingMcCameraFocus()
{
return HasLivingCameraFocus()
&& LifeSagaRoster.IsMc(EventFeedUtil.SafeId(MoveCamera._focus_unit));
}
private static InterestCandidate SelectNext(float now) private static InterestCandidate SelectNext(float now)
{ {
bool profile = IdleHitchProbe.Enabled; bool profile = IdleHitchProbe.Enabled;
@ -2889,7 +2917,8 @@ public static partial class InterestDirector
} }
Actor grounding = LifeSagaRoster.BestLivingMc(); Actor grounding = LifeSagaRoster.BestLivingMc();
if (grounding == null) long groundingId = EventFeedUtil.SafeId(grounding);
if (grounding == null || groundingId == 0 || !LifeSagaRoster.IsMc(groundingId))
{ {
return; return;
} }
@ -2931,6 +2960,22 @@ public static partial class InterestDirector
candidate.Completion = InterestCompletionKind.CharacterVignette; candidate.Completion = InterestCompletionKind.CharacterVignette;
// Fill is not an event - empty orange reason; task chip shows live job. // Fill is not an event - empty orange reason; task chip shows live job.
candidate.Label = ""; 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) if (AgentHarness.Busy)
{ {
candidate.ForceActive = false; candidate.ForceActive = false;

View file

@ -0,0 +1,393 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// 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.
/// </summary>
public static class BeatSignificance
{
private static readonly List<LifeSagaSlot> Slots =
new List<LifeSagaSlot>(LifeSagaRoster.Cap);
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -51,7 +51,7 @@ public static class CameraEligibility
long subjectId = candidate.SubjectId != 0 long subjectId = candidate.SubjectId != 0
? candidate.SubjectId ? candidate.SubjectId
: EventFeedUtil.SafeId(candidate.FollowUnit); : EventFeedUtil.SafeId(candidate.FollowUnit);
if (IsWorldCritical(candidate)) if (WorldCriticalPolicy.IsWorldCritical(candidate))
{ {
model.Kind = CameraEligibilityKind.WorldCritical; model.Kind = CameraEligibilityKind.WorldCritical;
model.Evidence = "world_critical"; model.Evidence = "world_critical";
@ -170,22 +170,13 @@ public static class CameraEligibility
return model.AnchorMcId != 0 ? model.ConnectionLine ?? "" : ""; 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( private static bool IsConfirmedDisasterImpact(
InterestCandidate c, InterestCandidate c,
NarrativeFunction function) NarrativeFunction function)
{ {
bool disaster = c.Completion == InterestCompletionKind.EarthquakeActive bool disaster = c.Completion == InterestCompletionKind.EarthquakeActive
|| c.Completion == InterestCompletionKind.StatusOutbreak || c.Completion == InterestCompletionKind.StatusOutbreak
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0 || WorldCriticalPolicy.IsTypedDisaster(c);
|| (c.Source ?? "").IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0;
if (!disaster || c.SubjectId == 0) if (!disaster || c.SubjectId == 0)
{ {
return false; return false;
@ -471,13 +462,77 @@ public static class CameraEligibility
NarrativeFunction = NarrativeFunction.Consequence, NarrativeFunction = NarrativeFunction.Consequence,
NarrativeDevelopmentId = "harness_death" 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 hatchModel = Classify(hatch);
CameraEligibilityModel deathModel = Classify(death); 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 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 detail = "hatch=" + hatchModel.Kind + ":" + hatchModel.Evidence
+ " death=" + deathModel.Kind + ":" + deathModel.Evidence; + " death=" + deathModel.Kind + ":" + deathModel.Evidence;
detail += " grief=" + griefModel.Kind
+ " duel=" + duelModel.Kind
+ " garden=" + gardenModel.Kind
+ " meteor=" + meteorModel.Kind
+ " war=" + warModel.Kind;
return pass; return pass;
} }
} }

View file

@ -31,7 +31,8 @@ public static class InterruptPolicy
{ {
return NarrativeInterruptClass.RelatedEscalation; return NarrativeInterruptClass.RelatedEscalation;
} }
if (IsWorldCritical(candidate)) return NarrativeInterruptClass.WorldCritical; if (WorldCriticalPolicy.IsWorldCritical(candidate))
return NarrativeInterruptClass.WorldCritical;
if (IsStoryPayoff(candidate)) return NarrativeInterruptClass.StoryPayoff; if (IsStoryPayoff(candidate)) return NarrativeInterruptClass.StoryPayoff;
return NarrativeInterruptClass.OrdinaryInteresting; return NarrativeInterruptClass.OrdinaryInteresting;
@ -74,14 +75,6 @@ public static class InterruptPolicy
return false; 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) private static bool IsStoryPayoff(InterestCandidate c)
{ {
return StoryReason.IsStoryAsset(c.AssetId) return StoryReason.IsStoryAsset(c.AssetId)

View file

@ -482,7 +482,10 @@ public static class NarrativePersistence
/// references must point at retained developments. Active casts and player-selected /// references must point at retained developments. Active casts and player-selected
/// lives are protected from character compaction. /// lives are protected from character compaction.
/// </summary> /// </summary>
private static void CompactGraph(NarrativeSaveState state, int characterLimit) private static void CompactGraph(
NarrativeSaveState state,
int characterLimit,
bool protectRuntimeRoster = true)
{ {
if (state == null) if (state == null)
{ {
@ -611,8 +614,9 @@ public static class NarrativePersistence
{ {
CharacterStoryStateDto character = state.characters[i]; CharacterStoryStateDto character = state.characters[i];
if (character == null) continue; if (character == null) continue;
if (LifeSagaRoster.IsMc(character.characterId) if (protectRuntimeRoster
|| LifeSagaRoster.IsPrefer(character.characterId)) && (LifeSagaRoster.IsMc(character.characterId)
|| LifeSagaRoster.IsPrefer(character.characterId)))
{ {
protectedCharacters.Add(character.characterId); protectedCharacters.Add(character.characterId);
} }
@ -745,7 +749,9 @@ public static class NarrativePersistence
storyPotential = 200f 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 = CharacterStoryStateDto protagonist =
state.characters.Find(item => item.characterId == 10); state.characters.Find(item => item.characterId == 10);
bool ok = state.developments.Count == 1 bool ok = state.developments.Count == 1

View file

@ -486,17 +486,32 @@ public static class NarrativeRenderer
case NarrativePresentationKind.FamilyEnded: case NarrativePresentationKind.FamilyEnded:
return "Their family line ends"; return "Their family line ends";
case NarrativePresentationKind.FamilyPackFormed: case NarrativePresentationKind.FamilyPackFormed:
{
bool pack = LifeSagaRoster.UsesPackLanguage(
EventFeedUtil.FindAliveById(model.PerspectiveId));
string group = pack ? "a family pack" : "a family";
return string.IsNullOrEmpty(other) return string.IsNullOrEmpty(other)
? "Forms a family pack" ? "Forms " + group
: "Forms a family pack with " + other; : "Forms " + group + " with " + other;
}
case NarrativePresentationKind.FamilyPackJoined: case NarrativePresentationKind.FamilyPackJoined:
{
bool pack = LifeSagaRoster.UsesPackLanguage(
EventFeedUtil.FindAliveById(model.PerspectiveId));
string group = pack ? "a family pack" : "a family";
return string.IsNullOrEmpty(other) return string.IsNullOrEmpty(other)
? "Joins a family pack" ? "Joins " + group
: "Joins a family pack with " + other; : "Joins " + group + " with " + other;
}
case NarrativePresentationKind.FamilyPackLeft: case NarrativePresentationKind.FamilyPackLeft:
{
bool pack = LifeSagaRoster.UsesPackLanguage(
EventFeedUtil.FindAliveById(model.PerspectiveId));
string group = pack ? "a family pack" : "a family";
return string.IsNullOrEmpty(other) return string.IsNullOrEmpty(other)
? "Leaves a family pack" ? "Leaves " + group
: "Leaves a family pack shared with " + other; : "Leaves " + group + " shared with " + other;
}
case NarrativePresentationKind.GiftGiven: case NarrativePresentationKind.GiftGiven:
return string.IsNullOrEmpty(other) ? "Gives a gift" : "Gives " + other + " a gift"; return string.IsNullOrEmpty(other) ? "Gives a gift" : "Gives " + other + " a gift";
case NarrativePresentationKind.GiftReceived: case NarrativePresentationKind.GiftReceived:

View file

@ -1275,8 +1275,13 @@ public static class NarrativeStoryStore
const int importedDevelopments = 2300; const int importedDevelopments = 2300;
const int importedThreads = 900; const int importedThreads = 900;
const int importedConsequences = 1300; 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++) for (int i = 0; i < importedDevelopments; i++)
{ {
long characterId = importedCharacterBase + i;
string developmentId = "probe:compact:development:" + i; string developmentId = "probe:compact:development:" + i;
ImportDevelopment(new NarrativeDevelopment ImportDevelopment(new NarrativeDevelopment
{ {
@ -1285,7 +1290,7 @@ public static class NarrativeStoryStore
Function = i == importedDevelopments - 1 Function = i == importedDevelopments - 1
? NarrativeFunction.TurningPoint ? NarrativeFunction.TurningPoint
: NarrativeFunction.CharacterTexture, : NarrativeFunction.CharacterTexture,
SubjectId = i + 1, SubjectId = characterId,
OccurredAt = i + 1, OccurredAt = i + 1,
Magnitude = i == importedDevelopments - 1 ? 100f : 5f Magnitude = i == importedDevelopments - 1 ? 100f : 5f
}); });
@ -1293,13 +1298,13 @@ public static class NarrativeStoryStore
{ {
Id = "event:" + developmentId, Id = "event:" + developmentId,
DevelopmentId = developmentId, DevelopmentId = developmentId,
SubjectId = i + 1, SubjectId = characterId,
ObservedAt = i + 1, ObservedAt = i + 1,
WorldTime = i + 1 WorldTime = i + 1
}); });
ImportCharacter(new CharacterStoryState ImportCharacter(new CharacterStoryState
{ {
CharacterId = i + 1, CharacterId = characterId,
LastMeaningfulChangeAt = i + 1, LastMeaningfulChangeAt = i + 1,
StoryPotential = i % 20 StoryPotential = i % 20
}); });
@ -1309,7 +1314,7 @@ public static class NarrativeStoryStore
{ {
Id = "probe:compact:thread:" + i, Id = "probe:compact:thread:" + i,
Kind = NarrativeThreadKind.Founding, Kind = NarrativeThreadKind.Founding,
ProtagonistId = i + 1, ProtagonistId = characterId,
OpenedByDevelopmentId = developmentId, OpenedByDevelopmentId = developmentId,
LatestMeaningfulChangeId = developmentId, LatestMeaningfulChangeId = developmentId,
Status = NarrativeThreadStatus.Resolved, Status = NarrativeThreadStatus.Resolved,
@ -1319,7 +1324,7 @@ public static class NarrativeStoryStore
Momentum = i / 100f Momentum = i / 100f
}; };
thread.DevelopmentIds.Add(developmentId); thread.DevelopmentIds.Add(developmentId);
thread.AddCast(i + 1, "protagonist"); thread.AddCast(characterId, "protagonist");
ImportThread(thread); ImportThread(thread);
} }
if (i < importedConsequences) if (i < importedConsequences)
@ -1327,7 +1332,7 @@ public static class NarrativeStoryStore
ImportConsequence(new CharacterConsequence ImportConsequence(new CharacterConsequence
{ {
Id = "probe:compact:consequence:" + i, Id = "probe:compact:consequence:" + i,
CharacterId = i + 1, CharacterId = characterId,
DevelopmentId = developmentId, DevelopmentId = developmentId,
Kind = CharacterConsequenceKind.FoundHome, Kind = CharacterConsequenceKind.FoundHome,
OccurredAt = i + 1, OccurredAt = i + 1,

View file

@ -0,0 +1,68 @@
using System;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// 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.
/// </summary>
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));
}
}

View file

@ -89,10 +89,12 @@ public static class CaptionComposer
model.ConnectionLine = CameraEligibility.ConnectionLine(ownedTip, focusId); model.ConnectionLine = CameraEligibility.ConnectionLine(ownedTip, focusId);
model.ReasonRelatedId = reasonRelatedId; model.ReasonRelatedId = reasonRelatedId;
string beat = presentableBeat ?? ""; bool suppressBeat = BeatSignificance.SuppressBeat(ownedTip);
string beat = suppressBeat ? "" : presentableBeat ?? "";
string narrativeContext = ""; string narrativeContext = "";
bool structuredNarrativeBeat = false; bool structuredNarrativeBeat = false;
if (!statusBannerOwnsBeat if (!statusBannerOwnsBeat
&& !suppressBeat
&& NarrativeProse.TryBuildBeat( && NarrativeProse.TryBuildBeat(
focus, focus,
ownedTip, ownedTip,
@ -122,6 +124,8 @@ public static class CaptionComposer
{ {
model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine); model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine);
} }
model.BeatLine = BeatSignificance.Enrich(focus, ownedTip, model.BeatLine);
} }
model.ContextLine = BuildContext( model.ContextLine = BuildContext(

View file

@ -61,6 +61,39 @@ public static class LifeSagaPresentation
return model; 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;
}
/// <summary> /// <summary>
/// Pick the strongest unresolved pressure that adds information beyond Identity. /// Pick the strongest unresolved pressure that adds information beyond Identity.
/// Current-role/founding facts belong in the title, not a duplicate tooltip stake. /// 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); Add(LifeSagaLens.CrownClan, 8f);
break; break;
case LifeSagaAdmissionReason.Alpha: case LifeSagaAdmissionReason.Alpha:
if (LifeSagaRoster.UsesPackLanguage(actor))
{
Add(LifeSagaLens.PackAlpha, 8f); Add(LifeSagaLens.PackAlpha, 8f);
}
break; break;
case LifeSagaAdmissionReason.PlotAuthor: case LifeSagaAdmissionReason.PlotAuthor:
Add(LifeSagaLens.Plotter, 8f); Add(LifeSagaLens.Plotter, 8f);
@ -340,9 +376,12 @@ public static class LifeSagaPresentation
break; break;
case LifeSagaFactKind.RoleChange: case LifeSagaFactKind.RoleChange:
if ((f.Note ?? "").IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0) if ((f.Note ?? "").IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
{
if (LifeSagaRoster.UsesPackLanguage(actor))
{ {
Add(LifeSagaLens.PackAlpha, 14f); Add(LifeSagaLens.PackAlpha, 14f);
} }
}
else else
{ {
Add(LifeSagaLens.CrownClan, 12f); Add(LifeSagaLens.CrownClan, 12f);
@ -350,7 +389,10 @@ public static class LifeSagaPresentation
break; break;
case LifeSagaFactKind.ParentChild: case LifeSagaFactKind.ParentChild:
if (LifeSagaRoster.UsesPackLanguage(actor))
{
Add(LifeSagaLens.PackAlpha, 6f); Add(LifeSagaLens.PackAlpha, 6f);
}
Add(LifeSagaLens.LoveGrief, 4f); Add(LifeSagaLens.LoveGrief, 4f);
break; break;
} }
@ -577,6 +619,7 @@ public static class LifeSagaPresentation
|| primary == LifeSagaLens.Plotter)) || primary == LifeSagaLens.Plotter))
{ {
string peerLabel = primary == LifeSagaLens.PackAlpha string peerLabel = primary == LifeSagaLens.PackAlpha
&& LifeSagaRoster.UsesPackLanguage(actor)
? SagaProse.CastLabelLive("packmate") ? SagaProse.CastLabelLive("packmate")
: SagaProse.CastLabelLive("kin"); : SagaProse.CastLabelLive("kin");
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCastCandidates + 2)) foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCastCandidates + 2))

View file

@ -1944,7 +1944,7 @@ public static class LifeSagaRoster
public static bool IsPackAlpha(Actor actor) public static bool IsPackAlpha(Actor actor)
{ {
if (actor == null) if (!UsesPackLanguage(actor))
{ {
return false; return false;
} }
@ -1966,6 +1966,24 @@ public static class LifeSagaRoster
} }
} }
/// <summary>
/// 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.
/// </summary>
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) public static bool IsClanChief(Actor actor)
{ {
if (actor == null) if (actor == null)

View file

@ -90,6 +90,7 @@ public static class LifeSagaViewController
_hoverExitAt = -1f; _hoverExitAt = -1f;
_hoverPreviewId = unitId; _hoverPreviewId = unitId;
WatchCaption.PrimeSagaHoverHeader(unitId);
if (!_pauseHeld) if (!_pauseHeld)
{ {
InterestDirector.AcquireReadPause(ReadPauseOwner); InterestDirector.AcquireReadPause(ReadPauseOwner);

View file

@ -213,10 +213,15 @@ public static class SagaProse
} }
if (note.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0) if (note.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
{
if (LifeSagaRoster.UsesPackLanguage(actor))
{ {
return TitleLabel("Pack Alpha"); return TitleLabel("Pack Alpha");
} }
continue;
}
if (note.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0 if (note.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0
|| note.IndexOf("clan", StringComparison.OrdinalIgnoreCase) >= 0) || note.IndexOf("clan", StringComparison.OrdinalIgnoreCase) >= 0)
{ {
@ -560,7 +565,9 @@ public static class SagaProse
return string.IsNullOrEmpty(k) ? Sentence("Rules a kingdom") : Sentence("Rules " + k); return string.IsNullOrEmpty(k) ? Sentence("Rules a kingdom") : Sentence("Rules " + k);
} }
case LifeSagaAdmissionReason.Alpha: case LifeSagaAdmissionReason.Alpha:
return Sentence("Leads the pack"); return LifeSagaRoster.UsesPackLanguage(actor)
? Sentence("Leads the pack")
: Sentence("Leads a family");
case LifeSagaAdmissionReason.PlotAuthor: case LifeSagaAdmissionReason.PlotAuthor:
return Sentence("Leads a plot"); return Sentence("Leads a plot");
case LifeSagaAdmissionReason.Singleton: case LifeSagaAdmissionReason.Singleton:

View file

@ -448,9 +448,9 @@ public sealed class UnitDossier
return ""; return "";
} }
// Character-fill vignettes: hide reason (task chip shows live activity). // Character-fill vignettes: always hide the Beat. Live combat state cannot turn an
if (scene.LeadKind == InterestLeadKind.CharacterLed // ambient candidate's generated species presentation into an event reason.
&& !InterestScoring.IsCombatAction(scene)) if (BeatSignificance.SuppressBeat(scene))
{ {
if (dossier != null) if (dossier != null)
{ {

View file

@ -617,7 +617,28 @@ public static class WatchCaption
{ {
if (interest != null) if (interest != null)
{ {
// Location-only / no unit: tip text for logs; dossier has no owned subject reason. SetFromLocationInterest(interest);
}
return;
}
// Reason ownership comes from InterestDirector.TryGetOwnedReasonCandidate
// (active dwell + owned unit), not from a sticky InterestEvent.Label.
SetFromActor(unit, label: null);
}
/// <summary>
/// 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.
/// </summary>
public static void SetFromLocationInterest(InterestEvent interest)
{
if (interest == null)
{
return;
}
LastHeadline = CameraDirector.FormatWatchTip(interest); LastHeadline = CameraDirector.FormatWatchTip(interest);
LastDetail = ""; LastDetail = "";
LastCaptionText = LastHeadline; LastCaptionText = LastHeadline;
@ -637,14 +658,6 @@ public static class WatchCaption
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active); SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
} }
return;
}
// Reason ownership comes from InterestDirector.TryGetOwnedReasonCandidate
// (active dwell + owned unit), not from a sticky InterestEvent.Label.
SetFromActor(unit, label: null);
}
public static void SetFromActor(Actor actor, string label = null) public static void SetFromActor(Actor actor, string label = null)
{ {
InvalidateOwnedReasonCache(); InvalidateOwnedReasonCache();
@ -927,6 +940,52 @@ public static class WatchCaption
RefreshStorySpine(force: true); RefreshStorySpine(force: true);
} }
/// <summary>
/// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback
/// immediate even when the deeper saga model has several relationships to resolve.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public static void RefreshOwnedReasonNow()
{
if (!_visible || _current == null)
{
return;
}
InvalidateOwnedReasonCache();
RefreshOwnedReason();
}
private static bool HasStatusBanner() private static bool HasStatusBanner()
{ {
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil; return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
@ -1615,12 +1674,15 @@ public static class WatchCaption
Sprite sexSprite = null; Sprite sexSprite = null;
if (actor != null && actor.isAlive()) if (actor != null && actor.isAlive())
{ {
UnitDossier hovered = UnitDossier.FromActor(actor); try
if (hovered != null)
{ {
sexSprite = hovered.IsMale sexSprite = actor.isSexMale()
? HudIcons.SexMale() ? 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; 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<RectTransform>() : null,
y,
reasonH,
PadX);
y += reasonH + Gap;
}
bool hasStorySpine = _storySpineText != null
&& _storySpineText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_storySpineText.text);
if (hasStorySpine)
{
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
y += StorySpineH + Gap;
}
// Row visibility is owned by Place*/fill - never leave empty rows active at stale // 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). // positions (SetDossierBodyVisible only hides on hover; it must not resurrect them).
if (historyCount <= 0 && _historyCol != null) if (historyCount <= 0 && _historyCol != null)
@ -3216,22 +3300,6 @@ public static class WatchCaption
} }
} }
if (hasReason)
{
float reasonH = MeasureReasonHeight();
PlaceLine(_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null, y, reasonH, PadX);
y += reasonH + Gap;
}
bool hasStorySpine = _storySpineText != null
&& _storySpineText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_storySpineText.text);
if (hasStorySpine)
{
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
y += StorySpineH + Gap;
}
if (panelMode) if (panelMode)
{ {
LifeSagaPanel.EnsureBuilt(_root.transform); LifeSagaPanel.EnsureBuilt(_root.transform);

View file

@ -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 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, 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 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 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 per-world sidecar at the game save boundary. Runtime camera state and episode timing remain

View file

@ -2,7 +2,27 @@
## Status ## 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 ## Product outcome
@ -189,6 +209,12 @@ immediately.
- Connection is presentation metadata. It does not become part of the event sentence or replace - Connection is presentation metadata. It does not become part of the event sentence or replace
story Context. 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`, - 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 `Waf's best friend`, `Waf's old foe`, `Fights beside Waf`, `Opposes Waf in the war`, or
`Caught in Waf's city`. `Caught in Waf's city`.

View file

@ -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)` | | 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 | | 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 | | 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`. 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 The story scheduler owns selection; `identity_filter` remains a safety gate against presenting
identity text as a development. identity text as a development.
Beat participants come from the exact presentation/event receipt (no latest-similar lookup and no 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), 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 while camera Beat/Context are hidden to prevent false attribution. Camera composition keeps
running underneath and restores immediately when hover ends. 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 ## 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 `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. 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. 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. - 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`). - 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. - 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 ## 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). - 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. - 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). - 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. - 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. - 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. - RelationEvidence caches invalidate on memory revision + live lover/friend change.