feat(narrative): enhance narrative probes and combat lifecycle tracking

- Introduce new probes for combat lifecycle, scheduler bounds, and exposure
- Implement narrative scheduler health probe in harness scenarios
- Update InterestCandidate to include approved presentation details
- Refactor EventPresentability to manage candidate approval and caching
- Improve IdleHitchProbe to track performance metrics during narrative events
This commit is contained in:
DazedAnon 2026-07-23 17:21:23 -05:00
parent af17d9acd1
commit 54476fb7a1
23 changed files with 1716 additions and 118 deletions

View file

@ -2954,10 +2954,22 @@ public static class AgentHarness
bool episodeOk = bool episodeOk =
StoryScheduler.HarnessProbeEpisodeContinuity(out string episode); StoryScheduler.HarnessProbeEpisodeContinuity(out string episode);
bool proseOk = EventReason.HarnessProbePresentationProse(out string prose); bool proseOk = EventReason.HarnessProbePresentationProse(out string prose);
bool lifecycleOk =
NarrativeStoryStore.HarnessProbeCombatLifecycle(out string lifecycle);
bool schedulerBoundsOk =
NarrativeStoryStore.HarnessProbeSchedulerBounds(out string schedulerBounds);
bool exposureOk =
NarrativeExposureLedger.HarnessProbeBalance(out string exposure);
bool interruptOk =
InterestDirector.HarnessProbePersonalCombatInterrupt(out string interrupt);
bool graphOk =
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
&& participantOk && loverOk && identityOk && episodeOk && participantOk && loverOk && identityOk && episodeOk
&& proseOk && persistenceOk; && proseOk && lifecycleOk && schedulerBoundsOk
&& exposureOk && interruptOk
&& graphOk && persistenceOk;
if (ok) _cmdOk++; else _cmdFail++; if (ok) _cmdOk++; else _cmdFail++;
Emit( Emit(
cmd, cmd,
@ -2966,7 +2978,10 @@ public static class AgentHarness
+ "} combat={" + combat + "} novelty={" + novelty + "} combat={" + combat + "} novelty={" + novelty
+ "} participant={" + participant + "} lover={" + lover + "} participant={" + participant + "} lover={" + lover
+ "} identity={" + identity + "} episode={" + episode + "} identity={" + identity + "} episode={" + episode
+ "} prose={" + prose + "} prose={" + prose + "} lifecycle={" + lifecycle
+ "} schedulerBounds={" + schedulerBounds
+ "} exposure={" + exposure + "} interrupt={" + interrupt
+ "} graph={" + graph
+ "} persistence={" + persistence + "}"); + "} persistence={" + persistence + "}");
break; break;
} }
@ -4408,6 +4423,7 @@ public static class AgentHarness
case "hitch_probe": case "hitch_probe":
{ {
_preserveSagaAfterBatch = true;
bool on = ParseBool(cmd.value, defaultValue: true); bool on = ParseBool(cmd.value, defaultValue: true);
IdleHitchProbe.SetEnabled(on); IdleHitchProbe.SetEnabled(on);
IdleHitchProbe.ResetStats(); IdleHitchProbe.ResetStats();
@ -4416,6 +4432,25 @@ public static class AgentHarness
break; break;
} }
case "narrative_scheduler_health_probe":
{
_preserveSagaAfterBatch = true;
bool bounded =
StoryScheduler.LastCandidateCount <= NarrativeStoryStore.SchedulerCopyLimit;
bool responsive = StoryScheduler.LastTickMs < 50f;
bool ok = bounded && responsive;
if (ok) _cmdOk++; else _cmdFail++;
Emit(
cmd,
ok,
detail: "schedulerMs=" + StoryScheduler.LastTickMs.ToString("0.00")
+ " candidates=" + StoryScheduler.LastCandidateCount
+ "/" + NarrativeStoryStore.SchedulerCopyLimit
+ " graphThreads=" + NarrativeStoryStore.ThreadCount
+ " developments=" + NarrativeStoryStore.DevelopmentCount);
break;
}
case "load_save_slot": case "load_save_slot":
{ {
int slot = Mathf.Clamp(ParseInt(cmd.value, 2), 1, 5); int slot = Mathf.Clamp(ParseInt(cmd.value, 2), 1, 5);

View file

@ -119,8 +119,11 @@ public static class EventFeedUtil
return null; return null;
} }
EventPresentability.MarkApproved(candidate);
InterestScoring.ScoreCheap(candidate); InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate); InterestCandidate registered = InterestRegistry.Upsert(candidate);
EventPresentability.MarkApproved(registered);
return registered;
} }
/// <summary> /// <summary>
@ -183,8 +186,17 @@ public static class EventFeedUtil
return null; return null;
} }
if (!IsHarnessSource(candidate.Source))
{
EventPresentability.MarkApproved(candidate);
}
InterestScoring.ScoreCheap(candidate); InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate); InterestCandidate registered = InterestRegistry.Upsert(candidate);
if (!IsHarnessSource(candidate.Source))
{
EventPresentability.MarkApproved(registered);
}
return registered;
} }
private static bool IsHarnessSource(string source) => private static bool IsHarnessSource(string source) =>

View file

@ -1,3 +1,7 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator; namespace IdleSpectator;
/// <summary> /// <summary>
@ -6,6 +10,35 @@ namespace IdleSpectator;
/// </summary> /// </summary>
public static class EventPresentability public static class EventPresentability
{ {
private const int CacheLimit = 256;
private sealed class CacheEntry
{
public string Label = "";
public long SubjectId;
public long RelatedId;
public InterestLeadKind LeadKind;
public bool Result;
public float LastAccessAt;
}
private sealed class CacheTicket
{
public string Key = "";
public CacheEntry Entry;
}
private static readonly Dictionary<string, CacheEntry> Cache =
new Dictionary<string, CacheEntry>(CacheLimit, StringComparer.Ordinal);
private static readonly Queue<CacheTicket> CacheOrder =
new Queue<CacheTicket>(CacheLimit + 1);
public static void ClearCache()
{
Cache.Clear();
CacheOrder.Clear();
}
/// <summary> /// <summary>
/// True when the candidate may own Layer A camera. /// True when the candidate may own Layer A camera.
/// Empty Label is only allowed for CharacterLed fill (task chip, no orange reason). /// Empty Label is only allowed for CharacterLed fill (task chip, no orange reason).
@ -17,12 +50,94 @@ public static class EventPresentability
return false; return false;
} }
if (IsApproved(scene))
{
return true;
}
if (string.IsNullOrEmpty(scene.Label)) if (string.IsNullOrEmpty(scene.Label))
{ {
return scene.LeadKind == InterestLeadKind.CharacterLed; return scene.LeadKind == InterestLeadKind.CharacterLed;
} }
return !string.IsNullOrEmpty(UnitDossier.EvaluateSceneLabel(scene, recordDrops: false)); string key = scene.Key ?? "";
if (!string.IsNullOrEmpty(key)
&& Cache.TryGetValue(key, out CacheEntry cached)
&& cached.SubjectId == scene.SubjectId
&& cached.RelatedId == scene.RelatedId
&& cached.LeadKind == scene.LeadKind
&& string.Equals(cached.Label, scene.Label, StringComparison.Ordinal))
{
cached.LastAccessAt = Time.unscaledTime;
return cached.Result;
}
bool result =
!string.IsNullOrEmpty(UnitDossier.EvaluateSceneLabel(scene, recordDrops: false));
if (!string.IsNullOrEmpty(key))
{
var entry = new CacheEntry
{
Label = scene.Label,
SubjectId = scene.SubjectId,
RelatedId = scene.RelatedId,
LeadKind = scene.LeadKind,
Result = result,
LastAccessAt = Time.unscaledTime
};
Cache[key] = entry;
CacheOrder.Enqueue(new CacheTicket { Key = key, Entry = entry });
TrimCache();
}
return result;
}
/// <summary>
/// Stamp the exact identity that passed the intake gate. Repeated scheduler/director
/// checks can trust this fingerprint without rescanning the world; any later mutation
/// naturally invalidates it.
/// </summary>
public static void MarkApproved(InterestCandidate scene)
{
if (scene == null)
{
return;
}
scene.ApprovedPresentationLabel = scene.Label ?? "";
scene.ApprovedPresentationSubjectId = scene.SubjectId;
scene.ApprovedPresentationRelatedId = scene.RelatedId;
scene.ApprovedPresentationLeadKind = scene.LeadKind;
scene.HasApprovedPresentation = true;
}
public static bool IsApproved(InterestCandidate scene) =>
scene != null
&& scene.HasApprovedPresentation
&& scene.ApprovedPresentationSubjectId == scene.SubjectId
&& scene.ApprovedPresentationRelatedId == scene.RelatedId
&& scene.ApprovedPresentationLeadKind == scene.LeadKind
&& string.Equals(
scene.ApprovedPresentationLabel,
scene.Label ?? "",
StringComparison.Ordinal);
private static void TrimCache()
{
// Happiness bursts can publish hundreds of distinct keys. Scanning the whole
// dictionary to find the oldest entry for every insertion made cache maintenance
// quadratic. Tickets make eviction constant-time and safely ignore overwritten keys.
while (Cache.Count > CacheLimit && CacheOrder.Count > 0)
{
CacheTicket ticket = CacheOrder.Dequeue();
if (ticket != null
&& !string.IsNullOrEmpty(ticket.Key)
&& Cache.TryGetValue(ticket.Key, out CacheEntry current)
&& ReferenceEquals(current, ticket.Entry))
{
Cache.Remove(ticket.Key);
}
}
} }
/// <summary> /// <summary>

View file

@ -15,6 +15,13 @@ public static class InterestFeeds
private static float _lastScannerAt = -999f; private static float _lastScannerAt = -999f;
private const float ScannerInterval = 1.25f; private const float ScannerInterval = 1.25f;
private static int _tickParity; private static int _tickParity;
public static float LastHappinessMs { get; private set; }
public static float LastScannerMs { get; private set; }
public static float LastBattleScanMs { get; private set; }
public static float LastWarFeedMs { get; private set; }
public static float LastPlotFeedMs { get; private set; }
public static float LastFamilyFeedMs { get; private set; }
public static float LastStatusFeedMs { get; private set; }
// Civic aggregate boosts keyed by kingdom+effect. // Civic aggregate boosts keyed by kingdom+effect.
private static readonly Dictionary<string, float> CivicBoostUntil = new Dictionary<string, float>(32); private static readonly Dictionary<string, float> CivicBoostUntil = new Dictionary<string, float>(32);
@ -26,6 +33,13 @@ public static class InterestFeeds
_lastScannerAt = -999f; _lastScannerAt = -999f;
CivicBoostUntil.Clear(); CivicBoostUntil.Clear();
InterestDropLog.Clear(); InterestDropLog.Clear();
LastHappinessMs = 0f;
LastScannerMs = 0f;
LastBattleScanMs = 0f;
LastWarFeedMs = 0f;
LastPlotFeedMs = 0f;
LastFamilyFeedMs = 0f;
LastStatusFeedMs = 0f;
} }
/// <summary>Keep drain cursor coherent when the happiness router sequence resets.</summary> /// <summary>Keep drain cursor coherent when the happiness router sequence resets.</summary>
@ -107,12 +121,17 @@ public static class InterestFeeds
public static void Tick() public static void Tick()
{ {
long started = System.Diagnostics.Stopwatch.GetTimestamp();
DrainHappiness(); DrainHappiness();
LastHappinessMs = ElapsedMs(started);
PruneCivicBoosts(); PruneCivicBoosts();
LastScannerMs = 0f;
// Stagger battle/character scanner off the happiness drain frame. // Stagger battle/character scanner off the happiness drain frame.
if ((_tickParity++ & 1) == 0) if ((_tickParity++ & 1) == 0)
{ {
started = System.Diagnostics.Stopwatch.GetTimestamp();
TickScanner(); TickScanner();
LastScannerMs = ElapsedMs(started);
} }
} }
@ -750,13 +769,16 @@ public static class InterestFeeds
return; return;
} }
Actor subject = FindUnitById(occ.SubjectId); // Happiness can arrive in large bursts on old, populous worlds. Resolving each
// occurrence by walking every living unit turns one drain into O(events * units).
// ActorManager already owns an indexed lookup, so keep this path O(events).
Actor subject = EventFeedUtil.FindAliveById(occ.SubjectId);
if (subject == null || !subject.isAlive()) if (subject == null || !subject.isAlive())
{ {
return; return;
} }
Actor related = occ.RelatedId != 0 ? FindUnitById(occ.RelatedId) : null; Actor related = occ.RelatedId != 0 ? EventFeedUtil.FindUnitById(occ.RelatedId) : null;
NarrativePresentationModel narrativePresentation = NarrativePresentationModel narrativePresentation =
NarrativePresentationFactory.Happiness(occ, subject, related); NarrativePresentationFactory.Happiness(occ, subject, related);
string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory; string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory;
@ -1072,6 +1094,11 @@ public static class InterestFeeds
private static void TickScanner() private static void TickScanner()
{ {
LastBattleScanMs = 0f;
LastWarFeedMs = 0f;
LastPlotFeedMs = 0f;
LastFamilyFeedMs = 0f;
LastStatusFeedMs = 0f;
float now = Time.unscaledTime; float now = Time.unscaledTime;
if (now - _lastScannerAt < ScannerInterval) if (now - _lastScannerAt < ScannerInterval)
{ {
@ -1090,6 +1117,7 @@ public static class InterestFeeds
&& InterestDirector.CurrentIsActive && InterestDirector.CurrentIsActive
&& current.ParticipantCount >= 2; && current.ParticipantCount >= 2;
long stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
InterestEvent battle; InterestEvent battle;
if (stickyCombat) if (stickyCombat)
{ {
@ -1111,10 +1139,14 @@ public static class InterestFeeds
} }
else else
{ {
// Quiet / non-sticky: full ranking is OK (one fighter pass per ScanInterval). // BattleKeeper plus chunk-local ensembles yields the strongest combat scene
battle = WorldActivityScanner.FindHottestBattle(); // without collecting every fighter in the world. The old extra four combat
WorldActivityScanner.RegisterTopCharacterCandidates(max: 4); // vignettes duplicated that scene and made large peaceful worlds pay a full
// population scan every refresh.
battle = WorldActivityScanner.FindHottestBattleLight(
current != null ? current.Position : Vector3.zero);
} }
LastBattleScanMs = ElapsedMs(stageStarted);
if (battle != null) if (battle != null)
{ {
@ -1131,18 +1163,30 @@ public static class InterestFeeds
} }
// Ongoing wars from WarManager (complements WorldLog war start/end). // Ongoing wars from WarManager (complements WorldLog war start/end).
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
WarInterestFeed.Tick(); WarInterestFeed.Tick();
LastWarFeedMs = ElapsedMs(stageStarted);
// Active plots from PlotManager / World.plots. // Active plots from PlotManager / World.plots.
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
PlotInterestFeed.Tick(); PlotInterestFeed.Tick();
LastPlotFeedMs = ElapsedMs(stageStarted);
// Active family packs (2+ members) from World.families. // Active family packs (2+ members) from World.families.
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
FamilyInterestFeed.Tick(); FamilyInterestFeed.Tick();
LastFamilyFeedMs = ElapsedMs(stageStarted);
// Nearby multi-carrier status clusters. // Nearby multi-carrier status clusters.
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
StatusOutbreakFeed.Tick(); StatusOutbreakFeed.Tick();
LastStatusFeedMs = ElapsedMs(stageStarted);
} }
private static float ElapsedMs(long started) =>
(float)((System.Diagnostics.Stopwatch.GetTimestamp() - started) * 1000d
/ System.Diagnostics.Stopwatch.Frequency);
private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId) private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId)
{ {
var pending = new List<InterestCandidate>(32); var pending = new List<InterestCandidate>(32);
@ -1298,31 +1342,6 @@ public static class InterestFeeds
return (hash & 0xFFFF).ToString("x"); return (hash & 0xFFFF).ToString("x");
} }
private static Actor FindUnitById(long id)
{
if (id == 0)
{
return null;
}
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
try
{
if (actor != null && actor.getID() == id)
{
return actor;
}
}
catch
{
// continue
}
}
return null;
}
private static long SafeId(Actor actor) private static long SafeId(Actor actor)
{ {
if (actor == null) if (actor == null)

View file

@ -111,6 +111,36 @@ public sealed class LiveEnsemble
return ensemble != null && ensemble.HasFocus; return ensemble != null && ensemble.HasFocus;
} }
/// <summary>
/// Build a combat snapshot from the spatial chunk index only. Unlike
/// <see cref="TryBuildCombat"/>, an empty/stale tile never falls back to walking every
/// living unit in the world. Use this for recurring probes over BattleKeeper markers.
/// </summary>
public static bool TryBuildCombatLocal(
Vector2 pos,
float radius,
out LiveEnsemble ensemble,
ICollection<long> priorParticipantIds = null,
float newcomerBonus = 8f)
{
ensemble = null;
var fighters = new List<Actor>(16);
float r2 = radius * radius;
if (!TryForEachNearbyFromChunks(
pos,
radius,
r2,
requireCombat: true,
actor => fighters.Add(actor))
|| fighters.Count == 0)
{
return false;
}
ensemble = FromCombatFighters(fighters, pos, priorParticipantIds, newcomerBonus);
return ensemble != null && ensemble.HasFocus;
}
/// <summary> /// <summary>
/// Build a kingdom-vs-kingdom war front from a live <see cref="War"/>. /// Build a kingdom-vs-kingdom war front from a live <see cref="War"/>.
/// Counts use kingdom population; focus prefers living kings then any side unit. /// Counts use kingdom population; focus prefers living kings then any side unit.

View file

@ -22,6 +22,8 @@ public sealed class LiveSceneStickyState
public string PresentedReason = ""; public string PresentedReason = "";
public string PendingReason = ""; public string PendingReason = "";
public float ReasonChangeSince = -999f; public float ReasonChangeSince = -999f;
/// <summary>Combat must stay cold briefly before its headline is cleared.</summary>
public float CombatColdSince = -999f;
/// <summary> /// <summary>
/// After a camp hits 0, stay in mop-up until the thin side recovers to /// After a camp hits 0, stay in mop-up until the thin side recovers to
/// <see cref="StickyScoreboard.WipeRecoverMin"/> (stops 0↔1 Mass↔Battle thrash). /// <see cref="StickyScoreboard.WipeRecoverMin"/> (stops 0↔1 Mass↔Battle thrash).
@ -67,6 +69,7 @@ public sealed class LiveSceneStickyState
PresentedReason = ""; PresentedReason = "";
PendingReason = ""; PendingReason = "";
ReasonChangeSince = -999f; ReasonChangeSince = -999f;
CombatColdSince = -999f;
MopUpActive = false; MopUpActive = false;
MopUpSince = -999f; MopUpSince = -999f;
SideAKey = ""; SideAKey = "";
@ -99,6 +102,7 @@ public sealed class LiveSceneStickyState
other.PresentedReason = PresentedReason; other.PresentedReason = PresentedReason;
other.PendingReason = PendingReason; other.PendingReason = PendingReason;
other.ReasonChangeSince = ReasonChangeSince; other.ReasonChangeSince = ReasonChangeSince;
other.CombatColdSince = CombatColdSince;
other.MopUpActive = MopUpActive; other.MopUpActive = MopUpActive;
other.MopUpSince = MopUpSince; other.MopUpSince = MopUpSince;
other.SideAKey = SideAKey; other.SideAKey = SideAKey;

View file

@ -193,6 +193,9 @@ internal static class HarnessScenarios
case "narrative_story_first_soak": case "narrative_story_first_soak":
case "story_first_soak": case "story_first_soak":
return NarrativeStoryFirstSoak(); return NarrativeStoryFirstSoak();
case "narrative_scheduler_health":
case "scheduler_health":
return NarrativeSchedulerHealth();
case "narrative_catalog": case "narrative_catalog":
case "narrative_catalog_policy": case "narrative_catalog_policy":
return NarrativeCatalogPolicy(); return NarrativeCatalogPolicy();
@ -3346,6 +3349,23 @@ internal static class HarnessScenarios
}; };
} }
private static List<HarnessCommand> NarrativeSchedulerHealth()
{
return new List<HarnessCommand>
{
Step("nsh0", "dismiss_windows"),
Step("nsh1", "wait_world"),
Step("nsh2", "hitch_probe", value: "true"),
Step("nsh3", "spectator", value: "on"),
Step("nsh4", "director_run", wait: 15f),
Step("nsh5", "narrative_scheduler_health_probe"),
Step("nsh6", "hitch_probe", value: "false"),
Step("nsh7", "soak_probe"),
Step("nsh8", "assert", expect: "no_bad"),
Step("nsh99", "snapshot", value: "preserve_saga")
};
}
private static List<HarnessCommand> NarrativeCatalogPolicy() private static List<HarnessCommand> NarrativeCatalogPolicy()
{ {
return new List<HarnessCommand> return new List<HarnessCommand>

View file

@ -162,6 +162,15 @@ public static class IdleHitchProbe
+ $"dir={_directorMs:0.0}ms disc={_discoveryMs:0.0}ms cap={_captionMs:0.0}ms " + $"dir={_directorMs:0.0}ms disc={_discoveryMs:0.0}ms cap={_captionMs:0.0}ms "
+ $"other={_otherMs:0.0}ms roster={LifeSagaRoster.Count} pending={InterestRegistry.PendingCount} " + $"other={_otherMs:0.0}ms roster={LifeSagaRoster.Count} pending={InterestRegistry.PendingCount} "
+ $"scanMs={LifeSagaRoster.LastWorldScanMs:0.0} softMs={LifeSagaRoster.LastSoftRefillMs:0.0} " + $"scanMs={LifeSagaRoster.LastWorldScanMs:0.0} softMs={LifeSagaRoster.LastSoftRefillMs:0.0} "
+ $"schedulerMs={StoryScheduler.LastTickMs:0.0} schedulerCandidates={StoryScheduler.LastCandidateCount} "
+ $"schedulerParts={StoryScheduler.LastThreadCopyMs:0.0}/"
+ $"{StoryScheduler.LastThreadScoreMs:0.0}/"
+ $"{StoryScheduler.LastPendingCopyMs:0.0}/"
+ $"{StoryScheduler.LastProposalMs:0.0} "
+ $"feedParts={InterestFeeds.LastHappinessMs:0.0}/"
+ $"{InterestFeeds.LastScannerMs:0.0}[{InterestFeeds.LastBattleScanMs:0.0},"
+ $"{InterestFeeds.LastWarFeedMs:0.0},{InterestFeeds.LastPlotFeedMs:0.0},"
+ $"{InterestFeeds.LastFamilyFeedMs:0.0},{InterestFeeds.LastStatusFeedMs:0.0}] "
+ $"hot={_hotMark}({_hotMarkMs:0.0}ms)"); + $"hot={_hotMark}({_hotMarkMs:0.0}ms)");
_hotMark = ""; _hotMark = "";
_hotMarkMs = 0f; _hotMarkMs = 0f;

View file

@ -86,6 +86,15 @@ public sealed class InterestCandidate
/// <summary>Unscaled time the theater lead was last a live combat participant.</summary> /// <summary>Unscaled time the theater lead was last a live combat participant.</summary>
public float TheaterLeadLastCombatAt = -999f; public float TheaterLeadLastCombatAt = -999f;
public string Label = ""; public string Label = "";
/// <summary>
/// Intake-time orange-reason approval fingerprint. Registry candidates are immutable
/// with respect to this identity unless another fully validated upsert replaces it.
/// </summary>
public string ApprovedPresentationLabel = "";
public long ApprovedPresentationSubjectId;
public long ApprovedPresentationRelatedId;
public InterestLeadKind ApprovedPresentationLeadKind;
public bool HasApprovedPresentation;
public string AssetId = ""; public string AssetId = "";
public string SpeciesId = ""; public string SpeciesId = "";
public string Verb = ""; public string Verb = "";
@ -341,6 +350,11 @@ public sealed class InterestCandidate
TheaterLeadId = TheaterLeadId, TheaterLeadId = TheaterLeadId,
TheaterLeadLastCombatAt = TheaterLeadLastCombatAt, TheaterLeadLastCombatAt = TheaterLeadLastCombatAt,
Label = Label, Label = Label,
ApprovedPresentationLabel = ApprovedPresentationLabel,
ApprovedPresentationSubjectId = ApprovedPresentationSubjectId,
ApprovedPresentationRelatedId = ApprovedPresentationRelatedId,
ApprovedPresentationLeadKind = ApprovedPresentationLeadKind,
HasApprovedPresentation = HasApprovedPresentation,
AssetId = AssetId, AssetId = AssetId,
SpeciesId = SpeciesId, SpeciesId = SpeciesId,
Verb = Verb, Verb = Verb,

View file

@ -5,6 +5,8 @@ namespace IdleSpectator;
public static partial class InterestDirector public static partial class InterestDirector
{ {
private const float CombatHeadlineColdGraceSeconds = 4.5f;
/// <summary> /// <summary>
/// Keep camera on the highest-scored combat participant and rewrite tip/counts /// Keep camera on the highest-scored combat participant and rewrite tip/counts
/// from a live <see cref="LiveEnsemble"/> so subject + scale always match focus. /// from a live <see cref="LiveEnsemble"/> so subject + scale always match focus.
@ -20,9 +22,19 @@ public static partial class InterestDirector
bool combatHot = InterestCompletion.IsActive(_current, _currentStartedAt, now); bool combatHot = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (!combatHot) if (!combatHot)
{ {
if (_current.Sticky.CombatColdSince < 0f)
{
_current.Sticky.CombatColdSince = now;
return;
}
if (now - _current.Sticky.CombatColdSince < CombatHeadlineColdGraceSeconds)
{
return;
}
ClearStaleCombatLabel(now); ClearStaleCombatLabel(now);
return; return;
} }
_current.Sticky.CombatColdSince = -999f;
float throttle = _current.ParticipantCount >= CombatFocusLargeParticipantThreshold float throttle = _current.ParticipantCount >= CombatFocusLargeParticipantThreshold
? CombatFocusThrottleLargeSeconds ? CombatFocusThrottleLargeSeconds
@ -1876,7 +1888,7 @@ public static partial class InterestDirector
_current.ClearCombatSticky(); _current.ClearCombatSticky();
_current.ClearTheaterLead(); _current.ClearTheaterLead();
_lastCombatFocusAt = now; _lastCombatFocusAt = now;
CameraDirector.Watch(_current.ToInterestEvent()); CameraDirector.ClearWatchReason();
} }
/// <summary> /// <summary>

View file

@ -5,6 +5,9 @@ namespace IdleSpectator;
public static partial class InterestDirector public static partial class InterestDirector
{ {
private const float RelatedPersonalCombatCutSeconds = 3f;
private const float PersonalCombatCutSeconds = 6f;
private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch) private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch)
{ {
if (candidate == null) if (candidate == null)
@ -34,6 +37,15 @@ public static partial class InterestDirector
return false; return false;
} }
if (candidate != _current
&& !NarrativeExposureLedger.MayPresent(candidate))
{
InterestDropLog.Record(
"combat_exposure_budget",
candidate.Label ?? candidate.Key);
return false;
}
if (_current == null) if (_current == null)
{ {
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false); return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
@ -89,6 +101,21 @@ public static partial class InterestDirector
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false); return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
} }
if (IsPersonalStoryInterrupt(candidate))
{
float personalDelay = TouchesCurrentPrincipal(candidate)
? RelatedPersonalCombatCutSeconds
: PersonalCombatCutSeconds;
if (onCurrent >= personalDelay
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: false))
{
InterestDropLog.Record(
"combat_story_cut",
$"cur={_current.Key} next={candidate.Key} on={onCurrent:0.#}");
return true;
}
}
if (IsCombatUrgentPeer(_current, candidate)) if (IsCombatUrgentPeer(_current, candidate))
{ {
InterestDropLog.Record( InterestDropLog.Record(
@ -358,6 +385,143 @@ public static partial class InterestDirector
&& next.TotalScore >= current.TotalScore + urgentMargin; && next.TotalScore >= current.TotalScore + urgentMargin;
} }
private static bool IsPersonalStoryInterrupt(InterestCandidate candidate)
{
if (candidate == null
|| candidate.Completion == InterestCompletionKind.CombatActive
|| candidate.Completion == InterestCompletionKind.WarFront
|| InterestScoring.IsFillScore(candidate.TotalScore)
|| IsAmbientShot(candidate))
{
return false;
}
if (candidate.Completion == InterestCompletionKind.HappinessGrief)
{
return true;
}
NarrativeDevelopmentKind kind =
candidate.NarrativePresentation?.DevelopmentKind
?? NarrativeDevelopmentKind.Unknown;
if (kind == NarrativeDevelopmentKind.BondFormed
|| kind == NarrativeDevelopmentKind.BondBroken
|| kind == NarrativeDevelopmentKind.ChildBorn
|| kind == NarrativeDevelopmentKind.Death
|| kind == NarrativeDevelopmentKind.Recovery)
{
return true;
}
string text = ((candidate.AssetId ?? "") + " "
+ (candidate.HappinessEffectId ?? "") + " "
+ (candidate.Category ?? "") + " "
+ (candidate.Verb ?? "")).ToLowerInvariant();
return ContainsPersonalToken(
text,
"child",
"baby",
"born",
"parent",
"lover",
"love",
"partner",
"family",
"grief",
"mourn",
"kiss",
"marri");
}
private static bool TouchesCurrentPrincipal(InterestCandidate candidate)
{
if (_current == null || candidate == null)
{
return false;
}
long currentId = _current.SubjectId != 0
? _current.SubjectId
: EventFeedUtil.SafeId(_current.FollowUnit);
if (currentId == 0)
{
return false;
}
if (candidate.SubjectId == currentId
|| candidate.RelatedId == currentId
|| candidate.PairOwnerId == currentId
|| candidate.PairPartnerId == currentId)
{
return true;
}
for (int i = 0; i < candidate.ParticipantIds.Count; i++)
{
if (candidate.ParticipantIds[i] == currentId)
{
return true;
}
}
return false;
}
private static bool ContainsPersonalToken(string text, params string[] tokens)
{
if (string.IsNullOrEmpty(text) || tokens == null)
{
return false;
}
for (int i = 0; i < tokens.Length; i++)
{
if (text.IndexOf(tokens[i], StringComparison.Ordinal) >= 0)
{
return true;
}
}
return false;
}
public static bool HarnessProbePersonalCombatInterrupt(out string detail)
{
var birth = new InterestCandidate
{
AssetId = "add_child",
Category = "Family",
TotalScore = 80f
};
var love = new InterestCandidate
{
HappinessEffectId = "fell_in_love",
Category = "Relationship",
TotalScore = 75f
};
var routine = new InterestCandidate
{
HappinessEffectId = "just_slept",
Category = "Activity",
TotalScore = 70f
};
var building = new InterestCandidate
{
AssetId = "building_complete",
Category = "Building",
TotalScore = 80f
};
bool birthCuts = IsPersonalStoryInterrupt(birth);
bool loveCuts = IsPersonalStoryInterrupt(love);
bool routineWaits = !IsPersonalStoryInterrupt(routine);
bool buildingWaits = !IsPersonalStoryInterrupt(building);
bool ok = birthCuts && loveCuts && routineWaits && buildingWaits;
detail = "personal=" + birthCuts + "/" + loveCuts
+ " waits=" + routineWaits + "/" + buildingWaits
+ " pass=" + ok;
return ok;
}
/// <summary> /// <summary>
/// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers. /// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers.
/// </summary> /// </summary>

View file

@ -23,6 +23,7 @@ public static partial class InterestDirector
public const float InputExitGraceSeconds = 2.5f; public const float InputExitGraceSeconds = 2.5f;
public const float QuietGraceSeconds = 6f; public const float QuietGraceSeconds = 6f;
public const float CameraSettleGraceSeconds = 3f; public const float CameraSettleGraceSeconds = 3f;
public const float SelectionPollSeconds = 0.12f;
private static float _minDwell = MinDwellSeconds; private static float _minDwell = MinDwellSeconds;
private static float _curiosityDwell = CuriosityDwellSeconds; private static float _curiosityDwell = CuriosityDwellSeconds;
@ -42,6 +43,7 @@ public static partial class InterestDirector
private static float _lastInterestingAt = -999f; private static float _lastInterestingAt = -999f;
private static float _lastAmbientAt = -999f; private static float _lastAmbientAt = -999f;
private static float _lastFeedsAt = -999f; private static float _lastFeedsAt = -999f;
private static float _nextSelectAt = -999f;
private static float _enabledAt = -999f; private static float _enabledAt = -999f;
private static float _inactiveSince = -999f; private static float _inactiveSince = -999f;
private static float _lastCombatFocusAt = -999f; private static float _lastCombatFocusAt = -999f;
@ -527,6 +529,7 @@ public static partial class InterestDirector
_lastInterestingAt = now; _lastInterestingAt = now;
_lastAmbientAt = -999f; _lastAmbientAt = -999f;
_lastFeedsAt = -999f; _lastFeedsAt = -999f;
_nextSelectAt = now;
_enabledAt = now; _enabledAt = now;
_inactiveSince = -999f; _inactiveSince = -999f;
// Harness scenarios inject their own candidates after enable/focus. // Harness scenarios inject their own candidates after enable/focus.
@ -579,6 +582,7 @@ public static partial class InterestDirector
_inactiveSince = -999f; _inactiveSince = -999f;
_lastAmbientAt = -999f; _lastAmbientAt = -999f;
_lastFeedsAt = -999f; _lastFeedsAt = -999f;
_nextSelectAt = now;
// Re-bind camera to the frozen story beat when still watchable. // Re-bind camera to the frozen story beat when still watchable.
if (_current != null) if (_current != null)
{ {
@ -651,10 +655,15 @@ public static partial class InterestDirector
float sinceSwitch = now - _lastSwitchAt; float sinceSwitch = now - _lastSwitchAt;
IdleHitchProbe.Mark("story"); IdleHitchProbe.Mark("story");
IdleHitchProbe.Mark("planner");
StoryPlanner.Tick(now); StoryPlanner.Tick(now);
IdleHitchProbe.Mark("planner_done");
LifeSagaRoster.Tick(now); LifeSagaRoster.Tick(now);
IdleHitchProbe.Mark("roster_done");
LifeSagaSession.EnsureBound(); LifeSagaSession.EnsureBound();
IdleHitchProbe.Mark("session_done");
StoryScheduler.Tick(now); StoryScheduler.Tick(now);
IdleHitchProbe.Mark("scheduler_done");
IdleHitchProbe.Mark("story_done"); IdleHitchProbe.Mark("story_done");
if (ReadPauseActive) if (ReadPauseActive)
@ -676,19 +685,23 @@ public static partial class InterestDirector
MaintainFamilyPack(now, force: false); MaintainFamilyPack(now, force: false);
MaintainStatusOutbreak(now, force: false); MaintainStatusOutbreak(now, force: false);
IdleHitchProbe.Mark("select"); if (now >= _nextSelectAt)
InterestCandidate next = SelectNext(now);
IdleHitchProbe.Mark("select_done");
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
{ {
if (!InterestScoring.IsFillScore(next.TotalScore)) _nextSelectAt = now + SelectionPollSeconds;
IdleHitchProbe.Mark("select");
InterestCandidate next = SelectNext(now);
IdleHitchProbe.Mark("select_done");
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
{ {
_lastInterestingAt = now; if (!InterestScoring.IsFillScore(next.TotalScore))
} {
_lastInterestingAt = now;
}
SwitchTo(next, now, resumableInterrupt: false); SwitchTo(next, now, resumableInterrupt: false);
onCurrent = 0f; onCurrent = 0f;
sinceSwitch = 0f; sinceSwitch = 0f;
}
} }
// Pending events own the camera - never Character-fill over them. // Pending events own the camera - never Character-fill over them.
@ -1926,6 +1939,13 @@ public static partial class InterestDirector
resume.HappinessEffectId ?? resume.AssetId ?? resume.Key ?? "beat"); resume.HappinessEffectId ?? resume.AssetId ?? resume.Key ?? "beat");
InterestRegistry.Remove(resume.Key); InterestRegistry.Remove(resume.Key);
} }
else if (!NarrativeExposureLedger.MayPresent(resume))
{
InterestDropLog.Record(
"resume_exposure_budget",
resume.Label ?? resume.Key ?? "combat");
InterestRegistry.Remove(resume.Key);
}
else else
{ {
SwitchTo(resume, now, resumableInterrupt: false); SwitchTo(resume, now, resumableInterrupt: false);
@ -2732,7 +2752,6 @@ public static partial class InterestDirector
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt) private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
{ {
StoryScheduler.NoteActualSelection(next);
if (next == null) if (next == null)
{ {
return; return;
@ -2752,6 +2771,9 @@ public static partial class InterestDirector
return; return;
} }
StoryScheduler.NoteActualSelection(next);
NarrativeExposureLedger.NotePresented(next);
if (_current != null if (_current != null
&& _current.Resumable && _current.Resumable
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin && next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin

View file

@ -55,6 +55,7 @@ public static class InterestRegistry
{ {
ByKey.Clear(); ByKey.Clear();
} }
EventPresentability.ClearCache();
} }
public static InterestCandidate Upsert(InterestCandidate incoming) public static InterestCandidate Upsert(InterestCandidate incoming)

View file

@ -21,15 +21,22 @@ public static class NarrativeExposureLedger
new Dictionary<long, Exposure>(); new Dictionary<long, Exposure>();
private static readonly Dictionary<string, Exposure> Threads = private static readonly Dictionary<string, Exposure> Threads =
new Dictionary<string, Exposure>(StringComparer.Ordinal); new Dictionary<string, Exposure>(StringComparer.Ordinal);
private static readonly List<bool> PresentedCuts = new List<bool>(6);
private static int _maxCharacterCuts; private static int _maxCharacterCuts;
private static int _maxThreadCuts; private static int _maxThreadCuts;
private static int _combatRun;
private const int PresentedWindow = 6;
private const int CombatWindowBudget = 2;
private const int CombatRunBudget = 2;
public static void Clear() public static void Clear()
{ {
Characters.Clear(); Characters.Clear();
Threads.Clear(); Threads.Clear();
PresentedCuts.Clear();
_maxCharacterCuts = 0; _maxCharacterCuts = 0;
_maxThreadCuts = 0; _maxThreadCuts = 0;
_combatRun = 0;
} }
public static void NoteCut( public static void NoteCut(
@ -82,6 +89,38 @@ public static class NarrativeExposureLedger
return Mathf.Min(24f, exposure.SameFamilyRun * 6f); return Mathf.Min(24f, exposure.SameFamilyRun * 6f);
} }
/// <summary>
/// Two routine combat cuts may establish a conflict; another story family must then
/// receive space. World-critical combat bypasses this global exposure cap.
/// </summary>
public static bool MayPresent(InterestCandidate candidate)
{
if (!IsCombat(candidate) || candidate.EventStrength >= 95f)
{
return true;
}
int combat = 0;
for (int i = 0; i < PresentedCuts.Count; i++)
{
if (PresentedCuts[i]) combat++;
}
return _combatRun < CombatRunBudget
&& combat < CombatWindowBudget;
}
public static void NotePresented(InterestCandidate candidate)
{
bool combat = IsCombat(candidate);
PresentedCuts.Add(combat);
while (PresentedCuts.Count > PresentedWindow)
{
PresentedCuts.RemoveAt(0);
}
_combatRun = combat ? _combatRun + 1 : 0;
}
public static bool HarnessProbeBalance(out string detail) public static bool HarnessProbeBalance(out string detail)
{ {
Clear(); Clear();
@ -93,9 +132,36 @@ public static class NarrativeExposureLedger
float prolific = CharacterDebt(1); float prolific = CharacterDebt(1);
float repetition = RepetitionPenalty(1, "combat"); float repetition = RepetitionPenalty(1, "combat");
float diverse = RepetitionPenalty(1, "lineage"); float diverse = RepetitionPenalty(1, "lineage");
bool ok = underCovered > prolific && repetition > 0f && diverse == 0f; var combat = new InterestCandidate
{
Completion = InterestCompletionKind.CombatActive,
EventStrength = 80f
};
var criticalCombat = new InterestCandidate
{
Completion = InterestCompletionKind.CombatActive,
EventStrength = 100f
};
var family = new InterestCandidate
{
AssetId = "add_child",
EventStrength = 70f
};
NotePresented(combat);
NotePresented(combat);
bool routineBlocked = !MayPresent(combat);
bool criticalAllowed = MayPresent(criticalCombat);
for (int i = 0; i < 5; i++) NotePresented(family);
bool routineReopened = MayPresent(combat);
bool ok = underCovered > prolific
&& repetition > 0f
&& diverse == 0f
&& routineBlocked
&& criticalAllowed
&& 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
+ " pass=" + ok; + " pass=" + ok;
Clear(); Clear();
return ok; return ok;
@ -127,4 +193,19 @@ public static class NarrativeExposureLedger
exposure.SameFamilyRun = 1; exposure.SameFamilyRun = 1;
} }
} }
private static bool IsCombat(InterestCandidate candidate)
{
return candidate != null
&& (candidate.Completion == InterestCompletionKind.CombatActive
|| candidate.Completion == InterestCompletionKind.WarFront
|| string.Equals(
candidate.AssetId,
"live_combat",
StringComparison.OrdinalIgnoreCase)
|| string.Equals(
candidate.AssetId,
"live_battle",
StringComparison.OrdinalIgnoreCase));
}
} }

View file

@ -19,6 +19,7 @@ public static class NarrativePersistence
private const int MaxThreads = 500; private const int MaxThreads = 500;
private const int MaxConsequences = 2400; private const int MaxConsequences = 2400;
private const int MaxFacts = 4000; private const int MaxFacts = 4000;
private const int MaxCharacters = 1600;
private static readonly Dictionary<long, NarrativePreferenceDto> Preferences = private static readonly Dictionary<long, NarrativePreferenceDto> Preferences =
new Dictionary<long, NarrativePreferenceDto>(); new Dictionary<long, NarrativePreferenceDto>();
@ -109,6 +110,8 @@ public static class NarrativePersistence
LastPath = path; LastPath = path;
LastStatus = "saved events=" + state.events.Count LastStatus = "saved events=" + state.events.Count
+ " developments=" + state.developments.Count + " developments=" + state.developments.Count
+ " threads=" + state.threads.Count
+ " characters=" + state.characters.Count
+ " facts=" + state.facts.Count + " facts=" + state.facts.Count
+ " bytes=" + json.Length; + " bytes=" + json.Length;
LogService.LogInfo("[IdleSpectator] Narrative persistence " + LastStatus); LogService.LogInfo("[IdleSpectator] Narrative persistence " + LastStatus);
@ -330,6 +333,7 @@ public static class NarrativePersistence
Trim(state.threads, MaxThreads, (a, b) => b.updatedAt.CompareTo(a.updatedAt)); Trim(state.threads, MaxThreads, (a, b) => b.updatedAt.CompareTo(a.updatedAt));
Trim(state.consequences, MaxConsequences, (a, b) => b.occurredAt.CompareTo(a.occurredAt)); Trim(state.consequences, MaxConsequences, (a, b) => b.occurredAt.CompareTo(a.occurredAt));
Trim(state.facts, MaxFacts, (a, b) => b.worldTime.CompareTo(a.worldTime)); Trim(state.facts, MaxFacts, (a, b) => b.worldTime.CompareTo(a.worldTime));
CompactGraph(state, MaxCharacters);
state.Pack(); state.Pack();
return state; return state;
} }
@ -473,6 +477,296 @@ public static class NarrativePersistence
} }
} }
/// <summary>
/// Trim the sidecar as a graph: retained threads, consequences, and character
/// references must point at retained developments. Active casts and player-selected
/// lives are protected from character compaction.
/// </summary>
private static void CompactGraph(NarrativeSaveState state, int characterLimit)
{
if (state == null)
{
return;
}
var eventDevelopmentIds = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < state.events.Count; i++)
{
NarrativeEventRecordDto item = state.events[i];
if (item == null) continue;
if (!string.IsNullOrEmpty(item.developmentId))
{
eventDevelopmentIds.Add(item.developmentId);
}
else if (!string.IsNullOrEmpty(item.id)
&& item.id.StartsWith("event:", StringComparison.Ordinal))
{
eventDevelopmentIds.Add(item.id.Substring("event:".Length));
}
}
if (eventDevelopmentIds.Count > 0)
{
state.developments.RemoveAll(
item => item == null
|| string.IsNullOrEmpty(item.id)
|| !eventDevelopmentIds.Contains(item.id));
}
var developments = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < state.developments.Count; i++)
{
NarrativeDevelopmentDto item = state.developments[i];
if (item != null && !string.IsNullOrEmpty(item.id))
{
developments.Add(item.id);
}
}
for (int i = state.threads.Count - 1; i >= 0; i--)
{
NarrativeThreadDto thread = state.threads[i];
if (thread == null)
{
state.threads.RemoveAt(i);
continue;
}
thread.developmentIds = Filter(thread.developmentIds, developments);
if (!developments.Contains(thread.latestMeaningfulChangeId))
{
thread.latestMeaningfulChangeId = thread.developmentIds.Length > 0
? thread.developmentIds[thread.developmentIds.Length - 1]
: "";
}
if (!developments.Contains(thread.openedByDevelopmentId))
{
thread.openedByDevelopmentId = thread.developmentIds.Length > 0
? thread.developmentIds[0]
: thread.latestMeaningfulChangeId;
}
if (string.IsNullOrEmpty(thread.latestMeaningfulChangeId))
{
state.threads.RemoveAt(i);
}
}
var threads = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < state.threads.Count; i++)
{
NarrativeThreadDto item = state.threads[i];
if (item != null && !string.IsNullOrEmpty(item.id))
{
threads.Add(item.id);
}
}
state.consequences.RemoveAll(
item => item == null
|| string.IsNullOrEmpty(item.id)
|| !developments.Contains(item.developmentId));
var consequences = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < state.consequences.Count; i++)
{
CharacterConsequenceDto item = state.consequences[i];
if (!string.IsNullOrEmpty(item.threadId) && !threads.Contains(item.threadId))
{
item.threadId = "";
}
consequences.Add(item.id);
}
var protectedCharacters = new HashSet<long>();
for (int i = 0; i < state.preferences.Count; i++)
{
NarrativePreferenceDto preference = state.preferences[i];
if (preference != null && preference.characterId != 0)
{
protectedCharacters.Add(preference.characterId);
}
}
for (int i = 0; i < state.threads.Count; i++)
{
NarrativeThreadDto thread = state.threads[i];
if (thread == null
|| thread.status > (int)NarrativeThreadStatus.Dormant)
{
continue;
}
if (thread.protagonistId != 0)
{
protectedCharacters.Add(thread.protagonistId);
}
NarrativeCastRoleDto[] cast = thread.cast ?? new NarrativeCastRoleDto[0];
for (int j = 0; j < cast.Length; j++)
{
if (cast[j] != null && cast[j].characterId != 0)
{
protectedCharacters.Add(cast[j].characterId);
}
}
}
for (int i = 0; i < state.characters.Count; i++)
{
CharacterStoryStateDto character = state.characters[i];
if (character == null) continue;
if (LifeSagaRoster.IsMc(character.characterId)
|| LifeSagaRoster.IsPrefer(character.characterId))
{
protectedCharacters.Add(character.characterId);
}
character.openThreadIds = Filter(character.openThreadIds, threads);
character.resolvedThreadIds = Filter(character.resolvedThreadIds, threads);
character.recentDevelopmentIds = Filter(
character.recentDevelopmentIds,
developments);
character.consequenceIds = Filter(character.consequenceIds, consequences);
character.storyPotential = character.storyMomentum
+ character.openThreadIds.Length * 2.5f
+ Mathf.Min(6f, character.consequenceIds.Length * 0.8f);
}
state.characters.RemoveAll(item => item == null || item.characterId == 0);
state.characters.Sort((a, b) =>
{
bool aProtected = protectedCharacters.Contains(a.characterId);
bool bProtected = protectedCharacters.Contains(b.characterId);
if (aProtected != bProtected) return aProtected ? -1 : 1;
int potential = b.storyPotential.CompareTo(a.storyPotential);
if (potential != 0) return potential;
return b.lastMeaningfulChangeAt.CompareTo(a.lastMeaningfulChangeAt);
});
int protectedCount = 0;
for (int i = 0; i < state.characters.Count; i++)
{
if (protectedCharacters.Contains(state.characters[i].characterId))
{
protectedCount++;
}
}
int keep = Math.Max(Math.Max(0, characterLimit), protectedCount);
if (state.characters.Count > keep)
{
state.characters.RemoveRange(keep, state.characters.Count - keep);
}
}
private static string[] Filter(string[] values, HashSet<string> retained)
{
if (values == null || values.Length == 0 || retained == null)
{
return new string[0];
}
var result = new List<string>(values.Length);
for (int i = 0; i < values.Length; i++)
{
string value = values[i];
if (!string.IsNullOrEmpty(value)
&& retained.Contains(value)
&& !result.Contains(value))
{
result.Add(value);
}
}
return result.ToArray();
}
public static bool HarnessProbeGraphCompaction(out string detail)
{
var state = new NarrativeSaveState();
state.events.Add(new NarrativeEventRecordDto
{
id = "event:dev:kept",
developmentId = "dev:kept"
});
state.developments.Add(new NarrativeDevelopmentDto
{
id = "dev:kept",
subjectId = 10,
occurredAt = 2f
});
state.developments.Add(new NarrativeDevelopmentDto
{
id = "dev:orphan",
subjectId = 30,
occurredAt = 3f
});
state.threads.Add(new NarrativeThreadDto
{
id = "thread:kept",
protagonistId = 10,
cast = new[]
{
new NarrativeCastRoleDto { characterId = 10, role = "protagonist" },
new NarrativeCastRoleDto { characterId = 20, role = "partner" }
},
developmentIds = new[] { "dev:kept", "dev:orphan" },
latestMeaningfulChangeId = "dev:kept",
status = (int)NarrativeThreadStatus.Active
});
state.threads.Add(new NarrativeThreadDto
{
id = "thread:orphan",
protagonistId = 30,
developmentIds = new[] { "dev:orphan" },
latestMeaningfulChangeId = "dev:orphan",
status = (int)NarrativeThreadStatus.Active
});
state.consequences.Add(new CharacterConsequenceDto
{
id = "consequence:kept",
characterId = 10,
threadId = "thread:kept",
developmentId = "dev:kept"
});
state.consequences.Add(new CharacterConsequenceDto
{
id = "consequence:orphan",
characterId = 30,
threadId = "thread:orphan",
developmentId = "dev:orphan"
});
state.characters.Add(new CharacterStoryStateDto
{
characterId = 10,
openThreadIds = new[] { "thread:kept", "thread:orphan" },
recentDevelopmentIds = new[] { "dev:kept", "dev:orphan" },
consequenceIds = new[] { "consequence:kept", "consequence:orphan" },
storyMomentum = 2f,
storyPotential = 99f
});
state.characters.Add(new CharacterStoryStateDto { characterId = 20 });
state.characters.Add(new CharacterStoryStateDto
{
characterId = 30,
storyPotential = 200f
});
CompactGraph(state, 2);
CharacterStoryStateDto protagonist =
state.characters.Find(item => item.characterId == 10);
bool ok = state.developments.Count == 1
&& state.threads.Count == 1
&& state.consequences.Count == 1
&& state.characters.Count == 2
&& state.characters.Exists(item => item.characterId == 20)
&& !state.characters.Exists(item => item.characterId == 30)
&& protagonist != null
&& protagonist.openThreadIds.Length == 1
&& protagonist.recentDevelopmentIds.Length == 1
&& protagonist.consequenceIds.Length == 1
&& protagonist.storyPotential < 20f;
detail = "graph=" + state.developments.Count + "/"
+ state.threads.Count + "/" + state.consequences.Count
+ " characters=" + state.characters.Count
+ " potential=" + (protagonist?.storyPotential.ToString("0.0") ?? "none")
+ " pass=" + ok;
return ok;
}
private static void Trim<T>(List<T> items, int max, Comparison<T> comparison) private static void Trim<T>(List<T> items, int max, Comparison<T> comparison)
{ {
items.Sort(comparison); items.Sort(comparison);

View file

@ -9,6 +9,10 @@ public static class NarrativeStoryStore
{ {
private const int RecentPerCharacter = 16; private const int RecentPerCharacter = 16;
private const int ConsequencesPerCharacter = 24; private const int ConsequencesPerCharacter = 24;
private const int OpenThreadsPerCharacter = 16;
private const int ResolvedThreadsPerCharacter = 24;
internal const int SchedulerCandidateLimit = 96;
internal const int SchedulerCopyLimit = 128;
private static readonly Dictionary<string, NarrativeDevelopment> Developments = private static readonly Dictionary<string, NarrativeDevelopment> Developments =
new Dictionary<string, NarrativeDevelopment>(StringComparer.Ordinal); new Dictionary<string, NarrativeDevelopment>(StringComparer.Ordinal);
private static readonly Dictionary<long, CharacterStoryState> Characters = private static readonly Dictionary<long, CharacterStoryState> Characters =
@ -18,6 +22,17 @@ public static class NarrativeStoryStore
private static readonly Dictionary<string, CharacterConsequence> Consequences = private static readonly Dictionary<string, CharacterConsequence> Consequences =
new Dictionary<string, CharacterConsequence>(StringComparer.Ordinal); new Dictionary<string, CharacterConsequence>(StringComparer.Ordinal);
private static readonly List<NarrativeThread> ThreadScratch = new List<NarrativeThread>(32); private static readonly List<NarrativeThread> ThreadScratch = new List<NarrativeThread>(32);
private static readonly Dictionary<string, NarrativeThread> SchedulerCandidates =
new Dictionary<string, NarrativeThread>(SchedulerCandidateLimit, StringComparer.Ordinal);
private static readonly HashSet<string> SchedulerCopyIds =
new HashSet<string>(StringComparer.Ordinal);
private static readonly List<LifeSagaSlot> RosterScratch =
new List<LifeSagaSlot>(LifeSagaRoster.Cap);
private static readonly Dictionary<long, HashSet<string>> OpenConflictsByCharacter =
new Dictionary<long, HashSet<string>>();
private static readonly Dictionary<string, HashSet<long>> ConflictCastByThread =
new Dictionary<string, HashSet<long>>(StringComparer.Ordinal);
private static readonly List<string> IdScratch = new List<string>(SchedulerCandidateLimit);
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;
@ -31,6 +46,12 @@ public static class NarrativeStoryStore
Threads.Clear(); Threads.Clear();
Consequences.Clear(); Consequences.Clear();
ThreadScratch.Clear(); ThreadScratch.Clear();
SchedulerCandidates.Clear();
SchedulerCopyIds.Clear();
RosterScratch.Clear();
OpenConflictsByCharacter.Clear();
ConflictCastByThread.Clear();
IdScratch.Clear();
NarrativeEventStore.Clear(); NarrativeEventStore.Clear();
Revision++; Revision++;
StoryScheduler.Clear(); StoryScheduler.Clear();
@ -105,21 +126,52 @@ public static class NarrativeStoryStore
internal static void FinishImport() internal static void FinishImport()
{ {
foreach (CharacterStoryState state in Characters.Values) RepairConsistency();
{
if (state == null) continue;
state.OpenThreadIds.RemoveAll(id => !Threads.ContainsKey(id));
state.ResolvedThreadIds.RemoveAll(id => !Threads.ContainsKey(id));
state.RecentDevelopmentIds.RemoveAll(id => !Developments.ContainsKey(id));
state.ConsequenceIds.RemoveAll(id => !Consequences.ContainsKey(id));
}
Revision++; Revision++;
StoryScheduler.Clear(); StoryScheduler.Clear();
NarrativeTelemetry.Clear(); NarrativeTelemetry.Clear();
NarrativePresentationCoverage.Clear(); NarrativePresentationCoverage.Clear();
} }
/// <summary>
/// A death is a terminal answer to every live personal conflict involving that actor.
/// This keeps a kill/death from becoming a permanent unresolved-question multiplier.
/// </summary>
internal static int ResolveCombatThreadsForDeath(
long deceasedId,
NarrativeDevelopment death)
{
if (deceasedId == 0)
{
return 0;
}
if (!OpenConflictsByCharacter.TryGetValue(deceasedId, out HashSet<string> indexed)
|| indexed.Count == 0)
{
return 0;
}
IdScratch.Clear();
foreach (string threadId in indexed) IdScratch.Add(threadId);
int resolved = 0;
for (int i = 0; i < IdScratch.Count; i++)
{
NarrativeThread thread = Thread(IdScratch[i]);
if (!IsOpenConflict(thread) || !thread.HasCast(deceasedId)) continue;
ResolveConflictThread(thread, death, deceasedId);
resolved++;
}
IdScratch.Clear();
if (resolved > 0)
{
Revision++;
}
return resolved;
}
public static bool Record(NarrativeDevelopment development) public static bool Record(NarrativeDevelopment development)
{ {
if (development == null || development.SubjectId == 0 || string.IsNullOrEmpty(development.Id)) if (development == null || development.SubjectId == 0 || string.IsNullOrEmpty(development.Id))
@ -195,6 +247,7 @@ public static class NarrativeStoryStore
CharacterStoryState state = GetOrCreateCharacter(protagonistId, protagonistIdentity); CharacterStoryState state = GetOrCreateCharacter(protagonistId, protagonistIdentity);
MoveThreadReference(state, thread); MoveThreadReference(state, thread);
RecalculatePotential(state); RecalculatePotential(state);
TrackThread(thread);
Revision++; Revision++;
return thread; return thread;
} }
@ -219,16 +272,39 @@ public static class NarrativeStoryStore
{ {
if (into == null) return; if (into == null) return;
into.Clear(); into.Clear();
foreach (NarrativeThread thread in Threads.Values) SchedulerCopyIds.Clear();
IdScratch.Clear();
float now = Time.unscaledTime;
foreach (KeyValuePair<string, NarrativeThread> pair in SchedulerCandidates)
{ {
if (thread != null NarrativeThread thread = pair.Value;
&& (thread.IsOpen if (!IsSchedulerEligible(thread, now))
|| (thread.Status == NarrativeThreadStatus.Resolved
&& Time.unscaledTime - thread.UpdatedAt < 20f)))
{ {
into.Add(thread); IdScratch.Add(pair.Key);
continue;
}
AddSchedulerCopy(into, thread);
}
for (int i = 0; i < IdScratch.Count; i++) SchedulerCandidates.Remove(IdScratch[i]);
IdScratch.Clear();
// A bounded recent index handles ordinary challengers. Always fold the current
// main cast back in so a quieter long-running MC thread cannot be displaced by
// a burst of one-shot world events.
LifeSagaRoster.CopySlots(RosterScratch);
for (int i = 0; i < RosterScratch.Count && into.Count < SchedulerCopyLimit; i++)
{
CharacterStoryState state = Character(RosterScratch[i].UnitId);
if (state == null) continue;
for (int j = 0; j < state.OpenThreadIds.Count && into.Count < SchedulerCopyLimit; j++)
{
NarrativeThread thread = Thread(state.OpenThreadIds[j]);
if (IsSchedulerEligible(thread, now)) AddSchedulerCopy(into, thread);
} }
} }
RosterScratch.Clear();
SchedulerCopyIds.Clear();
} }
/// <summary>Copies bounded story-potential challengers without scanning the world.</summary> /// <summary>Copies bounded story-potential challengers without scanning the world.</summary>
@ -372,8 +448,22 @@ public static class NarrativeStoryStore
{ {
state.OpenThreadIds.Remove(thread.Id); state.OpenThreadIds.Remove(thread.Id);
state.ResolvedThreadIds.Remove(thread.Id); state.ResolvedThreadIds.Remove(thread.Id);
if (thread.IsOpen) state.OpenThreadIds.Insert(0, thread.Id); if (thread.IsOpen)
else state.ResolvedThreadIds.Insert(0, thread.Id); {
state.OpenThreadIds.Insert(0, thread.Id);
while (state.OpenThreadIds.Count > OpenThreadsPerCharacter)
{
state.OpenThreadIds.RemoveAt(state.OpenThreadIds.Count - 1);
}
}
else
{
state.ResolvedThreadIds.Insert(0, thread.Id);
while (state.ResolvedThreadIds.Count > ResolvedThreadsPerCharacter)
{
state.ResolvedThreadIds.RemoveAt(state.ResolvedThreadIds.Count - 1);
}
}
} }
private static void RecalculatePotential(CharacterStoryState state) private static void RecalculatePotential(CharacterStoryState state)
@ -384,6 +474,394 @@ public static class NarrativeStoryStore
state.StoryPotential = state.StoryMomentum + unresolved + consequences; state.StoryPotential = state.StoryMomentum + unresolved + consequences;
} }
/// <summary>
/// Rebuild derived character references and hot-path indexes from the retained graph,
/// close legacy terminal threads, and recalculate potential instead of trusting cached DTO values.
/// </summary>
internal static int RepairConsistency()
{
var deadIds = new HashSet<long>();
foreach (NarrativeDevelopment development in Developments.Values)
{
if (development != null
&& development.Kind == NarrativeDevelopmentKind.Death
&& development.SubjectId != 0)
{
deadIds.Add(development.SubjectId);
}
}
int repaired = 0;
foreach (NarrativeThread thread in Threads.Values)
{
if (thread != null
&& thread.Kind == NarrativeThreadKind.Founding
&& thread.IsOpen
&& thread.Phase == NarrativePhase.Outcome)
{
thread.Status = NarrativeThreadStatus.Resolved;
thread.Resolution = string.IsNullOrEmpty(thread.Resolution)
? "home established"
: thread.Resolution;
repaired++;
}
if (!IsOpenConflict(thread))
{
continue;
}
NarrativeDevelopment latest = Development(thread.LatestMeaningfulChangeId);
long deceased = DeadCastMember(thread, deadIds);
if (latest?.Kind != NarrativeDevelopmentKind.Kill && deceased == 0)
{
continue;
}
ResolveConflictThread(thread, latest, deceased);
repaired++;
}
foreach (CharacterStoryState state in Characters.Values)
{
if (state == null) continue;
state.OpenThreadIds.Clear();
state.ResolvedThreadIds.Clear();
state.RecentDevelopmentIds.RemoveAll(id => !Developments.ContainsKey(id));
state.ConsequenceIds.RemoveAll(id => !Consequences.ContainsKey(id));
}
ThreadScratch.Clear();
foreach (NarrativeThread thread in Threads.Values)
{
if (thread != null && thread.ProtagonistId != 0)
{
ThreadScratch.Add(thread);
}
}
ThreadScratch.Sort((a, b) => a.UpdatedAt.CompareTo(b.UpdatedAt));
for (int i = 0; i < ThreadScratch.Count; i++)
{
NarrativeThread thread = ThreadScratch[i];
CharacterStoryState state = GetOrCreateCharacter(
thread.ProtagonistId,
default);
MoveThreadReference(state, thread);
}
ThreadScratch.Clear();
foreach (CharacterStoryState state in Characters.Values)
{
RecalculatePotential(state);
}
RebuildThreadIndexes();
return repaired;
}
public static bool HarnessProbeCombatLifecycle(out string detail)
{
Clear();
var rival = new NarrativeDevelopment
{
Id = "rival:101:202",
Kind = NarrativeDevelopmentKind.RivalEncounter,
Function = NarrativeFunction.Escalation,
SubjectId = 101,
Subject = new LifeSagaIdentity { Id = 101, Name = "Aro", SpeciesId = "human" },
OtherId = 202,
Other = new LifeSagaIdentity { Id = 202, Name = "Bex", SpeciesId = "orc" },
OccurredAt = 1f,
Magnitude = 60f
};
Record(rival);
NarrativeThread thread = Thread("thread:conflict:conflict:101:202:101");
bool rivalryOpen = thread != null && thread.IsOpen;
var kill = new NarrativeDevelopment
{
Id = "kill:101:202",
Kind = NarrativeDevelopmentKind.Kill,
Function = NarrativeFunction.Consequence,
SubjectId = 101,
Subject = rival.Subject,
OtherId = 202,
Other = rival.Other,
OccurredAt = 2f,
Magnitude = 90f
};
Record(kill);
thread = Thread("thread:conflict:conflict:101:202:101");
CharacterStoryState killer = Character(101);
bool killResolved = thread != null
&& !thread.IsOpen
&& thread.Phase == NarrativePhase.Outcome
&& killer != null
&& killer.OpenThreadIds.Count == 0;
var stale = new NarrativeThread
{
Id = "thread:conflict:legacy:303:404",
Kind = NarrativeThreadKind.PersonalCombat,
ProtagonistId = 303,
LatestMeaningfulChangeId = kill.Id,
Status = NarrativeThreadStatus.Active,
Phase = NarrativePhase.TurningPoint,
UpdatedAt = 3f
};
stale.AddCast(303, "protagonist");
stale.AddCast(404, "opponent");
ImportThread(stale);
ImportCharacter(new CharacterStoryState
{
CharacterId = 303,
StoryMomentum = 2f,
StoryPotential = 99f
});
int repaired = RepairConsistency();
CharacterStoryState migrated = Character(303);
bool staleRepaired = !stale.IsOpen
&& migrated != null
&& migrated.OpenThreadIds.Count == 0
&& migrated.StoryPotential < 20f;
bool ok = rivalryOpen && killResolved && staleRepaired && repaired == 1;
detail = "rivalOpen=" + rivalryOpen
+ " killResolved=" + killResolved
+ " repaired=" + repaired
+ " migratedPotential=" + (migrated?.StoryPotential.ToString("0.0") ?? "none")
+ " pass=" + ok;
Clear();
return ok;
}
public static bool HarnessProbeSchedulerBounds(out string detail)
{
Clear();
const int imported = 600;
for (int i = 0; i < imported; i++)
{
var thread = new NarrativeThread
{
Id = "probe:scheduler:" + i,
Kind = NarrativeThreadKind.Partnership,
ProtagonistId = i + 1,
Status = NarrativeThreadStatus.Active,
Phase = NarrativePhase.Pressure,
UpdatedAt = i,
Momentum = i % 8,
Urgency = i % 5
};
thread.AddCast(i + 1, "protagonist");
ImportThread(thread);
}
RepairConsistency();
var candidates = new List<NarrativeThread>(SchedulerCopyLimit);
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
for (int i = 0; i < 200; i++) CopyOpenThreads(candidates);
stopwatch.Stop();
bool bounded = candidates.Count <= SchedulerCandidateLimit;
StoryScheduler.Clear();
StoryScheduler.Tick(Time.unscaledTime + 1f);
float schedulerMs = StoryScheduler.LastTickMs;
int schedulerCandidates = StoryScheduler.LastCandidateCount;
bool schedulerFast = schedulerCandidates <= SchedulerCandidateLimit
&& schedulerMs < 50f;
var home = new NarrativeDevelopment
{
Id = "probe:home:700",
Kind = NarrativeDevelopmentKind.HomeFounded,
Function = NarrativeFunction.Consequence,
SubjectId = 700,
Subject = new LifeSagaIdentity { Id = 700, Name = "Founder", SpeciesId = "human" },
CityKey = "probe-city",
OccurredAt = Time.unscaledTime,
Magnitude = 80f
};
Record(home);
NarrativeThread homeThread = Thread("thread:home:probe-city:700");
bool foundingTerminal = homeThread != null
&& !homeThread.IsOpen
&& homeThread.Status == NarrativeThreadStatus.Resolved;
bool fast = stopwatch.ElapsedMilliseconds < 50;
bool ok = bounded && schedulerFast && foundingTerminal && fast;
detail = "imported=" + imported
+ " candidates=" + candidates.Count + "/" + SchedulerCandidateLimit
+ " copies200Ms=" + stopwatch.ElapsedMilliseconds
+ " scheduler=" + schedulerMs.ToString("0.00") + "ms/"
+ schedulerCandidates
+ " foundingTerminal=" + foundingTerminal
+ " pass=" + ok;
Clear();
return ok;
}
private static void AddSchedulerCopy(List<NarrativeThread> into, NarrativeThread thread)
{
if (thread == null || into.Count >= SchedulerCopyLimit
|| !SchedulerCopyIds.Add(thread.Id))
{
return;
}
into.Add(thread);
}
private static bool IsSchedulerEligible(NarrativeThread thread, float now)
{
if (thread == null) return false;
if (thread.IsOpen) return true;
float age = now - thread.UpdatedAt;
return thread.Status == NarrativeThreadStatus.Resolved
&& age >= 0f
&& age < 20f;
}
private static void TrackThread(NarrativeThread thread)
{
if (thread == null || string.IsNullOrEmpty(thread.Id)) return;
TrackConflict(thread);
if (!IsSchedulerEligible(thread, Time.unscaledTime))
{
SchedulerCandidates.Remove(thread.Id);
return;
}
SchedulerCandidates[thread.Id] = thread;
if (SchedulerCandidates.Count <= SchedulerCandidateLimit) return;
string weakestId = null;
float weakest = float.MaxValue;
foreach (KeyValuePair<string, NarrativeThread> pair in SchedulerCandidates)
{
float priority = SchedulerIndexPriority(pair.Value);
if (priority < weakest)
{
weakest = priority;
weakestId = pair.Key;
}
}
if (!string.IsNullOrEmpty(weakestId)) SchedulerCandidates.Remove(weakestId);
}
private static float SchedulerIndexPriority(NarrativeThread thread)
{
if (thread == null) return float.MinValue;
float open = thread.IsOpen ? 50f : 0f;
float phase = thread.Phase == NarrativePhase.TurningPoint ? 16f
: thread.Phase == NarrativePhase.Outcome ? 12f
: thread.Phase == NarrativePhase.Consequence ? 10f : 0f;
float oneShot = thread.Kind == NarrativeThreadKind.Founding ? -40f : 0f;
return open + phase + oneShot + thread.Urgency + thread.Momentum
+ thread.UpdatedAt * 0.01f;
}
private static void RebuildThreadIndexes()
{
SchedulerCandidates.Clear();
OpenConflictsByCharacter.Clear();
ConflictCastByThread.Clear();
foreach (NarrativeThread thread in Threads.Values) TrackThread(thread);
}
private static void TrackConflict(NarrativeThread thread)
{
UntrackConflict(thread?.Id);
if (!IsOpenConflict(thread)) return;
var castIds = new HashSet<long>();
for (int i = 0; i < thread.Cast.Count; i++)
{
long characterId = thread.Cast[i]?.CharacterId ?? 0;
if (characterId == 0 || !castIds.Add(characterId)) continue;
if (!OpenConflictsByCharacter.TryGetValue(characterId, out HashSet<string> ids))
{
ids = new HashSet<string>(StringComparer.Ordinal);
OpenConflictsByCharacter[characterId] = ids;
}
ids.Add(thread.Id);
}
ConflictCastByThread[thread.Id] = castIds;
}
private static void UntrackConflict(string threadId)
{
if (string.IsNullOrEmpty(threadId)
|| !ConflictCastByThread.TryGetValue(threadId, out HashSet<long> castIds))
{
return;
}
foreach (long characterId in castIds)
{
if (!OpenConflictsByCharacter.TryGetValue(characterId, out HashSet<string> ids)) continue;
ids.Remove(threadId);
if (ids.Count == 0) OpenConflictsByCharacter.Remove(characterId);
}
ConflictCastByThread.Remove(threadId);
}
private static bool IsOpenConflict(NarrativeThread thread)
{
return thread != null
&& thread.IsOpen
&& (thread.Kind == NarrativeThreadKind.PersonalCombat
|| thread.Kind == NarrativeThreadKind.Rivalry);
}
private static long DeadCastMember(NarrativeThread thread, HashSet<long> deadIds)
{
if (thread == null || deadIds == null || deadIds.Count == 0)
{
return 0;
}
for (int i = 0; i < thread.Cast.Count; i++)
{
NarrativeCastRole cast = thread.Cast[i];
if (cast != null && deadIds.Contains(cast.CharacterId))
{
return cast.CharacterId;
}
}
return 0;
}
private static void ResolveConflictThread(
NarrativeThread thread,
NarrativeDevelopment terminal,
long deceasedId)
{
if (thread == null)
{
return;
}
thread.Status = NarrativeThreadStatus.Resolved;
thread.Phase = NarrativePhase.Outcome;
thread.Resolution = deceasedId != 0 && deceasedId == thread.ProtagonistId
? "protagonist died"
: "opponent defeated";
if (terminal != null)
{
thread.UpdatedAt = Mathf.Max(thread.UpdatedAt, terminal.OccurredAt);
thread.LatestMeaningfulChangeId = terminal.Id;
if (!thread.DevelopmentIds.Contains(terminal.Id))
{
thread.DevelopmentIds.Add(terminal.Id);
}
}
if (Characters.TryGetValue(thread.ProtagonistId, out CharacterStoryState state))
{
MoveThreadReference(state, thread);
RecalculatePotential(state);
}
TrackThread(thread);
}
private static float MomentumFor(NarrativeFunction function) private static float MomentumFor(NarrativeFunction function)
{ {
switch (function) switch (function)

View file

@ -127,19 +127,25 @@ public static class NarrativeThreadRules
"Their life has ended", NarrativePhase.Outcome, NarrativeThreadStatus.Resolved); "Their life has ended", NarrativePhase.Outcome, NarrativeThreadStatus.Resolved);
if (t != null) t.Resolution = "died"; if (t != null) t.Resolution = "died";
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.LostLife, "deceased", d.OtherId, 100f); AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.LostLife, "deceased", d.OtherId, 100f);
NarrativeStoryStore.ResolveCombatThreadsForDeath(d.SubjectId, d);
} }
private static void ApplyConflict(NarrativeDevelopment d) private static void ApplyConflict(NarrativeDevelopment d)
{ {
string anchor = PairAnchor("conflict", d.SubjectId, d.OtherId); string anchor = PairAnchor("conflict", d.SubjectId, d.OtherId);
NarrativeThreadKind kind = d.Kind == NarrativeDevelopmentKind.RivalEncounter bool terminal = d.Kind == NarrativeDevelopmentKind.Kill;
? NarrativeThreadKind.Rivalry : NarrativeThreadKind.PersonalCombat; NarrativeThreadKind kind = terminal
? NarrativeThreadKind.PersonalCombat
: NarrativeThreadKind.Rivalry;
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
"thread:conflict:" + anchor + ":" + d.SubjectId, kind, d.SubjectId, anchor, d, "thread:conflict:" + anchor + ":" + d.SubjectId, kind, d.SubjectId, anchor, d,
"Will this conflict end their repeated struggle?", NarrativePhase.TurningPoint, terminal ? "The fight has reached its outcome"
NarrativeThreadStatus.Active); : "Will this conflict become a repeated struggle?",
if (d.Kind == NarrativeDevelopmentKind.Kill) terminal ? NarrativePhase.Outcome : NarrativePhase.Pressure,
terminal ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active);
if (terminal)
{ {
if (t != null) t.Resolution = "killed opponent";
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.TookLife, "killer", d.OtherId, 82f); AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.TookLife, "killer", d.OtherId, 82f);
} }
} }
@ -164,9 +170,10 @@ public static class NarrativeThreadRules
"thread:home:" + anchor + ":" + d.SubjectId, "thread:home:" + anchor + ":" + d.SubjectId,
lost ? NarrativeThreadKind.HomeLoss : NarrativeThreadKind.Founding, lost ? NarrativeThreadKind.HomeLoss : NarrativeThreadKind.Founding,
d.SubjectId, anchor, d, d.SubjectId, anchor, d,
lost ? "Where will they go after losing their home?" : "Will what they founded endure?", lost ? "Where will they go after losing their home?" : "They established a home",
lost ? NarrativePhase.TurningPoint : NarrativePhase.Outcome, lost ? NarrativePhase.TurningPoint : NarrativePhase.Outcome,
NarrativeThreadStatus.Active); lost ? NarrativeThreadStatus.Active : NarrativeThreadStatus.Resolved);
if (!lost && t != null) t.Resolution = "home established";
AddConsequence(d, t, d.SubjectId, AddConsequence(d, t, d.SubjectId,
lost ? CharacterConsequenceKind.LostHome : CharacterConsequenceKind.FoundedHome, lost ? CharacterConsequenceKind.LostHome : CharacterConsequenceKind.FoundedHome,
lost ? "displaced" : "founder", 0, lost ? 90f : 82f); lost ? "displaced" : "founder", 0, lost ? 90f : 82f);

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using UnityEngine; using UnityEngine;
namespace IdleSpectator; namespace IdleSpectator;
@ -13,6 +14,7 @@ public static class StoryScheduler
private const float EpisodeMaxSeconds = 75f; private const float EpisodeMaxSeconds = 75f;
private const float EarlyEpisodeSwitchMargin = 30f; private const float EarlyEpisodeSwitchMargin = 30f;
private const float EstablishedEpisodeSwitchMargin = 20f; private const float EstablishedEpisodeSwitchMargin = 20f;
private const float SchedulerIntervalSeconds = 0.5f;
public const int EpisodeCombatShotBudget = 3; public const int EpisodeCombatShotBudget = 3;
private static readonly List<NarrativeThread> OpenThreads = new List<NarrativeThread>(32); private static readonly List<NarrativeThread> OpenThreads = new List<NarrativeThread>(32);
private static readonly List<InterestCandidate> Pending = new List<InterestCandidate>(96); private static readonly List<InterestCandidate> Pending = new List<InterestCandidate>(96);
@ -22,6 +24,12 @@ public static class StoryScheduler
public static EpisodePlan ActiveEpisode { get; private set; } public static EpisodePlan ActiveEpisode { get; private set; }
public static EpisodeShotProposal CurrentProposal { get; private set; } public static EpisodeShotProposal CurrentProposal { get; private set; }
public static string LastProposal => _lastProposal ?? ""; public static string LastProposal => _lastProposal ?? "";
public static float LastTickMs { get; private set; }
public static int LastCandidateCount { get; private set; }
public static float LastThreadCopyMs { get; private set; }
public static float LastThreadScoreMs { get; private set; }
public static float LastPendingCopyMs { get; private set; }
public static float LastProposalMs { get; private set; }
public static void Clear() public static void Clear()
{ {
@ -31,16 +39,47 @@ public static class StoryScheduler
Pending.Clear(); Pending.Clear();
_nextTickAt = 0f; _nextTickAt = 0f;
_lastProposal = ""; _lastProposal = "";
LastTickMs = 0f;
LastCandidateCount = 0;
LastThreadCopyMs = 0f;
LastThreadScoreMs = 0f;
LastPendingCopyMs = 0f;
LastProposalMs = 0f;
NarrativeExposureLedger.Clear(); NarrativeExposureLedger.Clear();
} }
public static void Tick(float now) public static void Tick(float now)
{ {
if (now < _nextTickAt) return; if (now < _nextTickAt) return;
_nextTickAt = now + 0.5f; long started = Stopwatch.GetTimestamp();
_nextTickAt = float.PositiveInfinity;
try
{
TickDue(now);
}
finally
{
LastTickMs = (float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency);
// Schedule from completed work, expressed in the caller's time domain.
// A slow tick therefore yields before running again instead of entering
// a permanent catch-up loop on the following frame.
_nextTickAt = now + SchedulerIntervalSeconds + LastTickMs / 1000f;
}
}
private static void TickDue(float now)
{
LastThreadCopyMs = 0f;
LastThreadScoreMs = 0f;
LastPendingCopyMs = 0f;
LastProposalMs = 0f;
long stageStarted = Stopwatch.GetTimestamp();
NarrativeStoryStore.CopyOpenThreads(OpenThreads); NarrativeStoryStore.CopyOpenThreads(OpenThreads);
LastThreadCopyMs = ElapsedMs(stageStarted);
LastCandidateCount = OpenThreads.Count;
NarrativeThread best = null; NarrativeThread best = null;
float bestScore = float.MinValue; float bestScore = float.MinValue;
stageStarted = Stopwatch.GetTimestamp();
for (int i = 0; i < OpenThreads.Count; i++) for (int i = 0; i < OpenThreads.Count; i++)
{ {
NarrativeThread thread = OpenThreads[i]; NarrativeThread thread = OpenThreads[i];
@ -56,6 +95,7 @@ public static class StoryScheduler
bestScore = score; bestScore = score;
} }
} }
LastThreadScoreMs = ElapsedMs(stageStarted);
NarrativeThread activeThread = ActiveEpisode != null NarrativeThread activeThread = ActiveEpisode != null
? NarrativeStoryStore.Thread(ActiveEpisode.ThreadId) ? NarrativeStoryStore.Thread(ActiveEpisode.ThreadId)
@ -99,8 +139,12 @@ public static class StoryScheduler
AddPendingForPurpose(ActiveEpisode, PurposeFor(selectedThread)); AddPendingForPurpose(ActiveEpisode, PurposeFor(selectedThread));
} }
stageStarted = Stopwatch.GetTimestamp();
InterestRegistry.CopyPending(Pending); InterestRegistry.CopyPending(Pending);
LastPendingCopyMs = ElapsedMs(stageStarted);
stageStarted = Stopwatch.GetTimestamp();
CurrentProposal = EpisodeShotSelector.Pick(Pending, ActiveEpisode, selectedThread); CurrentProposal = EpisodeShotSelector.Pick(Pending, ActiveEpisode, selectedThread);
LastProposalMs = ElapsedMs(stageStarted);
if (CurrentProposal != null) if (CurrentProposal != null)
{ {
NarrativeTelemetry.NoteProposal(ActiveEpisode, CurrentProposal, now); NarrativeTelemetry.NoteProposal(ActiveEpisode, CurrentProposal, now);
@ -117,6 +161,9 @@ public static class StoryScheduler
+ " reason=" + (CurrentProposal?.Reason ?? "none"); + " reason=" + (CurrentProposal?.Reason ?? "none");
} }
private static float ElapsedMs(long started) =>
(float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency);
public static void NoteActualSelection(InterestCandidate actual) public static void NoteActualSelection(InterestCandidate actual)
{ {
if (ActiveEpisode == null || actual == null) return; if (ActiveEpisode == null || actual == null) return;

View file

@ -19,6 +19,7 @@ public static class LifeSagaRoster
private static readonly List<LifeSagaSlot> Slots = new List<LifeSagaSlot>(Cap); private static readonly List<LifeSagaSlot> Slots = new List<LifeSagaSlot>(Cap);
private static readonly List<LifeSagaSlot> Scratch = new List<LifeSagaSlot>(48); private static readonly List<LifeSagaSlot> Scratch = new List<LifeSagaSlot>(48);
private static readonly List<Actor> ChallengerScratch = new List<Actor>(64); private static readonly List<Actor> ChallengerScratch = new List<Actor>(64);
private static readonly List<float> ChallengerPriorityScratch = new List<float>(64);
private static readonly List<Actor> UnitScanSnapshot = new List<Actor>(512); private static readonly List<Actor> UnitScanSnapshot = new List<Actor>(512);
private static readonly HashSet<long> ScanSeen = new HashSet<long>(); private static readonly HashSet<long> ScanSeen = new HashSet<long>();
private static readonly HashSet<long> PreviousIds = new HashSet<long>(); private static readonly HashSet<long> PreviousIds = new HashSet<long>();
@ -34,6 +35,9 @@ public static class LifeSagaRoster
private static bool _dirty = true; private static bool _dirty = true;
private static WorldScanPhase _scanPhase = WorldScanPhase.None; private static WorldScanPhase _scanPhase = WorldScanPhase.None;
private static int _scanIndex; private static int _scanIndex;
private static int _scanLimit;
private static int _scanCommitIndex;
private static List<Actor> _liveScanSource;
private static float _scanStartedAt; private static float _scanStartedAt;
private static int _scanPinCount; private static int _scanPinCount;
@ -45,7 +49,12 @@ public static class LifeSagaRoster
} }
/// <summary>Units probed per frame during amortized world admission.</summary> /// <summary>Units probed per frame during amortized world admission.</summary>
private const int WorldScanUnitsPerFrame = 260; private const int WorldScanUnitsPerFrame = 12;
/// <summary>Full challenger dossiers built per frame after a world scan.</summary>
private const int WorldScanBuildsPerFrame = 2;
private const int MaxScanAdmits = Cap * 2;
/// <summary>Cadence for starting a spread world admission scan.</summary> /// <summary>Cadence for starting a spread world admission scan.</summary>
private const float WorldScanIntervalSeconds = 3.5f; private const float WorldScanIntervalSeconds = 3.5f;
@ -74,6 +83,7 @@ public static class LifeSagaRoster
Slots.Clear(); Slots.Clear();
Scratch.Clear(); Scratch.Clear();
ChallengerScratch.Clear(); ChallengerScratch.Clear();
ChallengerPriorityScratch.Clear();
UnitScanSnapshot.Clear(); UnitScanSnapshot.Clear();
ScanSeen.Clear(); ScanSeen.Clear();
PreviousIds.Clear(); PreviousIds.Clear();
@ -86,6 +96,9 @@ public static class LifeSagaRoster
_roleCacheAt = -999f; _roleCacheAt = -999f;
_scanPhase = WorldScanPhase.None; _scanPhase = WorldScanPhase.None;
_scanIndex = 0; _scanIndex = 0;
_scanLimit = 0;
_scanCommitIndex = 0;
_liveScanSource = null;
_dirty = true; _dirty = true;
} }
@ -759,8 +772,10 @@ public static class LifeSagaRoster
PreviousIds.Clear(); PreviousIds.Clear();
ScanSeen.Clear(); ScanSeen.Clear();
ChallengerScratch.Clear(); ChallengerScratch.Clear();
ChallengerPriorityScratch.Clear();
UnitScanSnapshot.Clear(); UnitScanSnapshot.Clear();
_scanPinCount = 0; _scanPinCount = 0;
_scanCommitIndex = 0;
_scanStartedAt = now; _scanStartedAt = now;
for (int i = 0; i < Slots.Count; i++) for (int i = 0; i < Slots.Count; i++)
@ -795,7 +810,16 @@ public static class LifeSagaRoster
try try
{ {
if (World.world?.units != null) // The game already maintains an indexed living-unit list. Keep a reference and
// walk a bounded slice per frame instead of copying the entire population up
// front; that copy was itself a recurring main-thread hitch on mature worlds.
List<Actor> alive = World.world?.units?.units_only_alive;
if (alive != null && alive.Count > 0)
{
_liveScanSource = alive;
_scanLimit = alive.Count;
}
else if (World.world?.units != null)
{ {
foreach (Actor actor in World.world.units) foreach (Actor actor in World.world.units)
{ {
@ -804,6 +828,8 @@ public static class LifeSagaRoster
UnitScanSnapshot.Add(actor); UnitScanSnapshot.Add(actor);
} }
} }
_scanLimit = UnitScanSnapshot.Count;
} }
} }
catch catch
@ -822,10 +848,15 @@ public static class LifeSagaRoster
var sw = System.Diagnostics.Stopwatch.StartNew(); var sw = System.Diagnostics.Stopwatch.StartNew();
if (_scanPhase == WorldScanPhase.Walking) if (_scanPhase == WorldScanPhase.Walking)
{ {
int end = Mathf.Min(_scanIndex + WorldScanUnitsPerFrame, UnitScanSnapshot.Count); int available = _liveScanSource != null
? Mathf.Min(_scanLimit, _liveScanSource.Count)
: UnitScanSnapshot.Count;
int end = Mathf.Min(_scanIndex + WorldScanUnitsPerFrame, available);
for (int i = _scanIndex; i < end; i++) for (int i = _scanIndex; i < end; i++)
{ {
Actor actor = UnitScanSnapshot[i]; Actor actor = _liveScanSource != null
? _liveScanSource[i]
: UnitScanSnapshot[i];
if (actor == null || !actor.isAlive()) if (actor == null || !actor.isAlive())
{ {
continue; continue;
@ -842,12 +873,12 @@ public static class LifeSagaRoster
continue; continue;
} }
ChallengerScratch.Add(actor); ConsiderWorldScanChallenger(actor);
ScanSeen.Add(id); ScanSeen.Add(id);
} }
_scanIndex = end; _scanIndex = end;
if (_scanIndex < UnitScanSnapshot.Count) if (_scanIndex < available)
{ {
sw.Stop(); sw.Stop();
LastWorldScanMs = Mathf.Max(LastWorldScanMs, (float)sw.Elapsed.TotalMilliseconds); LastWorldScanMs = Mathf.Max(LastWorldScanMs, (float)sw.Elapsed.TotalMilliseconds);
@ -859,14 +890,10 @@ public static class LifeSagaRoster
if (_scanPhase == WorldScanPhase.Commit) if (_scanPhase == WorldScanPhase.Commit)
{ {
const int MaxScanAdmits = Cap * 2; int end = Mathf.Min(
if (ChallengerScratch.Count > MaxScanAdmits) _scanCommitIndex + WorldScanBuildsPerFrame,
{ ChallengerScratch.Count);
ChallengerScratch.Sort(CompareAdmitPriorityDesc); for (int i = _scanCommitIndex; i < end; i++)
ChallengerScratch.RemoveRange(MaxScanAdmits, ChallengerScratch.Count - MaxScanAdmits);
}
for (int i = 0; i < ChallengerScratch.Count; i++)
{ {
Actor actor = ChallengerScratch[i]; Actor actor = ChallengerScratch[i];
long id = EventFeedUtil.SafeId(actor); long id = EventFeedUtil.SafeId(actor);
@ -878,12 +905,27 @@ public static class LifeSagaRoster
Scratch.Add(BuildSlot(actor, now)); Scratch.Add(BuildSlot(actor, now));
} }
_scanCommitIndex = end;
if (_scanCommitIndex < ChallengerScratch.Count)
{
sw.Stop();
LastWorldScanMs = Mathf.Max(
LastWorldScanMs,
(float)sw.Elapsed.TotalMilliseconds);
return;
}
FinishRefillFromScratch(now, worldScan: true, sw); FinishRefillFromScratch(now, worldScan: true, sw);
ChallengerScratch.Clear(); ChallengerScratch.Clear();
ChallengerPriorityScratch.Clear();
UnitScanSnapshot.Clear(); UnitScanSnapshot.Clear();
ScanSeen.Clear(); ScanSeen.Clear();
_liveScanSource = null;
_scanLimit = 0;
_scanPhase = WorldScanPhase.None; _scanPhase = WorldScanPhase.None;
_scanIndex = 0; _scanIndex = 0;
_scanCommitIndex = 0;
_nextScanAt = now + WorldScanIntervalSeconds;
return; return;
} }
@ -1542,6 +1584,36 @@ public static class LifeSagaRoster
return AdmitPriority(b).CompareTo(AdmitPriority(a)); return AdmitPriority(b).CompareTo(AdmitPriority(a));
} }
/// <summary>
/// Maintain the best bounded challenger set while walking the world. This avoids
/// sorting every eligible unit together at scan completion.
/// </summary>
private static void ConsiderWorldScanChallenger(Actor actor)
{
float priority = AdmitPriority(actor);
int insert = 0;
while (insert < ChallengerPriorityScratch.Count
&& ChallengerPriorityScratch[insert] >= priority)
{
insert++;
}
if (insert >= MaxScanAdmits
&& ChallengerScratch.Count >= MaxScanAdmits)
{
return;
}
ChallengerScratch.Insert(insert, actor);
ChallengerPriorityScratch.Insert(insert, priority);
if (ChallengerScratch.Count > MaxScanAdmits)
{
int last = ChallengerScratch.Count - 1;
ChallengerScratch.RemoveAt(last);
ChallengerPriorityScratch.RemoveAt(last);
}
}
private static float AdmitPriority(Actor actor) private static float AdmitPriority(Actor actor)
{ {
if (actor == null) if (actor == null)
@ -1873,12 +1945,44 @@ public static class LifeSagaRoster
public static bool IsSpeciesSingleton(Actor actor) public static bool IsSpeciesSingleton(Actor actor)
{ {
if (actor == null) if (actor?.asset == null)
{ {
return false; return false;
} }
return WorldActivityScanner.IsSingletonSpeciesPublic(actor, SpeciesCountCache); string speciesId = actor.asset.id ?? "";
if (string.IsNullOrEmpty(speciesId))
{
return false;
}
if (!SpeciesCountCache.TryGetValue(speciesId, out int population))
{
// Roster admission only needs singleton/not-singleton. The actor asset already
// owns its units, so stop at two instead of rebuilding counts for the whole world.
population = 0;
try
{
if (actor.asset.units != null)
{
foreach (Actor sameSpecies in actor.asset.units)
{
if (sameSpecies != null && sameSpecies.isAlive() && ++population >= 2)
{
break;
}
}
}
}
catch
{
population = 2;
}
SpeciesCountCache[speciesId] = population;
}
return population == 1;
} }
private static bool ActorMatchesFounder(object meta, Actor actor) private static bool ActorMatchesFounder(object meta, Actor actor)
@ -1953,21 +2057,6 @@ public static class LifeSagaRoster
_roleCacheAt = now; _roleCacheAt = now;
PlotAuthorCache.Clear(); PlotAuthorCache.Clear();
SpeciesCountCache.Clear(); SpeciesCountCache.Clear();
try
{
Dictionary<string, int> counts = WorldActivityScanner.CountSpeciesPopulations();
if (counts != null)
{
foreach (KeyValuePair<string, int> kv in counts)
{
SpeciesCountCache[kv.Key] = kv.Value;
}
}
}
catch
{
// ignore
}
try try
{ {

View file

@ -582,8 +582,13 @@ public sealed class UnitDossier
return ""; return "";
} }
// Router happiness occurrences already carry authoritative subject/related context.
// Once their generated sentence is proven to start with that living subject, do not
// rescan the entire population looking for additional names on every occurrence.
bool routerOwned = IsTrustedHappinessReason(beat, scene);
// Ownership: never let the reason name a living stranger (not subject/related). // Ownership: never let the reason name a living stranger (not subject/related).
if (ReasonNamesStranger(beat, scene)) if (!routerOwned && ReasonNamesStranger(beat, scene))
{ {
// Tiered combat tips name sides / duelists on purpose. // Tiered combat tips name sides / duelists on purpose.
if (!IsEnsembleCombatReason(beat)) if (!IsEnsembleCombatReason(beat))
@ -611,6 +616,44 @@ public sealed class UnitDossier
return beat; return beat;
} }
private static bool IsTrustedHappinessReason(string reason, InterestCandidate scene)
{
if (string.IsNullOrEmpty(reason) || scene == null)
{
return false;
}
string source = scene.Source ?? "";
if (!source.Equals("happiness", StringComparison.OrdinalIgnoreCase)
&& !source.Equals("happiness_hatch", StringComparison.OrdinalIgnoreCase)
&& !source.Equals("happiness_alpha", StringComparison.OrdinalIgnoreCase))
{
return false;
}
Actor subject = scene.FollowUnit;
if (subject == null || !subject.isAlive())
{
return false;
}
long subjectId = EventFeedUtil.SafeId(subject);
if (scene.SubjectId != 0 && subjectId != scene.SubjectId)
{
return false;
}
string subjectName = EventFeedUtil.SafeName(subject);
if (string.IsNullOrEmpty(subjectName)
|| !reason.StartsWith(subjectName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
int end = subjectName.Length;
return end >= reason.Length || !char.IsLetterOrDigit(reason[end]);
}
private static bool LeadsWithForeignName(string reason, InterestCandidate scene) private static bool LeadsWithForeignName(string reason, InterestCandidate scene)
{ {
if (string.IsNullOrEmpty(reason) || scene == null) if (string.IsNullOrEmpty(reason) || scene == null)
@ -902,6 +945,14 @@ public sealed class UnitDossier
continue; continue;
} }
// Unnamed creatures commonly report their species as getName() ("Crab",
// "Wolf", ...). Those are catalog nouns, not proper-name strangers:
// "Crab Bomb" must not be rejected merely because a crab is alive.
if (IsSpeciesDisplayName(unit, name))
{
continue;
}
if (!string.IsNullOrEmpty(subjectName) if (!string.IsNullOrEmpty(subjectName)
&& name.Equals(subjectName, System.StringComparison.OrdinalIgnoreCase)) && name.Equals(subjectName, System.StringComparison.OrdinalIgnoreCase))
{ {
@ -928,6 +979,26 @@ public sealed class UnitDossier
return false; return false;
} }
private static bool IsSpeciesDisplayName(Actor unit, string name)
{
if (unit?.asset == null || string.IsNullOrEmpty(name))
{
return false;
}
string speciesId = unit.asset.id ?? "";
string display = ActivityAssetCatalog.SpeciesDisplayLabel(speciesId);
string nametag = ActivityAssetCatalog.SpeciesNametagLabel(speciesId);
return name.Equals(speciesId, System.StringComparison.OrdinalIgnoreCase)
|| name.Equals(
speciesId.Replace('_', ' '),
System.StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(display)
&& name.Equals(display, System.StringComparison.OrdinalIgnoreCase))
|| (!string.IsNullOrEmpty(nametag)
&& name.Equals(nametag, System.StringComparison.OrdinalIgnoreCase));
}
/// <summary>True when <paramref name="name"/> appears as a whole token in <paramref name="reason"/>.</summary> /// <summary>True when <paramref name="name"/> appears as a whole token in <paramref name="reason"/>.</summary>
private static bool ContainsWholeName(string reason, string name) private static bool ContainsWholeName(string reason, string name)
{ {

View file

@ -84,6 +84,7 @@ public static class WatchCaption
private static readonly TraitSlot[] _traitSlots = new TraitSlot[UnitDossier.MaxTraitChips]; private static readonly TraitSlot[] _traitSlots = new TraitSlot[UnitDossier.MaxTraitChips];
private static GameObject _traitsRow; private static GameObject _traitsRow;
private static readonly TraitSlot[] _statusSlots = new TraitSlot[UnitDossier.MaxStatusChips]; private static readonly TraitSlot[] _statusSlots = new TraitSlot[UnitDossier.MaxStatusChips];
private static readonly UnitDossier StatusProbe = new UnitDossier();
private static GameObject _statusesRow; private static GameObject _statusesRow;
private static int _lastStatusesFingerprint = int.MinValue; private static int _lastStatusesFingerprint = int.MinValue;
@ -110,6 +111,12 @@ public static class WatchCaption
private static bool _visible; private static bool _visible;
private static bool _layoutDirty = true; private static bool _layoutDirty = true;
private static string _layoutKey = ""; private static string _layoutKey = "";
private static string _lastAppliedCaptionFingerprint = "";
private static int _lastIdentityMemoryRevision = int.MinValue;
private static float _nextRoutineLiveRefreshAt;
private static float _nextRoutineSpineAt;
private const float RoutineLiveRefreshSeconds = 0.2f;
private const float RoutineSpineRefreshSeconds = 0.15f;
private static bool _dossierRowsNeedRestore; private static bool _dossierRowsNeedRestore;
private static float _nextPortraitAt; private static float _nextPortraitAt;
private static long _portraitActorId; private static long _portraitActorId;
@ -496,6 +503,10 @@ public static class WatchCaption
_lastHistoryCount = -1; _lastHistoryCount = -1;
_lastHistorySubjectId = 0; _lastHistorySubjectId = 0;
_lastStatusesFingerprint = int.MinValue; _lastStatusesFingerprint = int.MinValue;
_lastAppliedCaptionFingerprint = "";
_lastIdentityMemoryRevision = int.MinValue;
_nextRoutineLiveRefreshAt = 0f;
_nextRoutineSpineAt = 0f;
_headlineLocked = false; _headlineLocked = false;
_historyColW = HistoryColMinW; _historyColW = HistoryColMinW;
_statusBanner = ""; _statusBanner = "";
@ -619,6 +630,7 @@ public static class WatchCaption
LastCaptionText = dossier.CaptionText ?? ""; LastCaptionText = dossier.CaptionText ?? "";
EnsureBuilt(); EnsureBuilt();
ApplyVisual(actor, dossier); ApplyVisual(actor, dossier);
_nextRoutineLiveRefreshAt = Time.unscaledTime + RoutineLiveRefreshSeconds;
SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused)); SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused));
LogCaptionIfChanged(LastCaptionText); LogCaptionIfChanged(LastCaptionText);
} }
@ -747,6 +759,13 @@ public static class WatchCaption
// Caption Identity/Beat/Context always track camera focus; hover only adds panel depth. // Caption Identity/Beat/Context always track camera focus; hover only adds panel depth.
LifeSagaViewController.Tick(); LifeSagaViewController.Tick();
ReconcileDossierToFocus(); ReconcileDossierToFocus();
float now = Time.unscaledTime;
if (now < _nextRoutineLiveRefreshAt)
{
return;
}
_nextRoutineLiveRefreshAt = now + RoutineLiveRefreshSeconds;
if (!LifeSagaViewController.IsHoverPreview) if (!LifeSagaViewController.IsHoverPreview)
{ {
RefreshLivePortrait(); RefreshLivePortrait();
@ -822,7 +841,7 @@ public static class WatchCaption
_layoutDirty = true; _layoutDirty = true;
// Route through spine/rail refresh so caption compose and hover panel stay consistent. // Route through spine/rail refresh so caption compose and hover panel stay consistent.
RefreshStorySpine(); RefreshStorySpine(force: true);
} }
private static bool HasStatusBanner() private static bool HasStatusBanner()
@ -981,7 +1000,7 @@ public static class WatchCaption
// Mass tip headcount ticks must not Relayout the whole dossier every second. // Mass tip headcount ticks must not Relayout the whole dossier every second.
// RefreshStorySpine recomposes Beat+Context and Relayouts when chrome changes. // RefreshStorySpine recomposes Beat+Context and Relayouts when chrome changes.
RefreshStorySpine(); RefreshStorySpine(force: true);
if (headcountOnly) if (headcountOnly)
{ {
return; return;
@ -998,13 +1017,20 @@ public static class WatchCaption
/// Quiet context under the orange beat (story spine or stake). /// Quiet context under the orange beat (story spine or stake).
/// Recompose Identity/Beat/Context; show Cast/Legacy panel only while hovering. /// Recompose Identity/Beat/Context; show Cast/Legacy panel only while hovering.
/// </summary> /// </summary>
private static void RefreshStorySpine() private static void RefreshStorySpine(bool force = false)
{ {
if (!_visible) if (!_visible)
{ {
return; return;
} }
float now = Time.unscaledTime;
if (!force && now < _nextRoutineSpineAt)
{
return;
}
_nextRoutineSpineAt = now + RoutineSpineRefreshSeconds;
bool statusBanner = HasStatusBanner(); bool statusBanner = HasStatusBanner();
if (statusBanner) if (statusBanner)
{ {
@ -1112,6 +1138,15 @@ public static class WatchCaption
reasonRelatedId, reasonRelatedId,
spineFiltered, spineFiltered,
statusBannerOwnsBeat); statusBannerOwnsBeat);
if (string.Equals(
model.Fingerprint,
_lastAppliedCaptionFingerprint,
StringComparison.Ordinal))
{
return;
}
_lastAppliedCaptionFingerprint = model.Fingerprint;
if (!_headlineLocked) if (!_headlineLocked)
{ {
@ -1131,7 +1166,10 @@ public static class WatchCaption
shown = CaptionComposer.TruncateIdentity(shown, 48); shown = CaptionComposer.TruncateIdentity(shown, 48);
} }
_nameText.text = shown; if (!string.Equals(_nameText.text, shown, StringComparison.Ordinal))
{
_nameText.text = shown;
}
} }
} }
else if (_current != null && !string.IsNullOrEmpty(_current.Headline)) else if (_current != null && !string.IsNullOrEmpty(_current.Headline))
@ -1482,7 +1520,17 @@ public static class WatchCaption
private static void ApplyStorySpine(string spine) private static void ApplyStorySpine(string spine)
{ {
LastStorySpine = spine ?? ""; string next = spine ?? "";
bool show = !string.IsNullOrEmpty(next);
if (string.Equals(LastStorySpine, next, StringComparison.Ordinal)
&& (_storySpineText == null
|| (_storySpineText.gameObject.activeSelf == show
&& string.Equals(_storySpineText.text, next, StringComparison.Ordinal))))
{
return;
}
LastStorySpine = next;
if (_storySpineText == null) if (_storySpineText == null)
{ {
return; return;
@ -1490,7 +1538,6 @@ public static class WatchCaption
// This row is Caption Context (short-arc spine or durable stake). A status // This row is Caption Context (short-arc spine or durable stake). A status
// banner replaces Beat only, so Context remains visible and live. // banner replaces Beat only, so Context remains visible and live.
bool show = !string.IsNullOrEmpty(LastStorySpine);
_storySpineText.text = show ? LastStorySpine : ""; _storySpineText.text = show ? LastStorySpine : "";
_storySpineText.color = StorySpineColor; _storySpineText.color = StorySpineColor;
_storySpineText.gameObject.SetActive(show); _storySpineText.gameObject.SetActive(show);
@ -1607,6 +1654,15 @@ public static class WatchCaption
string prevHeadline = LastHeadline ?? ""; string prevHeadline = LastHeadline ?? "";
string prevJob = _current.JobLabel ?? ""; string prevJob = _current.JobLabel ?? "";
string prevTag = _current.IdentityTag ?? ""; string prevTag = _current.IdentityTag ?? "";
int memoryRevision = SagaProse.MemoryRevision;
if (string.Equals(job, prevJob, StringComparison.Ordinal)
&& string.Equals(tag, prevTag, StringComparison.Ordinal)
&& memoryRevision == _lastIdentityMemoryRevision)
{
return;
}
_lastIdentityMemoryRevision = memoryRevision;
ApplyComposedCaption( ApplyComposedCaption(
actor, actor,
_current.ReasonLine ?? "", _current.ReasonLine ?? "",
@ -1648,11 +1704,10 @@ public static class WatchCaption
return; return;
} }
UnitDossier probe = new UnitDossier(); UnitDossier.RefreshTopStatuses(StatusProbe, actor);
UnitDossier.RefreshTopStatuses(probe, actor); int fp = StatusFingerprint(StatusProbe);
int fp = StatusFingerprint(probe);
if (fp == _lastStatusesFingerprint if (fp == _lastStatusesFingerprint
&& StatusChipsMatch(_current.TopStatuses, probe.TopStatuses)) && StatusChipsMatch(_current.TopStatuses, StatusProbe.TopStatuses))
{ {
return; return;
} }
@ -2085,12 +2140,22 @@ public static class WatchCaption
} }
bool hasReason = !string.IsNullOrEmpty(reasonRichOrPlain); bool hasReason = !string.IsNullOrEmpty(reasonRichOrPlain);
string next = hasReason ? reasonRichOrPlain : "";
long subjectId = CurrentUnitId;
long nextOtherId = otherId != 0 && otherId != subjectId ? otherId : 0;
if (_reasonText.gameObject.activeSelf == hasReason
&& string.Equals(_reasonText.text, next, StringComparison.Ordinal)
&& _reasonOtherId == nextOtherId)
{
return;
}
_reasonText.supportRichText = true; _reasonText.supportRichText = true;
_reasonText.color = ReasonColor; _reasonText.color = ReasonColor;
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap; _reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow; _reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.resizeTextForBestFit = false; _reasonText.resizeTextForBestFit = false;
_reasonText.text = hasReason ? reasonRichOrPlain : ""; _reasonText.text = next;
_reasonText.gameObject.SetActive(hasReason); _reasonText.gameObject.SetActive(hasReason);
WireReasonClick(hasReason ? otherId : 0); WireReasonClick(hasReason ? otherId : 0);
} }

View file

@ -411,7 +411,9 @@ public static class WorldActivityScanner
private static InterestEvent BuildHottestBattleLight(Vector3 anchor) private static InterestEvent BuildHottestBattleLight(Vector3 anchor)
{ {
InterestEvent best = RankBattleKeeper(allFighters: null); InterestEvent best = RankBattleKeeper(allFighters: null);
if (LiveEnsemble.TryBuildCombat(anchor, 14f, out LiveEnsemble local) if (anchor != Vector3.zero
&& !float.IsNaN(anchor.x)
&& LiveEnsemble.TryBuildCombat(anchor, 14f, out LiveEnsemble local)
&& local != null && local != null
&& local.ParticipantCount >= 1) && local.ParticipantCount >= 1)
{ {
@ -449,7 +451,7 @@ public static class WorldActivityScanner
LiveEnsemble.CollectCombatFightersNear(allFighters, pos, 14f, nearby); LiveEnsemble.CollectCombatFightersNear(allFighters, pos, 14f, nearby);
ensemble = LiveEnsemble.FromCombatFighters(nearby, pos); ensemble = LiveEnsemble.FromCombatFighters(nearby, pos);
} }
else if (!LiveEnsemble.TryBuildCombat(pos, 14f, out ensemble)) else if (!LiveEnsemble.TryBuildCombatLocal(pos, 14f, out ensemble))
{ {
ensemble = null; ensemble = null;
} }

View file

@ -84,6 +84,13 @@ if [[ -z "$scenario" && -z "$raw_cmd" ]]; then
fi fi
worldbox_running() { worldbox_running() {
# Manually launched games may live outside the caller's PID namespace even though
# the mod inbox remains reachable. Allow diagnostics to bypass process discovery
# explicitly without starting a second Steam instance.
if [[ "${HARNESS_ASSUME_RUNNING:-0}" == "1" ]]; then
return 0
fi
local pid cmd local pid cmd
for pid in $(pgrep -a worldbox 2>/dev/null | awk '{print $1}'); do for pid in $(pgrep -a worldbox 2>/dev/null | awk '{print $1}'); do
cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true) cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)