feat(spectator): make narrative threads own camera direction

- Route confirmed life events into evidence-based threads, consequences, and Legacy chapters
- Schedule episode shots with critical interrupts, replay guards, and a combat budget
- Admit emerging main characters through story momentum instead of spectacle
- Remove causal heat, hard-arc memory writes, and synthetic epilogue continuity
- Expand narrative harness coverage, soak auditing, and product documentation
This commit is contained in:
DazedAnon 2026-07-23 12:17:18 -05:00
parent a0d1b9a688
commit 739cc6492c
39 changed files with 4505 additions and 679 deletions

View file

@ -61,6 +61,10 @@ public static class ActivityLog
private static int _statusRefreshesSinceClear;
private static string _lastStatusGainId = "";
private static string _lastStatusLossId = "";
private static readonly Dictionary<string, int> StatusGainHitsSinceReset =
new Dictionary<string, int>(System.StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, int> StatusLossHitsSinceReset =
new Dictionary<string, int>(System.StringComparer.OrdinalIgnoreCase);
private static int _happinessSinceClear;
private static string _lastHappinessId = "";
/// <summary>When non-zero, status counters only track this subject (harness isolation).</summary>
@ -78,6 +82,21 @@ public static class ActivityLog
public static int StatusRefreshesSinceClear => _statusRefreshesSinceClear;
public static string LastStatusGainId => _lastStatusGainId ?? "";
public static string LastStatusLossId => _lastStatusLossId ?? "";
public static int StatusGainCount(string statusId)
{
return !string.IsNullOrEmpty(statusId)
&& StatusGainHitsSinceReset.TryGetValue(statusId, out int count)
? count
: 0;
}
public static int StatusLossCount(string statusId)
{
return !string.IsNullOrEmpty(statusId)
&& StatusLossHitsSinceReset.TryGetValue(statusId, out int count)
? count
: 0;
}
public static int HappinessSinceClear => _happinessSinceClear;
public static string LastHappinessId => _lastHappinessId ?? "";
public static int VerbSuppressedSinceClear => _verbSuppressedSinceClear;
@ -119,6 +138,8 @@ public static class ActivityLog
_statusRefreshesSinceClear = 0;
_lastStatusGainId = "";
_lastStatusLossId = "";
StatusGainHitsSinceReset.Clear();
StatusLossHitsSinceReset.Clear();
_statusCounterSubjectId = 0;
}
@ -791,6 +812,8 @@ public static class ActivityLog
{
_statusGainsSinceClear++;
_lastStatusGainId = id;
StatusGainHitsSinceReset.TryGetValue(id, out int hits);
StatusGainHitsSinceReset[id] = hits + 1;
}
}
else
@ -799,6 +822,8 @@ public static class ActivityLog
{
_statusLossesSinceClear++;
_lastStatusLossId = id;
StatusLossHitsSinceReset.TryGetValue(id, out int hits);
StatusLossHitsSinceReset[id] = hits + 1;
}
}

View file

@ -13,6 +13,7 @@ public static class ActorChroniclePatches
public static void PostfixNewKill(Actor __instance, Actor pDeadUnit)
{
LifeSagaMemory.RecordKill(__instance, pDeadUnit);
NarrativeDevelopmentRouter.Kill(__instance, pDeadUnit, "newKillAction");
Chronicle.NoteKill(__instance, pDeadUnit);
}
@ -53,6 +54,7 @@ public static class ActorChroniclePatches
}
LifeSagaMemory.RecordDeath(__instance, __state, pType.ToString());
NarrativeDevelopmentRouter.Death(__instance, __state, pType.ToString(), "die");
Chronicle.NoteDeath(__instance, pType, __state);
GraveMarkers.AddDeath(__instance);
}
@ -62,6 +64,7 @@ public static class ActorChroniclePatches
public static void PostfixLovers(Actor __instance, Actor pTarget)
{
LifeSagaMemory.RecordBondFormed(__instance, pTarget, "becomeLoversWith");
NarrativeDevelopmentRouter.BondFormed(__instance, pTarget, "becomeLoversWith");
Chronicle.NoteLovers(__instance, pTarget);
}
@ -75,6 +78,7 @@ public static class ActorChroniclePatches
}
LifeSagaMemory.RecordFriendFormed(__instance, pActor);
NarrativeDevelopmentRouter.FriendFormed(__instance, pActor, "setBestFriend");
Chronicle.NoteBestFriends(__instance, pActor);
}
}

View file

@ -2884,6 +2884,14 @@ public static class AgentHarness
Emit(cmd, ok: true, detail: "variety_cleared");
break;
case "editorial_replay_probe":
{
bool ok = InterestVariety.HarnessProbeEditorialReplay(out string detail);
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: detail);
break;
}
case "interest_story_clear":
StoryPlanner.Clear();
_cmdOk++;
@ -2898,6 +2906,326 @@ public static class AgentHarness
break;
}
case "narrative_scheduler_probe":
{
bool ok = StoryScheduler.HarnessProbePolicy(out string detail);
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: detail);
break;
}
case "narrative_camera_cutover_probe":
{
bool ok = false;
string detail = "";
try
{
Actor protagonist = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
Actor child = FindOtherAliveUnit(protagonist, "human");
Actor outsider = null;
foreach (Actor candidate in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
if (candidate != null && candidate.isAlive()
&& candidate != protagonist && candidate != child)
{
outsider = candidate;
break;
}
}
if (protagonist == null || child == null || outsider == null)
{
detail = "missing actors";
}
else
{
InterestDirector.HarnessEndCurrent("narrative_cutover_setup");
InterestRegistry.Clear();
NarrativeStoryStore.Clear();
NarrativeDevelopmentRouter.ChildBorn(protagonist, child, "harness_cutover");
InterestCandidate unrelated = HarnessNarrativeCandidate(
"harness:narrative:unrelated", outsider, "Unrelated high event", 90f);
InterestCandidate related = HarnessNarrativeCandidate(
"harness:narrative:related", protagonist, "Lineage develops", 60f);
related.AssetId = "add_child";
EventFeedUtil.RegisterCandidate(unrelated);
EventFeedUtil.RegisterCandidate(related);
StoryScheduler.Tick(Time.unscaledTime + 1f);
InterestCandidate first = InterestDirector.HarnessPeekNext();
bool relatedWon = first != null && first.Key == related.Key;
bool watched = relatedWon && InterestDirector.HarnessForceSession(first);
InterestDirector.HarnessAgeCurrent(20f);
InterestCandidate critical = HarnessNarrativeCandidate(
"harness:narrative:critical", outsider, "Kingdom shattered", 100f);
critical.Category = "Disaster";
EventFeedUtil.RegisterCandidate(critical);
StoryScheduler.Tick(Time.unscaledTime + 2f);
InterestCandidate second = InterestDirector.HarnessPeekNext();
bool criticalWon = second != null && second.Key == critical.Key;
ok = relatedWon && watched && criticalWon;
detail = "first=" + (first?.Key ?? "none")
+ " watched=" + watched
+ " second=" + (second?.Key ?? "none")
+ " episode=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none")
+ " pass=" + ok;
}
}
catch (Exception ex)
{
detail = "exception=" + ex.GetType().Name + ":" + ex.Message;
ok = false;
}
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: detail);
break;
}
case "narrative_story_inputs_probe":
{
Actor protagonist = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
Actor child = FindOtherAliveUnit(protagonist, "human");
bool recorded = false;
bool admitted = false;
float potential = 0f;
if (protagonist != null && child != null)
{
NarrativeStoryStore.Clear();
LifeSagaRoster.Clear();
recorded = NarrativeDevelopmentRouter.ChildBorn(protagonist, child, "harness_story_inputs");
potential = StoryScheduler.StoryPotential(EventFeedUtil.SafeId(protagonist));
LifeSagaRoster.Tick(Time.unscaledTime + 2f);
LifeSagaSlot slot = LifeSagaRoster.Get(EventFeedUtil.SafeId(protagonist));
admitted = slot != null && slot.AdmissionReason == LifeSagaAdmissionReason.EmergingStory;
}
var consequence = new InterestCandidate
{
AssetId = "add_child", Category = "Family", EventStrength = 52f
};
var escalation = new InterestCandidate
{
Completion = InterestCompletionKind.WarFront, Category = "War", EventStrength = 65f
};
var texture = new InterestCandidate
{
LeadKind = InterestLeadKind.CharacterLed,
Completion = InterestCompletionKind.CharacterVignette,
EventStrength = 80f
};
NarrativeFunction cf = NarrativeFunctionPolicy.Classify(consequence);
NarrativeFunction ef = NarrativeFunctionPolicy.Classify(escalation);
NarrativeFunction tf = NarrativeFunctionPolicy.Classify(texture);
bool ok = recorded && potential >= 6f && admitted
&& cf == NarrativeFunction.Consequence
&& ef == NarrativeFunction.Escalation
&& tf == NarrativeFunction.CharacterTexture;
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: "recorded=" + recorded
+ " potential=" + potential.ToString("0.0")
+ " admitted=" + admitted
+ " functions=" + cf + "/" + ef + "/" + tf);
break;
}
case "narrative_soak_seed":
{
Actor protagonist = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
Actor child = FindOtherAliveUnit(protagonist, "human");
bool ok = protagonist != null && child != null;
if (ok)
{
InterestDirector.HarnessEndCurrent("narrative_soak_seed");
InterestRegistry.Clear();
NarrativeStoryStore.Clear();
NarrativeDevelopmentRouter.ChildBorn(protagonist, child, "harness_story_soak");
InterestCandidate related = HarnessNarrativeCandidate(
"harness:narrative:add_child", protagonist, "Welcomes a child", 58f);
related.AssetId = "add_child";
related.Category = "Family";
EventFeedUtil.RegisterCandidate(related);
}
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: "seeded=" + ok
+ " protagonist=" + EventFeedUtil.SafeId(protagonist)
+ " child=" + EventFeedUtil.SafeId(child));
break;
}
case "narrative_telemetry_probe":
{
bool ok = NarrativeTelemetry.Count > 0
&& NarrativeTelemetry.RelatedProposalCount > 0
&& NarrativeTelemetry.ComparedCount > 0
&& NarrativeTelemetry.RelatedActualCount > 0;
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: NarrativeTelemetry.Summary
+ " episode=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none"));
break;
}
case "narrative_catalog_probe":
{
NarrativeFunctionCatalog.ClearCoverage();
var child = new InterestCandidate { Key = "catalog:child", AssetId = "add_child" };
var war = new InterestCandidate
{
Key = "catalog:war", AssetId = "live_war", Completion = InterestCompletionKind.WarFront
};
var daily = new InterestCandidate { Key = "catalog:daily", HappinessEffectId = "just_slept" };
var disaster = new InterestCandidate { Key = "catalog:disaster", AssetId = "disaster_tornado" };
NarrativeFunctionCatalog.Stamp(child);
NarrativeFunctionCatalog.Stamp(war);
NarrativeFunctionCatalog.Stamp(daily);
NarrativeFunctionCatalog.Stamp(disaster);
bool ok = child.NarrativeFunction == NarrativeFunction.Consequence
&& war.NarrativeFunction == NarrativeFunction.Escalation
&& daily.NarrativeFunction == NarrativeFunction.CharacterTexture
&& disaster.NarrativeFunction == NarrativeFunction.WorldContext
&& NarrativeFunctionCatalog.ExplicitCount == 4
&& NarrativeFunctionCatalog.FallbackCount == 0;
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: NarrativeFunctionCatalog.CoverageSummary);
break;
}
case "narrative_natural_soak_begin":
InterestDirector.HarnessEndCurrent("narrative_natural_soak");
InterestRegistry.Clear();
NarrativeStoryStore.Clear();
NarrativeFunctionCatalog.ClearCoverage();
NarrativeTelemetry.Clear();
_cmdOk++;
Emit(cmd, ok: true, detail: "natural_soak_reset");
break;
case "narrative_natural_soak_probe":
{
int typed = NarrativeFunctionCatalog.ExplicitCount + NarrativeFunctionCatalog.StructuralCount;
float typedRatio = NarrativeFunctionCatalog.ObservedCount > 0
? typed / (float)NarrativeFunctionCatalog.ObservedCount
: 0f;
bool cameraHealthy = MoveCamera.hasFocusUnit()
&& MoveCamera._focus_unit != null
&& MoveCamera._focus_unit.isAlive();
bool ok = NarrativeFunctionCatalog.ObservedCount > 0
&& typed > 0
&& typedRatio >= 0.25f
&& cameraHealthy;
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: NarrativeFunctionCatalog.CoverageSummary
+ " typedRatio=" + typedRatio.ToString("0.00")
+ " cameraHealthy=" + cameraHealthy
+ " telemetry=" + NarrativeTelemetry.Summary
+ " episode=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none"));
break;
}
case "narrative_multithread_probe":
{
bool ok = false;
string detail = "";
try
{
var actors = new List<Actor>(6);
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
if (actor != null && actor.isAlive() && !actors.Contains(actor)) actors.Add(actor);
if (actors.Count >= 5) break;
}
if (actors.Count < 5)
{
detail = "actors=" + actors.Count;
}
else
{
Actor a = actors[0];
Actor childA = actors[1];
Actor b = actors[2];
Actor childB = actors[3];
Actor outsider = actors[4];
long aid = EventFeedUtil.SafeId(a);
long bid = EventFeedUtil.SafeId(b);
InterestDirector.HarnessEndCurrent("narrative_multithread_setup");
InterestRegistry.Clear();
NarrativeStoryStore.Clear();
LifeSagaRoster.Clear();
LifeSagaRoster.HarnessForceAdmit(a);
LifeSagaRoster.SetPrefer(aid, true);
NarrativeDevelopmentRouter.ChildBorn(a, childA, "harness_multithread_a");
NarrativeDevelopmentRouter.ChildBorn(b, childB, "harness_multithread_b");
InterestCandidate shotA = HarnessNarrativeCandidate(
"harness:multi:a", a, "Lineage A develops", 62f);
shotA.AssetId = "add_child";
InterestCandidate shotB = HarnessNarrativeCandidate(
"harness:multi:b", b, "Lineage B develops", 68f);
shotB.AssetId = "add_child";
EventFeedUtil.RegisterCandidate(shotA);
EventFeedUtil.RegisterCandidate(shotB);
float now = Time.unscaledTime;
StoryScheduler.Tick(now + 1f);
string firstThread = StoryScheduler.ActiveEpisode?.ThreadId ?? "";
InterestCandidate first = InterestDirector.HarnessPeekNext();
bool startsA = StoryScheduler.ActiveEpisode?.ProtagonistId == aid
&& first?.Key == shotA.Key;
bool watchedA = startsA && InterestDirector.HarnessForceSession(first);
NarrativeDevelopmentRouter.Role(b, "become_king", true, "harness_multithread_b");
StoryScheduler.Tick(now + 2f);
bool retainedA = StoryScheduler.ActiveEpisode?.ThreadId == firstThread;
InterestCandidate critical = HarnessNarrativeCandidate(
"harness:multi:critical", outsider, "Kingdom shattered", 100f);
critical.Category = "Disaster";
EventFeedUtil.RegisterCandidate(critical);
InterestDirector.HarnessAgeCurrent(20f);
StoryScheduler.Tick(now + 3f);
InterestCandidate criticalPick = InterestDirector.HarnessPeekNext();
bool criticalWon = criticalPick?.Key == critical.Key;
bool watchedCritical = criticalWon && InterestDirector.HarnessForceSession(criticalPick);
bool survivedInterrupt = StoryScheduler.ActiveEpisode?.ThreadId == firstThread;
InterestDirector.HarnessEndCurrent("critical_complete");
InterestCandidate resumeA = HarnessNarrativeCandidate(
"harness:multi:a:resume", a, "Lineage A continues", 64f);
resumeA.AssetId = "add_child";
EventFeedUtil.RegisterCandidate(resumeA);
StoryScheduler.Tick(now + 4f);
InterestCandidate resumed = StoryScheduler.SelectForCamera(
new List<InterestCandidate> { resumeA }, current: null, now: now + 4f);
bool resumedA = resumed?.Key == resumeA.Key
&& StoryScheduler.ActiveEpisode?.ThreadId == firstThread;
NarrativeThread closing = NarrativeStoryStore.Thread(firstThread);
if (closing != null) closing.Status = NarrativeThreadStatus.Abandoned;
StoryScheduler.Tick(now + 20f);
bool handedToB = StoryScheduler.ActiveEpisode?.ProtagonistId == bid;
ok = startsA && watchedA && retainedA && criticalWon && watchedCritical
&& survivedInterrupt && resumedA && handedToB;
detail = "startsA=" + startsA + " retainedA=" + retainedA
+ " critical=" + criticalWon + "/" + watchedCritical
+ " survived=" + survivedInterrupt + " resumedA=" + resumedA
+ " handedToB=" + handedToB
+ " firstThread=" + firstThread
+ " finalThread=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none");
}
}
catch (Exception ex)
{
detail = "exception=" + ex.GetType().Name + ":" + ex.Message;
ok = false;
}
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: detail);
break;
}
case "caption_status_banner_probe":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@ -3238,6 +3566,69 @@ public static class AgentHarness
break;
}
case "saga_legacy_combat_probe":
{
Actor killer = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
int recorded = 0;
if (killer != null && killer.isAlive() && World.world?.units != null)
{
try
{
foreach (Actor victim in World.world.units)
{
if (victim == null || !victim.isAlive() || victim == killer)
{
continue;
}
LifeSagaMemory.RecordKill(
killer, victim, "harness_combat_summary:" + recorded);
recorded++;
if (recorded >= 3) break;
}
}
catch
{
// failure is reported by the assertions below
}
}
LifeSagaViewModel model = LifeSagaPresentation.Build(EventFeedUtil.SafeId(killer));
int summaryCount = 0;
int rawKillCount = 0;
string lines = "";
if (model != null)
{
for (int i = 0; i < model.Legacy.Count; i++)
{
string line = model.Legacy[i]?.Line ?? "";
if (string.IsNullOrEmpty(line)) continue;
if (lines.Length > 0) lines += " | ";
lines += line;
if (line.IndexOf(
"Slew 3 opponents across repeated clashes",
StringComparison.OrdinalIgnoreCase) >= 0)
{
summaryCount++;
}
else if (model.Legacy[i].Kind == LifeSagaFactKind.Kill)
{
rawKillCount++;
}
}
}
bool ok = recorded == 3 && summaryCount == 1 && rawKillCount == 0;
if (ok) _cmdOk++;
else _cmdFail++;
Emit(
cmd,
ok,
detail: $"recorded={recorded} summary={summaryCount} rawKills={rawKillCount}"
+ $" legacy='{lines}'");
break;
}
case "interest_bind_related_partner":
{
InterestCandidate cur = InterestDirector.CurrentCandidate;
@ -3403,39 +3794,6 @@ public static class AgentHarness
break;
}
case "saga_consider_hard_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
float heat = 6f;
if (!string.IsNullOrEmpty(cmd.value)
&& float.TryParse(
cmd.value.Trim(),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out float parsed)
&& parsed > 0f)
{
heat = parsed;
}
if (id == 0)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no focus");
break;
}
bool admitted = LifeSagaRoster.ConsiderAdmit(id, heat: heat, hardArcAdmit: true);
_cmdOk++;
Emit(
cmd,
ok: true,
detail:
$"hardAdmit id={id} on={admitted} heat={heat:0.##} roster={LifeSagaRoster.Count}");
break;
}
case "saga_rail_refresh":
{
LifeSagaRoster.Tick(Time.unscaledTime);
@ -3594,11 +3952,6 @@ public static class AgentHarness
{
kind = LifeSagaFactKind.RoleChange;
}
else if (kindRaw.IndexOf("combat", StringComparison.OrdinalIgnoreCase) >= 0
|| kindRaw.IndexOf("hardarc", StringComparison.OrdinalIgnoreCase) >= 0)
{
kind = LifeSagaFactKind.HardArcCombat;
}
else if (kindRaw.IndexOf("parent", StringComparison.OrdinalIgnoreCase) >= 0
|| kindRaw.IndexOf("child", StringComparison.OrdinalIgnoreCase) >= 0)
{
@ -3660,16 +4013,6 @@ public static class AgentHarness
fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus));
}
else if (kind == LifeSagaFactKind.HardArcCombat)
{
LifeSagaMemory.RecordHardArc(
EventFeedUtil.SafeId(focus),
StoryArcKind.CombatDuel,
string.IsNullOrEmpty(cmd.value) ? "A fight marked them" : cmd.value,
relatedId: EventFeedUtil.SafeId(other),
provenance: "harness");
fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus));
}
else if (kind == LifeSagaFactKind.ParentChild)
{
if (other == null || !other.isAlive() || other == focus)
@ -4449,6 +4792,7 @@ public static class AgentHarness
{
LifeSagaRoster.Clear();
LifeSagaMemory.Clear();
NarrativeStoryStore.Clear();
}
string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL";
@ -4796,6 +5140,15 @@ public static class AgentHarness
string assetId = cmd.asset;
Actor follow = null;
if (string.IsNullOrEmpty(assetId) && MoveCamera.hasFocusUnit())
{
Actor focused = MoveCamera._focus_unit;
if (focused != null && focused.isAlive())
{
follow = focused;
}
}
if (!string.IsNullOrEmpty(assetId))
{
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId);
@ -8815,6 +9168,37 @@ public static class AgentHarness
Emit(cmd, ok, detail: detail);
}
private static InterestCandidate HarnessNarrativeCandidate(
string key,
Actor subject,
string label,
float eventStrength)
{
long id = EventFeedUtil.SafeId(subject);
return new InterestCandidate
{
Key = key ?? "",
Source = "harness",
Category = "Story",
AssetId = key ?? "",
Label = EventPresentability.EnsureSubjectLedLabel(subject, label ?? ""),
FollowUnit = subject,
SubjectId = id,
Position = subject != null ? subject.current_position : Vector3.zero,
EventStrength = eventStrength,
CharacterSignificance = 5f,
VisualConfidence = 0.8f,
Novelty = 1f,
Completion = InterestCompletionKind.FixedDwell,
MinWatch = 1f,
MaxWatch = 15f,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 30f,
Resumable = false
};
}
private static void DoAssert(HarnessCommand cmd)
{
string expect = (cmd.expect ?? "").Trim().ToLowerInvariant();
@ -10338,6 +10722,19 @@ public static class AgentHarness
detail = $"tip='{tip}' contains='{needle}'";
break;
}
case "tip_contains_or_story_hold":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
string tip = CameraDirector.LastWatchLabel ?? "";
bool contains = !string.IsNullOrEmpty(needle)
&& tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
bool held = StoryScheduler.ActiveEpisode != null
&& !contains;
pass = contains || held;
detail = $"tip='{tip}' contains='{needle}' storyHold={held}"
+ $" episode={StoryScheduler.ActiveEpisode?.ThreadId ?? "none"}";
break;
}
case "enabled":
case "setting_enabled":
{
@ -12308,10 +12705,14 @@ public static class AgentHarness
case "activity_status_gain_count":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
int got = ActivityLog.StatusGainsSinceClear;
string statusId = cmd.asset ?? "";
int got = string.IsNullOrEmpty(statusId)
? ActivityLog.StatusGainsSinceClear
: ActivityLog.StatusGainCount(statusId);
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
pass = mode == "min" ? got >= want : got == want;
detail = $"gains={got} want={want} mode={mode} last={ActivityLog.LastStatusGainId}";
detail = $"gains={got} want={want} mode={mode} status={statusId}"
+ $" total={ActivityLog.StatusGainsSinceClear} last={ActivityLog.LastStatusGainId}";
break;
}
case "activity_status_loss_count":
@ -12365,15 +12766,28 @@ public static class AgentHarness
string want = cmd.value ?? "";
string got = "";
IReadOnlyList<ActivityEntry> list = ActivityLog.LatestForSubject(id, 1);
if (list != null && list.Count > 0 && list[0] != null)
bool found = false;
IReadOnlyList<ActivityEntry> list = ActivityLog.LatestForSubject(id, 12);
if (list != null)
{
got = list[0].TaskId ?? "";
for (int i = 0; i < list.Count; i++)
{
string taskId = list[i]?.TaskId ?? "";
if (i == 0)
{
got = taskId;
}
if (taskId.Equals(want, StringComparison.OrdinalIgnoreCase))
{
found = true;
break;
}
}
}
pass = !string.IsNullOrEmpty(want)
&& got.Equals(want, StringComparison.OrdinalIgnoreCase);
detail = $"got='{got}' want='{want}'";
&& found;
detail = $"latest='{got}' found={found} want='{want}'";
break;
}
case "activity_prose_showcase":

View file

@ -136,6 +136,13 @@ public static class CameraDirector
MoveCamera.clearFocusUnitOnly();
}
public static void ClearWatchReason()
{
LastWatchLabel = "";
LastWatchAssetId = "";
LastFormattedWatchTip = "Watching";
}
public static void PanTo(Vector3 pos)
{
PanCamera(pos);

View file

@ -103,6 +103,7 @@ public static class EventFeedUtil
MaxWatch = maxWatch,
Completion = completion
};
NarrativeFunctionCatalog.Stamp(candidate);
if (!EventPresentability.WouldShow(candidate))
{
InterestDropLog.Record("unpresentable", candidate.Label ?? key);
@ -151,6 +152,8 @@ public static class EventFeedUtil
candidate.RelatedId = SafeId(candidate.RelatedUnit);
}
NarrativeFunctionCatalog.Stamp(candidate);
// Harness injects synthetic tip labels for director tests; dossier still blanks them.
// Production feeds (Source != harness) must be presentable to enter the registry.
if (!IsHarnessSource(candidate.Source) && !EventPresentability.WouldShow(candidate))

View file

@ -134,11 +134,10 @@ public static class InterestFeeds
float evtSeed = entry.EventStrength;
Vector3 position = message.getLocation();
Actor unit = null;
if (message.hasFollowLocation())
{
unit = message.unit;
}
// The message's unit is an explicit event participant even when the WorldLog asset
// suppresses vanilla follow-location UI (for example alien invasion). Preserve that
// ownership so camera-worthy events do not become anchorless and disappear.
Actor unit = message.unit;
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
string kingdom = message.special1 ?? "";

View file

@ -71,11 +71,13 @@ public static class RelationshipEventPatches
if (pActor == null)
{
LifeSagaMemory.RecordBondBroken(__instance, __state, "setLover(null)");
NarrativeDevelopmentRouter.BondBroken(__instance, __state, "setLover(null)");
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
return;
}
LifeSagaMemory.RecordBondFormed(__instance, pActor, "setLover");
NarrativeDevelopmentRouter.BondFormed(__instance, pActor, "setLover");
RelationshipInterestFeed.Emit("set_lover", __instance, pActor);
}
@ -126,6 +128,7 @@ public static class RelationshipEventPatches
}
LifeSagaMemory.RecordBondBroken(unit, lover, "dead_lover_cleanup");
NarrativeDevelopmentRouter.BondBroken(unit, lover, "dead_lover_cleanup");
RelationshipInterestFeed.Emit("clear_lover", unit, null);
}
catch
@ -168,6 +171,7 @@ public static class RelationshipEventPatches
() =>
{
LifeSagaMemory.RecordParentChild(parent, child);
NarrativeDevelopmentRouter.ChildBorn(parent, child, "setParent");
RelationshipInterestFeed.Emit("add_child", parent, child);
});
}
@ -190,6 +194,7 @@ public static class RelationshipEventPatches
}
LifeSagaMemory.RecordFounding(anchor, "family", familyKey, "new_family");
NarrativeDevelopmentRouter.Founding(anchor, "family", familyKey, "new_family");
}
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
@ -450,6 +455,7 @@ public static class RelationshipEventPatches
() =>
{
LifeSagaMemory.RecordRole(pActor, "become_alpha", "family_set_alpha");
NarrativeDevelopmentRouter.Role(pActor, "family alpha", true, "family_set_alpha");
InterestFeeds.EmitAlpha(pActor, "family_set_alpha");
});
}

View file

@ -156,6 +156,30 @@ internal static class HarnessScenarios
return StoryBoardParkResume();
case "story_cross_arc_correlation":
return StoryCrossArcCorrelation();
case "narrative_scheduler_policy":
case "narrative_interrupt_policy":
return NarrativeSchedulerPolicy();
case "narrative_camera_cutover":
case "story_first_camera":
return NarrativeCameraCutover();
case "narrative_story_inputs":
case "story_potential_roster":
return NarrativeStoryInputs();
case "narrative_story_first_soak":
case "story_first_soak":
return NarrativeStoryFirstSoak();
case "narrative_catalog":
case "narrative_catalog_policy":
return NarrativeCatalogPolicy();
case "narrative_natural_soak":
case "story_first_natural_soak":
return NarrativeNaturalSoak();
case "narrative_multithread_stress":
case "story_first_multithread":
return NarrativeMultiThreadStress();
case "narrative_editorial_replay":
case "story_first_replay_guard":
return NarrativeEditorialReplay();
case "story_arc_preempt_resume":
return StoryArcPreemptResume();
case "story_ledger_revisit":
@ -183,12 +207,12 @@ internal static class HarnessScenarios
return SagaCrossMcKill();
case "saga_rival_not_single_kill":
return SagaRivalNotSingleKill();
case "saga_legacy_combat_summary":
return SagaLegacyCombatSummary();
case "saga_rival_rematch":
return SagaRivalRematch();
case "saga_prefer_cuts_ambient":
return SagaPreferCutsAmbient();
case "saga_hard_arc_escalates":
return SagaHardArcEscalates();
case "saga_overview_has_mc":
return SagaOverviewHasMc();
case "saga_snapshot_popover":
@ -420,6 +444,14 @@ internal static class HarnessScenarios
Nested("reg_story_lore_pause", "story_lore_pause_keeps"),
Nested("reg_story_board_park", "story_board_park_resume"),
Nested("reg_story_cross_arc", "story_cross_arc_correlation"),
Nested("reg_narrative_scheduler", "narrative_scheduler_policy"),
Nested("reg_narrative_camera", "narrative_camera_cutover"),
Nested("reg_narrative_inputs", "narrative_story_inputs"),
Nested("reg_narrative_soak", "narrative_story_first_soak"),
Nested("reg_narrative_catalog", "narrative_catalog_policy"),
Nested("reg_narrative_natural", "narrative_natural_soak"),
Nested("reg_narrative_multithread", "narrative_multithread_stress"),
Nested("reg_narrative_replay", "narrative_editorial_replay"),
Nested("reg_caption_status_banner", "caption_status_banner_live"),
Nested("reg_saga_relation_revision", "saga_relation_cache_revision"),
Nested("reg_saga_cast_dedupe", "saga_cast_dedupe_scoped"),
@ -438,7 +470,6 @@ internal static class HarnessScenarios
Nested("reg_saga_rival_kill", "saga_rival_not_single_kill"),
Nested("reg_saga_rival_rematch", "saga_rival_rematch"),
Nested("reg_saga_ambient_cut", "saga_prefer_cuts_ambient"),
Nested("reg_saga_hard_arc_heat", "saga_hard_arc_escalates"),
Nested("reg_saga_overview", "saga_overview_has_mc"),
Nested("reg_saga_snapshot", "saga_adaptive_dossier"),
Nested("reg_saga_memory", "saga_memory_survives"),
@ -447,6 +478,7 @@ internal static class HarnessScenarios
Nested("reg_saga_cast_kin", "saga_cast_kin_non_pack_lens"),
Nested("reg_saga_stake_role", "saga_stake_from_role_live"),
Nested("reg_saga_legacy_quality", "saga_legacy_quality"),
Nested("reg_saga_legacy_combat", "saga_legacy_combat_summary"),
Nested("reg_saga_session_reset", "saga_session_reset"),
Nested("reg_saga_hover_pause", "saga_hover_read_pause"),
Nested("reg_saga_diversity", "saga_roster_diversity"),
@ -633,14 +665,14 @@ internal static class HarnessScenarios
// Gain path (confused is widely applicable across assets)
Step("st12", "status_apply", value: "confused"),
Step("st13", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
Step("st13", "assert", expect: "activity_status_gain_count", value: "1", label: "exact", asset: "confused"),
Step("st14", "assert", expect: "activity_log_contains", value: "confused"),
Step("st15", "assert", expect: "activity_status_task_id", value: "status_gain:confused"),
Step("st16", "assert", expect: "activity_status_active", value: "confused", label: "true"),
// Refresh must not create another gain line
Step("st17", "status_apply", value: "confused"),
Step("st18", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
Step("st18", "assert", expect: "activity_status_gain_count", value: "1", label: "exact", asset: "confused"),
Step("st19", "assert", expect: "activity_status_refresh_count", value: "1", label: "min"),
// Second distinct gain (min: world may emit an extra status mid-suite)
@ -666,7 +698,7 @@ internal static class HarnessScenarios
Step("st28c", "activity_status_reset"),
Step("st28d", "fast_timing", value: "true"),
Step("st29", "status_apply", value: "surprised", label: "0.25"),
Step("st30", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
Step("st30", "assert", expect: "activity_status_gain_count", value: "1", label: "exact", asset: "surprised"),
Step("st31", "wait", wait: 1.25f),
Step("st32", "assert", expect: "activity_status_active", value: "surprised", label: "false"),
Step("st33", "assert", expect: "activity_status_loss_count", value: "1", label: "min"),
@ -3124,6 +3156,124 @@ internal static class HarnessScenarios
};
}
private static List<HarnessCommand> NarrativeSchedulerPolicy()
{
return new List<HarnessCommand>
{
Step("nsp0", "dismiss_windows"),
Step("nsp1", "wait_world"),
Step("nsp2", "narrative_scheduler_probe"),
Step("nsp3", "assert", expect: "no_bad"),
Step("nsp99", "snapshot")
};
}
private static List<HarnessCommand> NarrativeCameraCutover()
{
return new List<HarnessCommand>
{
Step("ncc0", "dismiss_windows"),
Step("ncc1", "wait_world"),
Step("ncc2", "spawn", asset: "human", count: 3),
Step("ncc3", "focus", asset: "human"),
Step("ncc4", "narrative_camera_cutover_probe"),
Step("ncc5", "assert", expect: "no_bad"),
Step("ncc99", "snapshot")
};
}
private static List<HarnessCommand> NarrativeStoryInputs()
{
return new List<HarnessCommand>
{
Step("nsi0", "dismiss_windows"),
Step("nsi1", "wait_world"),
Step("nsi2", "spawn", asset: "human", count: 3),
Step("nsi3", "focus", asset: "human"),
Step("nsi4", "narrative_story_inputs_probe"),
Step("nsi5", "assert", expect: "no_bad"),
Step("nsi99", "snapshot")
};
}
private static List<HarnessCommand> NarrativeStoryFirstSoak()
{
return new List<HarnessCommand>
{
Step("nss0", "dismiss_windows"),
Step("nss1", "wait_world"),
Step("nss2", "spawn", asset: "human", count: 4),
Step("nss3", "spectator", value: "off"),
Step("nss4", "spectator", value: "on"),
Step("nss5", "focus", asset: "human"),
Step("nss8", "narrative_soak_seed"),
Step("nss9", "director_run", wait: 8f),
Step("nss10", "narrative_telemetry_probe"),
Step("nss11", "assert", expect: "has_focus"),
Step("nss12", "assert", expect: "tip_matches_focus"),
Step("nss13", "assert", expect: "no_bad"),
Step("nss99", "snapshot")
};
}
private static List<HarnessCommand> NarrativeCatalogPolicy()
{
return new List<HarnessCommand>
{
Step("ncp0", "dismiss_windows"),
Step("ncp1", "wait_world"),
Step("ncp2", "narrative_catalog_probe"),
Step("ncp3", "assert", expect: "no_bad"),
Step("ncp99", "snapshot")
};
}
private static List<HarnessCommand> NarrativeNaturalSoak()
{
return new List<HarnessCommand>
{
Step("nns0", "dismiss_windows"),
Step("nns1", "wait_world"),
Step("nns2", "spawn", asset: "human", count: 12),
Step("nns3", "spectator", value: "off"),
Step("nns4", "spectator", value: "on"),
Step("nns5", "focus", asset: "human"),
Step("nns6", "force_live_feeds", value: "true"),
Step("nns9", "narrative_natural_soak_begin"),
Step("nns10", "director_run", wait: 20f),
Step("nns11", "narrative_natural_soak_probe"),
Step("nns12", "assert", expect: "tip_matches_focus"),
Step("nns13", "assert", expect: "no_bad"),
Step("nns15", "force_live_feeds", value: "false"),
Step("nns99", "snapshot")
};
}
private static List<HarnessCommand> NarrativeMultiThreadStress()
{
return new List<HarnessCommand>
{
Step("nmt0", "dismiss_windows"),
Step("nmt1", "wait_world"),
Step("nmt2", "spawn", asset: "human", count: 6),
Step("nmt3", "narrative_multithread_probe"),
Step("nmt4", "assert", expect: "no_bad"),
Step("nmt99", "snapshot")
};
}
private static List<HarnessCommand> NarrativeEditorialReplay()
{
return new List<HarnessCommand>
{
Step("ner0", "dismiss_windows"),
Step("ner1", "wait_world"),
Step("ner2", "editorial_replay_probe"),
Step("ner3", "assert", expect: "no_bad"),
Step("ner99", "snapshot")
};
}
/// <summary>
/// Parenthood cools world-wide for a while; a second villager cannot chain the same tip.
/// </summary>
@ -3479,6 +3629,20 @@ internal static class HarnessScenarios
};
}
private static List<HarnessCommand> SagaLegacyCombatSummary()
{
return new List<HarnessCommand>
{
Step("slcs0", "dismiss_windows"),
Step("slcs1", "wait_world"),
Step("slcs2", "spawn", asset: "human", count: 4),
Step("slcs3", "focus", asset: "human"),
Step("slcs4", "interest_saga_clear"),
Step("slcs5", "saga_legacy_combat_probe"),
Step("slcs99", "snapshot"),
};
}
/// <summary>Threshold living rematches earn RivalEarned.</summary>
private static List<HarnessCommand> SagaRivalRematch()
{
@ -3535,40 +3699,6 @@ internal static class HarnessScenarios
};
}
/// <summary>
/// Prolonged hard-arc heat must beat a full Cap of stiff slots; one-shot heat 1.5 must not.
/// Mirrors soak: stranger duel stuck off-rail until admit heat escalates.
/// </summary>
private static List<HarnessCommand> SagaHardArcEscalates()
{
return new List<HarnessCommand>
{
Step("she0", "dismiss_windows"),
Step("she1", "wait_world"),
Step("she2", "set_setting", expect: "enabled", value: "true"),
Step("she3", "fast_timing", value: "true"),
Step("she4", "spawn", asset: "human", count: 14),
Step("she5", "spectator", value: "off"),
Step("she6", "spectator", value: "on"),
Step("she7", "interest_saga_clear"),
Step("she8", "pick_unit", asset: "human"),
Step("she9", "happiness_remember_partner"),
Step("she10", "saga_fill_cap", asset: "human"),
Step("she50", "assert", expect: "saga_roster_count", value: "10"),
Step("she51", "saga_stiffen_roster", value: "8"),
Step("she60", "focus_partner"),
Step("she62", "assert", expect: "saga_has_focus", value: "false"),
Step("she63", "saga_consider_hard_focus", value: "1.5"),
Step("she64", "assert", expect: "saga_has_focus", value: "false"),
Step("she65", "saga_consider_hard_focus", value: "12"),
Step("she66", "assert", expect: "saga_has_focus"),
Step("she67", "saga_rail_refresh"),
Step("she68", "assert", expect: "saga_rail_active_highlight", value: "true"),
Step("she90", "fast_timing", value: "false"),
Step("she99", "snapshot"),
};
}
/// <summary>
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
/// no planner jargon / trait descs / Chronicle dumps.
@ -3874,7 +4004,7 @@ internal static class HarnessScenarios
};
}
/// <summary>Live roster role rise updates Identity without duplicating its prior combat stake.</summary>
/// <summary>Live roster role rise updates Identity and the current supported stake.</summary>
private static List<HarnessCommand> SagaStakeFromRoleLive()
{
return new List<HarnessCommand>
@ -3890,8 +4020,6 @@ internal static class HarnessScenarios
Step("srl8", "pick_unit", asset: "human"),
Step("srl9", "focus", asset: "human"),
Step("srl10", "saga_force_admit_focus"),
Step("srl11", "saga_memory_record_focus", asset: "HardArcCombat", value: "Survived a hard fight"),
Step("srl12", "assert", expect: "saga_stake_contains", value: "Survived a hard fight|hard fight"),
Step("srl13", "saga_role_rise_focus", value: "become_clan_chief"),
Step("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"),
Step("srl15", "assert", expect: "saga_stake_contains", value: "Clan Chief"),
@ -4177,7 +4305,7 @@ internal static class HarnessScenarios
}
/// <summary>
/// Extended roles: singleton dragon overview + hard-arc nobody admit path.
/// Extended roles: singleton dragon overview + explicit ordinary-character admission.
/// </summary>
private static List<HarnessCommand> SagaAdmitRoles()
{
@ -4199,10 +4327,10 @@ internal static class HarnessScenarios
Step("sar12", "assert", expect: "saga_has_focus"),
Step("sar13", "saga_rail_refresh"),
Step("sar14", "assert", expect: "saga_role_tip", value: "Lone|Last of their kind|Dragon"),
// Nobody human may enter only via hard-arc ConsiderAdmit.
// Harness admission proves an ordinary human can occupy the ensemble.
Step("sar20", "pick_unit", asset: "human"),
Step("sar21", "focus", asset: "human"),
Step("sar22", "saga_consider_hard_focus"),
Step("sar22", "saga_force_admit_focus"),
Step("sar23", "assert", expect: "saga_has_focus"),
Step("sar24", "assert", expect: "saga_roster_count", value: "2", label: "min"),
Step("sar90", "fast_timing", value: "false"),
@ -4210,7 +4338,7 @@ internal static class HarnessScenarios
};
}
/// <summary>Full Cap: Prefer pin survives; hotter hard-arc admit displaces weakest non-pin.</summary>
/// <summary>Full Cap: Prefer pin survives; a new admitted character displaces the weakest non-pin.</summary>
private static List<HarnessCommand> SagaReplaceWeaker()
{
return new List<HarnessCommand>
@ -4258,10 +4386,10 @@ internal static class HarnessScenarios
Step("srw46", "saga_force_admit_focus"),
Step("srw50", "assert", expect: "saga_roster_count", value: "10"),
Step("srw51", "saga_weaken_nonprefer"),
// 11th human challenges Cap via hard-arc admit path.
// 11th human challenges the full ensemble.
Step("srw60", "pick_unit", asset: "human", value: "other"),
Step("srw61", "focus", asset: "human"),
Step("srw62", "saga_consider_hard_focus"),
Step("srw62", "saga_force_admit_focus"),
Step("srw63", "assert", expect: "saga_has_focus"),
Step("srw64", "assert", expect: "saga_roster_count", value: "10"),
// Prefer pin still present; challenger focus itself is not Prefer.
@ -5019,8 +5147,8 @@ internal static class HarnessScenarios
Step("wl10", "trigger_interest", asset: "auto", label: "Story: kingdom_new", tier: "Story"),
Step("wl11", "age_current", wait: 1.2f),
Step("wl12", "director_run", wait: 1.2f),
Step("wl13", "assert", expect: "tip_contains", value: "kingdom_new"),
Step("wl14", "assert", expect: "tip_contains", value: "kingdom_new"),
Step("wl13", "assert", expect: "tip_contains_or_story_hold", value: "kingdom_new"),
Step("wl14", "assert", expect: "tip_contains_or_story_hold", value: "kingdom_new"),
Step("wl20", "trigger_interest", asset: "auto", label: "Epic: diplomacy_war_started", tier: "Epic"),
Step("wl21", "age_current", wait: 1.2f),
@ -5344,7 +5472,7 @@ internal static class HarnessScenarios
Step("act4", "set_setting", expect: "chronicle_enabled", value: "true"),
Step("act5", "chronicle_clear"),
Step("act6", "activity_clear"),
Step("act7", "pick_unit", asset: "auto"),
Step("act7", "spawn", asset: "human"),
Step("act8", "spectator", value: "off"),
Step("act9", "spectator", value: "on"),
Step("act10", "focus", asset: "auto"),

View file

@ -53,6 +53,10 @@ public sealed class InterestCandidate
public float Novelty = 1f;
public float VisualConfidence = 0.5f;
public float TotalScore;
/// <summary>Editorial role assigned by the narrative catalog at registry intake.</summary>
public NarrativeFunction NarrativeFunction;
public bool HasNarrativeFunction;
public string NarrativeFunctionSource = "";
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
public int ParticipantCount;
/// <summary>Sticky multi-actor scoreboard (combat first; war/plot/family later).</summary>
@ -316,6 +320,9 @@ public sealed class InterestCandidate
Novelty = Novelty,
VisualConfidence = VisualConfidence,
TotalScore = TotalScore,
NarrativeFunction = NarrativeFunction,
HasNarrativeFunction = HasNarrativeFunction,
NarrativeFunctionSource = NarrativeFunctionSource,
ParticipantCount = ParticipantCount,
NotableParticipantCount = NotableParticipantCount,
Position = Position,

View file

@ -19,6 +19,21 @@ public static partial class InterestDirector
return false;
}
if (candidate != _current && InterestVariety.IsEditorialReplayCooled(candidate))
{
InterestDropLog.Record("editorial_replay", candidate.Label ?? candidate.Key);
return false;
}
if (candidate != _current
&& !StoryScheduler.MayUseCombatShot(candidate))
{
InterestDropLog.Record(
"episode_combat_budget",
candidate.Label ?? candidate.Key);
return false;
}
if (_current == null)
{
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
@ -88,6 +103,29 @@ public static partial class InterestDirector
return false;
}
// Once an episode has a meaningful related development ready, let it reclaim the
// camera from ordinary unrelated material after a brief visual settling beat.
// Legitimate critical/payoff interruptions and live-combat holds remain protected.
if (StoryScheduler.ActiveEpisode != null
&& onCurrent >= 0.75f)
{
NarrativeThread activeThread =
NarrativeStoryStore.Thread(StoryScheduler.ActiveEpisode.ThreadId);
NarrativeInterruptClass nextNarrativeClass =
InterruptPolicy.Classify(candidate, StoryScheduler.ActiveEpisode, activeThread);
NarrativeInterruptClass currentNarrativeClass =
InterruptPolicy.Classify(_current, StoryScheduler.ActiveEpisode, activeThread);
if (nextNarrativeClass == NarrativeInterruptClass.RelatedEscalation
&& !InterruptPolicy.MayInterrupt(currentNarrativeClass)
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace))
{
InterestDropLog.Record(
"episode_reclaim",
$"cur={_current.Key} next={candidate.Key}");
return true;
}
}
bool inGrace = InQuietGrace;
if (inGrace)
{

View file

@ -654,6 +654,7 @@ public static partial class InterestDirector
StoryPlanner.Tick(now);
LifeSagaRoster.Tick(now);
LifeSagaSession.EnsureBound();
StoryScheduler.Tick(now);
IdleHitchProbe.Mark("story_done");
if (ReadPauseActive)
@ -1950,6 +1951,7 @@ public static partial class InterestDirector
_current = null;
_harnessCombatForcedCold = false;
_inactiveSince = -999f;
CameraDirector.ClearWatchReason();
EnsureIdleFocus(now);
}
@ -2045,6 +2047,18 @@ public static partial class InterestDirector
return null;
}
InterestCandidate storyChoice = StoryScheduler.SelectForCamera(PendingScratch, _current, now);
if (storyChoice != null)
{
return storyChoice;
}
if (StoryScheduler.ShouldHoldForEpisode(now))
{
return null;
}
// When no episode owns a usable development, select bounded ambient/world context.
// EnrichTopK runs inside InterestVariety.Pick once - avoid a second top-K meta walk here.
return InterestVariety.Pick(PendingScratch, _current);
}
@ -2718,6 +2732,7 @@ public static partial class InterestDirector
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
{
StoryScheduler.NoteActualSelection(next);
if (next == null)
{
return;

View file

@ -159,7 +159,6 @@ public static class InterestScoring
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
float causalBonus = CausalHeat.ScoreBonus(c);
float sagaBonus = LifeSagaRoster.ScoreBonus(c);
float ownershipBonus = StoryPlanner.OwnershipBoost(c);
c.TotalScore = c.EventStrength + scaleBonus
@ -168,7 +167,6 @@ public static class InterestScoring
+ c.Novelty * w.noveltyMultiplier
- repeatPenalty
+ frequencyAdjust
+ causalBonus
+ sagaBonus
+ ownershipBonus;
string detail =
@ -185,11 +183,6 @@ public static class InterestScoring
+ frequencyAdjust.ToString("0.#");
}
if (causalBonus > 0.05f)
{
detail += $" causal=+{causalBonus:0.#}";
}
if (sagaBonus > 0.05f)
{
detail += $" saga=+{sagaBonus:0.#}";

View file

@ -16,6 +16,7 @@ public static class InterestVariety
public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds;
public static float FixedDwellBeatCooldownSeconds =>
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f);
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
private static readonly List<string> RecentArcs = new List<string>(32);
@ -163,10 +164,83 @@ public static class InterestVariety
}
}
// Sticky scenes are allowed to remain on screen while live, but once the editor
// leaves one it must not immediately re-announce the identical phase/card.
// The organic story-first soak otherwise produced Duel A-vs-B and "A is fighting"
// several times in succession, plus repeated unchanged family-pack cards.
string replayKey = EditorialReplayKey(chosen);
if (!string.IsNullOrEmpty(replayKey))
{
StampCooldown(
replayKey,
Time.unscaledTime + EditorialReplayCooldownSeconds);
}
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
}
/// <summary>
/// True when the exact sticky editorial card was shown recently. This is deliberately
/// separate from moment-beat cooling: a live scene may continue, but cannot be selected
/// again as a fresh cut until another editorial beat has had room to breathe.
/// </summary>
public static bool IsEditorialReplayCooled(InterestCandidate c, float now = -1f)
{
string key = EditorialReplayKey(c);
if (string.IsNullOrEmpty(key))
{
return false;
}
if (now < 0f)
{
now = Time.unscaledTime;
}
return CooldownUntil.TryGetValue(key, out float until) && now < until;
}
public static bool HarnessProbeEditorialReplay(out string detail)
{
Clear();
var first = new InterestCandidate
{
Key = "combat:pair:1:2",
Label = "Duel - Alpha vs Beta",
Completion = InterestCompletionKind.CombatActive
};
NoteSelection(first, updateMix: false);
var replay = new InterestCandidate
{
Key = "combat:pair:1:2:refresh",
Label = "Duel - Alpha vs Beta",
Completion = InterestCompletionKind.CombatActive
};
var different = new InterestCandidate
{
Key = "combat:pair:1:3",
Label = "Duel - Alpha vs Gamma",
Completion = InterestCompletionKind.CombatActive
};
var texture = new InterestCandidate
{
Key = "status:sleeping:1",
Label = "Alpha · Falls asleep",
Completion = InterestCompletionKind.StatusPhase,
StatusId = "sleeping"
};
bool sameCooled = IsEditorialReplayCooled(replay);
bool differentOpen = !IsEditorialReplayCooled(different);
bool textureUsesBeatPolicy = !IsEditorialReplayCooled(texture);
bool ok = sameCooled && differentOpen && textureUsesBeatPolicy;
detail = "sameCooled=" + sameCooled
+ " differentOpen=" + differentOpen
+ " textureUsesBeatPolicy=" + textureUsesBeatPolicy;
Clear();
return ok;
}
/// <summary>
/// Score to subtract when this candidate matches the last shown variety arc.
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak.
@ -637,6 +711,12 @@ public static class InterestVariety
continue;
}
if (IsEditorialReplayCooled(c, now))
{
InterestDropLog.Record("editorial_replay", c.Label ?? c.Key);
continue;
}
if (c.LeadKind == InterestLeadKind.CharacterLed)
{
CharPool.Add(c);
@ -838,6 +918,35 @@ public static class InterestVariety
}
}
private static string EditorialReplayKey(InterestCandidate c)
{
if (c == null)
{
return "";
}
bool stickyCard = c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.WarFront
|| c.Completion == InterestCompletionKind.PlotActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| c.Completion == InterestCompletionKind.FamilyPack
|| StoryReason.IsStoryAsset(c.AssetId)
|| string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase);
if (!stickyCard)
{
return "";
}
string label = (c.Label ?? "").Trim().ToLowerInvariant();
if (!string.IsNullOrEmpty(label))
{
return "replay:label:" + label;
}
string key = (c.Key ?? "").Trim().ToLowerInvariant();
return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key;
}
/// <summary>
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
/// </summary>

View file

@ -0,0 +1,92 @@
using System.Collections.Generic;
namespace IdleSpectator;
public sealed class EpisodeShotProposal
{
public InterestCandidate Candidate;
public NarrativeInterruptClass InterruptClass;
public string Reason = "";
public float EditorialScore;
}
/// <summary>Selects the best presentable shot that can explain its place in the active episode.</summary>
public static class EpisodeShotSelector
{
public static EpisodeShotProposal Pick(
IList<InterestCandidate> candidates,
EpisodePlan episode,
NarrativeThread thread,
bool requirePresentable = true)
{
if (candidates == null || candidates.Count == 0 || episode == null) return null;
EpisodeShotProposal bestRelated = null;
EpisodeShotProposal bestInterrupt = null;
for (int i = 0; i < candidates.Count; i++)
{
InterestCandidate c = candidates[i];
bool harness = c != null && string.Equals(c.Source, "harness", System.StringComparison.OrdinalIgnoreCase);
if (c == null || c.Selected
|| InterestVariety.IsEditorialReplayCooled(c)
|| !StoryScheduler.MayUseCombatShot(episode, c)
|| (requirePresentable && !harness && !EventPresentability.WouldShow(c))) continue;
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
if (function == NarrativeFunction.Noise) continue;
NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread);
float score = EditorialScore(c, episode, kind, function);
var proposal = new EpisodeShotProposal
{
Candidate = c,
InterruptClass = kind,
EditorialScore = score,
Reason = Reason(kind)
};
if (kind == NarrativeInterruptClass.RelatedEscalation)
{
if (bestRelated == null || score > bestRelated.EditorialScore) bestRelated = proposal;
}
else if (InterruptPolicy.MayInterrupt(kind)
&& (bestInterrupt == null || score > bestInterrupt.EditorialScore))
{
bestInterrupt = proposal;
}
}
// A productive related development owns the episode. Critical/payoff material interrupts
// only when no related shot is ready, or when its editorial advantage is decisive.
if (bestRelated != null
&& (bestInterrupt == null || bestInterrupt.EditorialScore < bestRelated.EditorialScore + 25f))
{
return bestRelated;
}
return bestInterrupt ?? bestRelated;
}
private static float EditorialScore(
InterestCandidate c,
EpisodePlan episode,
NarrativeInterruptClass kind,
NarrativeFunction function)
{
float relation = c.SubjectId == episode.ProtagonistId ? 40f
: c.RelatedId == episode.ProtagonistId ? 30f : 0f;
float interrupt = kind == NarrativeInterruptClass.WorldCritical ? 80f
: kind == NarrativeInterruptClass.StoryPayoff ? 35f
: kind == NarrativeInterruptClass.RelatedEscalation ? 25f : 0f;
return relation + interrupt + NarrativeFunctionPolicy.EditorialBonus(function)
+ c.EventStrength * 0.35f
+ c.VisualConfidence * 10f + c.Novelty * 3f;
}
private static string Reason(NarrativeInterruptClass kind)
{
switch (kind)
{
case NarrativeInterruptClass.RelatedEscalation: return "active_episode";
case NarrativeInterruptClass.WorldCritical: return "world_critical_interrupt";
case NarrativeInterruptClass.StoryPayoff: return "story_payoff_interrupt";
default: return "not_episode_worthy";
}
}
}

View file

@ -0,0 +1,94 @@
using System;
namespace IdleSpectator;
public enum NarrativeInterruptClass
{
None,
RelatedEscalation,
StoryPayoff,
WorldCritical,
OrdinaryInteresting,
TextureNoise
}
/// <summary>Explicit editorial interrupt classification for episode scheduling.</summary>
public static class InterruptPolicy
{
public static NarrativeInterruptClass Classify(
InterestCandidate candidate,
EpisodePlan episode,
NarrativeThread thread)
{
if (candidate == null) return NarrativeInterruptClass.None;
NarrativeFunction function = NarrativeFunctionPolicy.Classify(candidate);
if (function == NarrativeFunction.Noise || function == NarrativeFunction.CharacterTexture)
{
return NarrativeInterruptClass.TextureNoise;
}
if (IsRelated(candidate, episode, thread)
&& NarrativeFunctionPolicy.ChangesStoryState(function))
{
return NarrativeInterruptClass.RelatedEscalation;
}
if (IsWorldCritical(candidate)) return NarrativeInterruptClass.WorldCritical;
if (IsStoryPayoff(candidate)) return NarrativeInterruptClass.StoryPayoff;
return NarrativeInterruptClass.OrdinaryInteresting;
}
public static bool MayInterrupt(NarrativeInterruptClass kind)
{
return kind == NarrativeInterruptClass.WorldCritical
|| kind == NarrativeInterruptClass.StoryPayoff
|| kind == NarrativeInterruptClass.RelatedEscalation;
}
public static bool IsRelated(InterestCandidate candidate, EpisodePlan episode, NarrativeThread thread)
{
if (candidate == null || episode == null) return false;
if (Touches(candidate, episode.ProtagonistId)) return true;
for (int i = 0; i < episode.EligibleCastIds.Count; i++)
{
if (Touches(candidate, episode.EligibleCastIds[i])) return true;
}
if (thread == null || string.IsNullOrEmpty(thread.AnchorKey)) return false;
return EqualsKey(candidate.CorrelationKey, thread.AnchorKey)
|| EqualsKey(candidate.CityKey, thread.AnchorKey)
|| EqualsKey(candidate.KingdomKey, thread.AnchorKey)
|| (candidate.Key ?? "").IndexOf(thread.AnchorKey, StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool Touches(InterestCandidate c, long id)
{
if (id == 0) return false;
if (c.SubjectId == id || c.RelatedId == id || c.PairOwnerId == id
|| c.PairPartnerId == id || c.TheaterLeadId == id
|| EventFeedUtil.SafeId(c.FollowUnit) == id) return true;
for (int i = 0; i < c.ParticipantIds.Count; i++)
{
if (c.ParticipantIds[i] == id) return true;
}
return false;
}
private static bool IsWorldCritical(InterestCandidate c)
{
if (c.EventStrength >= 95f) return true;
return c.Completion == InterestCompletionKind.EarthquakeActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool IsStoryPayoff(InterestCandidate c)
{
return StoryReason.IsStoryAsset(c.AssetId)
|| (c.Source ?? "").IndexOf("story", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool EqualsKey(string a, string b) =>
!string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b)
&& string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
}

View file

@ -0,0 +1,162 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>Thin adapters from confirmed game observations to semantic narrative developments.</summary>
public static class NarrativeDevelopmentRouter
{
public static bool BondFormed(Actor subject, Actor partner, string source)
{
return Record(NarrativeDevelopmentKind.BondFormed, NarrativeFunction.ThreadOpener,
subject, partner, "bond:" + Pair(subject, partner) + ":formed:" + TimeBucket(), source, magnitude: 72f);
}
public static bool BondBroken(Actor subject, Actor former, string source)
{
return Record(NarrativeDevelopmentKind.BondBroken, NarrativeFunction.TurningPoint,
subject, former, "bond:" + Pair(subject, former) + ":broken:" + TimeBucket(), source, magnitude: 88f,
outcome: "partnership ended");
}
public static bool ChildBorn(Actor parent, Actor child, string source)
{
string family = FamilyKey(parent);
return Record(NarrativeDevelopmentKind.ChildBorn, NarrativeFunction.Consequence,
parent, child, "child:" + EventFeedUtil.SafeId(parent) + ":" + EventFeedUtil.SafeId(child),
source, familyKey: family, magnitude: 76f, outcome: "child born");
}
public static bool FriendFormed(Actor subject, Actor friend, string source)
{
return Record(NarrativeDevelopmentKind.FriendFormed, NarrativeFunction.ThreadOpener,
subject, friend, "friend:" + Pair(subject, friend), source, magnitude: 55f);
}
public static bool Death(Actor victim, Actor killer, string attackType, string source)
{
return Record(NarrativeDevelopmentKind.Death, NarrativeFunction.Resolution,
victim, killer, "death:" + EventFeedUtil.SafeId(victim), source,
newState: "dead", outcome: attackType ?? "", magnitude: 100f);
}
public static bool Kill(Actor killer, Actor victim, string source)
{
return Record(NarrativeDevelopmentKind.Kill, NarrativeFunction.TurningPoint,
killer, victim, "kill:" + EventFeedUtil.SafeId(killer) + ":" + EventFeedUtil.SafeId(victim),
source, outcome: "victim died", magnitude: 82f);
}
public static bool Role(Actor subject, string role, bool gained, string source)
{
return Record(gained ? NarrativeDevelopmentKind.RoleGained : NarrativeDevelopmentKind.RoleLost,
gained ? NarrativeFunction.TurningPoint : NarrativeFunction.Consequence,
subject, null, "role:" + EventFeedUtil.SafeId(subject) + ":" + (role ?? ""), source,
newState: role, magnitude: 78f);
}
public static bool Founding(Actor subject, string kind, string key, string source)
{
return Record(NarrativeDevelopmentKind.HomeFounded, NarrativeFunction.TurningPoint,
subject, null, "founding:" + kind + ":" + key + ":" + EventFeedUtil.SafeId(subject), source,
cityKey: kind == "city" ? key : "", kingdomKey: kind == "kingdom" ? key : "",
newState: kind, magnitude: 82f);
}
public static bool War(Actor subject, Actor other, string warKey, bool ended, string source)
{
return Record(ended ? NarrativeDevelopmentKind.WarEnded : NarrativeDevelopmentKind.WarJoined,
ended ? NarrativeFunction.Resolution : NarrativeFunction.Escalation,
subject, other, "war:" + warKey + ":" + EventFeedUtil.SafeId(subject) + ":" + ended,
source, warKey: warKey, magnitude: ended ? 86f : 68f,
outcome: ended ? "war ended" : "");
}
public static bool Plot(Actor subject, Actor other, string plotKey, bool ended, string source)
{
return Record(ended ? NarrativeDevelopmentKind.PlotEnded : NarrativeDevelopmentKind.PlotJoined,
ended ? NarrativeFunction.Resolution : NarrativeFunction.Escalation,
subject, other, "plot:" + plotKey + ":" + EventFeedUtil.SafeId(subject) + ":" + ended,
source, plotKey: plotKey, magnitude: ended ? 78f : 64f,
outcome: ended ? "plot ended" : "");
}
private static bool Record(
NarrativeDevelopmentKind kind,
NarrativeFunction function,
Actor subject,
Actor other,
string id,
string source,
string familyKey = "",
string cityKey = "",
string kingdomKey = "",
string warKey = "",
string plotKey = "",
string newState = "",
string outcome = "",
float magnitude = 50f)
{
long subjectId = EventFeedUtil.SafeId(subject);
if (subjectId == 0) return false;
long otherId = EventFeedUtil.SafeId(other);
return NarrativeStoryStore.Record(new NarrativeDevelopment
{
Id = id ?? "",
Kind = kind,
Function = function,
SubjectId = subjectId,
Subject = LifeSagaMemory.Snapshot(subject),
OtherId = otherId,
Other = LifeSagaMemory.Snapshot(other),
CorrelationKey = id ?? "",
FamilyKey = familyKey ?? "",
CityKey = cityKey ?? "",
KingdomKey = kingdomKey ?? "",
WarKey = warKey ?? "",
PlotKey = plotKey ?? "",
NewState = newState ?? "",
Outcome = outcome ?? "",
EvidenceSource = source ?? "",
DateLabel = LatestDate(subjectId),
OccurredAt = Time.unscaledTime,
Magnitude = magnitude,
Confidence = 1f,
Presentable = true,
IsNewStateChange = true
});
}
private static string LatestDate(long subjectId)
{
var facts = LifeSagaMemory.FactsFor(subjectId);
if (facts != null && facts.Count > 0 && facts[0] != null) return facts[0].DateLabel ?? "";
return "";
}
private static string Pair(Actor a, Actor b)
{
long ai = EventFeedUtil.SafeId(a);
long bi = EventFeedUtil.SafeId(b);
if (bi == 0) return ai.ToString();
return ai <= bi ? ai + ":" + bi : bi + ":" + ai;
}
private static string FamilyKey(Actor actor)
{
try
{
if (actor != null && actor.hasFamily() && actor.family != null)
{
return actor.family.getID().ToString();
}
}
catch
{
// Subject id remains a stable lineage fallback.
}
return EventFeedUtil.SafeId(actor).ToString();
}
private static int TimeBucket() => Mathf.FloorToInt(Time.unscaledTime * 10f);
}

View file

@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Explicit catalog-to-editorial-role mapping at the single candidate intake boundary.
/// Structural fallbacks cover live theaters; string heuristics remain only a compatibility fallback.
/// </summary>
public static class NarrativeFunctionCatalog
{
private static readonly Dictionary<string, NarrativeFunction> Exact =
new Dictionary<string, NarrativeFunction>(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<string> ObservedKeys = new HashSet<string>(StringComparer.Ordinal);
private static readonly int[] FunctionCounts = new int[9];
public static int ObservedCount { get; private set; }
public static int ExplicitCount { get; private set; }
public static int StructuralCount { get; private set; }
public static int FallbackCount { get; private set; }
static NarrativeFunctionCatalog()
{
Add(NarrativeFunction.ThreadOpener,
"set_lover", "fallen_in_love", "fell_in_love", "new_family", "family_group_new",
"just_made_friend");
Add(NarrativeFunction.Development,
"live_family", "family_group_join", "just_kissed", "alliance_join", "alliance_create",
"alliance_new", "just_gave_gift", "just_received_gift", "just_talked_gossip");
Add(NarrativeFunction.Escalation,
"live_combat", "live_battle", "live_war", "live_plot", "live_outbreak",
"new_war", "diplomacy_war_started", "just_started_war", "total_war_started",
"whisper_of_war", "rebellion", "cause_rebellion", "just_rebelled", "just_injured",
"just_cursed", "just_possessed", "status_soul_harvested", "attacker_stop_war");
Add(NarrativeFunction.TurningPoint,
"clear_lover", "just_killed", "milestone_kill", "become_alpha", "become_king",
"become_leader", "king_new", "kingdom_new", "city_new", "just_found_house",
"conquered_city", "was_conquered", "clan_ascension", "kingdom_royal_clan_changed",
"kingdom_royal_clan_new", "culture_divide", "religion_schism", "language_divergence");
Add(NarrativeFunction.Resolution,
"diplomacy_war_ended", "just_made_peace", "just_finished_plot", "just_won_war",
"aftermath_war_linger", "aftermath_plot_fallout", "recovery_outbreak");
Add(NarrativeFunction.Consequence,
"add_child", "just_had_child", "baby_created", "just_born", "just_got_out_of_egg",
"just_became_adult",
"death_best_friend", "death_child", "death_family_member", "death_lover", "king_dead",
"king_killed", "favorite_dead", "favorite_killed", "city_destroyed", "destroyed_city",
"kingdom_destroyed", "kingdom_fell_apart", "kingdom_fractured", "kingdom_shattered",
"just_lost_house", "just_lost_war", "lost_capital", "lost_city", "lost_crown",
"razed_capital", "razed_city", "family_removed", "alliance_destroy", "alliance_dissolved",
"aftermath_love_linger", "aftermath_mourner", "aftermath_survivor", "epilogue_crisis",
"epilogue_related", "wrote_book", "new_book");
Add(NarrativeFunction.WorldContext,
"earthquake", "small_earthquake", "small_meteorite", "alien_invasion", "ash_bandits",
"biomass", "dragon_from_farlands", "garden_surprise", "greg_abominations", "heatwave",
"hellspawn", "ice_ones_awoken", "mad_thoughts", "sudden_snowman", "tornado", "tumor",
"underground_necromancer", "new_culture", "new_language", "new_religion");
Add(NarrativeFunction.CharacterTexture,
"find_lover", "sexual_reproduction_try", "family_group_follow", "family_group_leave",
"just_ate", "just_cried", "just_laughed", "just_played", "just_pooped", "just_sang",
"just_slept", "slept_outside", "just_talked", "paid_tax", "had_bad_dream",
"had_good_dream", "had_nightmare", "just_surprised", "just_swore", "just_read_book");
}
public static void ClearCoverage()
{
ObservedKeys.Clear();
Array.Clear(FunctionCounts, 0, FunctionCounts.Length);
ObservedCount = 0;
ExplicitCount = 0;
StructuralCount = 0;
FallbackCount = 0;
}
public static void Stamp(InterestCandidate candidate)
{
if (candidate == null) return;
if (!candidate.HasNarrativeFunction)
{
if (TryExact(candidate.HappinessEffectId, out NarrativeFunction function)
|| TryExact(candidate.AssetId, out function)
|| TryExact(candidate.StatusId, out function)
|| TryExact(candidate.Verb, out function))
{
Set(candidate, function, "catalog");
}
else if (TryDomain(candidate, out function))
{
Set(candidate, function, "structural");
}
else
{
// Compatibility fallback is stamped once, so downstream policy is deterministic.
Set(candidate, NarrativeFunctionPolicy.Classify(candidate), "fallback");
}
}
NoteCoverage(candidate);
}
public static string CoverageSummary => "observed=" + ObservedCount
+ " explicit=" + ExplicitCount + " structural=" + StructuralCount
+ " fallback=" + FallbackCount + " functions=" + FormatCounts();
private static bool TryDomain(InterestCandidate c, out NarrativeFunction function)
{
string source = c.Source ?? "";
string category = c.Category ?? "";
if (c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.WarFront
|| c.Completion == InterestCompletionKind.PlotActive
|| c.Completion == InterestCompletionKind.StatusOutbreak)
{
function = NarrativeFunction.Escalation;
return true;
}
if (c.Completion == InterestCompletionKind.HappinessGrief)
{
function = NarrativeFunction.TurningPoint;
return true;
}
if (c.Completion == InterestCompletionKind.EarthquakeActive
|| Contains(source, "disaster") || Contains(category, "disaster") || Contains(source, "era"))
{
function = NarrativeFunction.WorldContext;
return true;
}
if (Contains(source, "plot") || Contains(category, "plot"))
{
function = NarrativeFunction.Development;
return true;
}
if (Contains(source, "book") || Contains(source, "trait")
|| c.Completion == InterestCompletionKind.CharacterVignette
|| c.Completion == InterestCompletionKind.StatusPhase)
{
function = NarrativeFunction.CharacterTexture;
return true;
}
function = NarrativeFunction.Noise;
return false;
}
private static void Set(InterestCandidate c, NarrativeFunction function, string source)
{
c.NarrativeFunction = function;
c.HasNarrativeFunction = true;
c.NarrativeFunctionSource = source;
}
private static void NoteCoverage(InterestCandidate c)
{
string key = c.Key ?? "";
if (!ObservedKeys.Add(key)) return;
ObservedCount++;
int index = (int)c.NarrativeFunction;
if (index >= 0 && index < FunctionCounts.Length) FunctionCounts[index]++;
if (c.NarrativeFunctionSource == "catalog") ExplicitCount++;
else if (c.NarrativeFunctionSource == "structural") StructuralCount++;
else FallbackCount++;
}
private static bool TryExact(string id, out NarrativeFunction function)
{
function = NarrativeFunction.Noise;
if (string.IsNullOrEmpty(id)) return false;
string key = id.Trim();
if (Exact.TryGetValue(key, out function)) return true;
if (key.StartsWith("disaster_", StringComparison.OrdinalIgnoreCase))
{
function = NarrativeFunction.WorldContext;
return true;
}
if (key.StartsWith("summon_", StringComparison.OrdinalIgnoreCase))
{
function = NarrativeFunction.Escalation;
return true;
}
if (key.StartsWith("asexual_reproduction_", StringComparison.OrdinalIgnoreCase))
{
function = NarrativeFunction.CharacterTexture;
return true;
}
return false;
}
private static void Add(NarrativeFunction function, params string[] ids)
{
for (int i = 0; i < ids.Length; i++) Exact[ids[i]] = function;
}
private static bool Contains(string text, string needle) =>
text.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
private static string FormatCounts()
{
var parts = new List<string>(FunctionCounts.Length);
for (int i = 0; i < FunctionCounts.Length; i++)
{
if (FunctionCounts[i] > 0) parts.Add(((NarrativeFunction)i) + ":" + FunctionCounts[i]);
}
return string.Join(",", parts.ToArray());
}
}

View file

@ -0,0 +1,110 @@
using System;
namespace IdleSpectator;
/// <summary>
/// Editorial meaning of a camera candidate. This is deliberately separate from numeric
/// spectacle strength: a loud event can still be context, while a quiet state change can
/// advance a life story.
/// </summary>
public static class NarrativeFunctionPolicy
{
public static NarrativeFunction Classify(InterestCandidate candidate)
{
if (candidate == null) return NarrativeFunction.Noise;
if (candidate.HasNarrativeFunction) return candidate.NarrativeFunction;
string asset = (candidate.AssetId ?? "").ToLowerInvariant();
string category = (candidate.Category ?? "").ToLowerInvariant();
string source = (candidate.Source ?? "").ToLowerInvariant();
string verb = (candidate.Verb ?? "").ToLowerInvariant();
string text = asset + " " + category + " " + source + " " + verb;
if (ContainsAny(text, "add_child", "child_born", "new_child", "became_parent"))
return NarrativeFunction.Consequence;
if (ContainsAny(text, "clear_lover", "lover_died", "partner_died", "grief"))
return NarrativeFunction.TurningPoint;
if (ContainsAny(text, "set_lover", "new_family", "married", "bond_formed"))
return NarrativeFunction.ThreadOpener;
if (ContainsAny(text, "war_ended", "plot_ended", "plot_resolved", "recovered"))
return NarrativeFunction.Resolution;
if (ContainsAny(text, "death", "died", "killed", "destroyed", "role_lost", "lost_home"))
return NarrativeFunction.Consequence;
if (ContainsAny(text, "become_king", "new_king", "founded", "new_city", "role_gained"))
return NarrativeFunction.TurningPoint;
if (ContainsAny(text, "war_started", "joined_war", "plot_joined", "betray", "rival"))
return NarrativeFunction.Escalation;
switch (candidate.Completion)
{
case InterestCompletionKind.CombatActive:
case InterestCompletionKind.WarFront:
case InterestCompletionKind.PlotActive:
case InterestCompletionKind.StatusOutbreak:
return NarrativeFunction.Escalation;
case InterestCompletionKind.HappinessGrief:
return NarrativeFunction.TurningPoint;
case InterestCompletionKind.EarthquakeActive:
return NarrativeFunction.WorldContext;
case InterestCompletionKind.FamilyPack:
return NarrativeFunction.Development;
case InterestCompletionKind.ActivityActive:
return candidate.LeadKind == InterestLeadKind.EventLed
? NarrativeFunction.Development
: NarrativeFunction.CharacterTexture;
case InterestCompletionKind.CharacterVignette:
case InterestCompletionKind.StatusPhase:
return NarrativeFunction.CharacterTexture;
}
if (StoryReason.IsStoryAsset(candidate.AssetId)
|| source.IndexOf("story", StringComparison.Ordinal) >= 0)
{
return NarrativeFunction.Development;
}
if (candidate.LeadKind == InterestLeadKind.CharacterLed)
return NarrativeFunction.CharacterTexture;
if (candidate.EventStrength >= 95f) return NarrativeFunction.WorldContext;
if (candidate.EventStrength >= 70f) return NarrativeFunction.Development;
return candidate.EventStrength < 35f
? NarrativeFunction.Noise
: NarrativeFunction.CharacterTexture;
}
public static bool ChangesStoryState(NarrativeFunction function)
{
return function == NarrativeFunction.ThreadOpener
|| function == NarrativeFunction.Development
|| function == NarrativeFunction.Escalation
|| function == NarrativeFunction.TurningPoint
|| function == NarrativeFunction.Resolution
|| function == NarrativeFunction.Consequence;
}
public static float EditorialBonus(NarrativeFunction function)
{
switch (function)
{
case NarrativeFunction.ThreadOpener: return 8f;
case NarrativeFunction.Development: return 12f;
case NarrativeFunction.Escalation: return 18f;
case NarrativeFunction.TurningPoint: return 28f;
case NarrativeFunction.Resolution: return 32f;
case NarrativeFunction.Consequence: return 24f;
case NarrativeFunction.WorldContext: return 10f;
case NarrativeFunction.CharacterTexture: return -15f;
default: return -35f;
}
}
private static bool ContainsAny(string text, params string[] needles)
{
for (int i = 0; i < needles.Length; i++)
{
if (text.IndexOf(needles[i], StringComparison.Ordinal) >= 0) return true;
}
return false;
}
}

View file

@ -0,0 +1,262 @@
using System.Collections.Generic;
namespace IdleSpectator;
public enum NarrativeDevelopmentKind
{
Unknown,
BondFormed,
BondBroken,
ChildBorn,
FriendFormed,
Death,
Kill,
RoleGained,
RoleLost,
HomeFounded,
HomeGained,
HomeLost,
WarJoined,
WarEnded,
PlotJoined,
PlotEnded,
RivalEncounter,
Transformation,
Recovery
}
public enum NarrativeFunction
{
Noise,
CharacterTexture,
WorldContext,
ThreadOpener,
Development,
Escalation,
TurningPoint,
Resolution,
Consequence
}
public enum NarrativeThreadKind
{
Unknown,
Courtship,
Partnership,
Lineage,
SeparationGrief,
RiseToRule,
Reign,
Succession,
Founding,
HomeLoss,
Rivalry,
PersonalCombat,
WarInvolvement,
PlotBetrayal,
Transformation,
Recovery
}
public enum NarrativePhase
{
Setup,
Pressure,
Escalation,
TurningPoint,
Outcome,
Consequence
}
public enum NarrativeThreadStatus
{
Emerging,
Active,
Dormant,
Resolved,
Abandoned
}
public enum CharacterConsequenceKind
{
Unknown,
FoundLove,
LostPartner,
BecameParent,
WasBorn,
LostFamily,
LostLife,
TookLife,
RoseToRole,
LostRole,
FoundedHome,
LostHome,
EnteredWar,
SurvivedWar,
JoinedPlot,
PlotResolved,
Transformed,
Recovered
}
/// <summary>Evidence-backed semantic state transition. Contains no final player-facing prose.</summary>
public sealed class NarrativeDevelopment
{
public string Id = "";
public NarrativeDevelopmentKind Kind;
public NarrativeFunction Function;
public long SubjectId;
public LifeSagaIdentity Subject;
public long OtherId;
public LifeSagaIdentity Other;
public readonly List<long> OtherIds = new List<long>(4);
public string CorrelationKey = "";
public string FamilyKey = "";
public string CityKey = "";
public string KingdomKey = "";
public string WarKey = "";
public string PlotKey = "";
public string PreviousState = "";
public string NewState = "";
public string Outcome = "";
public string EvidenceSource = "";
public string DateLabel = "";
public float OccurredAt;
public float Magnitude;
public float Confidence = 1f;
public bool Presentable = true;
public bool IsNewStateChange = true;
}
public sealed class NarrativeCastRole
{
public long CharacterId;
public string Role = "";
}
/// <summary>Durable unresolved question centered on one life.</summary>
public sealed class NarrativeThread
{
public string Id = "";
public NarrativeThreadKind Kind;
public long ProtagonistId;
public readonly List<NarrativeCastRole> Cast = new List<NarrativeCastRole>(6);
public readonly List<string> DevelopmentIds = new List<string>(12);
public string AnchorKey = "";
public string OpenedByDevelopmentId = "";
public string LatestMeaningfulChangeId = "";
public string CentralQuestion = "";
public string PressureEvidence = "";
public string Resolution = "";
public NarrativePhase Phase;
public NarrativeThreadStatus Status;
public float OpenedAt;
public float UpdatedAt;
public float Momentum;
public float Urgency;
public float Coherence;
public float Novelty;
public float CoverageDebt;
public bool IsOpen => Status == NarrativeThreadStatus.Emerging
|| Status == NarrativeThreadStatus.Active
|| Status == NarrativeThreadStatus.Dormant;
public bool HasCast(long id)
{
if (id == 0) return false;
for (int i = 0; i < Cast.Count; i++)
{
if (Cast[i] != null && Cast[i].CharacterId == id) return true;
}
return false;
}
public void AddCast(long id, string role)
{
if (id == 0 || HasCast(id)) return;
Cast.Add(new NarrativeCastRole { CharacterId = id, Role = role ?? "" });
}
}
public sealed class CharacterConsequence
{
public string Id = "";
public long CharacterId;
public string ThreadId = "";
public string DevelopmentId = "";
public CharacterConsequenceKind Kind;
public string Role = "";
public long OtherId;
public LifeSagaIdentity Other;
public string Context = "";
public string Outcome = "";
public string DateLabel = "";
public float OccurredAt;
public float Importance;
public float Confidence = 1f;
}
public sealed class CharacterStoryState
{
public long CharacterId;
public LifeSagaIdentity Identity;
public readonly List<string> OpenThreadIds = new List<string>(6);
public readonly List<string> ResolvedThreadIds = new List<string>(8);
public readonly List<string> RecentDevelopmentIds = new List<string>(16);
public readonly List<string> ConsequenceIds = new List<string>(16);
public float StoryMomentum;
public float StoryPotential;
public float LastMeaningfulChangeAt;
}
public sealed class NarrativeChapter
{
public string Id = "";
public string ThreadId = "";
public string Line = "";
public string DateLabel = "";
public float At;
public float Importance;
}
public enum EpisodePurpose
{
Establish,
Develop,
Payoff,
Consequence,
Reintroduce
}
public sealed class EpisodePlan
{
public string ThreadId = "";
public long ProtagonistId;
public readonly List<long> EligibleCastIds = new List<long>(6);
public string EntryDevelopmentId = "";
public EpisodePurpose Purpose;
public string InformationGoal = "";
public float StartedAt;
public float LastProgressAt;
public float InterruptedAt = -1f;
public string LastShotKey = "";
public int CombatShotCount;
public bool GoalMet;
}
public sealed class NarrativeBeatModel
{
public long PerspectiveCharacterId;
public string DevelopmentId = "";
public string ThreadId = "";
public NarrativeDevelopmentKind ActionKind;
public long OtherId;
public LifeSagaIdentity Other;
public string Change = "";
public string Outcome = "";
public NarrativePhase ThreadPhase;
public string ContextEvidence = "";
public float Confidence = 1f;
}

View file

@ -0,0 +1,199 @@
using System;
namespace IdleSpectator;
/// <summary>Evidence-backed renderer for structured narrative beats and consequences.</summary>
public static class NarrativeProse
{
public static bool TryBuildBeat(
Actor focus,
InterestCandidate tip,
out NarrativeBeatModel model,
out string beat,
out string context)
{
model = null;
beat = "";
context = "";
long focusId = EventFeedUtil.SafeId(focus);
if (focusId == 0 || tip == null) return false;
NarrativeDevelopmentKind kind = KindForTip(tip);
if (kind == NarrativeDevelopmentKind.Unknown) return false;
NarrativeDevelopment d = NarrativeStoryStore.LatestFor(focusId, kind, 8f);
if (d == null) return false;
model = new NarrativeBeatModel
{
PerspectiveCharacterId = focusId,
DevelopmentId = d.Id,
ActionKind = d.Kind,
OtherId = d.OtherId,
Other = d.Other,
Change = d.NewState,
Outcome = d.Outcome,
Confidence = d.Confidence
};
NarrativeThread thread = LatestThreadFor(focusId, d.Id);
if (thread != null)
{
model.ThreadId = thread.Id;
model.ThreadPhase = thread.Phase;
model.ContextEvidence = thread.PressureEvidence;
}
beat = BeatLine(model);
context = ContextLine(model, thread);
return !string.IsNullOrEmpty(beat);
}
public static string BeatLine(NarrativeBeatModel model)
{
if (model == null || model.Confidence < 0.75f) return "";
string other = model.Other.Name ?? "";
switch (model.ActionKind)
{
case NarrativeDevelopmentKind.BondFormed:
return string.IsNullOrEmpty(other) ? "Finds love" : "Finds love with " + other;
case NarrativeDevelopmentKind.BondBroken:
return string.IsNullOrEmpty(other) ? "Parts from a lover" : "Parts from " + other;
case NarrativeDevelopmentKind.ChildBorn:
{
int count = NarrativeStoryStore.CountConsequences(
model.PerspectiveCharacterId, CharacterConsequenceKind.BecameParent);
string first = count == 1 ? " first" : "";
return string.IsNullOrEmpty(other)
? "Welcomes a" + first + " child"
: "Welcomes" + first + " child " + other;
}
case NarrativeDevelopmentKind.FriendFormed:
return string.IsNullOrEmpty(other) ? "Makes a true friend" : "Befriends " + other;
case NarrativeDevelopmentKind.Death:
return string.IsNullOrEmpty(other) ? "Dies" : "Is slain by " + other;
case NarrativeDevelopmentKind.Kill:
return string.IsNullOrEmpty(other) ? "Takes a life" : "Slays " + other;
case NarrativeDevelopmentKind.RoleGained:
return string.IsNullOrEmpty(model.Change) ? "Rises in standing" : "Becomes " + Humanize(model.Change);
case NarrativeDevelopmentKind.RoleLost:
return string.IsNullOrEmpty(model.Change) ? "Loses their standing" : "Is no longer " + Humanize(model.Change);
case NarrativeDevelopmentKind.HomeFounded:
return "Founds a new home";
case NarrativeDevelopmentKind.HomeLost:
return "Loses their home";
case NarrativeDevelopmentKind.WarJoined:
return "Enters the war";
case NarrativeDevelopmentKind.WarEnded:
return "Emerges from the war";
case NarrativeDevelopmentKind.PlotJoined:
return "Enters a plot";
case NarrativeDevelopmentKind.PlotEnded:
return "Sees the plot resolved";
case NarrativeDevelopmentKind.Transformation:
return string.IsNullOrEmpty(model.Change) ? "Is transformed" : "Becomes " + Humanize(model.Change);
case NarrativeDevelopmentKind.Recovery:
return "Recovers";
default:
return "";
}
}
public static string ContextLine(NarrativeBeatModel model, NarrativeThread thread)
{
if (model == null || thread == null) return "";
// Only show concrete pressure. A generic central question is useful scheduler state,
// but is not evidence suitable for player-facing narration.
if (!string.IsNullOrEmpty(thread.PressureEvidence)) return SagaProse.Sentence(thread.PressureEvidence);
return "";
}
public static string ConsequenceLine(CharacterConsequence consequence)
{
if (consequence == null || consequence.Confidence < 0.75f) return "";
string other = consequence.Other.Name ?? "";
switch (consequence.Kind)
{
case CharacterConsequenceKind.FoundLove:
return string.IsNullOrEmpty(other) ? "Found love" : "Found love with " + other;
case CharacterConsequenceKind.LostPartner:
return string.IsNullOrEmpty(other) ? "Lost a partner" : "Lost " + other;
case CharacterConsequenceKind.BecameParent:
return string.IsNullOrEmpty(other) ? "Had a child" : "Welcomed child " + other;
case CharacterConsequenceKind.WasBorn:
return string.IsNullOrEmpty(other) ? "Was born" : "Was born to " + other;
case CharacterConsequenceKind.LostLife:
return string.IsNullOrEmpty(other) ? "Died" : "Was slain by " + other;
case CharacterConsequenceKind.TookLife:
return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other;
case CharacterConsequenceKind.RoseToRole:
return "Rose in standing";
case CharacterConsequenceKind.LostRole:
return "Lost their former standing";
case CharacterConsequenceKind.FoundedHome:
return "Founded a new home";
case CharacterConsequenceKind.LostHome:
return "Lost their home";
case CharacterConsequenceKind.EnteredWar:
return "Entered a war";
case CharacterConsequenceKind.SurvivedWar:
return "Survived the war";
case CharacterConsequenceKind.JoinedPlot:
return "Joined a plot";
case CharacterConsequenceKind.PlotResolved:
return "Saw a plot resolved";
case CharacterConsequenceKind.Transformed:
return "Was transformed";
case CharacterConsequenceKind.Recovered:
return "Recovered";
default:
return "";
}
}
public static string TryMergeChapter(string current, CharacterConsequence next)
{
if (string.IsNullOrEmpty(current) || next == null) return "";
// Partnership chapters keep formation and loss separate because both are identity-changing.
// Lineage repeats collapse naturally in the presentation layer's family summary.
return "";
}
private static NarrativeDevelopmentKind KindForTip(InterestCandidate tip)
{
string id = (tip?.AssetId ?? "").ToLowerInvariant();
switch (id)
{
case "set_lover": return NarrativeDevelopmentKind.BondFormed;
case "clear_lover": return NarrativeDevelopmentKind.BondBroken;
case "add_child": return NarrativeDevelopmentKind.ChildBorn;
case "baby_created": return NarrativeDevelopmentKind.ChildBorn;
case "king_new":
case "become_alpha": return NarrativeDevelopmentKind.RoleGained;
default: return NarrativeDevelopmentKind.Unknown;
}
}
private static NarrativeThread LatestThreadFor(long characterId, string developmentId)
{
CharacterStoryState state = NarrativeStoryStore.Character(characterId);
if (state == null) return null;
for (int i = 0; i < state.OpenThreadIds.Count; i++)
{
NarrativeThread t = NarrativeStoryStore.Thread(state.OpenThreadIds[i]);
if (t != null && t.DevelopmentIds.Contains(developmentId)) return t;
}
for (int i = 0; i < state.ResolvedThreadIds.Count; i++)
{
NarrativeThread t = NarrativeStoryStore.Thread(state.ResolvedThreadIds[i]);
if (t != null && t.DevelopmentIds.Contains(developmentId)) return t;
}
return null;
}
private static string Humanize(string raw)
{
if (string.IsNullOrEmpty(raw)) return "";
return ActivityAssetCatalog.TitleCaseWords(raw.Replace('_', ' ').Trim());
}
}

View file

@ -0,0 +1,375 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>World-session narrative state. Updated from developments; independent of Saga roster churn.</summary>
public static class NarrativeStoryStore
{
private const int RecentPerCharacter = 16;
private const int ConsequencesPerCharacter = 24;
private static readonly Dictionary<string, NarrativeDevelopment> Developments =
new Dictionary<string, NarrativeDevelopment>(StringComparer.Ordinal);
private static readonly Dictionary<long, CharacterStoryState> Characters =
new Dictionary<long, CharacterStoryState>();
private static readonly Dictionary<string, NarrativeThread> Threads =
new Dictionary<string, NarrativeThread>(StringComparer.Ordinal);
private static readonly Dictionary<string, CharacterConsequence> Consequences =
new Dictionary<string, CharacterConsequence>(StringComparer.Ordinal);
private static readonly List<NarrativeThread> ThreadScratch = new List<NarrativeThread>(32);
public static int Revision { get; private set; }
public static int DevelopmentCount => Developments.Count;
public static int ThreadCount => Threads.Count;
public static int CharacterCount => Characters.Count;
public static void Clear()
{
Developments.Clear();
Characters.Clear();
Threads.Clear();
Consequences.Clear();
ThreadScratch.Clear();
Revision++;
StoryScheduler.Clear();
NarrativeTelemetry.Clear();
}
public static CharacterStoryState Character(long id)
{
Characters.TryGetValue(id, out CharacterStoryState state);
return state;
}
public static NarrativeDevelopment Development(string id)
{
if (string.IsNullOrEmpty(id)) return null;
Developments.TryGetValue(id, out NarrativeDevelopment development);
return development;
}
public static NarrativeThread Thread(string id)
{
if (string.IsNullOrEmpty(id)) return null;
Threads.TryGetValue(id, out NarrativeThread thread);
return thread;
}
public static CharacterConsequence Consequence(string id)
{
if (string.IsNullOrEmpty(id)) return null;
Consequences.TryGetValue(id, out CharacterConsequence consequence);
return consequence;
}
public static bool Record(NarrativeDevelopment development)
{
if (development == null || development.SubjectId == 0 || string.IsNullOrEmpty(development.Id))
{
return false;
}
if (Developments.TryGetValue(development.Id, out NarrativeDevelopment existing))
{
existing.OccurredAt = Mathf.Max(existing.OccurredAt, development.OccurredAt);
existing.Magnitude = Mathf.Max(existing.Magnitude, development.Magnitude);
existing.Confidence = Mathf.Max(existing.Confidence, development.Confidence);
existing.IsNewStateChange = false;
return false;
}
Developments[development.Id] = development;
CharacterStoryState subject = GetOrCreateCharacter(development.SubjectId, development.Subject);
TouchDevelopment(subject, development);
if (development.OtherId != 0)
{
GetOrCreateCharacter(development.OtherId, development.Other);
}
NarrativeThreadRules.Apply(development);
Revision++;
return true;
}
public static NarrativeThread OpenOrUpdateThread(
string id,
NarrativeThreadKind kind,
long protagonistId,
string anchor,
NarrativeDevelopment development,
string centralQuestion,
NarrativePhase phase,
NarrativeThreadStatus status)
{
if (string.IsNullOrEmpty(id) || protagonistId == 0 || development == null) return null;
bool created = !Threads.TryGetValue(id, out NarrativeThread thread);
if (created)
{
thread = new NarrativeThread
{
Id = id,
Kind = kind,
ProtagonistId = protagonistId,
AnchorKey = anchor ?? "",
OpenedByDevelopmentId = development.Id,
OpenedAt = development.OccurredAt,
Coherence = 1f,
Novelty = 1f
};
Threads[id] = thread;
}
thread.Kind = kind;
thread.CentralQuestion = centralQuestion ?? thread.CentralQuestion;
thread.Phase = phase;
thread.Status = status;
thread.UpdatedAt = development.OccurredAt;
thread.LatestMeaningfulChangeId = development.Id;
if (!thread.DevelopmentIds.Contains(development.Id)) thread.DevelopmentIds.Add(development.Id);
thread.AddCast(protagonistId, "protagonist");
if (development.OtherId != 0) thread.AddCast(development.OtherId, CastRole(development.Kind));
thread.Momentum = Mathf.Min(12f, thread.Momentum + MomentumFor(development.Function));
thread.Urgency = Mathf.Max(thread.Urgency, UrgencyFor(development));
CharacterStoryState state = GetOrCreateCharacter(protagonistId, development.Subject);
MoveThreadReference(state, thread);
RecalculatePotential(state);
Revision++;
return thread;
}
public static void AddConsequence(CharacterConsequence consequence)
{
if (consequence == null || consequence.CharacterId == 0 || string.IsNullOrEmpty(consequence.Id)) return;
if (Consequences.ContainsKey(consequence.Id)) return;
Consequences[consequence.Id] = consequence;
CharacterStoryState state = GetOrCreateCharacter(consequence.CharacterId, default);
state.ConsequenceIds.Insert(0, consequence.Id);
while (state.ConsequenceIds.Count > ConsequencesPerCharacter)
{
state.ConsequenceIds.RemoveAt(state.ConsequenceIds.Count - 1);
}
RecalculatePotential(state);
Revision++;
}
public static void CopyOpenThreads(List<NarrativeThread> into)
{
if (into == null) return;
into.Clear();
foreach (NarrativeThread thread in Threads.Values)
{
if (thread != null
&& (thread.IsOpen
|| (thread.Status == NarrativeThreadStatus.Resolved
&& Time.unscaledTime - thread.UpdatedAt < 20f)))
{
into.Add(thread);
}
}
}
/// <summary>Copies bounded story-potential challengers without scanning the world.</summary>
public static void CopyEmergingCharacters(List<CharacterStoryState> into, float minimumPotential, int max)
{
if (into == null) return;
into.Clear();
float now = Time.unscaledTime;
foreach (CharacterStoryState state in Characters.Values)
{
if (state != null && state.CharacterId != 0
&& EffectiveStoryPotential(state, now) >= minimumPotential)
{
into.Add(state);
}
}
into.Sort((a, b) => EffectiveStoryPotential(b, now).CompareTo(EffectiveStoryPotential(a, now)));
if (max > 0 && into.Count > max) into.RemoveRange(max, into.Count - max);
}
public static float EffectiveStoryPotential(CharacterStoryState state, float now = -1f)
{
if (state == null) return 0f;
if (now < 0f) now = Time.unscaledTime;
float age = Mathf.Max(0f, now - state.LastMeaningfulChangeAt);
// Narrative momentum has a long half-life, but is not a permanent celebrity badge.
float freshness = Mathf.Pow(0.5f, age / 300f);
return state.StoryPotential * freshness;
}
public static NarrativeDevelopment LatestFor(long characterId, NarrativeDevelopmentKind kind, float maxAge)
{
CharacterStoryState state = Character(characterId);
if (state == null) return null;
float now = Time.unscaledTime;
for (int i = 0; i < state.RecentDevelopmentIds.Count; i++)
{
NarrativeDevelopment development = Development(state.RecentDevelopmentIds[i]);
if (development == null || development.Kind != kind) continue;
if (maxAge > 0f && now - development.OccurredAt > maxAge) continue;
return development;
}
return null;
}
public static List<NarrativeChapter> ChaptersFor(long characterId, int max)
{
var chapters = new List<NarrativeChapter>(Mathf.Max(0, max));
CharacterStoryState state = Character(characterId);
if (state == null || max <= 0) return chapters;
var seenThreads = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < state.ConsequenceIds.Count && chapters.Count < max; i++)
{
CharacterConsequence consequence = Consequence(state.ConsequenceIds[i]);
if (consequence == null || consequence.Confidence < 0.75f) continue;
string line;
if (consequence.Kind == CharacterConsequenceKind.BecameParent)
{
int children = CountConsequences(characterId, CharacterConsequenceKind.BecameParent);
line = children >= 3 ? "Raised a lineage of " + children + " children"
: NarrativeProse.ConsequenceLine(consequence);
}
else
{
line = NarrativeProse.ConsequenceLine(consequence);
}
if (string.IsNullOrEmpty(line)) continue;
if (!string.IsNullOrEmpty(consequence.ThreadId) && !seenThreads.Add(consequence.ThreadId))
{
NarrativeChapter current = FindChapter(chapters, consequence.ThreadId);
string merged = NarrativeProse.TryMergeChapter(current?.Line, consequence);
if (current != null && !string.IsNullOrEmpty(merged))
{
current.Line = merged;
current.Importance = Mathf.Max(current.Importance, consequence.Importance);
}
continue;
}
chapters.Add(new NarrativeChapter
{
Id = consequence.Id,
ThreadId = consequence.ThreadId,
Line = line,
DateLabel = consequence.DateLabel ?? "",
At = consequence.OccurredAt,
Importance = consequence.Importance
});
}
chapters.Sort((a, b) => b.Importance.CompareTo(a.Importance));
return chapters;
}
public static int CountConsequences(long characterId, CharacterConsequenceKind kind)
{
CharacterStoryState state = Character(characterId);
if (state == null) return 0;
int count = 0;
for (int i = 0; i < state.ConsequenceIds.Count; i++)
{
CharacterConsequence c = Consequence(state.ConsequenceIds[i]);
if (c != null && c.Kind == kind) count++;
}
return count;
}
private static CharacterStoryState GetOrCreateCharacter(long id, LifeSagaIdentity identity)
{
if (!Characters.TryGetValue(id, out CharacterStoryState state))
{
state = new CharacterStoryState { CharacterId = id, Identity = identity };
Characters[id] = state;
}
else if (identity.Id != 0)
{
state.Identity = identity;
}
return state;
}
private static void TouchDevelopment(CharacterStoryState state, NarrativeDevelopment development)
{
state.RecentDevelopmentIds.Insert(0, development.Id);
while (state.RecentDevelopmentIds.Count > RecentPerCharacter)
{
state.RecentDevelopmentIds.RemoveAt(state.RecentDevelopmentIds.Count - 1);
}
state.LastMeaningfulChangeAt = development.OccurredAt;
state.StoryMomentum = Mathf.Min(12f, state.StoryMomentum + MomentumFor(development.Function));
RecalculatePotential(state);
}
private static void MoveThreadReference(CharacterStoryState state, NarrativeThread thread)
{
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);
}
private static void RecalculatePotential(CharacterStoryState state)
{
if (state == null) return;
float unresolved = state.OpenThreadIds.Count * 2.5f;
float consequences = Mathf.Min(6f, state.ConsequenceIds.Count * 0.8f);
state.StoryPotential = state.StoryMomentum + unresolved + consequences;
}
private static float MomentumFor(NarrativeFunction function)
{
switch (function)
{
case NarrativeFunction.ThreadOpener: return 2f;
case NarrativeFunction.Development: return 1.5f;
case NarrativeFunction.Escalation: return 2.5f;
case NarrativeFunction.TurningPoint: return 4f;
case NarrativeFunction.Resolution: return 3.5f;
case NarrativeFunction.Consequence: return 3f;
default: return 0.25f;
}
}
private static float UrgencyFor(NarrativeDevelopment development)
{
if (development == null) return 0f;
if (development.Function == NarrativeFunction.TurningPoint) return 4f;
if (development.Function == NarrativeFunction.Resolution) return 3f;
return Mathf.Clamp(development.Magnitude / 25f, 0f, 3f);
}
private static string CastRole(NarrativeDevelopmentKind kind)
{
switch (kind)
{
case NarrativeDevelopmentKind.BondFormed:
case NarrativeDevelopmentKind.BondBroken: return "partner";
case NarrativeDevelopmentKind.ChildBorn: return "child";
case NarrativeDevelopmentKind.FriendFormed: return "friend";
case NarrativeDevelopmentKind.Kill:
case NarrativeDevelopmentKind.RivalEncounter: return "opponent";
default: return "involved";
}
}
private static NarrativeChapter FindChapter(List<NarrativeChapter> chapters, string threadId)
{
for (int i = 0; i < chapters.Count; i++)
{
if (chapters[i] != null && string.Equals(chapters[i].ThreadId, threadId, StringComparison.Ordinal))
{
return chapters[i];
}
}
return null;
}
}

View file

@ -0,0 +1,127 @@
using System.Collections.Generic;
namespace IdleSpectator;
public sealed class NarrativeTelemetryEntry
{
public float At;
public string ThreadId = "";
public long ProtagonistId;
public string ProposedKey = "";
public string ActualKey = "";
public string Reason = "";
public NarrativeInterruptClass InterruptClass;
public bool ActualRelated;
}
/// <summary>Bounded in-memory trace for shadow-vs-actual story scheduling.</summary>
public static class NarrativeTelemetry
{
private const int Cap = 256;
private static readonly List<NarrativeTelemetryEntry> Entries = new List<NarrativeTelemetryEntry>(Cap);
public static int Count => Entries.Count;
public static int RelatedActualCount { get; private set; }
public static int ComparedCount { get; private set; }
public static int RelatedProposalCount { get; private set; }
public static int CriticalProposalCount { get; private set; }
public static int AlignedSampleCount { get; private set; }
public static int PermittedInterruptSampleCount { get; private set; }
public static int PermittedContinuitySampleCount { get; private set; }
public static int UnalignedSampleCount { get; private set; }
public static int UnrelatedActualCount => ComparedCount - RelatedActualCount;
public static void Clear()
{
Entries.Clear();
RelatedActualCount = 0;
ComparedCount = 0;
RelatedProposalCount = 0;
CriticalProposalCount = 0;
AlignedSampleCount = 0;
PermittedInterruptSampleCount = 0;
PermittedContinuitySampleCount = 0;
UnalignedSampleCount = 0;
}
public static void NoteProposal(EpisodePlan episode, EpisodeShotProposal proposal, float now)
{
if (episode == null || proposal?.Candidate == null) return;
if (proposal.InterruptClass == NarrativeInterruptClass.RelatedEscalation) RelatedProposalCount++;
if (proposal.InterruptClass == NarrativeInterruptClass.WorldCritical) CriticalProposalCount++;
Entries.Add(new NarrativeTelemetryEntry
{
At = now,
ThreadId = episode.ThreadId,
ProtagonistId = episode.ProtagonistId,
ProposedKey = proposal.Candidate.Key ?? "",
Reason = proposal.Reason ?? "",
InterruptClass = proposal.InterruptClass
});
Trim();
}
public static void NoteActual(EpisodePlan episode, NarrativeThread thread, InterestCandidate actual)
{
if (episode == null || actual == null) return;
bool related = InterruptPolicy.IsRelated(actual, episode, thread);
ComparedCount++;
if (related) RelatedActualCount++;
NarrativeTelemetryEntry last = Entries.Count > 0 ? Entries[Entries.Count - 1] : null;
if (last != null && last.ThreadId == episode.ThreadId && string.IsNullOrEmpty(last.ActualKey))
{
last.ActualKey = actual.Key ?? "";
last.ActualRelated = related;
}
}
public static void NoteCurrentAlignment(
EpisodePlan episode,
NarrativeThread thread,
InterestCandidate current)
{
if (episode == null || current == null) return;
if (InterruptPolicy.IsRelated(current, episode, thread))
{
AlignedSampleCount++;
return;
}
NarrativeInterruptClass kind = InterruptPolicy.Classify(current, episode, thread);
if (InterruptPolicy.MayInterrupt(kind))
{
AlignedSampleCount++;
PermittedInterruptSampleCount++;
return;
}
// Live sticky scenes are deliberately allowed to finish their immediate outcome
// before an episode reclaims the camera; report them separately from true drift.
if (current.Completion == InterestCompletionKind.CombatActive
|| current.Completion == InterestCompletionKind.WarFront
|| current.Completion == InterestCompletionKind.PlotActive
|| current.Completion == InterestCompletionKind.StatusOutbreak)
{
AlignedSampleCount++;
PermittedContinuitySampleCount++;
return;
}
UnalignedSampleCount++;
}
public static string Summary => "compared=" + ComparedCount
+ " actualRelated=" + RelatedActualCount
+ " actualUnrelated=" + UnrelatedActualCount
+ " proposals=" + Count
+ " relatedProposals=" + RelatedProposalCount
+ " criticalProposals=" + CriticalProposalCount
+ " alignedSamples=" + AlignedSampleCount
+ " permittedInterruptSamples=" + PermittedInterruptSampleCount
+ " permittedContinuitySamples=" + PermittedContinuitySampleCount
+ " unalignedSamples=" + UnalignedSampleCount;
private static void Trim()
{
while (Entries.Count > Cap) Entries.RemoveAt(0);
}
}

View file

@ -0,0 +1,265 @@
using System;
namespace IdleSpectator;
/// <summary>Maps semantic developments onto durable character-centered threads.</summary>
public static class NarrativeThreadRules
{
public static void Apply(NarrativeDevelopment d)
{
if (d == null || d.SubjectId == 0) return;
switch (d.Kind)
{
case NarrativeDevelopmentKind.BondFormed:
ApplyBondFormed(d);
break;
case NarrativeDevelopmentKind.BondBroken:
ApplyBondBroken(d);
break;
case NarrativeDevelopmentKind.ChildBorn:
ApplyChildBorn(d);
break;
case NarrativeDevelopmentKind.FriendFormed:
ApplyFriend(d);
break;
case NarrativeDevelopmentKind.Death:
ApplyDeath(d);
break;
case NarrativeDevelopmentKind.Kill:
case NarrativeDevelopmentKind.RivalEncounter:
ApplyConflict(d);
break;
case NarrativeDevelopmentKind.RoleGained:
case NarrativeDevelopmentKind.RoleLost:
ApplyPower(d);
break;
case NarrativeDevelopmentKind.HomeFounded:
case NarrativeDevelopmentKind.HomeGained:
case NarrativeDevelopmentKind.HomeLost:
ApplyHome(d);
break;
case NarrativeDevelopmentKind.WarJoined:
case NarrativeDevelopmentKind.WarEnded:
ApplyWar(d);
break;
case NarrativeDevelopmentKind.PlotJoined:
case NarrativeDevelopmentKind.PlotEnded:
ApplyPlot(d);
break;
case NarrativeDevelopmentKind.Transformation:
case NarrativeDevelopmentKind.Recovery:
ApplySurvival(d);
break;
}
}
private static void ApplyBondFormed(NarrativeDevelopment d)
{
string anchor = PairAnchor("bond", d.SubjectId, d.OtherId);
string id = "thread:partnership:" + anchor + ":" + d.SubjectId;
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
id, NarrativeThreadKind.Partnership, d.SubjectId, anchor, d,
"How will this partnership shape their lives?", NarrativePhase.Outcome,
NarrativeThreadStatus.Active);
t?.AddCast(d.OtherId, "partner");
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.FoundLove, "partner", d.OtherId, 72f);
if (d.OtherId != 0)
{
string mirrorId = "thread:partnership:" + anchor + ":" + d.OtherId;
NarrativeThread mirror = NarrativeStoryStore.OpenOrUpdateThread(
mirrorId, NarrativeThreadKind.Partnership, d.OtherId, anchor, d,
"How will this partnership shape their lives?", NarrativePhase.Outcome,
NarrativeThreadStatus.Active);
mirror?.AddCast(d.SubjectId, "partner");
AddConsequence(d, mirror, d.OtherId, CharacterConsequenceKind.FoundLove, "partner", d.SubjectId, 72f);
}
}
private static void ApplyBondBroken(NarrativeDevelopment d)
{
string anchor = PairAnchor("bond", d.SubjectId, d.OtherId);
string id = "thread:partnership:" + anchor + ":" + d.SubjectId;
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
id, NarrativeThreadKind.SeparationGrief, d.SubjectId, anchor, d,
"What follows the loss of this partnership?", NarrativePhase.Consequence,
NarrativeThreadStatus.Resolved);
if (t != null) t.Resolution = "partnership ended";
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.LostPartner, "survivor", d.OtherId, 88f);
}
private static void ApplyChildBorn(NarrativeDevelopment d)
{
string anchor = !string.IsNullOrEmpty(d.FamilyKey) ? d.FamilyKey : d.SubjectId.ToString();
string id = "thread:lineage:" + anchor + ":" + d.SubjectId;
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
id, NarrativeThreadKind.Lineage, d.SubjectId, anchor, d,
"How will this growing lineage endure?", NarrativePhase.Outcome,
NarrativeThreadStatus.Active);
t?.AddCast(d.OtherId, "child");
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.BecameParent, "parent", d.OtherId, 76f);
if (d.OtherId != 0)
{
string childId = "thread:lineage:" + anchor + ":" + d.OtherId;
NarrativeThread childThread = NarrativeStoryStore.OpenOrUpdateThread(
childId, NarrativeThreadKind.Lineage, d.OtherId, anchor, d,
"What life will begin from this lineage?", NarrativePhase.Setup,
NarrativeThreadStatus.Emerging);
childThread?.AddCast(d.SubjectId, "parent");
AddConsequence(d, childThread, d.OtherId, CharacterConsequenceKind.WasBorn, "child", d.SubjectId, 65f);
}
}
private static void ApplyFriend(NarrativeDevelopment d)
{
string anchor = PairAnchor("friend", d.SubjectId, d.OtherId);
NarrativeStoryStore.OpenOrUpdateThread(
"thread:partnership:" + anchor + ":" + d.SubjectId,
NarrativeThreadKind.Partnership, d.SubjectId, anchor, d,
"Where will this friendship lead?", NarrativePhase.Setup, NarrativeThreadStatus.Emerging);
}
private static void ApplyDeath(NarrativeDevelopment d)
{
string id = "thread:life-end:" + d.SubjectId;
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
id, NarrativeThreadKind.SeparationGrief, d.SubjectId, id, d,
"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);
}
private static void ApplyConflict(NarrativeDevelopment d)
{
string anchor = PairAnchor("conflict", d.SubjectId, d.OtherId);
NarrativeThreadKind kind = d.Kind == NarrativeDevelopmentKind.RivalEncounter
? NarrativeThreadKind.Rivalry : NarrativeThreadKind.PersonalCombat;
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)
{
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.TookLife, "killer", d.OtherId, 82f);
}
}
private static void ApplyPower(NarrativeDevelopment d)
{
string anchor = "role:" + d.SubjectId + ":" + d.NewState;
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
"thread:power:" + anchor, NarrativeThreadKind.RiseToRule, d.SubjectId, anchor, d,
"What will they do with their new standing?", NarrativePhase.Outcome,
d.Kind == NarrativeDevelopmentKind.RoleLost ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active);
AddConsequence(d, t, d.SubjectId,
d.Kind == NarrativeDevelopmentKind.RoleLost ? CharacterConsequenceKind.LostRole : CharacterConsequenceKind.RoseToRole,
"office holder", 0, 78f);
}
private static void ApplyHome(NarrativeDevelopment d)
{
string anchor = FirstNonEmpty(d.CityKey, d.KingdomKey, d.CorrelationKey, d.SubjectId.ToString());
bool lost = d.Kind == NarrativeDevelopmentKind.HomeLost;
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
"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 ? NarrativePhase.TurningPoint : NarrativePhase.Outcome,
NarrativeThreadStatus.Active);
AddConsequence(d, t, d.SubjectId,
lost ? CharacterConsequenceKind.LostHome : CharacterConsequenceKind.FoundedHome,
lost ? "displaced" : "founder", 0, lost ? 90f : 82f);
}
private static void ApplyWar(NarrativeDevelopment d)
{
string anchor = FirstNonEmpty(d.WarKey, d.CorrelationKey, "unknown");
bool ended = d.Kind == NarrativeDevelopmentKind.WarEnded;
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
"thread:war:" + anchor + ":" + d.SubjectId, NarrativeThreadKind.WarInvolvement,
d.SubjectId, anchor, d,
ended ? "What did the war change for them?" : "Will they survive this war?",
ended ? NarrativePhase.Outcome : NarrativePhase.Pressure,
ended ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active);
AddConsequence(d, t, d.SubjectId,
ended ? CharacterConsequenceKind.SurvivedWar : CharacterConsequenceKind.EnteredWar,
"participant", d.OtherId, ended ? 86f : 62f);
}
private static void ApplyPlot(NarrativeDevelopment d)
{
string anchor = FirstNonEmpty(d.PlotKey, d.CorrelationKey, "unknown");
bool ended = d.Kind == NarrativeDevelopmentKind.PlotEnded;
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
"thread:plot:" + anchor + ":" + d.SubjectId, NarrativeThreadKind.PlotBetrayal,
d.SubjectId, anchor, d,
ended ? "What did the plot change?" : "Will their plot succeed?",
ended ? NarrativePhase.Outcome : NarrativePhase.Escalation,
ended ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active);
AddConsequence(d, t, d.SubjectId,
ended ? CharacterConsequenceKind.PlotResolved : CharacterConsequenceKind.JoinedPlot,
"plotter", d.OtherId, ended ? 78f : 58f);
}
private static void ApplySurvival(NarrativeDevelopment d)
{
bool recovered = d.Kind == NarrativeDevelopmentKind.Recovery;
string anchor = FirstNonEmpty(d.CorrelationKey, "change:" + d.SubjectId + ":" + d.NewState);
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
"thread:survival:" + anchor, recovered ? NarrativeThreadKind.Recovery : NarrativeThreadKind.Transformation,
d.SubjectId, anchor, d,
recovered ? "How will recovery change their path?" : "Will they endure this transformation?",
recovered ? NarrativePhase.Outcome : NarrativePhase.TurningPoint,
recovered ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active);
AddConsequence(d, t, d.SubjectId,
recovered ? CharacterConsequenceKind.Recovered : CharacterConsequenceKind.Transformed,
"subject", d.OtherId, 72f);
}
private static void AddConsequence(
NarrativeDevelopment d,
NarrativeThread thread,
long characterId,
CharacterConsequenceKind kind,
string role,
long otherId,
float importance)
{
LifeSagaIdentity other = otherId == d.OtherId ? d.Other : LifeSagaMemory.SnapshotId(otherId);
NarrativeStoryStore.AddConsequence(new CharacterConsequence
{
Id = "consequence:" + d.Id + ":" + characterId + ":" + kind,
CharacterId = characterId,
ThreadId = thread?.Id ?? "",
DevelopmentId = d.Id,
Kind = kind,
Role = role ?? "",
OtherId = otherId,
Other = other,
Context = FirstNonEmpty(d.WarKey, d.PlotKey, d.CityKey, d.KingdomKey),
Outcome = d.Outcome ?? "",
DateLabel = d.DateLabel ?? "",
OccurredAt = d.OccurredAt,
Importance = importance,
Confidence = d.Confidence
});
}
private static string PairAnchor(string prefix, long a, long b)
{
if (b == 0) return prefix + ":" + a;
return a <= b ? prefix + ":" + a + ":" + b : prefix + ":" + b + ":" + a;
}
private static string FirstNonEmpty(params string[] values)
{
if (values == null) return "";
for (int i = 0; i < values.Length; i++)
{
if (!string.IsNullOrEmpty(values[i])) return values[i];
}
return "";
}
}

View file

@ -0,0 +1,357 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Story-first scheduler and sole camera-story owner.
/// </summary>
public static class StoryScheduler
{
private const float EpisodeNoProgressSeconds = 14f;
private const float PayoffSettleSeconds = 8f;
private const float EpisodeMaxSeconds = 75f;
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);
private static float _nextTickAt;
private static string _lastProposal = "";
public static EpisodePlan ActiveEpisode { get; private set; }
public static EpisodeShotProposal CurrentProposal { get; private set; }
public static string LastProposal => _lastProposal ?? "";
public static void Clear()
{
ActiveEpisode = null;
CurrentProposal = null;
OpenThreads.Clear();
Pending.Clear();
_nextTickAt = 0f;
_lastProposal = "";
}
public static void Tick(float now)
{
if (now < _nextTickAt) return;
_nextTickAt = now + 0.5f;
NarrativeStoryStore.CopyOpenThreads(OpenThreads);
NarrativeThread best = null;
float bestScore = float.MinValue;
for (int i = 0; i < OpenThreads.Count; i++)
{
NarrativeThread thread = OpenThreads[i];
float score = Score(thread, now);
if (score > bestScore)
{
best = thread;
bestScore = score;
}
}
NarrativeThread activeThread = ActiveEpisode != null
? NarrativeStoryStore.Thread(ActiveEpisode.ThreadId)
: null;
if (ActiveEpisode != null && !EpisodeStillValid(ActiveEpisode, activeThread, now))
{
ActiveEpisode = null;
CurrentProposal = null;
activeThread = null;
}
if (best == null && activeThread == null)
{
ActiveEpisode = null;
CurrentProposal = null;
_lastProposal = "none";
return;
}
NarrativeThread selectedThread = RetainOrChoose(activeThread, best, bestScore, now);
if (selectedThread == null)
{
ActiveEpisode = null;
CurrentProposal = null;
_lastProposal = "none";
return;
}
if (ActiveEpisode == null || ActiveEpisode.ThreadId != selectedThread.Id)
{
ActiveEpisode = BuildEpisode(selectedThread, now);
}
else if (selectedThread.LatestMeaningfulChangeId != ActiveEpisode.EntryDevelopmentId)
{
ActiveEpisode.EntryDevelopmentId = selectedThread.LatestMeaningfulChangeId;
ActiveEpisode.LastProgressAt = now;
ActiveEpisode.CombatShotCount = 0;
ActiveEpisode.GoalMet = false;
}
InterestRegistry.CopyPending(Pending);
CurrentProposal = EpisodeShotSelector.Pick(Pending, ActiveEpisode, selectedThread);
if (CurrentProposal != null)
{
NarrativeTelemetry.NoteProposal(ActiveEpisode, CurrentProposal, now);
}
if (CurrentProposal?.Candidate != null)
{
NarrativeTelemetry.NoteCurrentAlignment(
ActiveEpisode, selectedThread, InterestDirector.CurrentCandidate);
}
_lastProposal = selectedThread.Id + " protagonist=" + selectedThread.ProtagonistId
+ " phase=" + selectedThread.Phase + " score=" + Score(selectedThread, now).ToString("0.0")
+ " shot=" + (CurrentProposal?.Candidate?.Key ?? "none")
+ " reason=" + (CurrentProposal?.Reason ?? "none");
}
public static void NoteActualSelection(InterestCandidate actual)
{
if (ActiveEpisode == null || actual == null) return;
NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId);
NarrativeTelemetry.NoteActual(ActiveEpisode, thread, actual);
NarrativeInterruptClass kind = InterruptPolicy.Classify(actual, ActiveEpisode, thread);
if (kind == NarrativeInterruptClass.RelatedEscalation)
{
if (IsCombatShot(actual))
{
ActiveEpisode.CombatShotCount++;
}
ActiveEpisode.LastProgressAt = UnityEngine.Time.unscaledTime;
ActiveEpisode.LastShotKey = actual.Key ?? "";
ActiveEpisode.InterruptedAt = -1f;
ActiveEpisode.GoalMet = thread != null
&& (thread.Phase == NarrativePhase.Outcome
|| thread.Phase == NarrativePhase.Consequence);
}
else if (kind == NarrativeInterruptClass.WorldCritical || kind == NarrativeInterruptClass.StoryPayoff)
{
ActiveEpisode.InterruptedAt = UnityEngine.Time.unscaledTime;
}
}
/// <summary>
/// A narrative episode may establish and develop a fight without following every new
/// opponent. A decisive world-critical combat signal still bypasses the budget.
/// </summary>
public static bool MayUseCombatShot(EpisodePlan episode, InterestCandidate candidate)
{
if (episode == null || candidate == null || !IsCombatShot(candidate))
{
return true;
}
if (candidate.EventStrength >= 95f)
{
return true;
}
return episode.CombatShotCount < EpisodeCombatShotBudget;
}
public static bool MayUseCombatShot(InterestCandidate candidate) =>
MayUseCombatShot(ActiveEpisode, candidate);
/// <summary>Camera-owner selection from candidates already admitted by director safety gates.</summary>
public static InterestCandidate SelectForCamera(
IList<InterestCandidate> eligible,
InterestCandidate current,
float now)
{
if (ActiveEpisode == null) return null;
NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId);
if (!EpisodeStillValid(ActiveEpisode, thread, now))
{
ActiveEpisode = null;
CurrentProposal = null;
return null;
}
EpisodeShotProposal proposal = EpisodeShotSelector.Pick(eligible, ActiveEpisode, thread);
CurrentProposal = proposal;
if (proposal?.Candidate == null || proposal.Candidate == current) return null;
return proposal.Candidate;
}
public static bool ShouldHoldForEpisode(float now)
{
if (ActiveEpisode == null
|| CurrentProposal?.Candidate == null) return false;
NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId);
return EpisodeStillValid(ActiveEpisode, thread, now)
&& now - ActiveEpisode.LastProgressAt < EpisodeNoProgressSeconds;
}
public static bool HarnessProbePolicy(out string detail)
{
var episode = new EpisodePlan { ThreadId = "probe:a", ProtagonistId = 101 };
episode.EligibleCastIds.Add(102);
var thread = new NarrativeThread
{
Id = "probe:a",
ProtagonistId = 101,
AnchorKey = "family-a",
Status = NarrativeThreadStatus.Active
};
var candidates = new List<InterestCandidate>
{
new InterestCandidate { Key = "unrelated", Label = "Unrelated event", SubjectId = 201, EventStrength = 90f },
new InterestCandidate
{
Key = "related", Label = "Family develops", AssetId = "add_child",
SubjectId = 102, EventStrength = 60f
}
};
EpisodeShotProposal relatedPick = EpisodeShotSelector.Pick(candidates, episode, thread, requirePresentable: false);
var episodeB = new EpisodePlan { ThreadId = "probe:b", ProtagonistId = 201 };
var threadB = new NarrativeThread
{
Id = "probe:b",
ProtagonistId = 201,
AnchorKey = "family-b",
Status = NarrativeThreadStatus.Active
};
EpisodeShotProposal concurrentPick = EpisodeShotSelector.Pick(
candidates, episodeB, threadB, requirePresentable: false);
candidates.Add(new InterestCandidate
{
Key = "critical", Label = "Kingdom shattered", SubjectId = 301,
Category = "Disaster", EventStrength = 100f
});
EpisodeShotProposal criticalPick = EpisodeShotSelector.Pick(candidates, episode, thread, requirePresentable: false);
NarrativeInterruptClass ordinary = InterruptPolicy.Classify(candidates[0], episode, thread);
var saturated = new EpisodePlan
{
ThreadId = "probe:combat",
ProtagonistId = 101,
CombatShotCount = EpisodeCombatShotBudget
};
var routineCombat = new InterestCandidate
{
SubjectId = 101,
Completion = InterestCompletionKind.CombatActive,
EventStrength = 80f
};
var criticalCombat = new InterestCandidate
{
SubjectId = 101,
Completion = InterestCompletionKind.CombatActive,
EventStrength = 100f
};
bool routineBlocked = !MayUseCombatShot(saturated, routineCombat);
bool criticalAllowed = MayUseCombatShot(saturated, criticalCombat);
bool nonCombatAllowed = MayUseCombatShot(saturated, candidates[1]);
bool pass = relatedPick?.Candidate?.Key == "related"
&& concurrentPick?.Candidate?.Key == "unrelated"
&& criticalPick?.Candidate?.Key == "critical"
&& ordinary == NarrativeInterruptClass.OrdinaryInteresting
&& !InterruptPolicy.MayInterrupt(ordinary)
&& routineBlocked && criticalAllowed && nonCombatAllowed;
detail = "related=" + (relatedPick?.Candidate?.Key ?? "none")
+ " concurrent=" + (concurrentPick?.Candidate?.Key ?? "none")
+ " critical=" + (criticalPick?.Candidate?.Key ?? "none")
+ " ordinary=" + ordinary
+ " combatBudget=" + routineBlocked + "/" + criticalAllowed + "/" + nonCombatAllowed
+ " pass=" + pass;
return pass;
}
public static float StoryPotential(long characterId)
{
CharacterStoryState state = NarrativeStoryStore.Character(characterId);
return NarrativeStoryStore.EffectiveStoryPotential(state);
}
private static float Score(NarrativeThread thread, float now)
{
if (thread == null
|| (!thread.IsOpen
&& !(thread.Status == NarrativeThreadStatus.Resolved && now - thread.UpdatedAt < 20f)))
{
return float.MinValue;
}
CharacterStoryState state = NarrativeStoryStore.Character(thread.ProtagonistId);
float potential = NarrativeStoryStore.EffectiveStoryPotential(state, now);
float freshness = Mathf.Max(0f, 8f - (now - thread.UpdatedAt) / 15f);
float payoff = thread.Phase == NarrativePhase.TurningPoint ? 8f
: thread.Phase == NarrativePhase.Outcome ? 7f
: thread.Phase == NarrativePhase.Consequence ? 6f : 0f;
float prefer = LifeSagaRoster.IsPrefer(thread.ProtagonistId) ? 8f : 0f;
float mc = LifeSagaRoster.IsMc(thread.ProtagonistId) ? 4f : 0f;
float repeat = ActiveEpisode != null && ActiveEpisode.ThreadId == thread.Id ? 2f : 0f;
return potential + thread.Momentum + thread.Urgency + thread.CoverageDebt
+ freshness + payoff + prefer + mc + repeat;
}
private static NarrativeThread RetainOrChoose(
NarrativeThread active,
NarrativeThread best,
float bestScore,
float now)
{
if (active == null) return best;
if (best == null || best.Id == active.Id) return active;
float activeScore = Score(active, now);
bool settled = ActiveEpisode != null && ActiveEpisode.GoalMet
&& now - ActiveEpisode.LastProgressAt >= PayoffSettleSeconds;
bool stale = ActiveEpisode != null
&& now - ActiveEpisode.LastProgressAt >= EpisodeNoProgressSeconds;
return settled || stale || bestScore >= activeScore + 20f ? best : active;
}
private static bool EpisodeStillValid(EpisodePlan episode, NarrativeThread thread, float now)
{
bool recentResolution = thread != null
&& thread.Status == NarrativeThreadStatus.Resolved
&& now - thread.UpdatedAt < 20f;
if (episode == null || thread == null || (!thread.IsOpen && !recentResolution)) return false;
if (now - episode.StartedAt >= EpisodeMaxSeconds) return false;
if (episode.GoalMet && now - episode.LastProgressAt >= PayoffSettleSeconds) return false;
return now - episode.LastProgressAt < EpisodeNoProgressSeconds
|| thread.LatestMeaningfulChangeId != episode.EntryDevelopmentId;
}
private static EpisodePlan BuildEpisode(NarrativeThread thread, float now)
{
var episode = new EpisodePlan
{
ThreadId = thread.Id,
ProtagonistId = thread.ProtagonistId,
EntryDevelopmentId = thread.LatestMeaningfulChangeId,
Purpose = PurposeFor(thread),
InformationGoal = thread.CentralQuestion ?? "",
StartedAt = now,
LastProgressAt = now
};
for (int i = 0; i < thread.Cast.Count; i++)
{
NarrativeCastRole cast = thread.Cast[i];
if (cast != null && cast.CharacterId != 0) episode.EligibleCastIds.Add(cast.CharacterId);
}
return episode;
}
private static bool IsCombatShot(InterestCandidate candidate)
{
return candidate != null
&& (candidate.Completion == InterestCompletionKind.CombatActive
|| string.Equals(candidate.AssetId, "live_combat", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(candidate.AssetId, "live_battle", System.StringComparison.OrdinalIgnoreCase));
}
private static EpisodePurpose PurposeFor(NarrativeThread thread)
{
if (thread == null) return EpisodePurpose.Develop;
switch (thread.Phase)
{
case NarrativePhase.Setup: return EpisodePurpose.Establish;
case NarrativePhase.TurningPoint:
case NarrativePhase.Outcome: return EpisodePurpose.Payoff;
case NarrativePhase.Consequence: return EpisodePurpose.Consequence;
default: return EpisodePurpose.Develop;
}
}
}

View file

@ -80,6 +80,24 @@ public static class CaptionComposer
model.ReasonRelatedId = reasonRelatedId;
string beat = presentableBeat ?? "";
string narrativeContext = "";
bool structuredNarrativeBeat = false;
if (!statusBannerOwnsBeat
&& NarrativeProse.TryBuildBeat(
focus,
ownedTip,
out NarrativeBeatModel narrativeModel,
out string narrativeBeat,
out narrativeContext))
{
beat = narrativeBeat;
structuredNarrativeBeat = true;
if (narrativeModel.OtherId != 0)
{
model.ReasonRelatedId = narrativeModel.OtherId;
}
}
if (statusBannerOwnsBeat)
{
// Banner owns orange row; composer still fills Identity + Context.
@ -87,7 +105,9 @@ public static class CaptionComposer
}
else
{
model.BeatLine = EnrichBeat(focus, focusId, ownedTip, beat, ref model.ReasonRelatedId, isReentry);
model.BeatLine = structuredNarrativeBeat
? beat
: EnrichBeat(focus, focusId, ownedTip, beat, ref model.ReasonRelatedId, isReentry);
if (!isReentry)
{
model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine);
@ -101,6 +121,10 @@ public static class CaptionComposer
model.IdentityLine,
spineLabel,
isReentry && !statusBannerOwnsBeat);
if (!string.IsNullOrEmpty(narrativeContext))
{
model.ContextLine = narrativeContext;
}
model.Fingerprint =
focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine

View file

@ -1,134 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Decaying per-unit heat from featured cast. Soft-spreads to live relations and Chronicle links.
/// </summary>
public static class CausalHeat
{
private struct HeatEntry
{
public float Value;
public float TouchedAt;
}
private static readonly Dictionary<long, HeatEntry> HeatById = new Dictionary<long, HeatEntry>(64);
private static readonly List<long> ScratchIds = new List<long>(16);
private const float FeaturedAmount = 1f;
private const float RelatedSpread = 0.45f;
public static void Clear()
{
HeatById.Clear();
}
public static void NoteFeatured(long unitId, float amount = FeaturedAmount)
{
if (unitId == 0 || amount <= 0f)
{
return;
}
float now = Time.unscaledTime;
AddHeat(unitId, amount, now);
SpreadRelated(unitId, amount * RelatedSpread, now);
}
public static void NoteCast(IList<long> castIds, float amount = FeaturedAmount)
{
if (castIds == null || castIds.Count == 0)
{
return;
}
for (int i = 0; i < castIds.Count; i++)
{
NoteFeatured(castIds[i], amount);
}
}
public static float Get(long unitId)
{
if (unitId == 0 || !HeatById.TryGetValue(unitId, out HeatEntry entry))
{
return 0f;
}
return Decayed(entry, Time.unscaledTime);
}
public static float ScoreBonus(InterestCandidate c)
{
if (c == null)
{
return 0f;
}
StoryWeights s = InterestScoringConfig.Story;
float subject = Get(c.SubjectId);
float related = c.RelatedId != 0 && c.RelatedId != c.SubjectId ? Get(c.RelatedId) : 0f;
return subject * s.causalHeatWeight + related * s.causalRelatedWeight;
}
private static float Decayed(HeatEntry entry, float now)
{
StoryWeights s = InterestScoringConfig.Story;
float halfLife = s.causalHeatHalfLifeSeconds > 1f ? s.causalHeatHalfLifeSeconds : 180f;
float age = Mathf.Max(0f, now - entry.TouchedAt);
return entry.Value * Mathf.Pow(0.5f, age / halfLife);
}
private static void AddHeat(long unitId, float amount, float now)
{
float cur = 0f;
if (HeatById.TryGetValue(unitId, out HeatEntry entry))
{
cur = Decayed(entry, now);
}
HeatById[unitId] = new HeatEntry
{
Value = Mathf.Min(4f, cur + amount),
TouchedAt = now
};
}
private static void SpreadRelated(long unitId, float amount, float now)
{
if (amount < 0.05f)
{
return;
}
Actor actor = EventFeedUtil.FindAliveById(unitId);
if (actor != null)
{
Actor lover = ActorRelation.GetLover(actor);
if (lover != null)
{
AddHeat(EventFeedUtil.SafeId(lover), amount, now);
}
Actor friend = ActorRelation.GetBestFriend(actor);
if (friend != null)
{
AddHeat(EventFeedUtil.SafeId(friend), amount * 0.75f, now);
}
}
ScratchIds.Clear();
Chronicle.EnumerateRelatedIds(unitId, ScratchIds, max: 6);
for (int i = 0; i < ScratchIds.Count; i++)
{
long other = ScratchIds[i];
if (other == 0 || other == unitId)
{
continue;
}
AddHeat(other, amount * 0.5f, now);
}
}
}

View file

@ -297,6 +297,7 @@ public static class LifeSagaMemory
+ Time.unscaledTime.ToString("0.###"),
strength: 70f,
provenance: provenance);
NarrativeDevelopmentRouter.BondFormed(a, b, provenance);
}
public static void RecordBondBroken(Actor survivor, Actor former, string provenance = "clear_lover")
@ -342,6 +343,7 @@ public static class LifeSagaMemory
strength: 75f,
provenance: provenance,
resolved: true);
NarrativeDevelopmentRouter.BondBroken(survivor, former, provenance);
}
public static void RecordParentChild(Actor parent, Actor child, string provenance = "add_child")
@ -360,6 +362,7 @@ public static class LifeSagaMemory
correlationKey: "parent:" + parentId + ":" + childId,
strength: 65f,
provenance: provenance);
NarrativeDevelopmentRouter.ChildBorn(parent, child, provenance);
}
public static void RecordFriendFormed(Actor a, Actor b, string provenance = "set_best_friend")
@ -376,6 +379,7 @@ public static class LifeSagaMemory
correlationKey: "friend:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)),
strength: 55f,
provenance: provenance);
NarrativeDevelopmentRouter.FriendFormed(a, b, provenance);
}
public static void RecordKill(Actor killer, Actor victim, string provenance = "newKillAction")
@ -395,6 +399,7 @@ public static class LifeSagaMemory
+ Time.unscaledTime.ToString("0.###"),
strength: 80f,
provenance: provenance);
NarrativeDevelopmentRouter.Kill(killer, victim, provenance);
// Kill/death must not increment living rematch counters (single kill ≠ rival).
TryMarkKinKillRival(killer, victim);
@ -422,6 +427,7 @@ public static class LifeSagaMemory
provenance: provenance,
resolved: true,
note: attackType ?? "");
NarrativeDevelopmentRouter.Death(victim, killer, attackType, provenance);
if (killer != null)
{
@ -644,6 +650,7 @@ public static class LifeSagaMemory
warKey: warKey,
resolved: ended,
note: note ?? "");
NarrativeDevelopmentRouter.War(subject, other, warKey, ended, provenance);
}
/// <summary>
@ -738,6 +745,7 @@ public static class LifeSagaMemory
plotKey: plotKey,
note: display,
resolved: finished);
NarrativeDevelopmentRouter.Plot(subject, other, plotKey, finished, provenance);
}
/// <summary>
@ -977,6 +985,7 @@ public static class LifeSagaMemory
familyKey: metaKind == "family" ? metaKey : "",
clanKey: metaKind == "clan" ? metaKey : "",
note: metaKind ?? "");
NarrativeDevelopmentRouter.Founding(subject, metaKind, metaKey, provenance);
}
public static void RecordRole(Actor subject, string roleId, string provenance = "happiness")
@ -994,6 +1003,7 @@ public static class LifeSagaMemory
strength: 68f,
provenance: provenance,
note: roleId);
NarrativeDevelopmentRouter.Role(subject, roleId, true, provenance);
}
public static void RecordHome(Actor subject, bool gained, string provenance = "happiness")
@ -1013,33 +1023,6 @@ public static class LifeSagaMemory
provenance: provenance);
}
public static void RecordHardArc(
long unitId,
StoryArcKind arcKind,
string note,
long relatedId = 0,
string provenance = "story_planner")
{
if (unitId == 0)
{
return;
}
LifeSagaIdentity subject = SnapshotId(unitId);
LifeSagaIdentity other = relatedId != 0 ? SnapshotId(relatedId) : default;
LifeSagaFactKind kind = HardArcKind(arcKind);
RecordIds(
kind,
unitId,
subject,
relatedId,
other,
correlationKey: "arc:" + unitId + ":" + arcKind + ":" + (note ?? "").GetHashCode(),
strength: 60f,
provenance: provenance,
note: note ?? "");
}
public static LifeSagaIdentity Snapshot(Actor actor)
{
if (actor == null)
@ -1786,13 +1769,11 @@ public static class LifeSagaMemory
{
if (a != 0)
{
CausalHeat.NoteFeatured(a, 1.15f);
LifeSagaRoster.NoteFeaturedUnit(a, 1.2f);
}
if (b != 0 && b != a)
{
CausalHeat.NoteFeatured(b, 1.15f);
LifeSagaRoster.NoteFeaturedUnit(b, 1.2f);
}
}
@ -1859,26 +1840,6 @@ public static class LifeSagaMemory
return false;
}
private static LifeSagaFactKind HardArcKind(StoryArcKind arcKind)
{
switch (arcKind)
{
case StoryArcKind.Love:
return LifeSagaFactKind.HardArcLove;
case StoryArcKind.Grief:
return LifeSagaFactKind.HardArcGrief;
case StoryArcKind.CombatDuel:
case StoryArcKind.CombatMass:
return LifeSagaFactKind.HardArcCombat;
case StoryArcKind.WarFront:
return LifeSagaFactKind.HardArcWar;
case StoryArcKind.Plot:
return LifeSagaFactKind.HardArcPlot;
default:
return LifeSagaFactKind.Unknown;
}
}
private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind)
{
switch (kind)

View file

@ -650,14 +650,103 @@ public static class LifeSagaPresentation
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8);
var seenLines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
List<NarrativeChapter> narrativeChapters = NarrativeStoryStore.ChaptersFor(unitId, 4);
bool narrativeOwnsFamily = false;
bool narrativeOwnsConflict = false;
for (int i = 0; i < narrativeChapters.Count && model.Legacy.Count < 4; i++)
{
NarrativeChapter chapter = narrativeChapters[i];
if (chapter == null || string.IsNullOrEmpty(chapter.Line))
{
continue;
}
string line = !string.IsNullOrEmpty(chapter.DateLabel)
? chapter.DateLabel + " · " + chapter.Line
: chapter.Line;
if (!seenLines.Add(line))
{
continue;
}
NarrativeThread chapterThread = NarrativeStoryStore.Thread(chapter.ThreadId);
if (chapterThread != null)
{
narrativeOwnsFamily |= chapterThread.Kind == NarrativeThreadKind.Partnership
|| chapterThread.Kind == NarrativeThreadKind.Lineage
|| chapterThread.Kind == NarrativeThreadKind.SeparationGrief;
narrativeOwnsConflict |= chapterThread.Kind == NarrativeThreadKind.Rivalry
|| chapterThread.Kind == NarrativeThreadKind.PersonalCombat
|| chapterThread.Kind == NarrativeThreadKind.WarInvolvement;
}
model.Legacy.Add(new LifeSagaLegacyBeat
{
Kind = NarrativeLegacyKind(chapterThread),
Line = line,
Truth = "observed",
At = chapter.At,
DateLabel = chapter.DateLabel ?? ""
});
}
bool wroteCombatSummary = false;
if (TryBuildCombatSummary(unitId, out LifeSagaLegacyBeat combatSummary))
{
// PersonalCombat chapters are still one chapter per opponent. Replace those
// repeated kill lines with one observed run; earned Rivalry chapters use a
// different kind and remain intact.
for (int i = model.Legacy.Count - 1; i >= 0; i--)
{
if (model.Legacy[i] != null && model.Legacy[i].Kind == LifeSagaFactKind.Kill)
{
model.Legacy.RemoveAt(i);
}
}
if (model.Legacy.Count < factLimit && seenLines.Add(combatSummary.Line))
{
model.Legacy.Add(combatSummary);
wroteCombatSummary = true;
}
}
for (int i = 0; i < facts.Count; i++)
{
if (model.Legacy.Count >= factLimit)
{
break;
}
LifeSagaFact f = facts[i];
if (f == null)
{
continue;
}
if (narrativeOwnsFamily
&& (f.Kind == LifeSagaFactKind.BondFormed
|| f.Kind == LifeSagaFactKind.BondBroken
|| f.Kind == LifeSagaFactKind.ParentChild
|| f.Kind == LifeSagaFactKind.HardArcLove
|| f.Kind == LifeSagaFactKind.HardArcGrief))
{
continue;
}
if (narrativeOwnsConflict
&& (f.Kind == LifeSagaFactKind.Kill
|| f.Kind == LifeSagaFactKind.HardArcCombat
|| f.Kind == LifeSagaFactKind.HardArcWar))
{
continue;
}
if (wroteCombatSummary && f.Kind == LifeSagaFactKind.Kill)
{
continue;
}
// Stake already owns the strongest unresolved beat - do not repeat it in Legacy.
if (stake != null
&& ((!string.IsNullOrEmpty(stake.Key)
@ -725,6 +814,7 @@ public static class LifeSagaPresentation
}
if (!wroteParentChild
&& !narrativeOwnsFamily
&& parentedCount >= 2
&& model.Legacy.Count < 4)
{
@ -745,6 +835,81 @@ public static class LifeSagaPresentation
}
}
private static bool TryBuildCombatSummary(long unitId, out LifeSagaLegacyBeat summary)
{
summary = null;
IReadOnlyList<LifeSagaFact> all = LifeSagaMemory.FactsFor(unitId);
if (all == null)
{
return false;
}
int kills = 0;
LifeSagaFact earliest = null;
LifeSagaFact latest = null;
for (int i = 0; i < all.Count; i++)
{
LifeSagaFact fact = all[i];
if (fact == null || fact.Kind != LifeSagaFactKind.Kill)
{
continue;
}
kills++;
if (earliest == null || fact.At < earliest.At) earliest = fact;
if (latest == null || fact.At > latest.At) latest = fact;
}
if (kills < 2 || latest == null)
{
return false;
}
string date = latest.DateLabel ?? "";
if (earliest != null
&& !string.IsNullOrEmpty(earliest.DateLabel)
&& !string.IsNullOrEmpty(latest.DateLabel)
&& !string.Equals(earliest.DateLabel, latest.DateLabel, StringComparison.Ordinal))
{
date = earliest.DateLabel + "" + latest.DateLabel;
}
string body = "Slew " + kills + " opponents across repeated clashes";
string line = string.IsNullOrEmpty(date) ? body : date + " · " + body;
summary = new LifeSagaLegacyBeat
{
Kind = LifeSagaFactKind.Kill,
Line = line,
Truth = "observed",
At = latest.At,
DateLabel = date
};
return true;
}
private static LifeSagaFactKind NarrativeLegacyKind(NarrativeThread thread)
{
if (thread == null) return LifeSagaFactKind.Unknown;
switch (thread.Kind)
{
case NarrativeThreadKind.Partnership: return LifeSagaFactKind.BondFormed;
case NarrativeThreadKind.Lineage: return LifeSagaFactKind.ParentChild;
case NarrativeThreadKind.SeparationGrief: return LifeSagaFactKind.BondBroken;
case NarrativeThreadKind.RiseToRule:
case NarrativeThreadKind.Reign:
case NarrativeThreadKind.Succession: return LifeSagaFactKind.RoleChange;
case NarrativeThreadKind.Founding: return LifeSagaFactKind.Founding;
case NarrativeThreadKind.HomeLoss: return LifeSagaFactKind.HomeLost;
case NarrativeThreadKind.Rivalry: return LifeSagaFactKind.RivalEarned;
case NarrativeThreadKind.PersonalCombat: return LifeSagaFactKind.Kill;
case NarrativeThreadKind.WarInvolvement: return LifeSagaFactKind.WarJoin;
case NarrativeThreadKind.PlotBetrayal: return LifeSagaFactKind.PlotJoin;
case NarrativeThreadKind.Transformation:
case NarrativeThreadKind.Recovery: return LifeSagaFactKind.Unknown;
default: return LifeSagaFactKind.Unknown;
}
}
/// <summary>
/// Living Cast already presents lover/friend/kin - do not echo those names as Legacy crumbs.
/// Dead / past relations and wars/plots/kills still belong in Legacy.

View file

@ -26,7 +26,10 @@ public static class LifeSagaRoster
private static readonly Dictionary<string, int> SpeciesCountCache = new Dictionary<string, int>();
private static readonly HashSet<long> McOrbitCache = new HashSet<long>();
private static readonly List<long> TouchScratch = new List<long>(8);
private static readonly List<CharacterStoryState> NarrativeChallengerScratch =
new List<CharacterStoryState>(8);
private static float _nextScanAt;
private static float _nextNarrativeAdmissionAt;
private static float _roleCacheAt = -999f;
private static bool _dirty = true;
private static WorldScanPhase _scanPhase = WorldScanPhase.None;
@ -77,7 +80,9 @@ public static class LifeSagaRoster
PlotAuthorCache.Clear();
SpeciesCountCache.Clear();
McOrbitCache.Clear();
NarrativeChallengerScratch.Clear();
_nextScanAt = 0f;
_nextNarrativeAdmissionAt = 0f;
_roleCacheAt = -999f;
_scanPhase = WorldScanPhase.None;
_scanIndex = 0;
@ -699,156 +704,16 @@ public static class LifeSagaRoster
s.TouchedAt = now;
s.Heat = Mathf.Min(HeatCap, s.Heat + 1.5f);
_dirty = true;
LifeSagaMemory.RecordHardArc(
unitId,
kind == LifeSagaChapterKind.Love ? StoryArcKind.Love
: kind == LifeSagaChapterKind.Grief ? StoryArcKind.Grief
: kind == LifeSagaChapterKind.Combat ? StoryArcKind.CombatDuel
: kind == LifeSagaChapterKind.War ? StoryArcKind.WarFront
: kind == LifeSagaChapterKind.Plot ? StoryArcKind.Plot
: StoryArcKind.None,
trimmed);
}
/// <summary>
/// Hard-arc admit heat grows with climax age so prolonged theater can challenge a full Cap.
/// One-shot heat 1.5 loses to a roster of notables; ~2 minutes reaches <see cref="HeatCap"/>.
/// </summary>
public static float HardArcAdmitHeat(StoryArc arc, float now = -1f)
{
if (now < 0f)
{
now = Time.unscaledTime;
}
if (arc == null || arc.StartedAt <= 0f)
{
return 1.5f;
}
float age = Mathf.Max(0f, now - arc.StartedAt);
return Mathf.Min(HeatCap, 1.5f + age / 12f);
}
public static void StampChapterFromArc(StoryArc arc, InterestCandidate tip, float now)
{
if (arc == null || !arc.IsActive)
{
return;
}
string line = FormatChapterLine(arc, tip);
if (string.IsNullOrEmpty(line))
{
return;
}
bool hard = IsHardStoryKind(arc.Kind);
float subjectHeat = hard ? HardArcAdmitHeat(arc, now) : 1.5f;
float relatedHeat = hard ? Mathf.Max(1.2f, subjectHeat * 0.85f) : 1.2f;
if (arc.CastIds != null)
{
for (int i = 0; i < arc.CastIds.Count; i++)
{
long id = arc.CastIds[i];
if (hard)
{
ConsiderAdmit(id, subjectHeat, hardArcAdmit: true, now);
MarkHardArcContext(id, arc.Kind, line);
}
if (IsMc(id))
{
LifeSagaMemory.RecordHardArc(id, arc.Kind, line, tip?.RelatedId ?? 0);
StampChapter(id, ToChapterKind(arc.Kind), line, now);
}
}
}
if (tip != null)
{
if (hard)
{
ConsiderAdmit(tip.SubjectId, subjectHeat, hardArcAdmit: true, now);
MarkHardArcContext(tip.SubjectId, arc.Kind, line);
if (tip.RelatedId != 0)
{
ConsiderAdmit(tip.RelatedId, relatedHeat, hardArcAdmit: true, now);
MarkHardArcContext(tip.RelatedId, arc.Kind, line);
}
long follow = EventFeedUtil.SafeId(tip.FollowUnit);
if (follow != 0 && follow != tip.SubjectId && follow != tip.RelatedId)
{
ConsiderAdmit(follow, subjectHeat, hardArcAdmit: true, now);
MarkHardArcContext(follow, arc.Kind, line);
}
}
if (IsMc(tip.SubjectId))
{
StampChapter(tip.SubjectId, ToChapterKind(arc.Kind), line, now);
}
if (tip.RelatedId != 0 && IsMc(tip.RelatedId))
{
StampChapter(tip.RelatedId, ToChapterKind(arc.Kind), line, now);
}
long followStamp = EventFeedUtil.SafeId(tip.FollowUnit);
if (followStamp != 0
&& followStamp != tip.SubjectId
&& followStamp != tip.RelatedId
&& IsMc(followStamp))
{
StampChapter(followStamp, ToChapterKind(arc.Kind), line, now);
}
}
}
public static string FormatChapterLine(StoryArc arc, InterestCandidate tip)
{
if (arc == null)
{
return "";
}
if (!string.IsNullOrEmpty(arc.Headline))
{
return CleanChapterLine(arc.Headline);
}
if (tip != null && !string.IsNullOrEmpty(tip.Label))
{
string cleaned = CleanChapterLine(tip.Label);
if (!string.IsNullOrEmpty(cleaned))
{
return cleaned;
}
}
switch (arc.Kind)
{
case StoryArcKind.WarFront:
return "War front";
case StoryArcKind.CombatDuel:
return "Duel";
case StoryArcKind.CombatMass:
return "Battle";
case StoryArcKind.Love:
return "Love";
case StoryArcKind.Grief:
return "Grief";
case StoryArcKind.Plot:
return "Plot";
default:
return "";
}
}
public static void Tick(float now)
{
if (now >= _nextNarrativeAdmissionAt)
{
_nextNarrativeAdmissionAt = now + 1f;
ConsiderEmergingStories(now);
}
if (_scanPhase != WorldScanPhase.None)
{
ContinueAmortizedWorldScan(now);
@ -1086,9 +951,13 @@ public static class LifeSagaRoster
/// <summary>
/// Challenge Cap with a living unit. Admission roles always may compete;
/// nobodies only when <paramref name="hardArcAdmit"/> (hard-arc chapter path).
/// ordinary characters only when coherent story momentum qualifies them.
/// </summary>
public static bool ConsiderAdmit(long unitId, float heat, bool hardArcAdmit, float now = -1f)
public static bool ConsiderAdmit(
long unitId,
float heat,
float now = -1f,
bool emergingStoryAdmit = false)
{
if (unitId == 0)
{
@ -1117,7 +986,7 @@ public static class LifeSagaRoster
EnsureRoleCache(now, force: false);
bool admission = IsAdmissionCandidate(actor);
if (!admission && !hardArcAdmit)
if (!admission && !emergingStoryAdmit)
{
return false;
}
@ -1125,7 +994,8 @@ public static class LifeSagaRoster
LifeSagaSlot neu = BuildSlot(
actor,
now,
hardArcAdmit && !admission ? LifeSagaAdmissionReason.HardArc : LifeSagaAdmissionReason.None);
emergingStoryAdmit && !admission ? LifeSagaAdmissionReason.EmergingStory
: LifeSagaAdmissionReason.None);
neu.Heat = Mathf.Min(HeatCap, Mathf.Max(0f, heat));
Scratch.Clear();
PreviousIds.Clear();
@ -1150,6 +1020,22 @@ public static class LifeSagaRoster
return IsMc(unitId);
}
private static void ConsiderEmergingStories(float now)
{
NarrativeStoryStore.CopyEmergingCharacters(NarrativeChallengerScratch, 6f, 4);
for (int i = 0; i < NarrativeChallengerScratch.Count; i++)
{
CharacterStoryState state = NarrativeChallengerScratch[i];
if (Get(state.CharacterId) != null) continue;
float potential = NarrativeStoryStore.EffectiveStoryPotential(state, now);
ConsiderAdmit(
state.CharacterId,
heat: Mathf.Clamp(potential * 0.5f, 1f, HeatCap),
now: now,
emergingStoryAdmit: true);
}
}
private static void NoteUnit(long unitId, float heat, float now)
{
LifeSagaSlot s = Get(unitId);
@ -1527,7 +1413,8 @@ public static class LifeSagaRoster
}
}
float score = standing + heat;
float storyPotential = Mathf.Min(12f, StoryScheduler.StoryPotential(s.UnitId) * 0.8f);
float score = standing + heat + storyPotential;
if (s.GameFavorite)
{
score += 50f;
@ -2400,19 +2287,6 @@ public static class LifeSagaRoster
return LifeSagaAdmissionReason.NotableLife;
}
private static void MarkHardArcContext(long unitId, StoryArcKind kind, string line)
{
LifeSagaSlot slot = Get(unitId);
if (slot == null || slot.AdmissionReason != LifeSagaAdmissionReason.HardArc)
{
return;
}
slot.AdmissionArcKind = kind;
slot.AdmissionContext = CleanChapterLine(line);
_dirty = true;
}
private static float BaseInterest(Actor actor)
{
float score = 1f;
@ -2482,34 +2356,6 @@ public static class LifeSagaRoster
return score;
}
private static bool IsHardStoryKind(StoryArcKind kind) =>
kind == StoryArcKind.CombatDuel
|| kind == StoryArcKind.CombatMass
|| kind == StoryArcKind.WarFront
|| kind == StoryArcKind.Plot
|| kind == StoryArcKind.Love
|| kind == StoryArcKind.Grief;
private static LifeSagaChapterKind ToChapterKind(StoryArcKind kind)
{
switch (kind)
{
case StoryArcKind.CombatDuel:
case StoryArcKind.CombatMass:
return LifeSagaChapterKind.Combat;
case StoryArcKind.WarFront:
return LifeSagaChapterKind.War;
case StoryArcKind.Plot:
return LifeSagaChapterKind.Plot;
case StoryArcKind.Love:
return LifeSagaChapterKind.Love;
case StoryArcKind.Grief:
return LifeSagaChapterKind.Grief;
default:
return LifeSagaChapterKind.Unknown;
}
}
private static string CleanChapterLine(string line)
{
if (string.IsNullOrEmpty(line))
@ -2620,8 +2466,6 @@ public sealed class LifeSagaSlot
public float TouchedAt;
public float LastChapterAt;
public LifeSagaAdmissionReason AdmissionReason;
public StoryArcKind AdmissionArcKind;
public string AdmissionContext = "";
public readonly List<LifeSagaChapter> Chapters =
new List<LifeSagaChapter>(LifeSagaRoster.MaxChapters);
@ -2650,7 +2494,7 @@ public enum LifeSagaAdmissionReason
NotableKills,
NotableRenown,
NotableLife,
HardArc
EmergingStory
}
public enum LifeSagaChapterKind

View file

@ -33,6 +33,7 @@ public static class LifeSagaSession
LifeSagaViewController.Clear();
LifeSagaRail.Clear();
LifeSagaPanel.Clear();
NarrativeStoryStore.Clear();
_epoch++;
object world = SafeWorld();

View file

@ -712,6 +712,8 @@ public static class SagaProse
string c = FirstNonEmpty(slot?.CityKey, CityName(actor));
return string.IsNullOrEmpty(c) ? Sentence("Leads a city") : Sentence("Leads " + c);
}
case LifeSagaAdmissionReason.EmergingStory:
return Sentence("A life in motion");
default:
return "";
}

View file

@ -211,12 +211,6 @@ public static class StoryPlanner
_active.HardHold = hardHold || _active.HardHold;
NoteCastFrom(current, _active);
StampHeadline(_active, current);
// Re-challenge Cap while climax holds: admit heat escalates with arc age so a
// prolonged duel/war/plot on a stranger can still join the rail (Active chrome).
if (IsHardStoryKind(_active.Kind))
{
LifeSagaRoster.StampChapterFromArc(_active, current, Time.unscaledTime);
}
}
private static bool IsCombatKind(StoryArcKind kind) =>
@ -355,7 +349,6 @@ public static class StoryPlanner
_coldHookFired = false;
_coldHookClimaxKey = "";
_coldHookAnchor = "";
CausalHeat.Clear();
// LifeSagaRoster survives short-arc Clear (browse / brief idle-off).
}
@ -752,12 +745,6 @@ public static class StoryPlanner
return;
}
CausalHeat.NoteFeatured(next.SubjectId);
if (next.RelatedId != 0)
{
CausalHeat.NoteFeatured(next.RelatedId, 0.6f);
}
LifeSagaRoster.NoteFeatured(next);
if (IsStoryAssetCandidate(next))
@ -797,7 +784,6 @@ public static class StoryPlanner
}
NoteCastFrom(next, _active);
LifeSagaRoster.StampChapterFromArc(_active, next, now);
}
return;
@ -808,11 +794,6 @@ public static class StoryPlanner
if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
{
BeginOrRefreshArc(next, kind, hardHold, now);
if (_active != null && _active.IsActive)
{
LifeSagaRoster.StampChapterFromArc(_active, next, now);
}
return;
}
@ -1420,7 +1401,6 @@ public static class StoryPlanner
_coldHookFired = false;
_coldHookClimaxKey = "";
_coldHookAnchor = "";
CausalHeat.NoteCast(_active.CastIds);
TrimParkedBoard();
}
@ -2183,7 +2163,6 @@ public static class StoryPlanner
MaxWatch = registered.MaxWatch,
SynthesizeIfMissing = false
});
CausalHeat.NoteFeatured(followId);
return true;
}
@ -2194,139 +2173,9 @@ public static class StoryPlanner
return;
}
// Crisis chapter owns the closer - do not also fire a related soft epilogue.
if (_crisis != null && _crisis.IsLive)
{
if (!_crisis.EpilogueInjected)
{
BeginCrisisEpilogue(now);
}
if (_crisis.EpilogueInjected)
{
arc.Advance(StoryPhase.Done, now);
return;
}
}
Actor follow = null;
Actor related = null;
ActorScratch.Clear();
for (int i = 0; i < arc.CastIds.Count; i++)
{
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
if (a == null)
{
continue;
}
Actor lover = ActorRelation.GetLover(a);
Actor friend = ActorRelation.GetBestFriend(a);
bool hasBond = (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a))
|| (friend != null && friend.isAlive());
if (hasBond)
{
ActorScratch.Add(a);
}
}
Actor preferred = LifeSagaRoster.PreferRosterUnit(ActorScratch);
if (preferred != null)
{
follow = preferred;
related = ActorRelation.GetLover(preferred);
if (related == null || !related.isAlive() || EventFeedUtil.SafeId(related) == EventFeedUtil.SafeId(preferred))
{
related = ActorRelation.GetBestFriend(preferred);
}
}
else
{
for (int i = 0; i < arc.CastIds.Count; i++)
{
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
if (a == null)
{
continue;
}
Actor lover = ActorRelation.GetLover(a);
if (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a))
{
follow = a;
related = lover;
break;
}
Actor friend = ActorRelation.GetBestFriend(a);
if (friend != null && friend.isAlive())
{
follow = a;
related = friend;
break;
}
if (follow == null)
{
follow = a;
}
}
}
if (follow == null)
{
arc.Advance(StoryPhase.Done, now);
return;
}
// Need a distinct related for a meaningful epilogue.
if (related == null)
{
arc.Advance(StoryPhase.Done, now);
return;
}
StoryWeights s = InterestScoringConfig.Story;
string assetId = StoryReason.EpilogueRelated;
string label = StoryReason.AftermathLabel(assetId, follow, related);
string key = "epilogue:" + arc.Kind + ":" + arc.AnchorKey;
long followId = EventFeedUtil.SafeId(follow);
long relatedId = EventFeedUtil.SafeId(related);
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = "Story",
Source = "story_planner",
AssetId = assetId,
Label = label,
FollowUnit = follow,
SubjectId = followId,
RelatedUnit = related,
RelatedId = relatedId,
Position = follow.current_position,
EventStrength = s.epilogueStrength,
VisualConfidence = 0.7f,
Novelty = 1f,
MinWatch = 8f,
MaxWatch = s.epilogueMaxWatch > 0f ? s.epilogueMaxWatch : 14f,
Completion = InterestCompletionKind.FixedDwell,
Resumable = false,
CorrelationKey = "story:" + arc.AnchorKey,
CreatedAt = now,
LastSeenAt = now,
ExpiresAt = now + 40f
};
if (EventFeedUtil.RegisterCandidate(candidate) == null)
{
arc.Advance(StoryPhase.Done, now);
return;
}
arc.Advance(StoryPhase.Epilogue, now);
CausalHeat.NoteFeatured(followId);
// Narrative threads carry consequences forward. The old synthetic related-character
// closer duplicated that responsibility and generated generic unsupported epilogues.
arc.Advance(StoryPhase.Done, now);
}
private static Actor ResolveAftermathFollow(InterestCandidate climax)
@ -3134,10 +2983,6 @@ public static class StoryPlanner
EpilogueInjected = false
};
StampCrisisTheater(_crisis, InterestDirector.CurrentCandidate);
if (followId != 0)
{
CausalHeat.NoteFeatured(followId, 1.1f);
}
}
private static void StampCrisisTheater(CrisisChapter crisis, InterestCandidate tip)
@ -3474,7 +3319,6 @@ public static class StoryPlanner
}
crisis.FollowId = followId;
CausalHeat.NoteFeatured(followId, 1.2f);
return true;
}
}

View file

@ -1122,12 +1122,6 @@ public static class WorldActivityScanner
score += Mathf.Max(5f, InterestScoringConfig.Story.sagaMcWeight * 0.7f);
}
float causal = CausalHeat.Get(id);
if (causal > 0f)
{
score += causal * 2f;
}
return score;
}

View file

@ -10,9 +10,11 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
## What it follows
The camera follows **ranked events** from live feeds (wars, fights, relationships, buildings, statuses, plots, …).
`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending.
Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job).
The camera follows the developing lives and relationships of a small emerging cast. A story
scheduler chooses developments that advance an active character thread, yields to critical world
events, and returns only when the story has new evidence. `EventStrength` still models urgency and
visual value, but it no longer owns continuity. Bounded character/world fill runs when no episode
has a usable development.
While watching, a **character beat caption** shows who they are (Identity), an orange **event sentence** (Beat), and muted Context (story spine or open stake).
Main characters get `Name · Title` when they have a real role; tips may gain a self-explanatory relation clause.
@ -24,7 +26,7 @@ Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps
- `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML
- `scripts/verify-nml.sh` - load verification helper
- `docs/event-reason.md` - events-own-camera + EventReason contract
- `docs/event-reason.md` - evidence, orange-reason, and camera-alignment contract
## Deploy

View file

@ -11,13 +11,15 @@ Story commitment (climax → aftermath → epilogue) is owned by [`StoryPlanner`
Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only.
Discrete / status / WorldLog: `CreatesInterest` = A gate.
`InterestDirector` only ranks A candidates. It does not author inventory.
`InterestDirector` applies liveness and presentability gates to A candidates; the story scheduler
chooses episode developments before bounded ambient/world fallback. Neither authors inventory.
Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
Orange dossier reason = the owning A events `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
`CaptionComposer` may append a presentation-only relation clause (` · their 3rd clash`) and show a separate Identity line (`Name · King of X`).
Identity titles and stake slogans never enter `InterestCandidate.Label` - `identity_filter` / `IsIdentityWhy` stay event-first.
Identity titles and stake slogans never enter `InterestCandidate.Label`; `identity_filter` /
`IsIdentityWhy` remain selection safety gates.
The focused unit must be a tip principal:
- Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff).
- Pair-locked duels: PairOwner/PairPartner only.

View file

@ -43,15 +43,18 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
- Cap stays 10; order follows Effective = Standing + Heat + pin bonuses.
- Prefer and WorldBox favorites are hard pins and fully count toward Cap.
- Admission-role units always compete on the periodic scan.
- Non-admission "nobodies" challenge Cap only via hard-arc chapter stamps (Combat / War / Plot / Love / Grief).
- Hard-arc admit heat escalates with climax age (re-stamped while the arc holds) so a prolonged stranger duel can displace dull Cap slots and light Active chrome.
- Ordinary non-notables challenge the Cap through decaying story potential earned from confirmed
semantic developments.
- Prolonged spectacle alone does not admit a character; coherent relationship, lineage, power,
home, conflict, war, plot, or survival momentum can.
- Soft `NoteFeatured` heats existing MCs only.
- Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash.
## Camera (soft)
- No rail camera lock. Prefer is a toggle soft-favorite for idle scoring.
- Score ladder: crisis ownership > active short-arc ownership > Prefer soft > MC soft > MC cast > CausalHeat.
- Score ladder: critical interrupt > narrative episode > Prefer soft > MC soft > MC cast > bounded
ambient/world context.
- `nearTieEpsilon` (8) picks #1 for ordinary gaps; Prefer/MC/cast may still win within `sagaNearTieEpsilon` (28).
- Soft ranks: Prefer > CrossMC (>1 roster MC) > MC > MC cast (lover/friend/kin/earned rival of a roster MC).
- Tips that touch two or more roster MCs get `sagaCrossMcBonus` (~11); scoring touch inventory matches rail Involved ids.
@ -72,7 +75,8 @@ Always-on AFK chrome (no Dossier/Saga tabs):
| Context | muted | spine if live short-arc; else unresolved stake hitch (omits kill/role/founding under love/rest tips; RoleChange that echoes Identity always omitted) | spine only |
Hard rule: saga titles and stake slogans never enter `InterestCandidate.Label`.
Selection/`identity_filter` stay event-first.
The story scheduler owns selection; `identity_filter` remains a safety gate against presenting
identity text as a development.
Beat other resolves `RelatedId` then `PairPartnerId` (no living-name parse).
Status banners still own/suppress Beat; Identity + Context keep updating.
Outside hover, caption Identity/Beat/Context track camera focus. During Saga hover, the

File diff suppressed because it is too large Load diff

View file

@ -255,12 +255,12 @@ Catalog policy ids live under `event-catalog.json` → `story`
## Files
- `IdleSpectator/Story/StoryPlanner.cs` - short-arc ownership, cold-hook, inject, spine, crisis
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + chapter stamps
- `IdleSpectator/Narrative/StoryScheduler.cs` - narrative episode and camera-story ownership
- `IdleSpectator/Story/StoryPlanner.cs` - visual completion, cold-hook, spine, and crisis lifecycle
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + emerging-story admission
- `IdleSpectator/Story/SagaProse.cs` - player-facing relation/stake/title English
- `IdleSpectator/Story/CaptionComposer.cs` - Identity + Beat + Context
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - rail Prefer + hover
- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
- `IdleSpectator/WatchCaption.cs` - character beat caption + ContextLine

View file

@ -82,9 +82,10 @@ drops = []
bads = []
scenes = []
states = []
first_active_line = -1
last = None
for ln in lines:
for line_index, ln in enumerate(lines):
if harness_noise(ln):
continue
if "IdleSpectator" not in ln:
@ -105,6 +106,8 @@ for ln in lines:
scenes.append(ln.split("Scene end", 1)[1].strip())
elif "[STATE]" in ln:
states.append(ln)
if first_active_line < 0 and re.search(r"idle=True", ln):
first_active_line = line_index
if last is not None and last.get("unit") is None and "unit=" in ln:
last["unit"] = ln.split("unit=", 1)[1].strip()
@ -113,6 +116,7 @@ for d in drops:
drop_kinds[d.split(":", 1)[0].strip()] += 1
focus_true = focus_false = 0
idle_true = idle_false = 0
for s in states:
m = re.search(r"focus=(True|False)", s)
if not m:
@ -121,6 +125,12 @@ for s in states:
focus_false += 1
else:
focus_true += 1
idle = re.search(r"idle=(True|False)", s)
if idle:
if idle.group(1) == "True":
idle_true += 1
else:
idle_false += 1
def bucket(t: str) -> str:
tl = t.lower()
@ -150,6 +160,7 @@ def bucket(t: str) -> str:
return "other"
buckets = Counter(bucket(t) for t in watching)
bucket_sequence = [bucket(t) for t in watching]
scene_reasons = Counter()
for s in scenes:
m = re.match(r"\(([^)]+)\)", s)
@ -186,6 +197,9 @@ print()
print(f"Watching cuts: {len(watching)}")
print(f"Focus BADs: {no_focus_bads} (all BADs={len(bads)})")
print(f"focus True/False: {focus_true}/{focus_false}")
print(f"idle True/False: {idle_true}/{idle_false}")
if first_active_line >= 0:
print(f"first active state: line {first_active_line + 1}/{len(lines)}")
print(f"identity_filter: {identity}")
print(f"unpresentable: {unpresentable}")
print(f"placeholder tips: {placeholder}")
@ -228,23 +242,52 @@ genesis_intent = buckets.get("decision_genesis_intent", 0)
duel_n = buckets.get("combat_duel", 0)
multi_n = buckets.get("combat_multi", 0)
combat_total = duel_n + multi_n + buckets.get("combat_other", 0)
print(f"Variety: duel={duel_n} multi={multi_n} combat_total={combat_total} genesis_intent={genesis_intent}")
combat_share = combat_total / len(watching) if watching else 0.0
max_combat_run = 0
combat_run = 0
for kind in bucket_sequence:
if kind.startswith("combat_"):
combat_run += 1
max_combat_run = max(max_combat_run, combat_run)
else:
combat_run = 0
relationship_n = buckets.get("relationship", 0)
unique_tips = len(set(watching))
print(
f"Variety: duel={duel_n} multi={multi_n} combat_total={combat_total} "
f"combat_share={combat_share:.1%} max_combat_run={max_combat_run} "
f"relationship={relationship_n} unique_tips={unique_tips}/{len(watching)} "
f"genesis_intent={genesis_intent}"
)
if genesis_intent:
print(" NOTE: decision_genesis_intent tips should be 0 after 0.28.28 (meta outcomes only).")
if combat_total >= 4 and multi_n == 0 and duel_n >= 3:
print(" NOTE: combat tips are Duel-heavy; multi scrap may be scarce in this world.")
if first_active_line >= 0 and len(lines) > 0 and first_active_line > len(lines) * 0.25:
print(" NOTE: Idle Spectator activated late; most of this audit window is not a directed soak.")
if len(watching) >= 20 and combat_share > 0.35:
print(" NOTE: combat exceeds the story-first release target of 35%.")
if max_combat_run > 3:
print(" NOTE: more than three consecutive combat cuts; inspect critical bypasses and episode boundaries.")
print()
editorial_pacing_ok = len(watching) < 20 or combat_share <= 0.35
sample_valid = bool(watching) and idle_true > 0 and first_active_line >= 0
ok = (
no_focus_bads == 0
sample_valid
and no_focus_bads == 0
and focus_false == 0
and idle_false == 0
and identity == 0
and placeholder == 0
and len(veg) == 0
and len(mismatches) == 0
and len(sleep_lag) == 0
and genesis_intent == 0
and editorial_pacing_ok
)
if not sample_valid:
print(" NOTE: no valid active spectator sample was captured.")
print("VERDICT:", "PASS" if ok else "REVIEW")
sys.exit(0 if ok else 1)
PY