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 =
|
||||
StoryScheduler.HarnessProbeEpisodeContinuity(out string episode);
|
||||
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 ok = contextOk && spineOk && combatOk && noveltyOk
|
||||
&& participantOk && loverOk && identityOk && episodeOk
|
||||
&& proseOk && persistenceOk;
|
||||
&& proseOk && lifecycleOk && schedulerBoundsOk
|
||||
&& exposureOk && interruptOk
|
||||
&& graphOk && persistenceOk;
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(
|
||||
cmd,
|
||||
|
|
@ -2966,7 +2978,10 @@ public static class AgentHarness
|
|||
+ "} combat={" + combat + "} novelty={" + novelty
|
||||
+ "} participant={" + participant + "} lover={" + lover
|
||||
+ "} identity={" + identity + "} episode={" + episode
|
||||
+ "} prose={" + prose
|
||||
+ "} prose={" + prose + "} lifecycle={" + lifecycle
|
||||
+ "} schedulerBounds={" + schedulerBounds
|
||||
+ "} exposure={" + exposure + "} interrupt={" + interrupt
|
||||
+ "} graph={" + graph
|
||||
+ "} persistence={" + persistence + "}");
|
||||
break;
|
||||
}
|
||||
|
|
@ -4408,6 +4423,7 @@ public static class AgentHarness
|
|||
|
||||
case "hitch_probe":
|
||||
{
|
||||
_preserveSagaAfterBatch = true;
|
||||
bool on = ParseBool(cmd.value, defaultValue: true);
|
||||
IdleHitchProbe.SetEnabled(on);
|
||||
IdleHitchProbe.ResetStats();
|
||||
|
|
@ -4416,6 +4432,25 @@ public static class AgentHarness
|
|||
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":
|
||||
{
|
||||
int slot = Mathf.Clamp(ParseInt(cmd.value, 2), 1, 5);
|
||||
|
|
|
|||
|
|
@ -119,8 +119,11 @@ public static class EventFeedUtil
|
|||
return null;
|
||||
}
|
||||
|
||||
EventPresentability.MarkApproved(candidate);
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
return InterestRegistry.Upsert(candidate);
|
||||
InterestCandidate registered = InterestRegistry.Upsert(candidate);
|
||||
EventPresentability.MarkApproved(registered);
|
||||
return registered;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -183,8 +186,17 @@ public static class EventFeedUtil
|
|||
return null;
|
||||
}
|
||||
|
||||
if (!IsHarnessSource(candidate.Source))
|
||||
{
|
||||
EventPresentability.MarkApproved(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) =>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -6,6 +10,35 @@ namespace IdleSpectator;
|
|||
/// </summary>
|
||||
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>
|
||||
/// True when the candidate may own Layer A camera.
|
||||
/// Empty Label is only allowed for CharacterLed fill (task chip, no orange reason).
|
||||
|
|
@ -17,12 +50,94 @@ public static class EventPresentability
|
|||
return false;
|
||||
}
|
||||
|
||||
if (IsApproved(scene))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(scene.Label))
|
||||
{
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ public static class InterestFeeds
|
|||
private static float _lastScannerAt = -999f;
|
||||
private const float ScannerInterval = 1.25f;
|
||||
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.
|
||||
private static readonly Dictionary<string, float> CivicBoostUntil = new Dictionary<string, float>(32);
|
||||
|
|
@ -26,6 +33,13 @@ public static class InterestFeeds
|
|||
_lastScannerAt = -999f;
|
||||
CivicBoostUntil.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>
|
||||
|
|
@ -107,12 +121,17 @@ public static class InterestFeeds
|
|||
|
||||
public static void Tick()
|
||||
{
|
||||
long started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
DrainHappiness();
|
||||
LastHappinessMs = ElapsedMs(started);
|
||||
PruneCivicBoosts();
|
||||
LastScannerMs = 0f;
|
||||
// Stagger battle/character scanner off the happiness drain frame.
|
||||
if ((_tickParity++ & 1) == 0)
|
||||
{
|
||||
started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
TickScanner();
|
||||
LastScannerMs = ElapsedMs(started);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -750,13 +769,16 @@ public static class InterestFeeds
|
|||
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())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor related = occ.RelatedId != 0 ? FindUnitById(occ.RelatedId) : null;
|
||||
Actor related = occ.RelatedId != 0 ? EventFeedUtil.FindUnitById(occ.RelatedId) : null;
|
||||
NarrativePresentationModel narrativePresentation =
|
||||
NarrativePresentationFactory.Happiness(occ, subject, related);
|
||||
string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory;
|
||||
|
|
@ -1072,6 +1094,11 @@ public static class InterestFeeds
|
|||
|
||||
private static void TickScanner()
|
||||
{
|
||||
LastBattleScanMs = 0f;
|
||||
LastWarFeedMs = 0f;
|
||||
LastPlotFeedMs = 0f;
|
||||
LastFamilyFeedMs = 0f;
|
||||
LastStatusFeedMs = 0f;
|
||||
float now = Time.unscaledTime;
|
||||
if (now - _lastScannerAt < ScannerInterval)
|
||||
{
|
||||
|
|
@ -1090,6 +1117,7 @@ public static class InterestFeeds
|
|||
&& InterestDirector.CurrentIsActive
|
||||
&& current.ParticipantCount >= 2;
|
||||
|
||||
long stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
InterestEvent battle;
|
||||
if (stickyCombat)
|
||||
{
|
||||
|
|
@ -1111,10 +1139,14 @@ public static class InterestFeeds
|
|||
}
|
||||
else
|
||||
{
|
||||
// Quiet / non-sticky: full ranking is OK (one fighter pass per ScanInterval).
|
||||
battle = WorldActivityScanner.FindHottestBattle();
|
||||
WorldActivityScanner.RegisterTopCharacterCandidates(max: 4);
|
||||
// BattleKeeper plus chunk-local ensembles yields the strongest combat scene
|
||||
// without collecting every fighter in the world. The old extra four combat
|
||||
// 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)
|
||||
{
|
||||
|
|
@ -1131,18 +1163,30 @@ public static class InterestFeeds
|
|||
}
|
||||
|
||||
// Ongoing wars from WarManager (complements WorldLog war start/end).
|
||||
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
WarInterestFeed.Tick();
|
||||
LastWarFeedMs = ElapsedMs(stageStarted);
|
||||
|
||||
// Active plots from PlotManager / World.plots.
|
||||
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
PlotInterestFeed.Tick();
|
||||
LastPlotFeedMs = ElapsedMs(stageStarted);
|
||||
|
||||
// Active family packs (2+ members) from World.families.
|
||||
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
FamilyInterestFeed.Tick();
|
||||
LastFamilyFeedMs = ElapsedMs(stageStarted);
|
||||
|
||||
// Nearby multi-carrier status clusters.
|
||||
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
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)
|
||||
{
|
||||
var pending = new List<InterestCandidate>(32);
|
||||
|
|
@ -1298,31 +1342,6 @@ public static class InterestFeeds
|
|||
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)
|
||||
{
|
||||
if (actor == null)
|
||||
|
|
|
|||
|
|
@ -111,6 +111,36 @@ public sealed class LiveEnsemble
|
|||
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>
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ public sealed class LiveSceneStickyState
|
|||
public string PresentedReason = "";
|
||||
public string PendingReason = "";
|
||||
public float ReasonChangeSince = -999f;
|
||||
/// <summary>Combat must stay cold briefly before its headline is cleared.</summary>
|
||||
public float CombatColdSince = -999f;
|
||||
/// <summary>
|
||||
/// 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).
|
||||
|
|
@ -67,6 +69,7 @@ public sealed class LiveSceneStickyState
|
|||
PresentedReason = "";
|
||||
PendingReason = "";
|
||||
ReasonChangeSince = -999f;
|
||||
CombatColdSince = -999f;
|
||||
MopUpActive = false;
|
||||
MopUpSince = -999f;
|
||||
SideAKey = "";
|
||||
|
|
@ -99,6 +102,7 @@ public sealed class LiveSceneStickyState
|
|||
other.PresentedReason = PresentedReason;
|
||||
other.PendingReason = PendingReason;
|
||||
other.ReasonChangeSince = ReasonChangeSince;
|
||||
other.CombatColdSince = CombatColdSince;
|
||||
other.MopUpActive = MopUpActive;
|
||||
other.MopUpSince = MopUpSince;
|
||||
other.SideAKey = SideAKey;
|
||||
|
|
|
|||
|
|
@ -193,6 +193,9 @@ internal static class HarnessScenarios
|
|||
case "narrative_story_first_soak":
|
||||
case "story_first_soak":
|
||||
return NarrativeStoryFirstSoak();
|
||||
case "narrative_scheduler_health":
|
||||
case "scheduler_health":
|
||||
return NarrativeSchedulerHealth();
|
||||
case "narrative_catalog":
|
||||
case "narrative_catalog_policy":
|
||||
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()
|
||||
{
|
||||
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 "
|
||||
+ $"other={_otherMs:0.0}ms roster={LifeSagaRoster.Count} pending={InterestRegistry.PendingCount} "
|
||||
+ $"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)");
|
||||
_hotMark = "";
|
||||
_hotMarkMs = 0f;
|
||||
|
|
|
|||
|
|
@ -86,6 +86,15 @@ public sealed class InterestCandidate
|
|||
/// <summary>Unscaled time the theater lead was last a live combat participant.</summary>
|
||||
public float TheaterLeadLastCombatAt = -999f;
|
||||
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 SpeciesId = "";
|
||||
public string Verb = "";
|
||||
|
|
@ -341,6 +350,11 @@ public sealed class InterestCandidate
|
|||
TheaterLeadId = TheaterLeadId,
|
||||
TheaterLeadLastCombatAt = TheaterLeadLastCombatAt,
|
||||
Label = Label,
|
||||
ApprovedPresentationLabel = ApprovedPresentationLabel,
|
||||
ApprovedPresentationSubjectId = ApprovedPresentationSubjectId,
|
||||
ApprovedPresentationRelatedId = ApprovedPresentationRelatedId,
|
||||
ApprovedPresentationLeadKind = ApprovedPresentationLeadKind,
|
||||
HasApprovedPresentation = HasApprovedPresentation,
|
||||
AssetId = AssetId,
|
||||
SpeciesId = SpeciesId,
|
||||
Verb = Verb,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ namespace IdleSpectator;
|
|||
|
||||
public static partial class InterestDirector
|
||||
{
|
||||
private const float CombatHeadlineColdGraceSeconds = 4.5f;
|
||||
|
||||
/// <summary>
|
||||
/// Keep camera on the highest-scored combat participant and rewrite tip/counts
|
||||
/// 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);
|
||||
if (!combatHot)
|
||||
{
|
||||
if (_current.Sticky.CombatColdSince < 0f)
|
||||
{
|
||||
_current.Sticky.CombatColdSince = now;
|
||||
return;
|
||||
}
|
||||
if (now - _current.Sticky.CombatColdSince < CombatHeadlineColdGraceSeconds)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ClearStaleCombatLabel(now);
|
||||
return;
|
||||
}
|
||||
_current.Sticky.CombatColdSince = -999f;
|
||||
|
||||
float throttle = _current.ParticipantCount >= CombatFocusLargeParticipantThreshold
|
||||
? CombatFocusThrottleLargeSeconds
|
||||
|
|
@ -1876,7 +1888,7 @@ public static partial class InterestDirector
|
|||
_current.ClearCombatSticky();
|
||||
_current.ClearTheaterLead();
|
||||
_lastCombatFocusAt = now;
|
||||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
CameraDirector.ClearWatchReason();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ namespace IdleSpectator;
|
|||
|
||||
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)
|
||||
{
|
||||
if (candidate == null)
|
||||
|
|
@ -34,6 +37,15 @@ public static partial class InterestDirector
|
|||
return false;
|
||||
}
|
||||
|
||||
if (candidate != _current
|
||||
&& !NarrativeExposureLedger.MayPresent(candidate))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"combat_exposure_budget",
|
||||
candidate.Label ?? candidate.Key);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_current == null)
|
||||
{
|
||||
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
||||
|
|
@ -89,6 +101,21 @@ public static partial class InterestDirector
|
|||
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))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
|
|
@ -358,6 +385,143 @@ public static partial class InterestDirector
|
|||
&& 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>
|
||||
/// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ public static partial class InterestDirector
|
|||
public const float InputExitGraceSeconds = 2.5f;
|
||||
public const float QuietGraceSeconds = 6f;
|
||||
public const float CameraSettleGraceSeconds = 3f;
|
||||
public const float SelectionPollSeconds = 0.12f;
|
||||
|
||||
private static float _minDwell = MinDwellSeconds;
|
||||
private static float _curiosityDwell = CuriosityDwellSeconds;
|
||||
|
|
@ -42,6 +43,7 @@ public static partial class InterestDirector
|
|||
private static float _lastInterestingAt = -999f;
|
||||
private static float _lastAmbientAt = -999f;
|
||||
private static float _lastFeedsAt = -999f;
|
||||
private static float _nextSelectAt = -999f;
|
||||
private static float _enabledAt = -999f;
|
||||
private static float _inactiveSince = -999f;
|
||||
private static float _lastCombatFocusAt = -999f;
|
||||
|
|
@ -527,6 +529,7 @@ public static partial class InterestDirector
|
|||
_lastInterestingAt = now;
|
||||
_lastAmbientAt = -999f;
|
||||
_lastFeedsAt = -999f;
|
||||
_nextSelectAt = now;
|
||||
_enabledAt = now;
|
||||
_inactiveSince = -999f;
|
||||
// Harness scenarios inject their own candidates after enable/focus.
|
||||
|
|
@ -579,6 +582,7 @@ public static partial class InterestDirector
|
|||
_inactiveSince = -999f;
|
||||
_lastAmbientAt = -999f;
|
||||
_lastFeedsAt = -999f;
|
||||
_nextSelectAt = now;
|
||||
// Re-bind camera to the frozen story beat when still watchable.
|
||||
if (_current != null)
|
||||
{
|
||||
|
|
@ -651,10 +655,15 @@ public static partial class InterestDirector
|
|||
float sinceSwitch = now - _lastSwitchAt;
|
||||
|
||||
IdleHitchProbe.Mark("story");
|
||||
IdleHitchProbe.Mark("planner");
|
||||
StoryPlanner.Tick(now);
|
||||
IdleHitchProbe.Mark("planner_done");
|
||||
LifeSagaRoster.Tick(now);
|
||||
IdleHitchProbe.Mark("roster_done");
|
||||
LifeSagaSession.EnsureBound();
|
||||
IdleHitchProbe.Mark("session_done");
|
||||
StoryScheduler.Tick(now);
|
||||
IdleHitchProbe.Mark("scheduler_done");
|
||||
IdleHitchProbe.Mark("story_done");
|
||||
|
||||
if (ReadPauseActive)
|
||||
|
|
@ -676,19 +685,23 @@ public static partial class InterestDirector
|
|||
MaintainFamilyPack(now, force: false);
|
||||
MaintainStatusOutbreak(now, force: false);
|
||||
|
||||
IdleHitchProbe.Mark("select");
|
||||
InterestCandidate next = SelectNext(now);
|
||||
IdleHitchProbe.Mark("select_done");
|
||||
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
|
||||
if (now >= _nextSelectAt)
|
||||
{
|
||||
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);
|
||||
onCurrent = 0f;
|
||||
sinceSwitch = 0f;
|
||||
SwitchTo(next, now, resumableInterrupt: false);
|
||||
onCurrent = 0f;
|
||||
sinceSwitch = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
InterestRegistry.Remove(resume.Key);
|
||||
}
|
||||
else if (!NarrativeExposureLedger.MayPresent(resume))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"resume_exposure_budget",
|
||||
resume.Label ?? resume.Key ?? "combat");
|
||||
InterestRegistry.Remove(resume.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
SwitchTo(resume, now, resumableInterrupt: false);
|
||||
|
|
@ -2732,7 +2752,6 @@ public static partial class InterestDirector
|
|||
|
||||
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
|
||||
{
|
||||
StoryScheduler.NoteActualSelection(next);
|
||||
if (next == null)
|
||||
{
|
||||
return;
|
||||
|
|
@ -2752,6 +2771,9 @@ public static partial class InterestDirector
|
|||
return;
|
||||
}
|
||||
|
||||
StoryScheduler.NoteActualSelection(next);
|
||||
NarrativeExposureLedger.NotePresented(next);
|
||||
|
||||
if (_current != null
|
||||
&& _current.Resumable
|
||||
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ public static class InterestRegistry
|
|||
{
|
||||
ByKey.Clear();
|
||||
}
|
||||
EventPresentability.ClearCache();
|
||||
}
|
||||
|
||||
public static InterestCandidate Upsert(InterestCandidate incoming)
|
||||
|
|
|
|||
|
|
@ -21,15 +21,22 @@ public static class NarrativeExposureLedger
|
|||
new Dictionary<long, Exposure>();
|
||||
private static readonly Dictionary<string, Exposure> Threads =
|
||||
new Dictionary<string, Exposure>(StringComparer.Ordinal);
|
||||
private static readonly List<bool> PresentedCuts = new List<bool>(6);
|
||||
private static int _maxCharacterCuts;
|
||||
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()
|
||||
{
|
||||
Characters.Clear();
|
||||
Threads.Clear();
|
||||
PresentedCuts.Clear();
|
||||
_maxCharacterCuts = 0;
|
||||
_maxThreadCuts = 0;
|
||||
_combatRun = 0;
|
||||
}
|
||||
|
||||
public static void NoteCut(
|
||||
|
|
@ -82,6 +89,38 @@ public static class NarrativeExposureLedger
|
|||
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)
|
||||
{
|
||||
Clear();
|
||||
|
|
@ -93,9 +132,36 @@ public static class NarrativeExposureLedger
|
|||
float prolific = CharacterDebt(1);
|
||||
float repetition = RepetitionPenalty(1, "combat");
|
||||
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")
|
||||
+ " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0")
|
||||
+ " combat=" + routineBlocked + "/" + criticalAllowed + "/" + routineReopened
|
||||
+ " pass=" + ok;
|
||||
Clear();
|
||||
return ok;
|
||||
|
|
@ -127,4 +193,19 @@ public static class NarrativeExposureLedger
|
|||
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 MaxConsequences = 2400;
|
||||
private const int MaxFacts = 4000;
|
||||
private const int MaxCharacters = 1600;
|
||||
|
||||
private static readonly Dictionary<long, NarrativePreferenceDto> Preferences =
|
||||
new Dictionary<long, NarrativePreferenceDto>();
|
||||
|
|
@ -109,6 +110,8 @@ public static class NarrativePersistence
|
|||
LastPath = path;
|
||||
LastStatus = "saved events=" + state.events.Count
|
||||
+ " developments=" + state.developments.Count
|
||||
+ " threads=" + state.threads.Count
|
||||
+ " characters=" + state.characters.Count
|
||||
+ " facts=" + state.facts.Count
|
||||
+ " bytes=" + json.Length;
|
||||
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.consequences, MaxConsequences, (a, b) => b.occurredAt.CompareTo(a.occurredAt));
|
||||
Trim(state.facts, MaxFacts, (a, b) => b.worldTime.CompareTo(a.worldTime));
|
||||
CompactGraph(state, MaxCharacters);
|
||||
state.Pack();
|
||||
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)
|
||||
{
|
||||
items.Sort(comparison);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ public static class NarrativeStoryStore
|
|||
{
|
||||
private const int RecentPerCharacter = 16;
|
||||
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 =
|
||||
new Dictionary<string, NarrativeDevelopment>(StringComparer.Ordinal);
|
||||
private static readonly Dictionary<long, CharacterStoryState> Characters =
|
||||
|
|
@ -18,6 +22,17 @@ public static class NarrativeStoryStore
|
|||
private static readonly Dictionary<string, CharacterConsequence> Consequences =
|
||||
new Dictionary<string, CharacterConsequence>(StringComparer.Ordinal);
|
||||
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 DevelopmentCount => Developments.Count;
|
||||
|
|
@ -31,6 +46,12 @@ public static class NarrativeStoryStore
|
|||
Threads.Clear();
|
||||
Consequences.Clear();
|
||||
ThreadScratch.Clear();
|
||||
SchedulerCandidates.Clear();
|
||||
SchedulerCopyIds.Clear();
|
||||
RosterScratch.Clear();
|
||||
OpenConflictsByCharacter.Clear();
|
||||
ConflictCastByThread.Clear();
|
||||
IdScratch.Clear();
|
||||
NarrativeEventStore.Clear();
|
||||
Revision++;
|
||||
StoryScheduler.Clear();
|
||||
|
|
@ -105,21 +126,52 @@ public static class NarrativeStoryStore
|
|||
|
||||
internal static void FinishImport()
|
||||
{
|
||||
foreach (CharacterStoryState state in Characters.Values)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
RepairConsistency();
|
||||
Revision++;
|
||||
StoryScheduler.Clear();
|
||||
NarrativeTelemetry.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)
|
||||
{
|
||||
if (development == null || development.SubjectId == 0 || string.IsNullOrEmpty(development.Id))
|
||||
|
|
@ -195,6 +247,7 @@ public static class NarrativeStoryStore
|
|||
CharacterStoryState state = GetOrCreateCharacter(protagonistId, protagonistIdentity);
|
||||
MoveThreadReference(state, thread);
|
||||
RecalculatePotential(state);
|
||||
TrackThread(thread);
|
||||
Revision++;
|
||||
return thread;
|
||||
}
|
||||
|
|
@ -219,16 +272,39 @@ public static class NarrativeStoryStore
|
|||
{
|
||||
if (into == null) return;
|
||||
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
|
||||
&& (thread.IsOpen
|
||||
|| (thread.Status == NarrativeThreadStatus.Resolved
|
||||
&& Time.unscaledTime - thread.UpdatedAt < 20f)))
|
||||
NarrativeThread thread = pair.Value;
|
||||
if (!IsSchedulerEligible(thread, now))
|
||||
{
|
||||
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>
|
||||
|
|
@ -372,8 +448,22 @@ public static class NarrativeStoryStore
|
|||
{
|
||||
state.OpenThreadIds.Remove(thread.Id);
|
||||
state.ResolvedThreadIds.Remove(thread.Id);
|
||||
if (thread.IsOpen) state.OpenThreadIds.Insert(0, thread.Id);
|
||||
else state.ResolvedThreadIds.Insert(0, thread.Id);
|
||||
if (thread.IsOpen)
|
||||
{
|
||||
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)
|
||||
|
|
@ -384,6 +474,394 @@ public static class NarrativeStoryStore
|
|||
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)
|
||||
{
|
||||
switch (function)
|
||||
|
|
|
|||
|
|
@ -127,19 +127,25 @@ public static class NarrativeThreadRules
|
|||
"Their life has ended", NarrativePhase.Outcome, NarrativeThreadStatus.Resolved);
|
||||
if (t != null) t.Resolution = "died";
|
||||
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.LostLife, "deceased", d.OtherId, 100f);
|
||||
NarrativeStoryStore.ResolveCombatThreadsForDeath(d.SubjectId, d);
|
||||
}
|
||||
|
||||
private static void ApplyConflict(NarrativeDevelopment d)
|
||||
{
|
||||
string anchor = PairAnchor("conflict", d.SubjectId, d.OtherId);
|
||||
NarrativeThreadKind kind = d.Kind == NarrativeDevelopmentKind.RivalEncounter
|
||||
? NarrativeThreadKind.Rivalry : NarrativeThreadKind.PersonalCombat;
|
||||
bool terminal = d.Kind == NarrativeDevelopmentKind.Kill;
|
||||
NarrativeThreadKind kind = terminal
|
||||
? NarrativeThreadKind.PersonalCombat
|
||||
: NarrativeThreadKind.Rivalry;
|
||||
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||
"thread:conflict:" + anchor + ":" + d.SubjectId, kind, d.SubjectId, anchor, d,
|
||||
"Will this conflict end their repeated struggle?", NarrativePhase.TurningPoint,
|
||||
NarrativeThreadStatus.Active);
|
||||
if (d.Kind == NarrativeDevelopmentKind.Kill)
|
||||
terminal ? "The fight has reached its outcome"
|
||||
: "Will this conflict become a repeated struggle?",
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -164,9 +170,10 @@ public static class NarrativeThreadRules
|
|||
"thread:home:" + anchor + ":" + d.SubjectId,
|
||||
lost ? NarrativeThreadKind.HomeLoss : NarrativeThreadKind.Founding,
|
||||
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,
|
||||
NarrativeThreadStatus.Active);
|
||||
lost ? NarrativeThreadStatus.Active : NarrativeThreadStatus.Resolved);
|
||||
if (!lost && t != null) t.Resolution = "home established";
|
||||
AddConsequence(d, t, d.SubjectId,
|
||||
lost ? CharacterConsequenceKind.LostHome : CharacterConsequenceKind.FoundedHome,
|
||||
lost ? "displaced" : "founder", 0, lost ? 90f : 82f);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
|
@ -13,6 +14,7 @@ public static class StoryScheduler
|
|||
private const float EpisodeMaxSeconds = 75f;
|
||||
private const float EarlyEpisodeSwitchMargin = 30f;
|
||||
private const float EstablishedEpisodeSwitchMargin = 20f;
|
||||
private const float SchedulerIntervalSeconds = 0.5f;
|
||||
public const int EpisodeCombatShotBudget = 3;
|
||||
private static readonly List<NarrativeThread> OpenThreads = new List<NarrativeThread>(32);
|
||||
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 EpisodeShotProposal CurrentProposal { get; private set; }
|
||||
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()
|
||||
{
|
||||
|
|
@ -31,16 +39,47 @@ public static class StoryScheduler
|
|||
Pending.Clear();
|
||||
_nextTickAt = 0f;
|
||||
_lastProposal = "";
|
||||
LastTickMs = 0f;
|
||||
LastCandidateCount = 0;
|
||||
LastThreadCopyMs = 0f;
|
||||
LastThreadScoreMs = 0f;
|
||||
LastPendingCopyMs = 0f;
|
||||
LastProposalMs = 0f;
|
||||
NarrativeExposureLedger.Clear();
|
||||
}
|
||||
|
||||
public static void Tick(float now)
|
||||
{
|
||||
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);
|
||||
LastThreadCopyMs = ElapsedMs(stageStarted);
|
||||
LastCandidateCount = OpenThreads.Count;
|
||||
NarrativeThread best = null;
|
||||
float bestScore = float.MinValue;
|
||||
stageStarted = Stopwatch.GetTimestamp();
|
||||
for (int i = 0; i < OpenThreads.Count; i++)
|
||||
{
|
||||
NarrativeThread thread = OpenThreads[i];
|
||||
|
|
@ -56,6 +95,7 @@ public static class StoryScheduler
|
|||
bestScore = score;
|
||||
}
|
||||
}
|
||||
LastThreadScoreMs = ElapsedMs(stageStarted);
|
||||
|
||||
NarrativeThread activeThread = ActiveEpisode != null
|
||||
? NarrativeStoryStore.Thread(ActiveEpisode.ThreadId)
|
||||
|
|
@ -99,8 +139,12 @@ public static class StoryScheduler
|
|||
AddPendingForPurpose(ActiveEpisode, PurposeFor(selectedThread));
|
||||
}
|
||||
|
||||
stageStarted = Stopwatch.GetTimestamp();
|
||||
InterestRegistry.CopyPending(Pending);
|
||||
LastPendingCopyMs = ElapsedMs(stageStarted);
|
||||
stageStarted = Stopwatch.GetTimestamp();
|
||||
CurrentProposal = EpisodeShotSelector.Pick(Pending, ActiveEpisode, selectedThread);
|
||||
LastProposalMs = ElapsedMs(stageStarted);
|
||||
if (CurrentProposal != null)
|
||||
{
|
||||
NarrativeTelemetry.NoteProposal(ActiveEpisode, CurrentProposal, now);
|
||||
|
|
@ -117,6 +161,9 @@ public static class StoryScheduler
|
|||
+ " reason=" + (CurrentProposal?.Reason ?? "none");
|
||||
}
|
||||
|
||||
private static float ElapsedMs(long started) =>
|
||||
(float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency);
|
||||
|
||||
public static void NoteActualSelection(InterestCandidate actual)
|
||||
{
|
||||
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> Scratch = new List<LifeSagaSlot>(48);
|
||||
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 HashSet<long> ScanSeen = 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 WorldScanPhase _scanPhase = WorldScanPhase.None;
|
||||
private static int _scanIndex;
|
||||
private static int _scanLimit;
|
||||
private static int _scanCommitIndex;
|
||||
private static List<Actor> _liveScanSource;
|
||||
private static float _scanStartedAt;
|
||||
private static int _scanPinCount;
|
||||
|
||||
|
|
@ -45,7 +49,12 @@ public static class LifeSagaRoster
|
|||
}
|
||||
|
||||
/// <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>
|
||||
private const float WorldScanIntervalSeconds = 3.5f;
|
||||
|
|
@ -74,6 +83,7 @@ public static class LifeSagaRoster
|
|||
Slots.Clear();
|
||||
Scratch.Clear();
|
||||
ChallengerScratch.Clear();
|
||||
ChallengerPriorityScratch.Clear();
|
||||
UnitScanSnapshot.Clear();
|
||||
ScanSeen.Clear();
|
||||
PreviousIds.Clear();
|
||||
|
|
@ -86,6 +96,9 @@ public static class LifeSagaRoster
|
|||
_roleCacheAt = -999f;
|
||||
_scanPhase = WorldScanPhase.None;
|
||||
_scanIndex = 0;
|
||||
_scanLimit = 0;
|
||||
_scanCommitIndex = 0;
|
||||
_liveScanSource = null;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
|
|
@ -759,8 +772,10 @@ public static class LifeSagaRoster
|
|||
PreviousIds.Clear();
|
||||
ScanSeen.Clear();
|
||||
ChallengerScratch.Clear();
|
||||
ChallengerPriorityScratch.Clear();
|
||||
UnitScanSnapshot.Clear();
|
||||
_scanPinCount = 0;
|
||||
_scanCommitIndex = 0;
|
||||
_scanStartedAt = now;
|
||||
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
|
|
@ -795,7 +810,16 @@ public static class LifeSagaRoster
|
|||
|
||||
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)
|
||||
{
|
||||
|
|
@ -804,6 +828,8 @@ public static class LifeSagaRoster
|
|||
UnitScanSnapshot.Add(actor);
|
||||
}
|
||||
}
|
||||
|
||||
_scanLimit = UnitScanSnapshot.Count;
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
|
@ -822,10 +848,15 @@ public static class LifeSagaRoster
|
|||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
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++)
|
||||
{
|
||||
Actor actor = UnitScanSnapshot[i];
|
||||
Actor actor = _liveScanSource != null
|
||||
? _liveScanSource[i]
|
||||
: UnitScanSnapshot[i];
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
continue;
|
||||
|
|
@ -842,12 +873,12 @@ public static class LifeSagaRoster
|
|||
continue;
|
||||
}
|
||||
|
||||
ChallengerScratch.Add(actor);
|
||||
ConsiderWorldScanChallenger(actor);
|
||||
ScanSeen.Add(id);
|
||||
}
|
||||
|
||||
_scanIndex = end;
|
||||
if (_scanIndex < UnitScanSnapshot.Count)
|
||||
if (_scanIndex < available)
|
||||
{
|
||||
sw.Stop();
|
||||
LastWorldScanMs = Mathf.Max(LastWorldScanMs, (float)sw.Elapsed.TotalMilliseconds);
|
||||
|
|
@ -859,14 +890,10 @@ public static class LifeSagaRoster
|
|||
|
||||
if (_scanPhase == WorldScanPhase.Commit)
|
||||
{
|
||||
const int MaxScanAdmits = Cap * 2;
|
||||
if (ChallengerScratch.Count > MaxScanAdmits)
|
||||
{
|
||||
ChallengerScratch.Sort(CompareAdmitPriorityDesc);
|
||||
ChallengerScratch.RemoveRange(MaxScanAdmits, ChallengerScratch.Count - MaxScanAdmits);
|
||||
}
|
||||
|
||||
for (int i = 0; i < ChallengerScratch.Count; i++)
|
||||
int end = Mathf.Min(
|
||||
_scanCommitIndex + WorldScanBuildsPerFrame,
|
||||
ChallengerScratch.Count);
|
||||
for (int i = _scanCommitIndex; i < end; i++)
|
||||
{
|
||||
Actor actor = ChallengerScratch[i];
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
|
|
@ -878,12 +905,27 @@ public static class LifeSagaRoster
|
|||
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);
|
||||
ChallengerScratch.Clear();
|
||||
ChallengerPriorityScratch.Clear();
|
||||
UnitScanSnapshot.Clear();
|
||||
ScanSeen.Clear();
|
||||
_liveScanSource = null;
|
||||
_scanLimit = 0;
|
||||
_scanPhase = WorldScanPhase.None;
|
||||
_scanIndex = 0;
|
||||
_scanCommitIndex = 0;
|
||||
_nextScanAt = now + WorldScanIntervalSeconds;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1542,6 +1584,36 @@ public static class LifeSagaRoster
|
|||
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)
|
||||
{
|
||||
if (actor == null)
|
||||
|
|
@ -1873,12 +1945,44 @@ public static class LifeSagaRoster
|
|||
|
||||
public static bool IsSpeciesSingleton(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
if (actor?.asset == null)
|
||||
{
|
||||
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)
|
||||
|
|
@ -1953,21 +2057,6 @@ public static class LifeSagaRoster
|
|||
_roleCacheAt = now;
|
||||
PlotAuthorCache.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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -582,8 +582,13 @@ public sealed class UnitDossier
|
|||
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).
|
||||
if (ReasonNamesStranger(beat, scene))
|
||||
if (!routerOwned && ReasonNamesStranger(beat, scene))
|
||||
{
|
||||
// Tiered combat tips name sides / duelists on purpose.
|
||||
if (!IsEnsembleCombatReason(beat))
|
||||
|
|
@ -611,6 +616,44 @@ public sealed class UnitDossier
|
|||
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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason) || scene == null)
|
||||
|
|
@ -902,6 +945,14 @@ public sealed class UnitDossier
|
|||
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)
|
||||
&& name.Equals(subjectName, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
|
@ -928,6 +979,26 @@ public sealed class UnitDossier
|
|||
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>
|
||||
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 GameObject _traitsRow;
|
||||
private static readonly TraitSlot[] _statusSlots = new TraitSlot[UnitDossier.MaxStatusChips];
|
||||
private static readonly UnitDossier StatusProbe = new UnitDossier();
|
||||
private static GameObject _statusesRow;
|
||||
private static int _lastStatusesFingerprint = int.MinValue;
|
||||
|
||||
|
|
@ -110,6 +111,12 @@ public static class WatchCaption
|
|||
private static bool _visible;
|
||||
private static bool _layoutDirty = true;
|
||||
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 float _nextPortraitAt;
|
||||
private static long _portraitActorId;
|
||||
|
|
@ -496,6 +503,10 @@ public static class WatchCaption
|
|||
_lastHistoryCount = -1;
|
||||
_lastHistorySubjectId = 0;
|
||||
_lastStatusesFingerprint = int.MinValue;
|
||||
_lastAppliedCaptionFingerprint = "";
|
||||
_lastIdentityMemoryRevision = int.MinValue;
|
||||
_nextRoutineLiveRefreshAt = 0f;
|
||||
_nextRoutineSpineAt = 0f;
|
||||
_headlineLocked = false;
|
||||
_historyColW = HistoryColMinW;
|
||||
_statusBanner = "";
|
||||
|
|
@ -619,6 +630,7 @@ public static class WatchCaption
|
|||
LastCaptionText = dossier.CaptionText ?? "";
|
||||
EnsureBuilt();
|
||||
ApplyVisual(actor, dossier);
|
||||
_nextRoutineLiveRefreshAt = Time.unscaledTime + RoutineLiveRefreshSeconds;
|
||||
SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused));
|
||||
LogCaptionIfChanged(LastCaptionText);
|
||||
}
|
||||
|
|
@ -747,6 +759,13 @@ public static class WatchCaption
|
|||
// Caption Identity/Beat/Context always track camera focus; hover only adds panel depth.
|
||||
LifeSagaViewController.Tick();
|
||||
ReconcileDossierToFocus();
|
||||
float now = Time.unscaledTime;
|
||||
if (now < _nextRoutineLiveRefreshAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_nextRoutineLiveRefreshAt = now + RoutineLiveRefreshSeconds;
|
||||
if (!LifeSagaViewController.IsHoverPreview)
|
||||
{
|
||||
RefreshLivePortrait();
|
||||
|
|
@ -822,7 +841,7 @@ public static class WatchCaption
|
|||
|
||||
_layoutDirty = true;
|
||||
// Route through spine/rail refresh so caption compose and hover panel stay consistent.
|
||||
RefreshStorySpine();
|
||||
RefreshStorySpine(force: true);
|
||||
}
|
||||
|
||||
private static bool HasStatusBanner()
|
||||
|
|
@ -981,7 +1000,7 @@ public static class WatchCaption
|
|||
|
||||
// Mass tip headcount ticks must not Relayout the whole dossier every second.
|
||||
// RefreshStorySpine recomposes Beat+Context and Relayouts when chrome changes.
|
||||
RefreshStorySpine();
|
||||
RefreshStorySpine(force: true);
|
||||
if (headcountOnly)
|
||||
{
|
||||
return;
|
||||
|
|
@ -998,13 +1017,20 @@ public static class WatchCaption
|
|||
/// Quiet context under the orange beat (story spine or stake).
|
||||
/// Recompose Identity/Beat/Context; show Cast/Legacy panel only while hovering.
|
||||
/// </summary>
|
||||
private static void RefreshStorySpine()
|
||||
private static void RefreshStorySpine(bool force = false)
|
||||
{
|
||||
if (!_visible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
if (!force && now < _nextRoutineSpineAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_nextRoutineSpineAt = now + RoutineSpineRefreshSeconds;
|
||||
bool statusBanner = HasStatusBanner();
|
||||
if (statusBanner)
|
||||
{
|
||||
|
|
@ -1112,6 +1138,15 @@ public static class WatchCaption
|
|||
reasonRelatedId,
|
||||
spineFiltered,
|
||||
statusBannerOwnsBeat);
|
||||
if (string.Equals(
|
||||
model.Fingerprint,
|
||||
_lastAppliedCaptionFingerprint,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastAppliedCaptionFingerprint = model.Fingerprint;
|
||||
|
||||
if (!_headlineLocked)
|
||||
{
|
||||
|
|
@ -1131,7 +1166,10 @@ public static class WatchCaption
|
|||
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))
|
||||
|
|
@ -1482,7 +1520,17 @@ public static class WatchCaption
|
|||
|
||||
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)
|
||||
{
|
||||
return;
|
||||
|
|
@ -1490,7 +1538,6 @@ public static class WatchCaption
|
|||
|
||||
// This row is Caption Context (short-arc spine or durable stake). A status
|
||||
// banner replaces Beat only, so Context remains visible and live.
|
||||
bool show = !string.IsNullOrEmpty(LastStorySpine);
|
||||
_storySpineText.text = show ? LastStorySpine : "";
|
||||
_storySpineText.color = StorySpineColor;
|
||||
_storySpineText.gameObject.SetActive(show);
|
||||
|
|
@ -1607,6 +1654,15 @@ public static class WatchCaption
|
|||
string prevHeadline = LastHeadline ?? "";
|
||||
string prevJob = _current.JobLabel ?? "";
|
||||
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(
|
||||
actor,
|
||||
_current.ReasonLine ?? "",
|
||||
|
|
@ -1648,11 +1704,10 @@ public static class WatchCaption
|
|||
return;
|
||||
}
|
||||
|
||||
UnitDossier probe = new UnitDossier();
|
||||
UnitDossier.RefreshTopStatuses(probe, actor);
|
||||
int fp = StatusFingerprint(probe);
|
||||
UnitDossier.RefreshTopStatuses(StatusProbe, actor);
|
||||
int fp = StatusFingerprint(StatusProbe);
|
||||
if (fp == _lastStatusesFingerprint
|
||||
&& StatusChipsMatch(_current.TopStatuses, probe.TopStatuses))
|
||||
&& StatusChipsMatch(_current.TopStatuses, StatusProbe.TopStatuses))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -2085,12 +2140,22 @@ public static class WatchCaption
|
|||
}
|
||||
|
||||
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.color = ReasonColor;
|
||||
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
_reasonText.resizeTextForBestFit = false;
|
||||
_reasonText.text = hasReason ? reasonRichOrPlain : "";
|
||||
_reasonText.text = next;
|
||||
_reasonText.gameObject.SetActive(hasReason);
|
||||
WireReasonClick(hasReason ? otherId : 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -411,7 +411,9 @@ public static class WorldActivityScanner
|
|||
private static InterestEvent BuildHottestBattleLight(Vector3 anchor)
|
||||
{
|
||||
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.ParticipantCount >= 1)
|
||||
{
|
||||
|
|
@ -449,7 +451,7 @@ public static class WorldActivityScanner
|
|||
LiveEnsemble.CollectCombatFightersNear(allFighters, pos, 14f, nearby);
|
||||
ensemble = LiveEnsemble.FromCombatFighters(nearby, pos);
|
||||
}
|
||||
else if (!LiveEnsemble.TryBuildCombat(pos, 14f, out ensemble))
|
||||
else if (!LiveEnsemble.TryBuildCombatLocal(pos, 14f, out ensemble))
|
||||
{
|
||||
ensemble = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,13 @@ if [[ -z "$scenario" && -z "$raw_cmd" ]]; then
|
|||
fi
|
||||
|
||||
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
|
||||
for pid in $(pgrep -a worldbox 2>/dev/null | awk '{print $1}'); do
|
||||
cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)
|
||||
|
|
|
|||
Loading…
Reference in a new issue