feat(event): enhance event handling and narrative presentation probes
- Introduce new probes for framing approval and family coalescing - Implement exact presentation replay checks to prevent duplicate events - Refactor InterestVariety to manage exact replay cooldowns effectively - Update EventPresentability to improve candidate approval logic - Enhance InterestFeeds to streamline event registration and labeling
This commit is contained in:
parent
54476fb7a1
commit
2293274d79
19 changed files with 1418 additions and 97 deletions
|
|
@ -2892,6 +2892,14 @@ public static class AgentHarness
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "framing_approval_probe":
|
||||||
|
{
|
||||||
|
bool ok = EventPresentability.HarnessProbeFramingApproval(out string detail);
|
||||||
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
Emit(cmd, ok, detail: detail);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "interest_story_clear":
|
case "interest_story_clear":
|
||||||
StoryPlanner.Clear();
|
StoryPlanner.Clear();
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
|
|
@ -2944,6 +2952,10 @@ public static class AgentHarness
|
||||||
bool spineOk = WatchCaption.HarnessProbeStorySpineRelevance(out string spine);
|
bool spineOk = WatchCaption.HarnessProbeStorySpineRelevance(out string spine);
|
||||||
bool combatOk = StickyScoreboard.HarnessProbeReasonStability(out string combat);
|
bool combatOk = StickyScoreboard.HarnessProbeReasonStability(out string combat);
|
||||||
bool noveltyOk = InterestVariety.HarnessProbeRoutineReplay(out string novelty);
|
bool noveltyOk = InterestVariety.HarnessProbeRoutineReplay(out string novelty);
|
||||||
|
bool familyCoalescingOk =
|
||||||
|
InterestVariety.HarnessProbeFamilyCoalescing(out string familyCoalescing);
|
||||||
|
bool nameBoundaryOk =
|
||||||
|
UnitDossier.HarnessProbeNameBoundaries(out string nameBoundary);
|
||||||
bool participantOk =
|
bool participantOk =
|
||||||
NarrativePresentationFactory.HarnessProbeNamedParticipantFallback(
|
NarrativePresentationFactory.HarnessProbeNamedParticipantFallback(
|
||||||
out string participant);
|
out string participant);
|
||||||
|
|
@ -2958,6 +2970,12 @@ public static class AgentHarness
|
||||||
NarrativeStoryStore.HarnessProbeCombatLifecycle(out string lifecycle);
|
NarrativeStoryStore.HarnessProbeCombatLifecycle(out string lifecycle);
|
||||||
bool schedulerBoundsOk =
|
bool schedulerBoundsOk =
|
||||||
NarrativeStoryStore.HarnessProbeSchedulerBounds(out string schedulerBounds);
|
NarrativeStoryStore.HarnessProbeSchedulerBounds(out string schedulerBounds);
|
||||||
|
bool runtimeCompactionOk =
|
||||||
|
NarrativeStoryStore.HarnessProbeRuntimeCompaction(out string runtimeCompaction);
|
||||||
|
bool exactPresentationOk =
|
||||||
|
CameraDirector.HarnessProbeExactPresentationReplay(out string exactPresentation);
|
||||||
|
bool framingApprovalOk =
|
||||||
|
EventPresentability.HarnessProbeFramingApproval(out string framingApproval);
|
||||||
bool exposureOk =
|
bool exposureOk =
|
||||||
NarrativeExposureLedger.HarnessProbeBalance(out string exposure);
|
NarrativeExposureLedger.HarnessProbeBalance(out string exposure);
|
||||||
bool interruptOk =
|
bool interruptOk =
|
||||||
|
|
@ -2966,8 +2984,12 @@ public static class AgentHarness
|
||||||
NarrativePersistence.HarnessProbeGraphCompaction(out string graph);
|
NarrativePersistence.HarnessProbeGraphCompaction(out string graph);
|
||||||
bool persistenceOk = NarrativePersistence.HarnessRoundTrip(out string persistence);
|
bool persistenceOk = NarrativePersistence.HarnessRoundTrip(out string persistence);
|
||||||
bool ok = contextOk && spineOk && combatOk && noveltyOk
|
bool ok = contextOk && spineOk && combatOk && noveltyOk
|
||||||
|
&& familyCoalescingOk && nameBoundaryOk
|
||||||
&& participantOk && loverOk && identityOk && episodeOk
|
&& participantOk && loverOk && identityOk && episodeOk
|
||||||
&& proseOk && lifecycleOk && schedulerBoundsOk
|
&& proseOk && lifecycleOk && schedulerBoundsOk
|
||||||
|
&& runtimeCompactionOk
|
||||||
|
&& exactPresentationOk
|
||||||
|
&& framingApprovalOk
|
||||||
&& exposureOk && interruptOk
|
&& exposureOk && interruptOk
|
||||||
&& graphOk && persistenceOk;
|
&& graphOk && persistenceOk;
|
||||||
if (ok) _cmdOk++; else _cmdFail++;
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
|
@ -2976,10 +2998,15 @@ public static class AgentHarness
|
||||||
ok,
|
ok,
|
||||||
detail: "context={" + context + "} spine={" + spine
|
detail: "context={" + context + "} spine={" + spine
|
||||||
+ "} combat={" + combat + "} novelty={" + novelty
|
+ "} combat={" + combat + "} novelty={" + novelty
|
||||||
|
+ "} familyCoalescing={" + familyCoalescing
|
||||||
|
+ "} nameBoundary={" + nameBoundary
|
||||||
+ "} participant={" + participant + "} lover={" + lover
|
+ "} participant={" + participant + "} lover={" + lover
|
||||||
+ "} identity={" + identity + "} episode={" + episode
|
+ "} identity={" + identity + "} episode={" + episode
|
||||||
+ "} prose={" + prose + "} lifecycle={" + lifecycle
|
+ "} prose={" + prose + "} lifecycle={" + lifecycle
|
||||||
+ "} schedulerBounds={" + schedulerBounds
|
+ "} schedulerBounds={" + schedulerBounds
|
||||||
|
+ "} runtimeCompaction={" + runtimeCompaction
|
||||||
|
+ "} exactPresentation={" + exactPresentation
|
||||||
|
+ "} framingApproval={" + framingApproval
|
||||||
+ "} exposure={" + exposure + "} interrupt={" + interrupt
|
+ "} exposure={" + exposure + "} interrupt={" + interrupt
|
||||||
+ "} graph={" + graph
|
+ "} graph={" + graph
|
||||||
+ "} persistence={" + persistence + "}");
|
+ "} persistence={" + persistence + "}");
|
||||||
|
|
@ -4446,8 +4473,13 @@ public static class AgentHarness
|
||||||
detail: "schedulerMs=" + StoryScheduler.LastTickMs.ToString("0.00")
|
detail: "schedulerMs=" + StoryScheduler.LastTickMs.ToString("0.00")
|
||||||
+ " candidates=" + StoryScheduler.LastCandidateCount
|
+ " candidates=" + StoryScheduler.LastCandidateCount
|
||||||
+ "/" + NarrativeStoryStore.SchedulerCopyLimit
|
+ "/" + NarrativeStoryStore.SchedulerCopyLimit
|
||||||
|
+ " graphEvents=" + NarrativeEventStore.Count
|
||||||
+ " graphThreads=" + NarrativeStoryStore.ThreadCount
|
+ " graphThreads=" + NarrativeStoryStore.ThreadCount
|
||||||
+ " developments=" + NarrativeStoryStore.DevelopmentCount);
|
+ " developments=" + NarrativeStoryStore.DevelopmentCount
|
||||||
|
+ " consequences=" + NarrativeStoryStore.ConsequenceCount
|
||||||
|
+ " characters=" + NarrativeStoryStore.CharacterCount
|
||||||
|
+ " compact=" + NarrativeStoryStore.CompactionCount
|
||||||
|
+ "/" + NarrativeStoryStore.LastCompactionMs.ToString("0.0") + "ms");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7214,7 +7246,7 @@ public static class AgentHarness
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Force a sticky StatusOutbreak session: Outbreak - Status (n).
|
/// Force a sticky StatusOutbreak session: Outbreak - Status.
|
||||||
/// value = status id (default cursed). Seeds live cursed carriers so maintain recounts stick.
|
/// value = status id (default cursed). Seeds live cursed carriers so maintain recounts stick.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void DoInterestOutbreakSession(HarnessCommand cmd)
|
private static void DoInterestOutbreakSession(HarnessCommand cmd)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using NeoModLoader.services;
|
using NeoModLoader.services;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
|
|
@ -9,6 +10,9 @@ public static class CameraDirector
|
||||||
public static string LastWatchLabel { get; private set; } = "";
|
public static string LastWatchLabel { get; private set; } = "";
|
||||||
public static string LastWatchAssetId { get; private set; } = "";
|
public static string LastWatchAssetId { get; private set; } = "";
|
||||||
public static string LastFormattedWatchTip { get; private set; } = "";
|
public static string LastFormattedWatchTip { get; private set; } = "";
|
||||||
|
private const float ExactPresentationCooldownSeconds = 90f;
|
||||||
|
private static readonly Dictionary<string, float> PresentedLabels =
|
||||||
|
new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public static void FocusUnit(Actor actor, bool updateCaption = true)
|
public static void FocusUnit(Actor actor, bool updateCaption = true)
|
||||||
{
|
{
|
||||||
|
|
@ -34,6 +38,16 @@ public static class CameraDirector
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string incomingLabel = (interest.Label ?? "").Trim();
|
||||||
|
if (!string.IsNullOrEmpty(incomingLabel)
|
||||||
|
&& !string.Equals(incomingLabel, LastWatchLabel, StringComparison.Ordinal)
|
||||||
|
&& WasPresentedRecently(incomingLabel, Time.unscaledTime))
|
||||||
|
{
|
||||||
|
// Sticky maintain paths may reassert a prior label without going through
|
||||||
|
// candidate selection. Do not turn that refresh into a new camera/caption cut.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Actor follow = ResolveFollowUnit(interest);
|
Actor follow = ResolveFollowUnit(interest);
|
||||||
if (follow != null && follow.isAlive())
|
if (follow != null && follow.isAlive())
|
||||||
{
|
{
|
||||||
|
|
@ -90,10 +104,59 @@ public static class CameraDirector
|
||||||
// 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)
|
||||||
{
|
{
|
||||||
|
NotePresentedLabel(label, Time.unscaledTime);
|
||||||
LogService.LogInfo($"[IdleSpectator] {tip}");
|
LogService.LogInfo($"[IdleSpectator] {tip}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool WasPresentedRecently(string label, float now)
|
||||||
|
{
|
||||||
|
string key = EventReason.ReplayStructureKey(label);
|
||||||
|
return !string.IsNullOrEmpty(key)
|
||||||
|
&& PresentedLabels.TryGetValue(key, out float shownAt)
|
||||||
|
&& now - shownAt >= 0f
|
||||||
|
&& now - shownAt < ExactPresentationCooldownSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void NotePresentedLabel(string label, float now)
|
||||||
|
{
|
||||||
|
string key = EventReason.ReplayStructureKey(label);
|
||||||
|
if (string.IsNullOrEmpty(key)) return;
|
||||||
|
PresentedLabels[key] = now;
|
||||||
|
if (PresentedLabels.Count <= 128) return;
|
||||||
|
var expired = new List<string>();
|
||||||
|
foreach (KeyValuePair<string, float> entry in PresentedLabels)
|
||||||
|
{
|
||||||
|
if (now - entry.Value >= ExactPresentationCooldownSeconds * 2f)
|
||||||
|
{
|
||||||
|
expired.Add(entry.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < expired.Count; i++) PresentedLabels.Remove(expired[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ClearReplayLedger()
|
||||||
|
{
|
||||||
|
PresentedLabels.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HarnessProbeExactPresentationReplay(out string detail)
|
||||||
|
{
|
||||||
|
PresentedLabels.Clear();
|
||||||
|
NotePresentedLabel("Duel - Aro vs Bex", 10f);
|
||||||
|
bool sameBlocked = WasPresentedRecently("Duel - Aro vs Bex", 20f);
|
||||||
|
bool reframedBlocked =
|
||||||
|
WasPresentedRecently("Mass - Bex (4) vs Aro (7)", 20f);
|
||||||
|
bool otherOpen = !WasPresentedRecently("Duel - Aro vs Cid", 20f);
|
||||||
|
bool expired = !WasPresentedRecently("Duel - Aro vs Bex", 101f);
|
||||||
|
bool ok = sameBlocked && reframedBlocked && otherOpen && expired;
|
||||||
|
detail = "same=" + sameBlocked + " reframed=" + reframedBlocked
|
||||||
|
+ " other=" + otherOpen
|
||||||
|
+ " expired=" + expired + " pass=" + ok;
|
||||||
|
PresentedLabels.Clear();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
private static Actor ResolveFollowUnit(InterestEvent interest)
|
private static Actor ResolveFollowUnit(InterestEvent interest)
|
||||||
{
|
{
|
||||||
if (interest == null)
|
if (interest == null)
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,13 @@ public static class EventLivePipelinesHarness
|
||||||
}
|
}
|
||||||
|
|
||||||
InterestDropLog.Clear();
|
InterestDropLog.Clear();
|
||||||
InterestRegistry.ForceExpireContaining("worldlog:" + id, Time.unscaledTime);
|
string expectedKey = string.Equals(
|
||||||
|
id,
|
||||||
|
"disaster_earthquake",
|
||||||
|
StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "earthquake:active"
|
||||||
|
: "worldlog:" + id;
|
||||||
|
InterestRegistry.ForceExpireContaining(expectedKey, Time.unscaledTime);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!TryPostWorldLog(id, unit))
|
if (!TryPostWorldLog(id, unit))
|
||||||
|
|
@ -264,10 +270,14 @@ public static class EventLivePipelinesHarness
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (InterestRegistry.CountKeysContaining("worldlog:" + id) > 0)
|
if (InterestRegistry.CountKeysContaining(expectedKey) > 0)
|
||||||
{
|
{
|
||||||
EventLiveCoverageLedger.Claim(
|
EventLiveCoverageLedger.Claim(
|
||||||
"worldLog", id, "id", "WorldLogMessage.add", "busy_bypass=ForceLiveEventFeeds");
|
"worldLog",
|
||||||
|
id,
|
||||||
|
"id",
|
||||||
|
"WorldLogMessage.add",
|
||||||
|
"busy_bypass=ForceLiveEventFeeds;key=" + expectedKey);
|
||||||
}
|
}
|
||||||
else if (DropMentions("createsInterest=false", id))
|
else if (DropMentions("createsInterest=false", id))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,8 @@ public static class EventPresentability
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stamp the exact identity that passed the intake gate. Repeated scheduler/director
|
/// Stamp the exact identity that passed the intake gate. Repeated scheduler/director
|
||||||
/// checks can trust this fingerprint without rescanning the world; any later mutation
|
/// checks can trust this fingerprint without rescanning the world. Ownership changes
|
||||||
/// naturally invalidates it.
|
/// invalidate it; explicitly recognized live framing updates retain approval.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void MarkApproved(InterestCandidate scene)
|
public static void MarkApproved(InterestCandidate scene)
|
||||||
{
|
{
|
||||||
|
|
@ -111,16 +111,71 @@ public static class EventPresentability
|
||||||
scene.HasApprovedPresentation = true;
|
scene.HasApprovedPresentation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsApproved(InterestCandidate scene) =>
|
public static bool IsApproved(InterestCandidate scene)
|
||||||
scene != null
|
{
|
||||||
&& scene.HasApprovedPresentation
|
if (scene == null
|
||||||
&& scene.ApprovedPresentationSubjectId == scene.SubjectId
|
|| !scene.HasApprovedPresentation
|
||||||
&& scene.ApprovedPresentationRelatedId == scene.RelatedId
|
|| scene.ApprovedPresentationSubjectId != scene.SubjectId
|
||||||
&& scene.ApprovedPresentationLeadKind == scene.LeadKind
|
|| scene.ApprovedPresentationRelatedId != scene.RelatedId
|
||||||
&& string.Equals(
|
|| scene.ApprovedPresentationLeadKind != scene.LeadKind)
|
||||||
scene.ApprovedPresentationLabel,
|
{
|
||||||
scene.Label ?? "",
|
return false;
|
||||||
StringComparison.Ordinal);
|
}
|
||||||
|
|
||||||
|
string approved = scene.ApprovedPresentationLabel ?? "";
|
||||||
|
string current = scene.Label ?? "";
|
||||||
|
if (string.Equals(approved, current, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sticky scene maintenance may refresh scale/tier without changing ownership.
|
||||||
|
// Re-running UnitDossier's stranger scan across a developed world for every
|
||||||
|
// headcount tick made the scheduler proposal stage hitch badly.
|
||||||
|
bool liveFraming =
|
||||||
|
scene.Completion == InterestCompletionKind.CombatActive
|
||||||
|
|| scene.Completion == InterestCompletionKind.WarFront
|
||||||
|
|| scene.Completion == InterestCompletionKind.StatusOutbreak
|
||||||
|
|| scene.Completion == InterestCompletionKind.FamilyPack;
|
||||||
|
if (!liveFraming)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EventReason.IsHeadcountOnlyChange(approved, current))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scene.Completion == InterestCompletionKind.CombatActive
|
||||||
|
&& EventReason.IsCombatFramingOnlyChange(approved, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HarnessProbeFramingApproval(out string detail)
|
||||||
|
{
|
||||||
|
var scene = new InterestCandidate
|
||||||
|
{
|
||||||
|
SubjectId = 41,
|
||||||
|
RelatedId = 42,
|
||||||
|
LeadKind = InterestLeadKind.EventLed,
|
||||||
|
Completion = InterestCompletionKind.CombatActive,
|
||||||
|
Label = "Battle - Alpha (8) vs Beta (4)"
|
||||||
|
};
|
||||||
|
MarkApproved(scene);
|
||||||
|
scene.Label = "Mass - Beta (3) vs Alpha (12)";
|
||||||
|
bool framingKept = IsApproved(scene);
|
||||||
|
scene.Label = "Mass - Gamma (3) vs Alpha (12)";
|
||||||
|
bool foreignCampRejected = !IsApproved(scene);
|
||||||
|
scene.Label = "Mass - Beta (3) vs Alpha (12)";
|
||||||
|
scene.SubjectId = 99;
|
||||||
|
bool ownerMutationRejected = !IsApproved(scene);
|
||||||
|
bool ok = framingKept && foreignCampRejected && ownerMutationRejected;
|
||||||
|
detail = "framing=" + framingKept
|
||||||
|
+ " foreignCamp=" + foreignCampRejected
|
||||||
|
+ " ownerMutation=" + ownerMutationRejected
|
||||||
|
+ " pass=" + ok;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
private static void TrimCache()
|
private static void TrimCache()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,25 @@ public static class EventReason
|
||||||
return SameCombatTheaterCamps(prevKey, nextKey);
|
return SameCombatTheaterCamps(prevKey, nextKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable player-facing replay identity. Combat tiers, side order, and live
|
||||||
|
/// headcounts are framing, not new story developments.
|
||||||
|
/// </summary>
|
||||||
|
public static string ReplayStructureKey(string label)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(label))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryCombatCampKey(label, out string combatKey))
|
||||||
|
{
|
||||||
|
return "combat:" + combatKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return label.Trim().ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
private static bool SameCombatTheaterCamps(string prevKey, string nextKey)
|
private static bool SameCombatTheaterCamps(string prevKey, string nextKey)
|
||||||
{
|
{
|
||||||
if (TryPackSide(prevKey, out string pack) && TryVsSides(nextKey, out string a, out string b))
|
if (TryPackSide(prevKey, out string pack) && TryVsSides(nextKey, out string a, out string b))
|
||||||
|
|
@ -140,7 +159,10 @@ public static class EventReason
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
string rest = StripParenCounts(t.Substring(dash + 3).Trim());
|
string rest = StripParenCounts(t.Substring(dash + 3).Trim())
|
||||||
|
.Replace(" ()", "")
|
||||||
|
.Replace("()", "")
|
||||||
|
.Trim();
|
||||||
int vs = rest.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase);
|
int vs = rest.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase);
|
||||||
if (vs < 0)
|
if (vs < 0)
|
||||||
{
|
{
|
||||||
|
|
@ -714,19 +736,12 @@ public static class EventReason
|
||||||
return an + " changes status";
|
return an + " changes status";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prose often already includes a verb phrase ("is burning").
|
return SubjectLedClause(an, p);
|
||||||
if (p.StartsWith("is ", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| p.StartsWith("are ", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return an + " " + p;
|
|
||||||
}
|
|
||||||
|
|
||||||
return an + " · " + p;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sticky status outbreak orange reason: <c>Outbreak - Cursed (4)</c>.
|
/// Stable sticky status outbreak reason, e.g. <c>Outbreak - Cursed</c>.
|
||||||
/// Collective status label + carrier count; never unit proper names.
|
/// Counts remain in the typed ensemble but do not churn the orange story sentence.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string StatusOutbreak(LiveEnsemble ensemble)
|
public static string StatusOutbreak(LiveEnsemble ensemble)
|
||||||
{
|
{
|
||||||
|
|
@ -741,12 +756,12 @@ public static class EventReason
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)
|
||||||
|
? ensemble.SideA.Key.Substring("status:".Length)
|
||||||
|
: "";
|
||||||
string label = ensemble.SideA.Display ?? "";
|
string label = ensemble.SideA.Display ?? "";
|
||||||
if (string.IsNullOrEmpty(label) || LiveEnsemble.IsStatusOutbreakSideKey(label))
|
if (string.IsNullOrEmpty(label) || LiveEnsemble.IsStatusOutbreakSideKey(label))
|
||||||
{
|
{
|
||||||
string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)
|
|
||||||
? ensemble.SideA.Key.Substring("status:".Length)
|
|
||||||
: "";
|
|
||||||
label = LiveEnsemble.StatusDisplayName(statusId);
|
label = LiveEnsemble.StatusDisplayName(statusId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -767,9 +782,29 @@ public static class EventReason
|
||||||
label = "Afflicted";
|
label = "Afflicted";
|
||||||
}
|
}
|
||||||
|
|
||||||
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 "Outbreak - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
|
return StatusClusterLabel(statusId, label, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string StatusClusterLabel(string statusId, string display, int count)
|
||||||
|
{
|
||||||
|
string id = (statusId ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (id == "burning" || id == "burned" || id == "on_fire")
|
||||||
|
{
|
||||||
|
return "Fire spreads through the crowd";
|
||||||
|
}
|
||||||
|
if (id == "drowning")
|
||||||
|
{
|
||||||
|
return "A crowd is drowning";
|
||||||
|
}
|
||||||
|
|
||||||
|
string label = (display ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(label))
|
||||||
|
{
|
||||||
|
label = "Afflicted";
|
||||||
|
}
|
||||||
|
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
|
||||||
|
return "Outbreak - " + label;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Trait(Actor a, string traitId, bool gained)
|
public static string Trait(Actor a, string traitId, bool gained)
|
||||||
|
|
@ -1086,6 +1121,87 @@ public static class EventReason
|
||||||
: "Building completed: " + (string.IsNullOrEmpty(id) ? "structure" : id);
|
: "Building completed: " + (string.IsNullOrEmpty(id) ? "structure" : id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Turn killer-POV Chronicle shorthand into a camera-readable subject-led beat.</summary>
|
||||||
|
public static string SubjectLedKill(string subjectName, string chronicleLine)
|
||||||
|
{
|
||||||
|
string subject = (subjectName ?? "").Trim();
|
||||||
|
string line = (chronicleLine ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(subject))
|
||||||
|
{
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("Killed ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return subject + " kills " + line.Substring("Killed ".Length);
|
||||||
|
}
|
||||||
|
return string.IsNullOrEmpty(line) ? subject + " takes a life" : subject + " · " + line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Turn friend-POV Chronicle shorthand into a camera-readable subject-led beat.</summary>
|
||||||
|
public static string SubjectLedFriend(string subjectName, string chronicleLine)
|
||||||
|
{
|
||||||
|
string subject = (subjectName ?? "").Trim();
|
||||||
|
string line = (chronicleLine ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(subject))
|
||||||
|
{
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("Befriended ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return subject + " befriends " + line.Substring("Befriended ".Length);
|
||||||
|
}
|
||||||
|
return string.IsNullOrEmpty(line)
|
||||||
|
? subject + " forms a friendship"
|
||||||
|
: subject + " · " + line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Turn lover-POV Chronicle shorthand into a complete relationship beat.</summary>
|
||||||
|
public static string SubjectLedLover(string subjectName, string chronicleLine)
|
||||||
|
{
|
||||||
|
const string prefix = "Became lovers with ";
|
||||||
|
string subject = (subjectName ?? "").Trim();
|
||||||
|
string line = (chronicleLine ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(subject))
|
||||||
|
{
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
string partner = line.Substring(prefix.Length).Trim();
|
||||||
|
if (!string.IsNullOrEmpty(partner))
|
||||||
|
{
|
||||||
|
return subject + " and " + partner + " become lovers";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SubjectLedClause(subject, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turn a POV-style clause into a complete orange story sentence.
|
||||||
|
/// Chronicle and happiness sources sometimes omit the subject or capitalize
|
||||||
|
/// the verb as though it were a standalone history entry.
|
||||||
|
/// </summary>
|
||||||
|
public static string SubjectLedClause(string subjectName, string clause)
|
||||||
|
{
|
||||||
|
string subject = (subjectName ?? "").Trim();
|
||||||
|
string line = (clause ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(subject))
|
||||||
|
{
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(line))
|
||||||
|
{
|
||||||
|
return subject + " experiences a change";
|
||||||
|
}
|
||||||
|
if (line.StartsWith(subject, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return subject + " " + char.ToLowerInvariant(line[0])
|
||||||
|
+ (line.Length > 1 ? line.Substring(1) : "");
|
||||||
|
}
|
||||||
|
|
||||||
private static string NormalizeBuildingDisplay(string buildingId, string display)
|
private static string NormalizeBuildingDisplay(string buildingId, string display)
|
||||||
{
|
{
|
||||||
string raw = (buildingId ?? "").Trim().ToLowerInvariant();
|
string raw = (buildingId ?? "").Trim().ToLowerInvariant();
|
||||||
|
|
@ -1094,12 +1210,28 @@ public static class EventReason
|
||||||
for (int i = 0; i < cultureSuffixes.Length; i++)
|
for (int i = 0; i < cultureSuffixes.Length; i++)
|
||||||
{
|
{
|
||||||
string suffix = "_" + cultureSuffixes[i];
|
string suffix = "_" + cultureSuffixes[i];
|
||||||
if (!raw.EndsWith(suffix, StringComparison.Ordinal))
|
int suffixAt = raw.LastIndexOf(suffix, StringComparison.Ordinal);
|
||||||
|
if (suffixAt < 0)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
string baseId = raw.Substring(0, raw.Length - suffix.Length);
|
string variant = raw.Substring(suffixAt + suffix.Length);
|
||||||
|
bool validVariant = string.IsNullOrEmpty(variant);
|
||||||
|
if (!validVariant && variant[0] == '_')
|
||||||
|
{
|
||||||
|
validVariant = variant.Length > 1;
|
||||||
|
for (int j = 1; j < variant.Length && validVariant; j++)
|
||||||
|
{
|
||||||
|
validVariant = char.IsDigit(variant[j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!validVariant)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string baseId = raw.Substring(0, suffixAt);
|
||||||
string fallback = HumanizeId(raw);
|
string fallback = HumanizeId(raw);
|
||||||
if (string.IsNullOrEmpty(value)
|
if (string.IsNullOrEmpty(value)
|
||||||
|| value.Equals(fallback, StringComparison.OrdinalIgnoreCase))
|
|| value.Equals(fallback, StringComparison.OrdinalIgnoreCase))
|
||||||
|
|
@ -1133,12 +1265,35 @@ public static class EventReason
|
||||||
string building = BuildingComplete(null, "tent_human", spectacle: false);
|
string building = BuildingComplete(null, "tent_human", spectacle: false);
|
||||||
bool buildingClean = building == "Building completed: Tent"
|
bool buildingClean = building == "Building completed: Tent"
|
||||||
&& building.IndexOf("Human", StringComparison.OrdinalIgnoreCase) < 0;
|
&& building.IndexOf("Human", StringComparison.OrdinalIgnoreCase) < 0;
|
||||||
|
string variantBuilding = BuildingComplete(null, "windmill_elf_0", spectacle: false);
|
||||||
|
bool variantBuildingClean = variantBuilding == "Building completed: Windmill"
|
||||||
|
&& variantBuilding.IndexOf("Elf", StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
&& variantBuilding.IndexOf("0", StringComparison.Ordinal) < 0;
|
||||||
|
string kill = SubjectLedKill("Aro", "Killed Tampoo (monkey)");
|
||||||
|
bool killClean = kill == "Aro kills Tampoo (monkey)";
|
||||||
|
string friend = SubjectLedFriend("Eceas", "Befriended Oreero");
|
||||||
|
bool friendClean = friend == "Eceas befriends Oreero";
|
||||||
|
string lover = SubjectLedLover("Starna", "Became lovers with Skenstyr");
|
||||||
|
bool loverClean = lover == "Starna and Skenstyr become lovers";
|
||||||
|
string clause = SubjectLedClause("Tampoo", "Is starving");
|
||||||
|
bool clauseClean = clause == "Tampoo is starving";
|
||||||
|
string fire = StatusClusterLabel("burning", "Burning", 3);
|
||||||
|
string drowning = StatusClusterLabel("drowning", "Drowning", 8);
|
||||||
|
string outbreak = StatusClusterLabel("cursed", "Cursed", 12);
|
||||||
|
bool fireClean = fire == "Fire spreads through the crowd";
|
||||||
|
bool hazardClean = drowning == "A crowd is drowning"
|
||||||
|
&& outbreak == "Outbreak - Cursed";
|
||||||
string species = LiveEnsemble.SpeciesDisplay("slava");
|
string species = LiveEnsemble.SpeciesDisplay("slava");
|
||||||
string kingdomAsSpecies = LiveEnsemble.SpeciesDisplay("Slava");
|
string kingdomAsSpecies = LiveEnsemble.SpeciesDisplay("Slava");
|
||||||
bool collectiveClean = string.Equals(
|
bool collectiveClean = string.Equals(
|
||||||
species, kingdomAsSpecies, StringComparison.OrdinalIgnoreCase);
|
species, kingdomAsSpecies, StringComparison.OrdinalIgnoreCase);
|
||||||
bool ok = buildingClean && collectiveClean;
|
bool ok = buildingClean && variantBuildingClean && killClean && friendClean
|
||||||
detail = "building='" + building + "' species='" + species
|
&& loverClean && clauseClean && fireClean && hazardClean && collectiveClean;
|
||||||
|
detail = "building='" + building + "' variant='" + variantBuilding
|
||||||
|
+ "' kill='" + kill + "' friend='" + friend + "' lover='" + lover
|
||||||
|
+ "' clause='" + clause
|
||||||
|
+ "' fire='" + fire + "' drowning='" + drowning
|
||||||
|
+ "' outbreak='" + outbreak + "' species='" + species
|
||||||
+ "' kingdomSpecies='" + kingdomAsSpecies + "' pass=" + ok;
|
+ "' kingdomSpecies='" + kingdomAsSpecies + "' pass=" + ok;
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,24 +160,40 @@ public static class InterestFeeds
|
||||||
|
|
||||||
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
|
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
|
||||||
string kingdom = message.special1 ?? "";
|
string kingdom = message.special1 ?? "";
|
||||||
string key = "worldlog:" + assetId + ":" + kingdom;
|
bool earthquake = string.Equals(
|
||||||
|
assetId,
|
||||||
|
"disaster_earthquake",
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
// Earthquake.startQuake and its WorldLog are two observations of one event.
|
||||||
|
// Give both the same durable card so the player never sees
|
||||||
|
// "Earthquake shakes the land" immediately followed by bare "Earthquake".
|
||||||
|
string key = earthquake
|
||||||
|
? "earthquake:active"
|
||||||
|
: "worldlog:" + assetId + ":" + kingdom;
|
||||||
float boost = PeekCivicBoost(kingdom, assetId);
|
float boost = PeekCivicBoost(kingdom, assetId);
|
||||||
string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category;
|
string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category;
|
||||||
string label = entry.MakeLabel(message);
|
string label = entry.MakeLabel(message);
|
||||||
|
|
||||||
if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos))
|
if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos))
|
||||||
{
|
{
|
||||||
|
if (earthquake)
|
||||||
|
{
|
||||||
|
label = EventReason.Earthquake(follow);
|
||||||
|
}
|
||||||
EventFeedUtil.Register(
|
EventFeedUtil.Register(
|
||||||
key,
|
key,
|
||||||
"worldlog",
|
earthquake ? "earthquake" : "worldlog",
|
||||||
category,
|
category,
|
||||||
label,
|
label,
|
||||||
assetId,
|
earthquake ? "earthquake" : assetId,
|
||||||
evtSeed + boost,
|
evtSeed + boost,
|
||||||
pos,
|
pos,
|
||||||
follow,
|
follow,
|
||||||
minWatch: entry.MinWatch,
|
minWatch: entry.MinWatch,
|
||||||
maxWatch: entry.MaxWatch);
|
maxWatch: earthquake ? 45f : entry.MaxWatch,
|
||||||
|
completion: earthquake
|
||||||
|
? InterestCompletionKind.EarthquakeActive
|
||||||
|
: InterestCompletionKind.FixedDwell);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,16 +202,19 @@ public static class InterestFeeds
|
||||||
{
|
{
|
||||||
EventFeedUtil.Register(
|
EventFeedUtil.Register(
|
||||||
key,
|
key,
|
||||||
"worldlog",
|
earthquake ? "earthquake" : "worldlog",
|
||||||
category,
|
category,
|
||||||
label,
|
earthquake ? "Earthquake shakes the land" : label,
|
||||||
assetId,
|
earthquake ? "earthquake" : assetId,
|
||||||
evtSeed + boost,
|
evtSeed + boost,
|
||||||
position,
|
position,
|
||||||
follow: null,
|
follow: null,
|
||||||
locationOnly: true,
|
locationOnly: true,
|
||||||
minWatch: entry.MinWatch,
|
minWatch: entry.MinWatch,
|
||||||
maxWatch: entry.MaxWatch);
|
maxWatch: earthquake ? 45f : entry.MaxWatch,
|
||||||
|
completion: earthquake
|
||||||
|
? InterestCompletionKind.EarthquakeActive
|
||||||
|
: InterestCompletionKind.FixedDwell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -657,6 +676,19 @@ public static class InterestFeeds
|
||||||
|
|
||||||
long sid = SafeId(subject);
|
long sid = SafeId(subject);
|
||||||
long rid = SafeId(related);
|
long rid = SafeId(related);
|
||||||
|
string cameraLabel = label ?? milestoneKey;
|
||||||
|
if (milestoneKey.Equals("milestone_kill", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
cameraLabel = EventReason.SubjectLedKill(SafeName(subject), cameraLabel);
|
||||||
|
}
|
||||||
|
else if (milestoneKey.Equals("milestone_friend", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
cameraLabel = EventReason.SubjectLedFriend(SafeName(subject), cameraLabel);
|
||||||
|
}
|
||||||
|
else if (milestoneKey.Equals("milestone_lover", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
cameraLabel = EventReason.SubjectLedLover(SafeName(subject), cameraLabel);
|
||||||
|
}
|
||||||
// Shared key space with happiness canonical merges.
|
// Shared key space with happiness canonical merges.
|
||||||
string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid;
|
string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid;
|
||||||
var candidate = new InterestCandidate
|
var candidate = new InterestCandidate
|
||||||
|
|
@ -672,7 +704,7 @@ public static class InterestFeeds
|
||||||
RelatedUnit = related,
|
RelatedUnit = related,
|
||||||
SubjectId = sid,
|
SubjectId = sid,
|
||||||
RelatedId = rid,
|
RelatedId = rid,
|
||||||
Label = label ?? milestoneKey,
|
Label = cameraLabel,
|
||||||
// Milestone id owns AssetId so kill/lover/friend crumbs cool as a class
|
// Milestone id owns AssetId so kill/lover/friend crumbs cool as a class
|
||||||
// (species id used to land every hunter tip on arc:asset:elf).
|
// (species id used to land every hunter tip on arc:asset:elf).
|
||||||
AssetId = milestoneKey,
|
AssetId = milestoneKey,
|
||||||
|
|
@ -831,7 +863,10 @@ public static class InterestFeeds
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrEmpty(occ.PlainClause))
|
else if (!string.IsNullOrEmpty(occ.PlainClause))
|
||||||
{
|
{
|
||||||
label = occ.SubjectName + " " + occ.PlainClause;
|
string subjectName = string.IsNullOrWhiteSpace(occ.SubjectName)
|
||||||
|
? EventFeedUtil.SafeName(subject)
|
||||||
|
: occ.SubjectName.Trim();
|
||||||
|
label = EventReason.SubjectLedClause(subjectName, occ.PlainClause);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,16 @@ public static class StatusOutbreakFeed
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplySticky(registered, ensemble, statusId, pos);
|
// A group is a different story object from the unit's onset. Publish one
|
||||||
|
// canonical outbreak card instead of mutating every per-unit status key
|
||||||
|
// into an identical crowd card. The latter left several changing cards in
|
||||||
|
// the queue and forced repeated full-world presentation validation.
|
||||||
|
if (TryRegisterOutbreak(subject, statusId, entry))
|
||||||
|
{
|
||||||
|
InterestRegistry.ForceExpireContaining(
|
||||||
|
"status:" + statusId + ":",
|
||||||
|
Time.unscaledTime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float _lastTickAt = -999f;
|
private static float _lastTickAt = -999f;
|
||||||
|
|
@ -498,5 +507,8 @@ public static class StatusOutbreakFeed
|
||||||
registered.Label = EventReason.StatusOutbreak(ensemble);
|
registered.Label = EventReason.StatusOutbreak(ensemble);
|
||||||
registered.ParticipantCount = ensemble.ParticipantCount;
|
registered.ParticipantCount = ensemble.ParticipantCount;
|
||||||
InterestScoring.ScoreCheap(registered);
|
InterestScoring.ScoreCheap(registered);
|
||||||
|
// ApplySticky runs after registry intake. Its generated crowd framing is
|
||||||
|
// authoritative and ownership-preserving, so retain that approval.
|
||||||
|
EventPresentability.MarkApproved(registered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,13 @@ public static class IdleHitchProbe
|
||||||
+ $"{StoryScheduler.LastThreadScoreMs:0.0}/"
|
+ $"{StoryScheduler.LastThreadScoreMs:0.0}/"
|
||||||
+ $"{StoryScheduler.LastPendingCopyMs:0.0}/"
|
+ $"{StoryScheduler.LastPendingCopyMs:0.0}/"
|
||||||
+ $"{StoryScheduler.LastProposalMs:0.0} "
|
+ $"{StoryScheduler.LastProposalMs:0.0} "
|
||||||
|
+ $"selectorParts={EpisodeShotSelector.LastReplayFilterMs:0.0}/"
|
||||||
|
+ $"{EpisodeShotSelector.LastPresentabilityMs:0.0}/"
|
||||||
|
+ $"{EpisodeShotSelector.LastClassificationMs:0.0}/"
|
||||||
|
+ $"{EpisodeShotSelector.LastScoringMs:0.0} "
|
||||||
|
+ $"selectorPresent={EpisodeShotSelector.LastPresentabilityChecks} "
|
||||||
|
+ $"selectorSlow={EpisodeShotSelector.LastSlowCandidateMs:0.0}:"
|
||||||
|
+ $"{EpisodeShotSelector.LastSlowCandidateKey} "
|
||||||
+ $"feedParts={InterestFeeds.LastHappinessMs:0.0}/"
|
+ $"feedParts={InterestFeeds.LastHappinessMs:0.0}/"
|
||||||
+ $"{InterestFeeds.LastScannerMs:0.0}[{InterestFeeds.LastBattleScanMs:0.0},"
|
+ $"{InterestFeeds.LastScannerMs:0.0}[{InterestFeeds.LastBattleScanMs:0.0},"
|
||||||
+ $"{InterestFeeds.LastWarFeedMs:0.0},{InterestFeeds.LastPlotFeedMs:0.0},"
|
+ $"{InterestFeeds.LastWarFeedMs:0.0},{InterestFeeds.LastPlotFeedMs:0.0},"
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,25 @@ public sealed class InterestCandidate
|
||||||
public string ScoreDetail = "";
|
public string ScoreDetail = "";
|
||||||
public readonly List<long> ParticipantIds = new List<long>(4);
|
public readonly List<long> ParticipantIds = new List<long>(4);
|
||||||
|
|
||||||
|
// Replay-key memoization. The scheduler consults all three policies repeatedly while
|
||||||
|
// candidates wait in the registry; rebuilding lowercase/prefixed strings every pass
|
||||||
|
// created avoidable garbage-collection hitches in developed worlds.
|
||||||
|
internal bool ReplayKeysCached;
|
||||||
|
internal string ReplayCacheLabel = "";
|
||||||
|
internal string ReplayCacheKey = "";
|
||||||
|
internal string ReplayCacheAssetId = "";
|
||||||
|
internal string ReplayCacheSource = "";
|
||||||
|
internal string ReplayCacheStatusId = "";
|
||||||
|
internal string ReplayCacheHappinessEffectId = "";
|
||||||
|
internal string ReplayCacheActionId = "";
|
||||||
|
internal long ReplayCacheSubjectId;
|
||||||
|
internal InterestCompletionKind ReplayCacheCompletion;
|
||||||
|
internal NarrativeFunction ReplayCacheFunction;
|
||||||
|
internal bool ReplayCacheHasFunction;
|
||||||
|
internal string CachedEditorialReplayKey = "";
|
||||||
|
internal string CachedExactReplayKey = "";
|
||||||
|
internal string CachedRoutineReplayKey = "";
|
||||||
|
|
||||||
// Compat forwarders - existing combat maintain/harness code.
|
// Compat forwarders - existing combat maintain/harness code.
|
||||||
public int CombatPeakParticipants
|
public int CombatPeakParticipants
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1135,7 +1135,7 @@ public static partial class InterestDirector
|
||||||
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
|
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outbreak stickies are groups only - never "Outbreak - X (0)" / solo (1).
|
// Outbreak stickies are groups only; solo carriers remain individual status beats.
|
||||||
int carriers = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0;
|
int carriers = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0;
|
||||||
if (carriers < 2)
|
if (carriers < 2)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ public static partial class InterestDirector
|
||||||
InterestDropLog.Record("editorial_replay", candidate.Label ?? candidate.Key);
|
InterestDropLog.Record("editorial_replay", candidate.Label ?? candidate.Key);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (candidate != _current && InterestVariety.IsExactReplayCooled(candidate))
|
||||||
|
{
|
||||||
|
InterestDropLog.Record("exact_replay", candidate.Label ?? candidate.Key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (candidate != _current
|
if (candidate != _current
|
||||||
&& !StoryScheduler.MayUseCombatShot(candidate))
|
&& !StoryScheduler.MayUseCombatShot(candidate))
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ public static class InterestVariety
|
||||||
public static float FixedDwellBeatCooldownSeconds =>
|
public static float FixedDwellBeatCooldownSeconds =>
|
||||||
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
|
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
|
||||||
public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f);
|
public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f);
|
||||||
|
public static float ExactReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 90f);
|
||||||
public static float RoutineReplayCooldownSeconds =>
|
public static float RoutineReplayCooldownSeconds =>
|
||||||
Mathf.Max(FixedDwellBeatCooldownSeconds, 120f);
|
Mathf.Max(FixedDwellBeatCooldownSeconds, 120f);
|
||||||
|
|
||||||
|
|
@ -52,6 +53,7 @@ public static class InterestVariety
|
||||||
LastPickHadBothPools = false;
|
LastPickHadBothPools = false;
|
||||||
_lastArcKey = "";
|
_lastArcKey = "";
|
||||||
_arcStreak = 0;
|
_arcStreak = 0;
|
||||||
|
CameraDirector.ClearReplayLedger();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Harness: push an arc into the rarity window without a full selection.</summary>
|
/// <summary>Harness: push an arc into the rarity window without a full selection.</summary>
|
||||||
|
|
@ -186,6 +188,14 @@ public static class InterestVariety
|
||||||
Time.unscaledTime + RoutineReplayCooldownSeconds);
|
Time.unscaledTime + RoutineReplayCooldownSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string exactKey = ExactReplayKey(chosen);
|
||||||
|
if (!string.IsNullOrEmpty(exactKey))
|
||||||
|
{
|
||||||
|
StampCooldown(
|
||||||
|
exactKey,
|
||||||
|
Time.unscaledTime + ExactReplayCooldownSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
|
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
|
||||||
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
|
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
|
||||||
}
|
}
|
||||||
|
|
@ -211,6 +221,21 @@ public static class InterestVariety
|
||||||
return CooldownUntil.TryGetValue(key, out float until) && now < until;
|
return CooldownUntil.TryGetValue(key, out float until) && now < until;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prevent an identical player-facing sentence from being presented as a fresh cut,
|
||||||
|
/// even when separate hooks generated different candidate keys.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsExactReplayCooled(InterestCandidate c, float now = -1f)
|
||||||
|
{
|
||||||
|
string key = ExactReplayKey(c);
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (now < 0f) now = Time.unscaledTime;
|
||||||
|
return CooldownUntil.TryGetValue(key, out float until) && now < until;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool HarnessProbeEditorialReplay(out string detail)
|
public static bool HarnessProbeEditorialReplay(out string detail)
|
||||||
{
|
{
|
||||||
Clear();
|
Clear();
|
||||||
|
|
@ -224,7 +249,7 @@ public static class InterestVariety
|
||||||
var replay = new InterestCandidate
|
var replay = new InterestCandidate
|
||||||
{
|
{
|
||||||
Key = "combat:pair:1:2:refresh",
|
Key = "combat:pair:1:2:refresh",
|
||||||
Label = "Duel - Alpha vs Beta",
|
Label = "Mass - Beta (3) vs Alpha (5)",
|
||||||
Completion = InterestCompletionKind.CombatActive
|
Completion = InterestCompletionKind.CombatActive
|
||||||
};
|
};
|
||||||
var different = new InterestCandidate
|
var different = new InterestCandidate
|
||||||
|
|
@ -241,12 +266,39 @@ public static class InterestVariety
|
||||||
StatusId = "sleeping"
|
StatusId = "sleeping"
|
||||||
};
|
};
|
||||||
bool sameCooled = IsEditorialReplayCooled(replay);
|
bool sameCooled = IsEditorialReplayCooled(replay);
|
||||||
|
bool structuralExactCooled = IsExactReplayCooled(replay);
|
||||||
bool differentOpen = !IsEditorialReplayCooled(different);
|
bool differentOpen = !IsEditorialReplayCooled(different);
|
||||||
bool textureUsesBeatPolicy = !IsEditorialReplayCooled(texture);
|
bool textureUsesBeatPolicy = !IsEditorialReplayCooled(texture);
|
||||||
bool ok = sameCooled && differentOpen && textureUsesBeatPolicy;
|
var life = new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = "relationship:1:2:first",
|
||||||
|
Label = "Norron mates with Onaano",
|
||||||
|
Completion = InterestCompletionKind.FixedDwell
|
||||||
|
};
|
||||||
|
NoteSelection(life, updateMix: false);
|
||||||
|
var lifeReplay = new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = "relationship:1:2:second",
|
||||||
|
Label = "Norron mates with Onaano",
|
||||||
|
Completion = InterestCompletionKind.FixedDwell
|
||||||
|
};
|
||||||
|
var lifeProgress = new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = "relationship:1:2:home",
|
||||||
|
Label = "Norron finds a home",
|
||||||
|
Completion = InterestCompletionKind.FixedDwell
|
||||||
|
};
|
||||||
|
bool exactLifeCooled = IsExactReplayCooled(lifeReplay);
|
||||||
|
bool progressedLifeOpen = !IsExactReplayCooled(lifeProgress);
|
||||||
|
bool ok = sameCooled && structuralExactCooled
|
||||||
|
&& differentOpen && textureUsesBeatPolicy
|
||||||
|
&& exactLifeCooled && progressedLifeOpen;
|
||||||
detail = "sameCooled=" + sameCooled
|
detail = "sameCooled=" + sameCooled
|
||||||
|
+ " structuralExact=" + structuralExactCooled
|
||||||
+ " differentOpen=" + differentOpen
|
+ " differentOpen=" + differentOpen
|
||||||
+ " textureUsesBeatPolicy=" + textureUsesBeatPolicy;
|
+ " textureUsesBeatPolicy=" + textureUsesBeatPolicy
|
||||||
|
+ " exactLife=" + exactLifeCooled
|
||||||
|
+ " progressedLife=" + progressedLifeOpen;
|
||||||
Clear();
|
Clear();
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
@ -319,6 +371,43 @@ public static class InterestVariety
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool HarnessProbeFamilyCoalescing(out string detail)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
var birthFact = new InterestCandidate
|
||||||
|
{
|
||||||
|
SubjectId = 41,
|
||||||
|
RelatedId = 43,
|
||||||
|
AssetId = "add_child",
|
||||||
|
Completion = InterestCompletionKind.FixedDwell
|
||||||
|
};
|
||||||
|
NoteSelection(birthFact, updateMix: false);
|
||||||
|
|
||||||
|
var sameParentReaction = new InterestCandidate
|
||||||
|
{
|
||||||
|
SubjectId = 41,
|
||||||
|
HappinessEffectId = "just_had_child",
|
||||||
|
AssetId = "just_had_child",
|
||||||
|
Completion = InterestCompletionKind.FixedDwell
|
||||||
|
};
|
||||||
|
var otherParentReaction = new InterestCandidate
|
||||||
|
{
|
||||||
|
SubjectId = 42,
|
||||||
|
HappinessEffectId = "just_had_child",
|
||||||
|
AssetId = "just_had_child",
|
||||||
|
Completion = InterestCompletionKind.FixedDwell
|
||||||
|
};
|
||||||
|
|
||||||
|
bool echoCooled = IsBeatCooled(sameParentReaction);
|
||||||
|
bool unrelatedOpen = !IsBeatCooled(otherParentReaction);
|
||||||
|
bool ok = echoCooled && unrelatedOpen;
|
||||||
|
detail = "echo=" + echoCooled
|
||||||
|
+ " unrelated=" + unrelatedOpen
|
||||||
|
+ " pass=" + ok;
|
||||||
|
Clear();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Score to subtract when this candidate matches the last shown variety arc.
|
/// Score to subtract when this candidate matches the last shown variety arc.
|
||||||
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak.
|
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak.
|
||||||
|
|
@ -795,6 +884,12 @@ public static class InterestVariety
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsExactReplayCooled(c, now))
|
||||||
|
{
|
||||||
|
InterestDropLog.Record("exact_replay", c.Label ?? c.Key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (IsRoutineReplayCooled(c, now))
|
if (IsRoutineReplayCooled(c, now))
|
||||||
{
|
{
|
||||||
InterestDropLog.Record("routine_replay", c.Label ?? c.Key);
|
InterestDropLog.Record("routine_replay", c.Label ?? c.Key);
|
||||||
|
|
@ -1008,6 +1103,14 @@ public static class InterestVariety
|
||||||
{
|
{
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
EnsureReplayKeys(c);
|
||||||
|
return c.CachedEditorialReplayKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildEditorialReplayKey(
|
||||||
|
InterestCandidate c,
|
||||||
|
string normalizedLabel)
|
||||||
|
{
|
||||||
|
|
||||||
bool stickyCard = c.Completion == InterestCompletionKind.CombatActive
|
bool stickyCard = c.Completion == InterestCompletionKind.CombatActive
|
||||||
|| c.Completion == InterestCompletionKind.WarFront
|
|| c.Completion == InterestCompletionKind.WarFront
|
||||||
|
|
@ -1021,22 +1124,37 @@ public static class InterestVariety
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
if (!string.IsNullOrEmpty(normalizedLabel))
|
||||||
if (!string.IsNullOrEmpty(label))
|
|
||||||
{
|
{
|
||||||
return "replay:label:" + label;
|
return "replay:label:" + normalizedLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
string key = (c.Key ?? "").Trim().ToLowerInvariant();
|
string key = (c.Key ?? "").Trim().ToLowerInvariant();
|
||||||
return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key;
|
return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ExactReplayKey(InterestCandidate c)
|
||||||
|
{
|
||||||
|
if (c == null)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
EnsureReplayKeys(c);
|
||||||
|
return c.CachedExactReplayKey;
|
||||||
|
}
|
||||||
|
|
||||||
private static string RoutineReplayKey(InterestCandidate c)
|
private static string RoutineReplayKey(InterestCandidate c)
|
||||||
{
|
{
|
||||||
if (c == null || c.SubjectId == 0)
|
if (c == null || c.SubjectId == 0)
|
||||||
{
|
{
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
EnsureReplayKeys(c);
|
||||||
|
return c.CachedRoutineReplayKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildRoutineReplayKey(InterestCandidate c)
|
||||||
|
{
|
||||||
|
|
||||||
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
|
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
|
||||||
if (function != NarrativeFunction.CharacterTexture
|
if (function != NarrativeFunction.CharacterTexture
|
||||||
|
|
@ -1057,6 +1175,53 @@ public static class InterestVariety
|
||||||
: "routine:" + c.SubjectId + ":" + family;
|
: "routine:" + c.SubjectId + ":" + family;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void EnsureReplayKeys(InterestCandidate c)
|
||||||
|
{
|
||||||
|
string label = c.Label ?? "";
|
||||||
|
string key = c.Key ?? "";
|
||||||
|
string assetId = c.AssetId ?? "";
|
||||||
|
string source = c.Source ?? "";
|
||||||
|
string statusId = c.StatusId ?? "";
|
||||||
|
string happinessId = c.HappinessEffectId ?? "";
|
||||||
|
string actionId = c.NarrativePresentation?.ActionId ?? "";
|
||||||
|
if (c.ReplayKeysCached
|
||||||
|
&& c.ReplayCacheSubjectId == c.SubjectId
|
||||||
|
&& c.ReplayCacheCompletion == c.Completion
|
||||||
|
&& c.ReplayCacheFunction == c.NarrativeFunction
|
||||||
|
&& c.ReplayCacheHasFunction == c.HasNarrativeFunction
|
||||||
|
&& string.Equals(c.ReplayCacheLabel, label, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(c.ReplayCacheKey, key, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(c.ReplayCacheAssetId, assetId, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(c.ReplayCacheSource, source, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(c.ReplayCacheStatusId, statusId, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(
|
||||||
|
c.ReplayCacheHappinessEffectId, happinessId, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(c.ReplayCacheActionId, actionId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ReplayKeysCached = true;
|
||||||
|
c.ReplayCacheLabel = label;
|
||||||
|
c.ReplayCacheKey = key;
|
||||||
|
c.ReplayCacheAssetId = assetId;
|
||||||
|
c.ReplayCacheSource = source;
|
||||||
|
c.ReplayCacheStatusId = statusId;
|
||||||
|
c.ReplayCacheHappinessEffectId = happinessId;
|
||||||
|
c.ReplayCacheActionId = actionId;
|
||||||
|
c.ReplayCacheSubjectId = c.SubjectId;
|
||||||
|
c.ReplayCacheCompletion = c.Completion;
|
||||||
|
c.ReplayCacheFunction = c.NarrativeFunction;
|
||||||
|
c.ReplayCacheHasFunction = c.HasNarrativeFunction;
|
||||||
|
|
||||||
|
string normalizedLabel = EventReason.ReplayStructureKey(label);
|
||||||
|
c.CachedEditorialReplayKey = BuildEditorialReplayKey(c, normalizedLabel);
|
||||||
|
c.CachedExactReplayKey = string.IsNullOrEmpty(normalizedLabel)
|
||||||
|
? ""
|
||||||
|
: "exact:label:" + normalizedLabel;
|
||||||
|
c.CachedRoutineReplayKey = BuildRoutineReplayKey(c);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
|
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -1839,6 +2004,15 @@ public static class InterestVariety
|
||||||
yield return "beat:life:love:world";
|
yield return "beat:life:love:world";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The relationship feed reports the concrete birth first ("gains a child");
|
||||||
|
// happiness can report the same parent's reaction a frame later. They are one
|
||||||
|
// family chapter, so showing the fact suppresses that immediate emotional echo
|
||||||
|
// without cooling unrelated parents or later births world-wide.
|
||||||
|
if (string.Equals(asset, "add_child", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
yield return "beat:life:family:" + subject;
|
||||||
|
}
|
||||||
|
|
||||||
if (EventCatalog.Decision.IsLifeIntentDecision(asset))
|
if (EventCatalog.Decision.IsLifeIntentDecision(asset))
|
||||||
{
|
{
|
||||||
yield return "beat:asset:" + asset + ":world";
|
yield return "beat:asset:" + asset + ":world";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace IdleSpectator;
|
namespace IdleSpectator;
|
||||||
|
|
@ -16,32 +17,90 @@ public sealed class EpisodeShotProposal
|
||||||
/// <summary>Selects the best presentable shot that can explain its place in the active episode.</summary>
|
/// <summary>Selects the best presentable shot that can explain its place in the active episode.</summary>
|
||||||
public static class EpisodeShotSelector
|
public static class EpisodeShotSelector
|
||||||
{
|
{
|
||||||
|
public static float LastReplayFilterMs { get; private set; }
|
||||||
|
public static float LastPresentabilityMs { get; private set; }
|
||||||
|
public static float LastClassificationMs { get; private set; }
|
||||||
|
public static float LastScoringMs { get; private set; }
|
||||||
|
public static int LastPresentabilityChecks { get; private set; }
|
||||||
|
public static string LastSlowCandidateKey { get; private set; } = "";
|
||||||
|
public static float LastSlowCandidateMs { get; private set; }
|
||||||
|
|
||||||
public static EpisodeShotProposal Pick(
|
public static EpisodeShotProposal Pick(
|
||||||
IList<InterestCandidate> candidates,
|
IList<InterestCandidate> candidates,
|
||||||
EpisodePlan episode,
|
EpisodePlan episode,
|
||||||
NarrativeThread thread,
|
NarrativeThread thread,
|
||||||
bool requirePresentable = true)
|
bool requirePresentable = true)
|
||||||
{
|
{
|
||||||
|
ResetTiming();
|
||||||
if (candidates == null || candidates.Count == 0 || episode == null) return null;
|
if (candidates == null || candidates.Count == 0 || episode == null) return null;
|
||||||
EpisodeShotProposal bestRelated = null;
|
bool trace = IdleHitchProbe.Enabled;
|
||||||
EpisodeShotProposal bestInterrupt = null;
|
InterestCandidate bestRelatedCandidate = null;
|
||||||
|
NarrativeInterruptClass bestRelatedKind = NarrativeInterruptClass.OrdinaryInteresting;
|
||||||
|
EpisodeShotRole bestRelatedRole = EpisodeShotRole.Unknown;
|
||||||
|
string bestRelatedSemanticKey = "";
|
||||||
|
float bestRelatedScore = float.MinValue;
|
||||||
|
|
||||||
|
InterestCandidate bestInterruptCandidate = null;
|
||||||
|
NarrativeInterruptClass bestInterruptKind = NarrativeInterruptClass.OrdinaryInteresting;
|
||||||
|
EpisodeShotRole bestInterruptRole = EpisodeShotRole.Unknown;
|
||||||
|
float bestInterruptScore = float.MinValue;
|
||||||
|
|
||||||
for (int i = 0; i < candidates.Count; i++)
|
for (int i = 0; i < candidates.Count; i++)
|
||||||
{
|
{
|
||||||
InterestCandidate c = candidates[i];
|
InterestCandidate c = candidates[i];
|
||||||
bool harness = c != null && string.Equals(c.Source, "harness", System.StringComparison.OrdinalIgnoreCase);
|
bool harness = c != null && string.Equals(c.Source, "harness", System.StringComparison.OrdinalIgnoreCase);
|
||||||
if (c == null || c.Selected
|
if (c == null || c.Selected) continue;
|
||||||
|| InterestVariety.IsEditorialReplayCooled(c)
|
|
||||||
|| InterestVariety.IsRoutineReplayCooled(c)
|
long stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
|
||||||
|| !StoryScheduler.MayUseCombatShot(episode, c)
|
bool replayCooled =
|
||||||
|| (requirePresentable && !harness && !EventPresentability.WouldShow(c))) continue;
|
InterestVariety.IsEditorialReplayCooled(c)
|
||||||
|
|| InterestVariety.IsExactReplayCooled(c)
|
||||||
|
|| InterestVariety.IsRoutineReplayCooled(c);
|
||||||
|
if (trace)
|
||||||
|
{
|
||||||
|
LastReplayFilterMs += ElapsedMs(stageStarted);
|
||||||
|
}
|
||||||
|
if (replayCooled || !StoryScheduler.MayUseCombatShot(episode, c)) continue;
|
||||||
|
|
||||||
|
if (requirePresentable && !harness)
|
||||||
|
{
|
||||||
|
stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
|
||||||
|
bool presentable = EventPresentability.WouldShow(c);
|
||||||
|
if (trace)
|
||||||
|
{
|
||||||
|
float elapsed = ElapsedMs(stageStarted);
|
||||||
|
LastPresentabilityMs += elapsed;
|
||||||
|
LastPresentabilityChecks++;
|
||||||
|
if (elapsed > LastSlowCandidateMs)
|
||||||
|
{
|
||||||
|
LastSlowCandidateMs = elapsed;
|
||||||
|
LastSlowCandidateKey = c.Key ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!presentable) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
|
||||||
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
|
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
|
||||||
if (function == NarrativeFunction.Noise) continue;
|
if (function == NarrativeFunction.Noise)
|
||||||
|
{
|
||||||
|
if (trace)
|
||||||
|
{
|
||||||
|
LastClassificationMs += ElapsedMs(stageStarted);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread);
|
NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread);
|
||||||
EpisodeShotRole role = RoleFor(c, function, kind, episode);
|
EpisodeShotRole role = RoleFor(c, function, kind, episode);
|
||||||
string semanticKey = SemanticKey(c, role);
|
|
||||||
bool related = kind == NarrativeInterruptClass.RelatedEscalation;
|
bool related = kind == NarrativeInterruptClass.RelatedEscalation;
|
||||||
|
bool advances = related && AdvancesEpisode(episode, role);
|
||||||
|
string semanticKey = related && !advances ? SemanticKey(c, role) : "";
|
||||||
|
if (trace)
|
||||||
|
{
|
||||||
|
LastClassificationMs += ElapsedMs(stageStarted);
|
||||||
|
}
|
||||||
if (related
|
if (related
|
||||||
&& !AdvancesEpisode(episode, role)
|
&& !advances
|
||||||
&& episode.RecentSemanticKeys.Contains(semanticKey))
|
&& episode.RecentSemanticKeys.Contains(semanticKey))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -53,36 +112,91 @@ public static class EpisodeShotSelector
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
|
||||||
float score = EditorialScore(c, episode, kind, function, role);
|
float score = EditorialScore(c, episode, kind, function, role);
|
||||||
var proposal = new EpisodeShotProposal
|
if (trace)
|
||||||
{
|
{
|
||||||
Candidate = c,
|
LastScoringMs += ElapsedMs(stageStarted);
|
||||||
InterruptClass = kind,
|
}
|
||||||
EditorialScore = score,
|
|
||||||
Role = role,
|
|
||||||
SemanticKey = semanticKey,
|
|
||||||
Reason = Reason(kind) + ":" + role
|
|
||||||
};
|
|
||||||
if (kind == NarrativeInterruptClass.RelatedEscalation)
|
if (kind == NarrativeInterruptClass.RelatedEscalation)
|
||||||
{
|
{
|
||||||
if (bestRelated == null || score > bestRelated.EditorialScore) bestRelated = proposal;
|
if (bestRelatedCandidate == null || score > bestRelatedScore)
|
||||||
|
{
|
||||||
|
bestRelatedCandidate = c;
|
||||||
|
bestRelatedKind = kind;
|
||||||
|
bestRelatedRole = role;
|
||||||
|
bestRelatedSemanticKey = string.IsNullOrEmpty(semanticKey)
|
||||||
|
? SemanticKey(c, role)
|
||||||
|
: semanticKey;
|
||||||
|
bestRelatedScore = score;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (InterruptPolicy.MayInterrupt(kind)
|
else if (InterruptPolicy.MayInterrupt(kind)
|
||||||
&& (bestInterrupt == null || score > bestInterrupt.EditorialScore))
|
&& (bestInterruptCandidate == null || score > bestInterruptScore))
|
||||||
{
|
{
|
||||||
bestInterrupt = proposal;
|
bestInterruptCandidate = c;
|
||||||
|
bestInterruptKind = kind;
|
||||||
|
bestInterruptRole = role;
|
||||||
|
bestInterruptScore = score;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A productive related development owns the episode. Critical/payoff material interrupts
|
// A productive related development owns the episode. Critical/payoff material interrupts
|
||||||
// only when no related shot is ready, or when its editorial advantage is decisive.
|
// only when no related shot is ready, or when its editorial advantage is decisive.
|
||||||
if (bestRelated != null
|
if (bestRelatedCandidate != null
|
||||||
&& (bestInterrupt == null || bestInterrupt.EditorialScore < bestRelated.EditorialScore + 25f))
|
&& (bestInterruptCandidate == null || bestInterruptScore < bestRelatedScore + 25f))
|
||||||
{
|
{
|
||||||
return bestRelated;
|
return BuildProposal(
|
||||||
|
bestRelatedCandidate,
|
||||||
|
bestRelatedKind,
|
||||||
|
bestRelatedRole,
|
||||||
|
bestRelatedSemanticKey,
|
||||||
|
bestRelatedScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestInterrupt ?? bestRelated;
|
if (bestInterruptCandidate != null)
|
||||||
|
{
|
||||||
|
return BuildProposal(
|
||||||
|
bestInterruptCandidate,
|
||||||
|
bestInterruptKind,
|
||||||
|
bestInterruptRole,
|
||||||
|
SemanticKey(bestInterruptCandidate, bestInterruptRole),
|
||||||
|
bestInterruptScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ResetTiming()
|
||||||
|
{
|
||||||
|
LastReplayFilterMs = 0f;
|
||||||
|
LastPresentabilityMs = 0f;
|
||||||
|
LastClassificationMs = 0f;
|
||||||
|
LastScoringMs = 0f;
|
||||||
|
LastPresentabilityChecks = 0;
|
||||||
|
LastSlowCandidateKey = "";
|
||||||
|
LastSlowCandidateMs = 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ElapsedMs(long started) =>
|
||||||
|
(float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency);
|
||||||
|
|
||||||
|
private static EpisodeShotProposal BuildProposal(
|
||||||
|
InterestCandidate candidate,
|
||||||
|
NarrativeInterruptClass kind,
|
||||||
|
EpisodeShotRole role,
|
||||||
|
string semanticKey,
|
||||||
|
float score)
|
||||||
|
{
|
||||||
|
return new EpisodeShotProposal
|
||||||
|
{
|
||||||
|
Candidate = candidate,
|
||||||
|
InterruptClass = kind,
|
||||||
|
EditorialScore = score,
|
||||||
|
Role = role,
|
||||||
|
SemanticKey = semanticKey ?? "",
|
||||||
|
Reason = Reason(kind) + ":" + role
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float EditorialScore(
|
private static float EditorialScore(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
namespace IdleSpectator;
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
|
@ -51,4 +52,53 @@ public static class NarrativeEventStore
|
||||||
Events[record.Id] = record;
|
Events[record.Id] = record;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keep the event side of the runtime graph aligned with retained developments.
|
||||||
|
/// Recent unprojected observations fill any remaining room so dedupe remains useful.
|
||||||
|
/// </summary>
|
||||||
|
internal static int Compact(HashSet<string> retainedDevelopmentIds, int max)
|
||||||
|
{
|
||||||
|
if (Events.Count <= max || max <= 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var retained = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var ranked = new List<NarrativeEventRecord>(Events.Values);
|
||||||
|
ranked.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
bool aLinked = a != null
|
||||||
|
&& retainedDevelopmentIds != null
|
||||||
|
&& retainedDevelopmentIds.Contains(a.DevelopmentId ?? "");
|
||||||
|
bool bLinked = b != null
|
||||||
|
&& retainedDevelopmentIds != null
|
||||||
|
&& retainedDevelopmentIds.Contains(b.DevelopmentId ?? "");
|
||||||
|
if (aLinked != bLinked) return aLinked ? -1 : 1;
|
||||||
|
float aAt = a?.ObservedAt ?? float.MinValue;
|
||||||
|
float bAt = b?.ObservedAt ?? float.MinValue;
|
||||||
|
int observed = bAt.CompareTo(aAt);
|
||||||
|
if (observed != 0) return observed;
|
||||||
|
return (b?.WorldTime ?? double.MinValue).CompareTo(
|
||||||
|
a?.WorldTime ?? double.MinValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (int i = 0; i < ranked.Count && retained.Count < max; i++)
|
||||||
|
{
|
||||||
|
NarrativeEventRecord record = ranked[i];
|
||||||
|
if (record != null && !string.IsNullOrEmpty(record.Id))
|
||||||
|
{
|
||||||
|
retained.Add(record.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int before = Events.Count;
|
||||||
|
var remove = new List<string>();
|
||||||
|
foreach (string id in Events.Keys)
|
||||||
|
{
|
||||||
|
if (!retained.Contains(id)) remove.Add(id);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < remove.Count; i++) Events.Remove(remove[i]);
|
||||||
|
return Mathf.Max(0, before - Events.Count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,12 +90,13 @@ public static class NarrativeExposureLedger
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Two routine combat cuts may establish a conflict; another story family must then
|
/// Two combat cuts may establish and escalate a conflict; another story family must
|
||||||
/// receive space. World-critical combat bypasses this global exposure cap.
|
/// then receive space. Criticality affects which combat cut wins, not whether combat
|
||||||
|
/// may exceed the global story-first exposure budget.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool MayPresent(InterestCandidate candidate)
|
public static bool MayPresent(InterestCandidate candidate)
|
||||||
{
|
{
|
||||||
if (!IsCombat(candidate) || candidate.EventStrength >= 95f)
|
if (!IsCombat(candidate))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -150,18 +151,19 @@ public static class NarrativeExposureLedger
|
||||||
NotePresented(combat);
|
NotePresented(combat);
|
||||||
NotePresented(combat);
|
NotePresented(combat);
|
||||||
bool routineBlocked = !MayPresent(combat);
|
bool routineBlocked = !MayPresent(combat);
|
||||||
bool criticalAllowed = MayPresent(criticalCombat);
|
bool criticalBounded = !MayPresent(criticalCombat);
|
||||||
for (int i = 0; i < 5; i++) NotePresented(family);
|
for (int i = 0; i < 5; i++) NotePresented(family);
|
||||||
bool routineReopened = MayPresent(combat);
|
bool routineReopened = MayPresent(combat);
|
||||||
bool ok = underCovered > prolific
|
bool ok = underCovered > prolific
|
||||||
&& repetition > 0f
|
&& repetition > 0f
|
||||||
&& diverse == 0f
|
&& diverse == 0f
|
||||||
&& routineBlocked
|
&& routineBlocked
|
||||||
&& criticalAllowed
|
&& criticalBounded
|
||||||
&& routineReopened;
|
&& routineReopened;
|
||||||
detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0")
|
detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0")
|
||||||
+ " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0")
|
+ " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0")
|
||||||
+ " combat=" + routineBlocked + "/" + criticalAllowed + "/" + routineReopened
|
+ " combat=" + routineBlocked + "/" + criticalBounded
|
||||||
|
+ "/" + routineReopened
|
||||||
+ " pass=" + ok;
|
+ " pass=" + ok;
|
||||||
Clear();
|
Clear();
|
||||||
return ok;
|
return ok;
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,8 @@ public sealed class NarrativeCastRole
|
||||||
/// <summary>Durable unresolved question centered on one life.</summary>
|
/// <summary>Durable unresolved question centered on one life.</summary>
|
||||||
public sealed class NarrativeThread
|
public sealed class NarrativeThread
|
||||||
{
|
{
|
||||||
|
private const int MaxRetainedCast = 16;
|
||||||
|
|
||||||
public string Id = "";
|
public string Id = "";
|
||||||
public NarrativeThreadKind Kind;
|
public NarrativeThreadKind Kind;
|
||||||
public long ProtagonistId;
|
public long ProtagonistId;
|
||||||
|
|
@ -177,6 +179,18 @@ public sealed class NarrativeThread
|
||||||
{
|
{
|
||||||
if (id == 0 || HasCast(id)) return;
|
if (id == 0 || HasCast(id)) return;
|
||||||
Cast.Add(new NarrativeCastRole { CharacterId = id, Role = role ?? "" });
|
Cast.Add(new NarrativeCastRole { CharacterId = id, Role = role ?? "" });
|
||||||
|
while (Cast.Count > MaxRetainedCast)
|
||||||
|
{
|
||||||
|
int removeAt = 0;
|
||||||
|
while (removeAt < Cast.Count
|
||||||
|
&& Cast[removeAt] != null
|
||||||
|
&& Cast[removeAt].CharacterId == ProtagonistId)
|
||||||
|
{
|
||||||
|
removeAt++;
|
||||||
|
}
|
||||||
|
if (removeAt >= Cast.Count) break;
|
||||||
|
Cast.RemoveAt(removeAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ public static class NarrativeStoryStore
|
||||||
private const int ConsequencesPerCharacter = 24;
|
private const int ConsequencesPerCharacter = 24;
|
||||||
private const int OpenThreadsPerCharacter = 16;
|
private const int OpenThreadsPerCharacter = 16;
|
||||||
private const int ResolvedThreadsPerCharacter = 24;
|
private const int ResolvedThreadsPerCharacter = 24;
|
||||||
|
private const int DevelopmentsPerThread = 16;
|
||||||
|
private const int MaxRuntimeEvents = 2200;
|
||||||
|
private const int MaxRuntimeDevelopments = 2000;
|
||||||
|
private const int MaxRuntimeThreads = 650;
|
||||||
|
private const int MaxRuntimeConsequences = 1200;
|
||||||
|
private const int MaxRuntimeCharacters = 2000;
|
||||||
|
private const float RuntimeCompactionIntervalSeconds = 60f;
|
||||||
internal const int SchedulerCandidateLimit = 96;
|
internal const int SchedulerCandidateLimit = 96;
|
||||||
internal const int SchedulerCopyLimit = 128;
|
internal const int SchedulerCopyLimit = 128;
|
||||||
private static readonly Dictionary<string, NarrativeDevelopment> Developments =
|
private static readonly Dictionary<string, NarrativeDevelopment> Developments =
|
||||||
|
|
@ -33,11 +40,15 @@ public static class NarrativeStoryStore
|
||||||
private static readonly Dictionary<string, HashSet<long>> ConflictCastByThread =
|
private static readonly Dictionary<string, HashSet<long>> ConflictCastByThread =
|
||||||
new Dictionary<string, HashSet<long>>(StringComparer.Ordinal);
|
new Dictionary<string, HashSet<long>>(StringComparer.Ordinal);
|
||||||
private static readonly List<string> IdScratch = new List<string>(SchedulerCandidateLimit);
|
private static readonly List<string> IdScratch = new List<string>(SchedulerCandidateLimit);
|
||||||
|
private static float _nextRuntimeCompactionAt;
|
||||||
|
|
||||||
public static int Revision { get; private set; }
|
public static int Revision { get; private set; }
|
||||||
public static int DevelopmentCount => Developments.Count;
|
public static int DevelopmentCount => Developments.Count;
|
||||||
public static int ThreadCount => Threads.Count;
|
public static int ThreadCount => Threads.Count;
|
||||||
public static int CharacterCount => Characters.Count;
|
public static int CharacterCount => Characters.Count;
|
||||||
|
public static int ConsequenceCount => Consequences.Count;
|
||||||
|
public static float LastCompactionMs { get; private set; }
|
||||||
|
public static int CompactionCount { get; private set; }
|
||||||
|
|
||||||
public static void Clear()
|
public static void Clear()
|
||||||
{
|
{
|
||||||
|
|
@ -52,6 +63,9 @@ public static class NarrativeStoryStore
|
||||||
OpenConflictsByCharacter.Clear();
|
OpenConflictsByCharacter.Clear();
|
||||||
ConflictCastByThread.Clear();
|
ConflictCastByThread.Clear();
|
||||||
IdScratch.Clear();
|
IdScratch.Clear();
|
||||||
|
_nextRuntimeCompactionAt = 0f;
|
||||||
|
LastCompactionMs = 0f;
|
||||||
|
CompactionCount = 0;
|
||||||
NarrativeEventStore.Clear();
|
NarrativeEventStore.Clear();
|
||||||
Revision++;
|
Revision++;
|
||||||
StoryScheduler.Clear();
|
StoryScheduler.Clear();
|
||||||
|
|
@ -189,14 +203,19 @@ public static class NarrativeStoryStore
|
||||||
}
|
}
|
||||||
|
|
||||||
Developments[development.Id] = development;
|
Developments[development.Id] = development;
|
||||||
CharacterStoryState subject = GetOrCreateCharacter(development.SubjectId, development.Subject);
|
if (ShouldProjectDevelopment(development))
|
||||||
TouchDevelopment(subject, development);
|
|
||||||
if (development.OtherId != 0)
|
|
||||||
{
|
{
|
||||||
GetOrCreateCharacter(development.OtherId, development.Other);
|
CharacterStoryState subject =
|
||||||
}
|
GetOrCreateCharacter(development.SubjectId, development.Subject);
|
||||||
|
TouchDevelopment(subject, development);
|
||||||
|
if (development.OtherId != 0)
|
||||||
|
{
|
||||||
|
GetOrCreateCharacter(development.OtherId, development.Other);
|
||||||
|
}
|
||||||
|
|
||||||
NarrativeThreadRules.Apply(development);
|
NarrativeThreadRules.Apply(development);
|
||||||
|
}
|
||||||
|
MaybeCompactRuntimeGraph();
|
||||||
Revision++;
|
Revision++;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -236,6 +255,7 @@ public static class NarrativeStoryStore
|
||||||
thread.UpdatedAt = development.OccurredAt;
|
thread.UpdatedAt = development.OccurredAt;
|
||||||
thread.LatestMeaningfulChangeId = development.Id;
|
thread.LatestMeaningfulChangeId = development.Id;
|
||||||
if (!thread.DevelopmentIds.Contains(development.Id)) thread.DevelopmentIds.Add(development.Id);
|
if (!thread.DevelopmentIds.Contains(development.Id)) thread.DevelopmentIds.Add(development.Id);
|
||||||
|
TrimThreadDevelopmentHistory(thread);
|
||||||
thread.AddCast(protagonistId, "protagonist");
|
thread.AddCast(protagonistId, "protagonist");
|
||||||
if (development.OtherId != 0) thread.AddCast(development.OtherId, CastRole(development.Kind));
|
if (development.OtherId != 0) thread.AddCast(development.OtherId, CastRole(development.Kind));
|
||||||
thread.Momentum = Mathf.Min(12f, thread.Momentum + MomentumFor(development.Function));
|
thread.Momentum = Mathf.Min(12f, thread.Momentum + MomentumFor(development.Function));
|
||||||
|
|
@ -252,6 +272,18 @@ public static class NarrativeStoryStore
|
||||||
return thread;
|
return thread;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void TrimThreadDevelopmentHistory(NarrativeThread thread)
|
||||||
|
{
|
||||||
|
if (thread == null) return;
|
||||||
|
while (thread.DevelopmentIds.Count > DevelopmentsPerThread)
|
||||||
|
{
|
||||||
|
// The opening evidence remains useful for episode/Legacy context. Evict the
|
||||||
|
// oldest middle beat while retaining the newest state transition.
|
||||||
|
int removeAt = thread.DevelopmentIds.Count > 1 ? 1 : 0;
|
||||||
|
thread.DevelopmentIds.RemoveAt(removeAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void AddConsequence(CharacterConsequence consequence)
|
public static void AddConsequence(CharacterConsequence consequence)
|
||||||
{
|
{
|
||||||
if (consequence == null || consequence.CharacterId == 0 || string.IsNullOrEmpty(consequence.Id)) return;
|
if (consequence == null || consequence.CharacterId == 0 || string.IsNullOrEmpty(consequence.Id)) return;
|
||||||
|
|
@ -494,6 +526,7 @@ public static class NarrativeStoryStore
|
||||||
int repaired = 0;
|
int repaired = 0;
|
||||||
foreach (NarrativeThread thread in Threads.Values)
|
foreach (NarrativeThread thread in Threads.Values)
|
||||||
{
|
{
|
||||||
|
TrimThreadDevelopmentHistory(thread);
|
||||||
if (thread != null
|
if (thread != null
|
||||||
&& thread.Kind == NarrativeThreadKind.Founding
|
&& thread.Kind == NarrativeThreadKind.Founding
|
||||||
&& thread.IsOpen
|
&& thread.IsOpen
|
||||||
|
|
@ -559,6 +592,398 @@ public static class NarrativeStoryStore
|
||||||
return repaired;
|
return repaired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void MaybeCompactRuntimeGraph()
|
||||||
|
{
|
||||||
|
bool overHighWater =
|
||||||
|
NarrativeEventStore.Count > MaxRuntimeEvents * 3 / 2
|
||||||
|
|| Developments.Count > MaxRuntimeDevelopments * 3 / 2
|
||||||
|
|| Threads.Count > MaxRuntimeThreads * 3 / 2
|
||||||
|
|| Consequences.Count > MaxRuntimeConsequences * 3 / 2
|
||||||
|
|| Characters.Count > MaxRuntimeCharacters * 3 / 2;
|
||||||
|
if (!overHighWater)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float now = Time.unscaledTime;
|
||||||
|
bool emergency =
|
||||||
|
NarrativeEventStore.Count > MaxRuntimeEvents * 2
|
||||||
|
|| Developments.Count > MaxRuntimeDevelopments * 2
|
||||||
|
|| Threads.Count > MaxRuntimeThreads * 2
|
||||||
|
|| Consequences.Count > MaxRuntimeConsequences * 2
|
||||||
|
|| Characters.Count > MaxRuntimeCharacters * 2;
|
||||||
|
if (!emergency && now < _nextRuntimeCompactionAt)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CompactRuntimeGraph(force: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Birth evidence remains in the event/development stores for persistence and dedupe.
|
||||||
|
/// A full mirrored lineage graph is useful only once the family touches the authored cast;
|
||||||
|
/// projecting every anonymous newborn was creating hundreds of disposable threads per minute.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ShouldProjectDevelopment(NarrativeDevelopment development)
|
||||||
|
{
|
||||||
|
if (development == null
|
||||||
|
|| development.Kind != NarrativeDevelopmentKind.ChildBorn
|
||||||
|
|| (!string.IsNullOrEmpty(development.EvidenceSource)
|
||||||
|
&& development.EvidenceSource.StartsWith(
|
||||||
|
"harness", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LifeSagaRoster.IsMc(development.SubjectId)
|
||||||
|
|| LifeSagaRoster.IsPrefer(development.SubjectId)
|
||||||
|
|| LifeSagaRoster.IsMcCast(development.SubjectId)
|
||||||
|
|| LifeSagaRoster.IsMc(development.OtherId)
|
||||||
|
|| LifeSagaRoster.IsPrefer(development.OtherId)
|
||||||
|
|| LifeSagaRoster.IsMcCast(development.OtherId))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
EpisodePlan episode = StoryScheduler.ActiveEpisode;
|
||||||
|
if (episode == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (episode.ProtagonistId == development.SubjectId
|
||||||
|
|| episode.ProtagonistId == development.OtherId)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
NarrativeThread active = Thread(episode.ThreadId);
|
||||||
|
return active != null
|
||||||
|
&& (active.HasCast(development.SubjectId)
|
||||||
|
|| active.HasCast(development.OtherId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounds the live narrative graph while protecting the active episode and the
|
||||||
|
/// Saga cast. Persistence already compacts its sidecar; this prevents long-running
|
||||||
|
/// worlds from retaining every anonymous one-shot thread until the next reload.
|
||||||
|
/// </summary>
|
||||||
|
private static bool CompactRuntimeGraph(bool force)
|
||||||
|
{
|
||||||
|
bool needsCompaction =
|
||||||
|
NarrativeEventStore.Count > MaxRuntimeEvents
|
||||||
|
|| Developments.Count > MaxRuntimeDevelopments
|
||||||
|
|| Threads.Count > MaxRuntimeThreads
|
||||||
|
|| Consequences.Count > MaxRuntimeConsequences
|
||||||
|
|| Characters.Count > MaxRuntimeCharacters;
|
||||||
|
if (!force && !needsCompaction)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
int beforeEvents = NarrativeEventStore.Count;
|
||||||
|
int beforeDevelopments = Developments.Count;
|
||||||
|
int beforeThreads = Threads.Count;
|
||||||
|
int beforeConsequences = Consequences.Count;
|
||||||
|
int beforeCharacters = Characters.Count;
|
||||||
|
float now = Time.unscaledTime;
|
||||||
|
|
||||||
|
var protectedCharacters = new HashSet<long>();
|
||||||
|
LifeSagaRoster.CopySlots(RosterScratch);
|
||||||
|
for (int i = 0; i < RosterScratch.Count; i++)
|
||||||
|
{
|
||||||
|
long id = RosterScratch[i]?.UnitId ?? 0;
|
||||||
|
if (id != 0) protectedCharacters.Add(id);
|
||||||
|
}
|
||||||
|
RosterScratch.Clear();
|
||||||
|
|
||||||
|
EpisodePlan episode = StoryScheduler.ActiveEpisode;
|
||||||
|
if (episode != null && episode.ProtagonistId != 0)
|
||||||
|
{
|
||||||
|
protectedCharacters.Add(episode.ProtagonistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var retainedThreadIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
if (episode != null && !string.IsNullOrEmpty(episode.ThreadId)
|
||||||
|
&& Threads.ContainsKey(episode.ThreadId))
|
||||||
|
{
|
||||||
|
retainedThreadIds.Add(episode.ThreadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (long characterId in protectedCharacters)
|
||||||
|
{
|
||||||
|
CharacterStoryState state = Character(characterId);
|
||||||
|
if (state == null) continue;
|
||||||
|
AddExistingIds(state.OpenThreadIds, Threads, retainedThreadIds);
|
||||||
|
AddExistingIds(state.ResolvedThreadIds, Threads, retainedThreadIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThreadScratch.Clear();
|
||||||
|
foreach (NarrativeThread thread in Threads.Values)
|
||||||
|
{
|
||||||
|
if (thread != null && !retainedThreadIds.Contains(thread.Id))
|
||||||
|
{
|
||||||
|
ThreadScratch.Add(thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ThreadScratch.Sort((a, b) =>
|
||||||
|
ThreadRetentionScore(b, now).CompareTo(ThreadRetentionScore(a, now)));
|
||||||
|
for (int i = 0;
|
||||||
|
i < ThreadScratch.Count && retainedThreadIds.Count < MaxRuntimeThreads;
|
||||||
|
i++)
|
||||||
|
{
|
||||||
|
retainedThreadIds.Add(ThreadScratch[i].Id);
|
||||||
|
}
|
||||||
|
ThreadScratch.Clear();
|
||||||
|
|
||||||
|
var retainedDevelopmentIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (string threadId in retainedThreadIds)
|
||||||
|
{
|
||||||
|
NarrativeThread thread = Thread(threadId);
|
||||||
|
AddExistingId(thread?.LatestMeaningfulChangeId, Developments, retainedDevelopmentIds);
|
||||||
|
}
|
||||||
|
foreach (long characterId in protectedCharacters)
|
||||||
|
{
|
||||||
|
CharacterStoryState state = Character(characterId);
|
||||||
|
if (state == null) continue;
|
||||||
|
AddExistingIds(
|
||||||
|
state.RecentDevelopmentIds,
|
||||||
|
Developments,
|
||||||
|
retainedDevelopmentIds,
|
||||||
|
MaxRuntimeDevelopments);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rankedConsequences = new List<CharacterConsequence>(Consequences.Values);
|
||||||
|
rankedConsequences.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
bool aProtected = a != null && protectedCharacters.Contains(a.CharacterId);
|
||||||
|
bool bProtected = b != null && protectedCharacters.Contains(b.CharacterId);
|
||||||
|
if (aProtected != bProtected) return aProtected ? -1 : 1;
|
||||||
|
int importance = (b?.Importance ?? float.MinValue).CompareTo(
|
||||||
|
a?.Importance ?? float.MinValue);
|
||||||
|
if (importance != 0) return importance;
|
||||||
|
return (b?.OccurredAt ?? float.MinValue).CompareTo(
|
||||||
|
a?.OccurredAt ?? float.MinValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
var retainedConsequenceIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
for (int i = 0;
|
||||||
|
i < rankedConsequences.Count
|
||||||
|
&& retainedConsequenceIds.Count < MaxRuntimeConsequences;
|
||||||
|
i++)
|
||||||
|
{
|
||||||
|
CharacterConsequence consequence = rankedConsequences[i];
|
||||||
|
if (consequence == null || string.IsNullOrEmpty(consequence.Id)
|
||||||
|
|| string.IsNullOrEmpty(consequence.DevelopmentId)
|
||||||
|
|| !Developments.ContainsKey(consequence.DevelopmentId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!retainedDevelopmentIds.Contains(consequence.DevelopmentId)
|
||||||
|
&& retainedDevelopmentIds.Count >= MaxRuntimeDevelopments)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
retainedDevelopmentIds.Add(consequence.DevelopmentId);
|
||||||
|
retainedConsequenceIds.Add(consequence.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rankedDevelopments = new List<NarrativeDevelopment>(Developments.Values);
|
||||||
|
rankedDevelopments.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
float aScore = DevelopmentRetentionScore(a);
|
||||||
|
float bScore = DevelopmentRetentionScore(b);
|
||||||
|
int score = bScore.CompareTo(aScore);
|
||||||
|
if (score != 0) return score;
|
||||||
|
return (b?.OccurredAt ?? float.MinValue).CompareTo(
|
||||||
|
a?.OccurredAt ?? float.MinValue);
|
||||||
|
});
|
||||||
|
for (int i = 0;
|
||||||
|
i < rankedDevelopments.Count
|
||||||
|
&& retainedDevelopmentIds.Count < MaxRuntimeDevelopments;
|
||||||
|
i++)
|
||||||
|
{
|
||||||
|
NarrativeDevelopment development = rankedDevelopments[i];
|
||||||
|
if (development != null && !string.IsNullOrEmpty(development.Id))
|
||||||
|
{
|
||||||
|
retainedDevelopmentIds.Add(development.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveUnretained(Threads, retainedThreadIds);
|
||||||
|
RemoveUnretained(Consequences, retainedConsequenceIds);
|
||||||
|
RemoveUnretained(Developments, retainedDevelopmentIds);
|
||||||
|
|
||||||
|
foreach (NarrativeThread thread in Threads.Values)
|
||||||
|
{
|
||||||
|
if (thread == null) continue;
|
||||||
|
thread.DevelopmentIds.RemoveAll(id => !retainedDevelopmentIds.Contains(id));
|
||||||
|
if (!string.IsNullOrEmpty(thread.LatestMeaningfulChangeId)
|
||||||
|
&& retainedDevelopmentIds.Contains(thread.LatestMeaningfulChangeId)
|
||||||
|
&& !thread.DevelopmentIds.Contains(thread.LatestMeaningfulChangeId))
|
||||||
|
{
|
||||||
|
thread.DevelopmentIds.Add(thread.LatestMeaningfulChangeId);
|
||||||
|
}
|
||||||
|
if (!retainedDevelopmentIds.Contains(thread.OpenedByDevelopmentId))
|
||||||
|
{
|
||||||
|
thread.OpenedByDevelopmentId = thread.DevelopmentIds.Count > 0
|
||||||
|
? thread.DevelopmentIds[0]
|
||||||
|
: thread.LatestMeaningfulChangeId;
|
||||||
|
}
|
||||||
|
TrimThreadDevelopmentHistory(thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (CharacterConsequence consequence in Consequences.Values)
|
||||||
|
{
|
||||||
|
if (consequence != null && !retainedThreadIds.Contains(consequence.ThreadId))
|
||||||
|
{
|
||||||
|
consequence.ThreadId = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NarrativeEventStore.Compact(retainedDevelopmentIds, MaxRuntimeEvents);
|
||||||
|
|
||||||
|
var retainedCharacterIds = new HashSet<long>(protectedCharacters);
|
||||||
|
foreach (NarrativeThread thread in Threads.Values)
|
||||||
|
{
|
||||||
|
if (thread != null && thread.ProtagonistId != 0)
|
||||||
|
{
|
||||||
|
retainedCharacterIds.Add(thread.ProtagonistId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (CharacterConsequence consequence in Consequences.Values)
|
||||||
|
{
|
||||||
|
if (consequence == null) continue;
|
||||||
|
if (consequence.CharacterId != 0) retainedCharacterIds.Add(consequence.CharacterId);
|
||||||
|
if (consequence.OtherId != 0
|
||||||
|
&& retainedCharacterIds.Count < MaxRuntimeCharacters)
|
||||||
|
{
|
||||||
|
retainedCharacterIds.Add(consequence.OtherId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rankedCharacters = new List<CharacterStoryState>(Characters.Values);
|
||||||
|
rankedCharacters.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
float aScore = EffectiveStoryPotential(a, now);
|
||||||
|
float bScore = EffectiveStoryPotential(b, now);
|
||||||
|
int score = bScore.CompareTo(aScore);
|
||||||
|
if (score != 0) return score;
|
||||||
|
return (b?.LastMeaningfulChangeAt ?? float.MinValue).CompareTo(
|
||||||
|
a?.LastMeaningfulChangeAt ?? float.MinValue);
|
||||||
|
});
|
||||||
|
for (int i = 0;
|
||||||
|
i < rankedCharacters.Count
|
||||||
|
&& retainedCharacterIds.Count < MaxRuntimeCharacters;
|
||||||
|
i++)
|
||||||
|
{
|
||||||
|
CharacterStoryState state = rankedCharacters[i];
|
||||||
|
if (state != null && state.CharacterId != 0)
|
||||||
|
{
|
||||||
|
retainedCharacterIds.Add(state.CharacterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RemoveUnretained(Characters, retainedCharacterIds);
|
||||||
|
|
||||||
|
RepairConsistency();
|
||||||
|
Revision++;
|
||||||
|
stopwatch.Stop();
|
||||||
|
LastCompactionMs = (float)stopwatch.Elapsed.TotalMilliseconds;
|
||||||
|
CompactionCount++;
|
||||||
|
_nextRuntimeCompactionAt = now + RuntimeCompactionIntervalSeconds;
|
||||||
|
|
||||||
|
NeoModLoader.services.LogService.LogInfo(
|
||||||
|
"[IdleSpectator][NARRATIVE] runtime compact "
|
||||||
|
+ "events=" + beforeEvents + "->" + NarrativeEventStore.Count
|
||||||
|
+ " developments=" + beforeDevelopments + "->" + Developments.Count
|
||||||
|
+ " threads=" + beforeThreads + "->" + Threads.Count
|
||||||
|
+ " consequences=" + beforeConsequences + "->" + Consequences.Count
|
||||||
|
+ " characters=" + beforeCharacters + "->" + Characters.Count
|
||||||
|
+ " ms=" + LastCompactionMs.ToString("0.0"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ThreadRetentionScore(NarrativeThread thread, float now)
|
||||||
|
{
|
||||||
|
if (thread == null) return float.MinValue;
|
||||||
|
float age = Mathf.Max(0f, now - thread.UpdatedAt);
|
||||||
|
float freshness = Mathf.Max(0f, 60f - age / 10f);
|
||||||
|
float open = thread.IsOpen ? 45f : 0f;
|
||||||
|
float phase = thread.Phase == NarrativePhase.TurningPoint ? 24f
|
||||||
|
: thread.Phase == NarrativePhase.Outcome ? 18f
|
||||||
|
: thread.Phase == NarrativePhase.Consequence ? 14f : 0f;
|
||||||
|
return freshness + open + phase + thread.Urgency * 3f
|
||||||
|
+ thread.Momentum + thread.CoverageDebt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float DevelopmentRetentionScore(NarrativeDevelopment development)
|
||||||
|
{
|
||||||
|
if (development == null) return float.MinValue;
|
||||||
|
float function;
|
||||||
|
switch (development.Function)
|
||||||
|
{
|
||||||
|
case NarrativeFunction.TurningPoint: function = 60f; break;
|
||||||
|
case NarrativeFunction.Resolution: function = 55f; break;
|
||||||
|
case NarrativeFunction.Consequence: function = 50f; break;
|
||||||
|
case NarrativeFunction.Escalation: function = 40f; break;
|
||||||
|
case NarrativeFunction.Development: function = 30f; break;
|
||||||
|
case NarrativeFunction.ThreadOpener: function = 25f; break;
|
||||||
|
default: function = 0f; break;
|
||||||
|
}
|
||||||
|
return function + development.Magnitude * 0.2f
|
||||||
|
+ development.Confidence * 5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddExistingId<T>(
|
||||||
|
string id,
|
||||||
|
Dictionary<string, T> source,
|
||||||
|
HashSet<string> retained)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(id) && source.ContainsKey(id))
|
||||||
|
{
|
||||||
|
retained.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddExistingIds<T>(
|
||||||
|
List<string> ids,
|
||||||
|
Dictionary<string, T> source,
|
||||||
|
HashSet<string> retained,
|
||||||
|
int max = int.MaxValue)
|
||||||
|
{
|
||||||
|
if (ids == null) return;
|
||||||
|
for (int i = 0; i < ids.Count && retained.Count < max; i++)
|
||||||
|
{
|
||||||
|
AddExistingId(ids[i], source, retained);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveUnretained<T>(
|
||||||
|
Dictionary<string, T> source,
|
||||||
|
HashSet<string> retained)
|
||||||
|
{
|
||||||
|
var remove = new List<string>();
|
||||||
|
foreach (string id in source.Keys)
|
||||||
|
{
|
||||||
|
if (!retained.Contains(id)) remove.Add(id);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < remove.Count; i++) source.Remove(remove[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveUnretained<T>(
|
||||||
|
Dictionary<long, T> source,
|
||||||
|
HashSet<long> retained)
|
||||||
|
{
|
||||||
|
var remove = new List<long>();
|
||||||
|
foreach (long id in source.Keys)
|
||||||
|
{
|
||||||
|
if (!retained.Contains(id)) remove.Add(id);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < remove.Count; i++) source.Remove(remove[i]);
|
||||||
|
}
|
||||||
|
|
||||||
public static bool HarnessProbeCombatLifecycle(out string detail)
|
public static bool HarnessProbeCombatLifecycle(out string detail)
|
||||||
{
|
{
|
||||||
Clear();
|
Clear();
|
||||||
|
|
@ -697,6 +1122,121 @@ public static class NarrativeStoryStore
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool HarnessProbeRuntimeCompaction(out string detail)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
Record(new NarrativeDevelopment
|
||||||
|
{
|
||||||
|
Id = "probe:compact:ambient-child",
|
||||||
|
Kind = NarrativeDevelopmentKind.ChildBorn,
|
||||||
|
Function = NarrativeFunction.Consequence,
|
||||||
|
SubjectId = 9000001,
|
||||||
|
OtherId = 9000002,
|
||||||
|
FamilyKey = "probe:ambient-family",
|
||||||
|
EvidenceSource = "natural_soak",
|
||||||
|
OccurredAt = 1f,
|
||||||
|
Magnitude = 76f
|
||||||
|
});
|
||||||
|
bool ambientBirthDeferred = ThreadCount == 0 && CharacterCount == 0;
|
||||||
|
Record(new NarrativeDevelopment
|
||||||
|
{
|
||||||
|
Id = "probe:compact:authored-child",
|
||||||
|
Kind = NarrativeDevelopmentKind.ChildBorn,
|
||||||
|
Function = NarrativeFunction.Consequence,
|
||||||
|
SubjectId = 9000011,
|
||||||
|
OtherId = 9000012,
|
||||||
|
FamilyKey = "probe:authored-family",
|
||||||
|
EvidenceSource = "harness_compaction",
|
||||||
|
OccurredAt = 2f,
|
||||||
|
Magnitude = 76f
|
||||||
|
});
|
||||||
|
bool authoredBirthProjected = ThreadCount == 2 && CharacterCount == 2;
|
||||||
|
Clear();
|
||||||
|
|
||||||
|
const int importedDevelopments = 2300;
|
||||||
|
const int importedThreads = 900;
|
||||||
|
for (int i = 0; i < importedDevelopments; i++)
|
||||||
|
{
|
||||||
|
string developmentId = "probe:compact:development:" + i;
|
||||||
|
ImportDevelopment(new NarrativeDevelopment
|
||||||
|
{
|
||||||
|
Id = developmentId,
|
||||||
|
Kind = NarrativeDevelopmentKind.HomeGained,
|
||||||
|
Function = i == importedDevelopments - 1
|
||||||
|
? NarrativeFunction.TurningPoint
|
||||||
|
: NarrativeFunction.CharacterTexture,
|
||||||
|
SubjectId = i + 1,
|
||||||
|
OccurredAt = i + 1,
|
||||||
|
Magnitude = i == importedDevelopments - 1 ? 100f : 5f
|
||||||
|
});
|
||||||
|
NarrativeEventStore.Import(new NarrativeEventRecord
|
||||||
|
{
|
||||||
|
Id = "event:" + developmentId,
|
||||||
|
DevelopmentId = developmentId,
|
||||||
|
SubjectId = i + 1,
|
||||||
|
ObservedAt = i + 1,
|
||||||
|
WorldTime = i + 1
|
||||||
|
});
|
||||||
|
ImportCharacter(new CharacterStoryState
|
||||||
|
{
|
||||||
|
CharacterId = i + 1,
|
||||||
|
LastMeaningfulChangeAt = i + 1,
|
||||||
|
StoryPotential = i % 20
|
||||||
|
});
|
||||||
|
if (i < importedThreads)
|
||||||
|
{
|
||||||
|
var thread = new NarrativeThread
|
||||||
|
{
|
||||||
|
Id = "probe:compact:thread:" + i,
|
||||||
|
Kind = NarrativeThreadKind.Founding,
|
||||||
|
ProtagonistId = i + 1,
|
||||||
|
OpenedByDevelopmentId = developmentId,
|
||||||
|
LatestMeaningfulChangeId = developmentId,
|
||||||
|
Status = NarrativeThreadStatus.Resolved,
|
||||||
|
Phase = NarrativePhase.Outcome,
|
||||||
|
OpenedAt = i + 1,
|
||||||
|
UpdatedAt = i + 1,
|
||||||
|
Momentum = i / 100f
|
||||||
|
};
|
||||||
|
thread.DevelopmentIds.Add(developmentId);
|
||||||
|
thread.AddCast(i + 1, "protagonist");
|
||||||
|
ImportThread(thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool compacted = CompactRuntimeGraph(force: true);
|
||||||
|
bool graphClosed = true;
|
||||||
|
foreach (NarrativeThread thread in Threads.Values)
|
||||||
|
{
|
||||||
|
if (thread == null
|
||||||
|
|| Development(thread.LatestMeaningfulChangeId) == null)
|
||||||
|
{
|
||||||
|
graphClosed = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool newestThreadKept = Thread("probe:compact:thread:899") != null;
|
||||||
|
bool bounded = NarrativeEventStore.Count <= MaxRuntimeEvents
|
||||||
|
&& Developments.Count <= MaxRuntimeDevelopments
|
||||||
|
&& Threads.Count <= MaxRuntimeThreads
|
||||||
|
&& Consequences.Count <= MaxRuntimeConsequences
|
||||||
|
&& Characters.Count <= MaxRuntimeCharacters;
|
||||||
|
bool ok = ambientBirthDeferred && authoredBirthProjected
|
||||||
|
&& compacted && bounded && graphClosed && newestThreadKept;
|
||||||
|
detail = "events=" + NarrativeEventStore.Count + "/" + MaxRuntimeEvents
|
||||||
|
+ " developments=" + Developments.Count + "/" + MaxRuntimeDevelopments
|
||||||
|
+ " threads=" + Threads.Count + "/" + MaxRuntimeThreads
|
||||||
|
+ " consequences=" + Consequences.Count + "/" + MaxRuntimeConsequences
|
||||||
|
+ " characters=" + Characters.Count + "/" + MaxRuntimeCharacters
|
||||||
|
+ " newest=" + newestThreadKept
|
||||||
|
+ " closed=" + graphClosed
|
||||||
|
+ " birth=" + ambientBirthDeferred + "/" + authoredBirthProjected
|
||||||
|
+ " ms=" + LastCompactionMs.ToString("0.0")
|
||||||
|
+ " pass=" + ok;
|
||||||
|
Clear();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddSchedulerCopy(List<NarrativeThread> into, NarrativeThread thread)
|
private static void AddSchedulerCopy(List<NarrativeThread> into, NarrativeThread thread)
|
||||||
{
|
{
|
||||||
if (thread == null || into.Count >= SchedulerCopyLimit
|
if (thread == null || into.Count >= SchedulerCopyLimit
|
||||||
|
|
@ -852,6 +1392,7 @@ public static class NarrativeStoryStore
|
||||||
{
|
{
|
||||||
thread.DevelopmentIds.Add(terminal.Id);
|
thread.DevelopmentIds.Add(terminal.Id);
|
||||||
}
|
}
|
||||||
|
TrimThreadDevelopmentHistory(thread);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Characters.TryGetValue(thread.ProtagonistId, out CharacterStoryState state))
|
if (Characters.TryGetValue(thread.ProtagonistId, out CharacterStoryState state))
|
||||||
|
|
|
||||||
|
|
@ -841,7 +841,7 @@ public sealed class UnitDossier
|
||||||
}
|
}
|
||||||
|
|
||||||
int start = i;
|
int start = i;
|
||||||
while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_' || text[i] == '-'))
|
while (i < text.Length && IsNameTokenChar(text[i]))
|
||||||
{
|
{
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
@ -1016,9 +1016,9 @@ public sealed class UnitDossier
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool leftOk = idx == 0 || !char.IsLetterOrDigit(reason[idx - 1]);
|
bool leftOk = idx == 0 || !IsNameTokenChar(reason[idx - 1]);
|
||||||
int end = idx + name.Length;
|
int end = idx + name.Length;
|
||||||
bool rightOk = end >= reason.Length || !char.IsLetterOrDigit(reason[end]);
|
bool rightOk = end >= reason.Length || !IsNameTokenChar(reason[end]);
|
||||||
if (leftOk && rightOk)
|
if (leftOk && rightOk)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -1030,6 +1030,37 @@ public sealed class UnitDossier
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsNameTokenChar(char c)
|
||||||
|
{
|
||||||
|
return char.IsLetterOrDigit(c)
|
||||||
|
|| c == '_'
|
||||||
|
|| c == '-'
|
||||||
|
|| c == '\''
|
||||||
|
|| c == '\u2019';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HarnessProbeNameBoundaries(out string detail)
|
||||||
|
{
|
||||||
|
bool apostropheSuffixRejected =
|
||||||
|
!ContainsWholeName("O'Ehel decides to try to reproduce", "Ehel");
|
||||||
|
bool curlySuffixRejected =
|
||||||
|
!ContainsWholeName("O\u2019Ehel decides to try to reproduce", "Ehel");
|
||||||
|
bool hyphenSuffixRejected =
|
||||||
|
!ContainsWholeName("Ana-Maria gains a child", "Maria");
|
||||||
|
bool separateNameAccepted =
|
||||||
|
ContainsWholeName("Ulalfa gains a child, Asodahl", "Asodahl");
|
||||||
|
bool ok = apostropheSuffixRejected
|
||||||
|
&& curlySuffixRejected
|
||||||
|
&& hyphenSuffixRejected
|
||||||
|
&& separateNameAccepted;
|
||||||
|
detail = "apostrophe=" + apostropheSuffixRejected
|
||||||
|
+ " curly=" + curlySuffixRejected
|
||||||
|
+ " hyphen=" + hyphenSuffixRejected
|
||||||
|
+ " separate=" + separateNameAccepted
|
||||||
|
+ " pass=" + ok;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
private static string EventLabelBeat(string label, UnitDossier d, bool recordDrops = true)
|
private static string EventLabelBeat(string label, UnitDossier d, bool recordDrops = true)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(label))
|
if (string.IsNullOrEmpty(label))
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ public static class WatchCaption
|
||||||
private static bool _dossierRowsNeedRestore;
|
private static bool _dossierRowsNeedRestore;
|
||||||
private static float _nextPortraitAt;
|
private static float _nextPortraitAt;
|
||||||
private static long _portraitActorId;
|
private static long _portraitActorId;
|
||||||
|
private const float PortraitRefreshSeconds = 1f;
|
||||||
private static string _statusBanner = "";
|
private static string _statusBanner = "";
|
||||||
private static float _statusBannerUntil;
|
private static float _statusBannerUntil;
|
||||||
private static string _lastLoggedCaption = "";
|
private static string _lastLoggedCaption = "";
|
||||||
|
|
@ -921,7 +922,8 @@ public static class WatchCaption
|
||||||
|
|
||||||
long id = EventFeedUtil.SafeId(actor);
|
long id = EventFeedUtil.SafeId(actor);
|
||||||
float now = Time.unscaledTime;
|
float now = Time.unscaledTime;
|
||||||
// Same unit: tile refresh every frame hitchs; keep the live pose at ~5 Hz.
|
// The vanilla tile refresh can trigger an expensive UI/layout pass. A portrait is
|
||||||
|
// supporting context, not animation-critical, so refresh it at 1 Hz while held.
|
||||||
// Focus change always applies immediately.
|
// Focus change always applies immediately.
|
||||||
if (id == _portraitActorId && now < _nextPortraitAt)
|
if (id == _portraitActorId && now < _nextPortraitAt)
|
||||||
{
|
{
|
||||||
|
|
@ -929,7 +931,7 @@ public static class WatchCaption
|
||||||
}
|
}
|
||||||
|
|
||||||
_portraitActorId = id;
|
_portraitActorId = id;
|
||||||
_nextPortraitAt = now + 0.2f;
|
_nextPortraitAt = now + PortraitRefreshSeconds;
|
||||||
DossierAvatar.Show(actor);
|
DossierAvatar.Show(actor);
|
||||||
BringHeaderFront();
|
BringHeaderFront();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue