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:
parent
af17d9acd1
commit
54476fb7a1
23 changed files with 1716 additions and 118 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue