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:
parent
188d091166
commit
12e2ba0d01
23 changed files with 956 additions and 109 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
393
IdleSpectator/Narrative/BeatSignificance.cs
Normal file
393
IdleSpectator/Narrative/BeatSignificance.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
68
IdleSpectator/Narrative/WorldCriticalPolicy.cs
Normal file
68
IdleSpectator/Narrative/WorldCriticalPolicy.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue