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 int _statusRefreshesSinceClear;
private static string _lastStatusGainId = ""; private static string _lastStatusGainId = "";
private static string _lastStatusLossId = ""; 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 int _happinessSinceClear;
private static string _lastHappinessId = ""; private static string _lastHappinessId = "";
/// <summary>When non-zero, status counters only track this subject (harness isolation).</summary> /// <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 int StatusRefreshesSinceClear => _statusRefreshesSinceClear;
public static string LastStatusGainId => _lastStatusGainId ?? ""; public static string LastStatusGainId => _lastStatusGainId ?? "";
public static string LastStatusLossId => _lastStatusLossId ?? ""; 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 int HappinessSinceClear => _happinessSinceClear;
public static string LastHappinessId => _lastHappinessId ?? ""; public static string LastHappinessId => _lastHappinessId ?? "";
public static int VerbSuppressedSinceClear => _verbSuppressedSinceClear; public static int VerbSuppressedSinceClear => _verbSuppressedSinceClear;
@ -119,6 +138,8 @@ public static class ActivityLog
_statusRefreshesSinceClear = 0; _statusRefreshesSinceClear = 0;
_lastStatusGainId = ""; _lastStatusGainId = "";
_lastStatusLossId = ""; _lastStatusLossId = "";
StatusGainHitsSinceReset.Clear();
StatusLossHitsSinceReset.Clear();
_statusCounterSubjectId = 0; _statusCounterSubjectId = 0;
} }
@ -791,6 +812,8 @@ public static class ActivityLog
{ {
_statusGainsSinceClear++; _statusGainsSinceClear++;
_lastStatusGainId = id; _lastStatusGainId = id;
StatusGainHitsSinceReset.TryGetValue(id, out int hits);
StatusGainHitsSinceReset[id] = hits + 1;
} }
} }
else else
@ -799,6 +822,8 @@ public static class ActivityLog
{ {
_statusLossesSinceClear++; _statusLossesSinceClear++;
_lastStatusLossId = id; _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) public static void PostfixNewKill(Actor __instance, Actor pDeadUnit)
{ {
LifeSagaMemory.RecordKill(__instance, pDeadUnit); LifeSagaMemory.RecordKill(__instance, pDeadUnit);
NarrativeDevelopmentRouter.Kill(__instance, pDeadUnit, "newKillAction");
Chronicle.NoteKill(__instance, pDeadUnit); Chronicle.NoteKill(__instance, pDeadUnit);
} }
@ -53,6 +54,7 @@ public static class ActorChroniclePatches
} }
LifeSagaMemory.RecordDeath(__instance, __state, pType.ToString()); LifeSagaMemory.RecordDeath(__instance, __state, pType.ToString());
NarrativeDevelopmentRouter.Death(__instance, __state, pType.ToString(), "die");
Chronicle.NoteDeath(__instance, pType, __state); Chronicle.NoteDeath(__instance, pType, __state);
GraveMarkers.AddDeath(__instance); GraveMarkers.AddDeath(__instance);
} }
@ -62,6 +64,7 @@ public static class ActorChroniclePatches
public static void PostfixLovers(Actor __instance, Actor pTarget) public static void PostfixLovers(Actor __instance, Actor pTarget)
{ {
LifeSagaMemory.RecordBondFormed(__instance, pTarget, "becomeLoversWith"); LifeSagaMemory.RecordBondFormed(__instance, pTarget, "becomeLoversWith");
NarrativeDevelopmentRouter.BondFormed(__instance, pTarget, "becomeLoversWith");
Chronicle.NoteLovers(__instance, pTarget); Chronicle.NoteLovers(__instance, pTarget);
} }
@ -75,6 +78,7 @@ public static class ActorChroniclePatches
} }
LifeSagaMemory.RecordFriendFormed(__instance, pActor); LifeSagaMemory.RecordFriendFormed(__instance, pActor);
NarrativeDevelopmentRouter.FriendFormed(__instance, pActor, "setBestFriend");
Chronicle.NoteBestFriends(__instance, pActor); Chronicle.NoteBestFriends(__instance, pActor);
} }
} }

View file

@ -2884,6 +2884,14 @@ public static class AgentHarness
Emit(cmd, ok: true, detail: "variety_cleared"); Emit(cmd, ok: true, detail: "variety_cleared");
break; 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": case "interest_story_clear":
StoryPlanner.Clear(); StoryPlanner.Clear();
_cmdOk++; _cmdOk++;
@ -2898,6 +2906,326 @@ public static class AgentHarness
break; 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": case "caption_status_banner_probe":
{ {
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@ -3238,6 +3566,69 @@ public static class AgentHarness
break; 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": case "interest_bind_related_partner":
{ {
InterestCandidate cur = InterestDirector.CurrentCandidate; InterestCandidate cur = InterestDirector.CurrentCandidate;
@ -3403,39 +3794,6 @@ public static class AgentHarness
break; 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": case "saga_rail_refresh":
{ {
LifeSagaRoster.Tick(Time.unscaledTime); LifeSagaRoster.Tick(Time.unscaledTime);
@ -3594,11 +3952,6 @@ public static class AgentHarness
{ {
kind = LifeSagaFactKind.RoleChange; 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 else if (kindRaw.IndexOf("parent", StringComparison.OrdinalIgnoreCase) >= 0
|| kindRaw.IndexOf("child", StringComparison.OrdinalIgnoreCase) >= 0) || kindRaw.IndexOf("child", StringComparison.OrdinalIgnoreCase) >= 0)
{ {
@ -3660,16 +4013,6 @@ public static class AgentHarness
fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus)); 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) else if (kind == LifeSagaFactKind.ParentChild)
{ {
if (other == null || !other.isAlive() || other == focus) if (other == null || !other.isAlive() || other == focus)
@ -4449,6 +4792,7 @@ public static class AgentHarness
{ {
LifeSagaRoster.Clear(); LifeSagaRoster.Clear();
LifeSagaMemory.Clear(); LifeSagaMemory.Clear();
NarrativeStoryStore.Clear();
} }
string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL"; string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL";
@ -4796,6 +5140,15 @@ public static class AgentHarness
string assetId = cmd.asset; string assetId = cmd.asset;
Actor follow = null; 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)) if (!string.IsNullOrEmpty(assetId))
{ {
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId); follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId);
@ -8815,6 +9168,37 @@ public static class AgentHarness
Emit(cmd, ok, detail: detail); 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) private static void DoAssert(HarnessCommand cmd)
{ {
string expect = (cmd.expect ?? "").Trim().ToLowerInvariant(); string expect = (cmd.expect ?? "").Trim().ToLowerInvariant();
@ -10338,6 +10722,19 @@ public static class AgentHarness
detail = $"tip='{tip}' contains='{needle}'"; detail = $"tip='{tip}' contains='{needle}'";
break; 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 "enabled":
case "setting_enabled": case "setting_enabled":
{ {
@ -12308,10 +12705,14 @@ public static class AgentHarness
case "activity_status_gain_count": case "activity_status_gain_count":
{ {
int want = ParseCountExpect(cmd, defaultValue: 1); 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(); string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
pass = mode == "min" ? got >= want : got == want; 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; break;
} }
case "activity_status_loss_count": case "activity_status_loss_count":
@ -12365,15 +12766,28 @@ public static class AgentHarness
string want = cmd.value ?? ""; string want = cmd.value ?? "";
string got = ""; string got = "";
IReadOnlyList<ActivityEntry> list = ActivityLog.LatestForSubject(id, 1); bool found = false;
if (list != null && list.Count > 0 && list[0] != null) 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) pass = !string.IsNullOrEmpty(want)
&& got.Equals(want, StringComparison.OrdinalIgnoreCase); && found;
detail = $"got='{got}' want='{want}'"; detail = $"latest='{got}' found={found} want='{want}'";
break; break;
} }
case "activity_prose_showcase": case "activity_prose_showcase":

View file

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

View file

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

View file

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

View file

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

View file

@ -156,6 +156,30 @@ internal static class HarnessScenarios
return StoryBoardParkResume(); return StoryBoardParkResume();
case "story_cross_arc_correlation": case "story_cross_arc_correlation":
return StoryCrossArcCorrelation(); 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": case "story_arc_preempt_resume":
return StoryArcPreemptResume(); return StoryArcPreemptResume();
case "story_ledger_revisit": case "story_ledger_revisit":
@ -183,12 +207,12 @@ internal static class HarnessScenarios
return SagaCrossMcKill(); return SagaCrossMcKill();
case "saga_rival_not_single_kill": case "saga_rival_not_single_kill":
return SagaRivalNotSingleKill(); return SagaRivalNotSingleKill();
case "saga_legacy_combat_summary":
return SagaLegacyCombatSummary();
case "saga_rival_rematch": case "saga_rival_rematch":
return SagaRivalRematch(); return SagaRivalRematch();
case "saga_prefer_cuts_ambient": case "saga_prefer_cuts_ambient":
return SagaPreferCutsAmbient(); return SagaPreferCutsAmbient();
case "saga_hard_arc_escalates":
return SagaHardArcEscalates();
case "saga_overview_has_mc": case "saga_overview_has_mc":
return SagaOverviewHasMc(); return SagaOverviewHasMc();
case "saga_snapshot_popover": case "saga_snapshot_popover":
@ -420,6 +444,14 @@ internal static class HarnessScenarios
Nested("reg_story_lore_pause", "story_lore_pause_keeps"), Nested("reg_story_lore_pause", "story_lore_pause_keeps"),
Nested("reg_story_board_park", "story_board_park_resume"), Nested("reg_story_board_park", "story_board_park_resume"),
Nested("reg_story_cross_arc", "story_cross_arc_correlation"), 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_caption_status_banner", "caption_status_banner_live"),
Nested("reg_saga_relation_revision", "saga_relation_cache_revision"), Nested("reg_saga_relation_revision", "saga_relation_cache_revision"),
Nested("reg_saga_cast_dedupe", "saga_cast_dedupe_scoped"), 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_kill", "saga_rival_not_single_kill"),
Nested("reg_saga_rival_rematch", "saga_rival_rematch"), Nested("reg_saga_rival_rematch", "saga_rival_rematch"),
Nested("reg_saga_ambient_cut", "saga_prefer_cuts_ambient"), 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_overview", "saga_overview_has_mc"),
Nested("reg_saga_snapshot", "saga_adaptive_dossier"), Nested("reg_saga_snapshot", "saga_adaptive_dossier"),
Nested("reg_saga_memory", "saga_memory_survives"), 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_cast_kin", "saga_cast_kin_non_pack_lens"),
Nested("reg_saga_stake_role", "saga_stake_from_role_live"), Nested("reg_saga_stake_role", "saga_stake_from_role_live"),
Nested("reg_saga_legacy_quality", "saga_legacy_quality"), 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_session_reset", "saga_session_reset"),
Nested("reg_saga_hover_pause", "saga_hover_read_pause"), Nested("reg_saga_hover_pause", "saga_hover_read_pause"),
Nested("reg_saga_diversity", "saga_roster_diversity"), Nested("reg_saga_diversity", "saga_roster_diversity"),
@ -633,14 +665,14 @@ internal static class HarnessScenarios
// Gain path (confused is widely applicable across assets) // Gain path (confused is widely applicable across assets)
Step("st12", "status_apply", value: "confused"), 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("st14", "assert", expect: "activity_log_contains", value: "confused"),
Step("st15", "assert", expect: "activity_status_task_id", value: "status_gain:confused"), Step("st15", "assert", expect: "activity_status_task_id", value: "status_gain:confused"),
Step("st16", "assert", expect: "activity_status_active", value: "confused", label: "true"), Step("st16", "assert", expect: "activity_status_active", value: "confused", label: "true"),
// Refresh must not create another gain line // Refresh must not create another gain line
Step("st17", "status_apply", value: "confused"), 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"), Step("st19", "assert", expect: "activity_status_refresh_count", value: "1", label: "min"),
// Second distinct gain (min: world may emit an extra status mid-suite) // 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("st28c", "activity_status_reset"),
Step("st28d", "fast_timing", value: "true"), Step("st28d", "fast_timing", value: "true"),
Step("st29", "status_apply", value: "surprised", label: "0.25"), 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("st31", "wait", wait: 1.25f),
Step("st32", "assert", expect: "activity_status_active", value: "surprised", label: "false"), Step("st32", "assert", expect: "activity_status_active", value: "surprised", label: "false"),
Step("st33", "assert", expect: "activity_status_loss_count", value: "1", label: "min"), 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> /// <summary>
/// Parenthood cools world-wide for a while; a second villager cannot chain the same tip. /// Parenthood cools world-wide for a while; a second villager cannot chain the same tip.
/// </summary> /// </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> /// <summary>Threshold living rematches earn RivalEarned.</summary>
private static List<HarnessCommand> SagaRivalRematch() 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> /// <summary>
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters, /// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
/// no planner jargon / trait descs / Chronicle dumps. /// 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() private static List<HarnessCommand> SagaStakeFromRoleLive()
{ {
return new List<HarnessCommand> return new List<HarnessCommand>
@ -3890,8 +4020,6 @@ internal static class HarnessScenarios
Step("srl8", "pick_unit", asset: "human"), Step("srl8", "pick_unit", asset: "human"),
Step("srl9", "focus", asset: "human"), Step("srl9", "focus", asset: "human"),
Step("srl10", "saga_force_admit_focus"), 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("srl13", "saga_role_rise_focus", value: "become_clan_chief"),
Step("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"), Step("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"),
Step("srl15", "assert", expect: "saga_stake_contains", value: "Clan Chief"), Step("srl15", "assert", expect: "saga_stake_contains", value: "Clan Chief"),
@ -4177,7 +4305,7 @@ internal static class HarnessScenarios
} }
/// <summary> /// <summary>
/// Extended roles: singleton dragon overview + hard-arc nobody admit path. /// Extended roles: singleton dragon overview + explicit ordinary-character admission.
/// </summary> /// </summary>
private static List<HarnessCommand> SagaAdmitRoles() private static List<HarnessCommand> SagaAdmitRoles()
{ {
@ -4199,10 +4327,10 @@ internal static class HarnessScenarios
Step("sar12", "assert", expect: "saga_has_focus"), Step("sar12", "assert", expect: "saga_has_focus"),
Step("sar13", "saga_rail_refresh"), Step("sar13", "saga_rail_refresh"),
Step("sar14", "assert", expect: "saga_role_tip", value: "Lone|Last of their kind|Dragon"), 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("sar20", "pick_unit", asset: "human"),
Step("sar21", "focus", 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("sar23", "assert", expect: "saga_has_focus"),
Step("sar24", "assert", expect: "saga_roster_count", value: "2", label: "min"), Step("sar24", "assert", expect: "saga_roster_count", value: "2", label: "min"),
Step("sar90", "fast_timing", value: "false"), 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() private static List<HarnessCommand> SagaReplaceWeaker()
{ {
return new List<HarnessCommand> return new List<HarnessCommand>
@ -4258,10 +4386,10 @@ internal static class HarnessScenarios
Step("srw46", "saga_force_admit_focus"), Step("srw46", "saga_force_admit_focus"),
Step("srw50", "assert", expect: "saga_roster_count", value: "10"), Step("srw50", "assert", expect: "saga_roster_count", value: "10"),
Step("srw51", "saga_weaken_nonprefer"), 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("srw60", "pick_unit", asset: "human", value: "other"),
Step("srw61", "focus", asset: "human"), 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("srw63", "assert", expect: "saga_has_focus"),
Step("srw64", "assert", expect: "saga_roster_count", value: "10"), Step("srw64", "assert", expect: "saga_roster_count", value: "10"),
// Prefer pin still present; challenger focus itself is not Prefer. // 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("wl10", "trigger_interest", asset: "auto", label: "Story: kingdom_new", tier: "Story"),
Step("wl11", "age_current", wait: 1.2f), Step("wl11", "age_current", wait: 1.2f),
Step("wl12", "director_run", wait: 1.2f), Step("wl12", "director_run", wait: 1.2f),
Step("wl13", "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", 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("wl20", "trigger_interest", asset: "auto", label: "Epic: diplomacy_war_started", tier: "Epic"),
Step("wl21", "age_current", wait: 1.2f), 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("act4", "set_setting", expect: "chronicle_enabled", value: "true"),
Step("act5", "chronicle_clear"), Step("act5", "chronicle_clear"),
Step("act6", "activity_clear"), Step("act6", "activity_clear"),
Step("act7", "pick_unit", asset: "auto"), Step("act7", "spawn", asset: "human"),
Step("act8", "spectator", value: "off"), Step("act8", "spectator", value: "off"),
Step("act9", "spectator", value: "on"), Step("act9", "spectator", value: "on"),
Step("act10", "focus", asset: "auto"), Step("act10", "focus", asset: "auto"),

View file

@ -53,6 +53,10 @@ public sealed class InterestCandidate
public float Novelty = 1f; public float Novelty = 1f;
public float VisualConfidence = 0.5f; public float VisualConfidence = 0.5f;
public float TotalScore; 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> /// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
public int ParticipantCount; public int ParticipantCount;
/// <summary>Sticky multi-actor scoreboard (combat first; war/plot/family later).</summary> /// <summary>Sticky multi-actor scoreboard (combat first; war/plot/family later).</summary>
@ -316,6 +320,9 @@ public sealed class InterestCandidate
Novelty = Novelty, Novelty = Novelty,
VisualConfidence = VisualConfidence, VisualConfidence = VisualConfidence,
TotalScore = TotalScore, TotalScore = TotalScore,
NarrativeFunction = NarrativeFunction,
HasNarrativeFunction = HasNarrativeFunction,
NarrativeFunctionSource = NarrativeFunctionSource,
ParticipantCount = ParticipantCount, ParticipantCount = ParticipantCount,
NotableParticipantCount = NotableParticipantCount, NotableParticipantCount = NotableParticipantCount,
Position = Position, Position = Position,

View file

@ -19,6 +19,21 @@ public static partial class InterestDirector
return false; 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) if (_current == null)
{ {
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false); return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
@ -88,6 +103,29 @@ public static partial class InterestDirector
return false; 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; bool inGrace = InQuietGrace;
if (inGrace) if (inGrace)
{ {

View file

@ -654,6 +654,7 @@ public static partial class InterestDirector
StoryPlanner.Tick(now); StoryPlanner.Tick(now);
LifeSagaRoster.Tick(now); LifeSagaRoster.Tick(now);
LifeSagaSession.EnsureBound(); LifeSagaSession.EnsureBound();
StoryScheduler.Tick(now);
IdleHitchProbe.Mark("story_done"); IdleHitchProbe.Mark("story_done");
if (ReadPauseActive) if (ReadPauseActive)
@ -1950,6 +1951,7 @@ public static partial class InterestDirector
_current = null; _current = null;
_harnessCombatForcedCold = false; _harnessCombatForcedCold = false;
_inactiveSince = -999f; _inactiveSince = -999f;
CameraDirector.ClearWatchReason();
EnsureIdleFocus(now); EnsureIdleFocus(now);
} }
@ -2045,6 +2047,18 @@ public static partial class InterestDirector
return null; 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. // EnrichTopK runs inside InterestVariety.Pick once - avoid a second top-K meta walk here.
return InterestVariety.Pick(PendingScratch, _current); return InterestVariety.Pick(PendingScratch, _current);
} }
@ -2718,6 +2732,7 @@ public static partial class InterestDirector
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt) private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
{ {
StoryScheduler.NoteActualSelection(next);
if (next == null) if (next == null)
{ {
return; return;

View file

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

View file

@ -16,6 +16,7 @@ public static class InterestVariety
public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds; public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds;
public static float FixedDwellBeatCooldownSeconds => public static float FixedDwellBeatCooldownSeconds =>
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds); Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f);
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32); private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
private static readonly List<string> RecentArcs = new List<string>(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 chapters cool ledger heat so the same villagers do not monopolize.
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed). // Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
} }
/// <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> /// <summary>
/// Score to subtract when this candidate matches the last shown variety arc. /// Score to subtract when this candidate matches the last shown variety arc.
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak. /// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak.
@ -637,6 +711,12 @@ public static class InterestVariety
continue; continue;
} }
if (IsEditorialReplayCooled(c, now))
{
InterestDropLog.Record("editorial_replay", c.Label ?? c.Key);
continue;
}
if (c.LeadKind == InterestLeadKind.CharacterLed) if (c.LeadKind == InterestLeadKind.CharacterLed)
{ {
CharPool.Add(c); 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> /// <summary>
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class. /// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
/// </summary> /// </summary>

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; model.ReasonRelatedId = reasonRelatedId;
string beat = presentableBeat ?? ""; 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) if (statusBannerOwnsBeat)
{ {
// Banner owns orange row; composer still fills Identity + Context. // Banner owns orange row; composer still fills Identity + Context.
@ -87,7 +105,9 @@ public static class CaptionComposer
} }
else 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) if (!isReentry)
{ {
model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine); model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine);
@ -101,6 +121,10 @@ public static class CaptionComposer
model.IdentityLine, model.IdentityLine,
spineLabel, spineLabel,
isReentry && !statusBannerOwnsBeat); isReentry && !statusBannerOwnsBeat);
if (!string.IsNullOrEmpty(narrativeContext))
{
model.ContextLine = narrativeContext;
}
model.Fingerprint = model.Fingerprint =
focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine 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.###"), + Time.unscaledTime.ToString("0.###"),
strength: 70f, strength: 70f,
provenance: provenance); provenance: provenance);
NarrativeDevelopmentRouter.BondFormed(a, b, provenance);
} }
public static void RecordBondBroken(Actor survivor, Actor former, string provenance = "clear_lover") public static void RecordBondBroken(Actor survivor, Actor former, string provenance = "clear_lover")
@ -342,6 +343,7 @@ public static class LifeSagaMemory
strength: 75f, strength: 75f,
provenance: provenance, provenance: provenance,
resolved: true); resolved: true);
NarrativeDevelopmentRouter.BondBroken(survivor, former, provenance);
} }
public static void RecordParentChild(Actor parent, Actor child, string provenance = "add_child") public static void RecordParentChild(Actor parent, Actor child, string provenance = "add_child")
@ -360,6 +362,7 @@ public static class LifeSagaMemory
correlationKey: "parent:" + parentId + ":" + childId, correlationKey: "parent:" + parentId + ":" + childId,
strength: 65f, strength: 65f,
provenance: provenance); provenance: provenance);
NarrativeDevelopmentRouter.ChildBorn(parent, child, provenance);
} }
public static void RecordFriendFormed(Actor a, Actor b, string provenance = "set_best_friend") 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)), correlationKey: "friend:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)),
strength: 55f, strength: 55f,
provenance: provenance); provenance: provenance);
NarrativeDevelopmentRouter.FriendFormed(a, b, provenance);
} }
public static void RecordKill(Actor killer, Actor victim, string provenance = "newKillAction") public static void RecordKill(Actor killer, Actor victim, string provenance = "newKillAction")
@ -395,6 +399,7 @@ public static class LifeSagaMemory
+ Time.unscaledTime.ToString("0.###"), + Time.unscaledTime.ToString("0.###"),
strength: 80f, strength: 80f,
provenance: provenance); provenance: provenance);
NarrativeDevelopmentRouter.Kill(killer, victim, provenance);
// Kill/death must not increment living rematch counters (single kill ≠ rival). // Kill/death must not increment living rematch counters (single kill ≠ rival).
TryMarkKinKillRival(killer, victim); TryMarkKinKillRival(killer, victim);
@ -422,6 +427,7 @@ public static class LifeSagaMemory
provenance: provenance, provenance: provenance,
resolved: true, resolved: true,
note: attackType ?? ""); note: attackType ?? "");
NarrativeDevelopmentRouter.Death(victim, killer, attackType, provenance);
if (killer != null) if (killer != null)
{ {
@ -644,6 +650,7 @@ public static class LifeSagaMemory
warKey: warKey, warKey: warKey,
resolved: ended, resolved: ended,
note: note ?? ""); note: note ?? "");
NarrativeDevelopmentRouter.War(subject, other, warKey, ended, provenance);
} }
/// <summary> /// <summary>
@ -738,6 +745,7 @@ public static class LifeSagaMemory
plotKey: plotKey, plotKey: plotKey,
note: display, note: display,
resolved: finished); resolved: finished);
NarrativeDevelopmentRouter.Plot(subject, other, plotKey, finished, provenance);
} }
/// <summary> /// <summary>
@ -977,6 +985,7 @@ public static class LifeSagaMemory
familyKey: metaKind == "family" ? metaKey : "", familyKey: metaKind == "family" ? metaKey : "",
clanKey: metaKind == "clan" ? metaKey : "", clanKey: metaKind == "clan" ? metaKey : "",
note: metaKind ?? ""); note: metaKind ?? "");
NarrativeDevelopmentRouter.Founding(subject, metaKind, metaKey, provenance);
} }
public static void RecordRole(Actor subject, string roleId, string provenance = "happiness") public static void RecordRole(Actor subject, string roleId, string provenance = "happiness")
@ -994,6 +1003,7 @@ public static class LifeSagaMemory
strength: 68f, strength: 68f,
provenance: provenance, provenance: provenance,
note: roleId); note: roleId);
NarrativeDevelopmentRouter.Role(subject, roleId, true, provenance);
} }
public static void RecordHome(Actor subject, bool gained, string provenance = "happiness") public static void RecordHome(Actor subject, bool gained, string provenance = "happiness")
@ -1013,33 +1023,6 @@ public static class LifeSagaMemory
provenance: provenance); 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) public static LifeSagaIdentity Snapshot(Actor actor)
{ {
if (actor == null) if (actor == null)
@ -1786,13 +1769,11 @@ public static class LifeSagaMemory
{ {
if (a != 0) if (a != 0)
{ {
CausalHeat.NoteFeatured(a, 1.15f);
LifeSagaRoster.NoteFeaturedUnit(a, 1.2f); LifeSagaRoster.NoteFeaturedUnit(a, 1.2f);
} }
if (b != 0 && b != a) if (b != 0 && b != a)
{ {
CausalHeat.NoteFeatured(b, 1.15f);
LifeSagaRoster.NoteFeaturedUnit(b, 1.2f); LifeSagaRoster.NoteFeaturedUnit(b, 1.2f);
} }
} }
@ -1859,26 +1840,6 @@ public static class LifeSagaMemory
return false; 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) private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind)
{ {
switch (kind) switch (kind)

View file

@ -650,14 +650,103 @@ public static class LifeSagaPresentation
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8); List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8);
var seenLines = new HashSet<string>(StringComparer.OrdinalIgnoreCase); 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++) for (int i = 0; i < facts.Count; i++)
{ {
if (model.Legacy.Count >= factLimit)
{
break;
}
LifeSagaFact f = facts[i]; LifeSagaFact f = facts[i];
if (f == null) if (f == null)
{ {
continue; 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. // Stake already owns the strongest unresolved beat - do not repeat it in Legacy.
if (stake != null if (stake != null
&& ((!string.IsNullOrEmpty(stake.Key) && ((!string.IsNullOrEmpty(stake.Key)
@ -725,6 +814,7 @@ public static class LifeSagaPresentation
} }
if (!wroteParentChild if (!wroteParentChild
&& !narrativeOwnsFamily
&& parentedCount >= 2 && parentedCount >= 2
&& model.Legacy.Count < 4) && 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> /// <summary>
/// Living Cast already presents lover/friend/kin - do not echo those names as Legacy crumbs. /// 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. /// 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 Dictionary<string, int> SpeciesCountCache = new Dictionary<string, int>();
private static readonly HashSet<long> McOrbitCache = new HashSet<long>(); private static readonly HashSet<long> McOrbitCache = new HashSet<long>();
private static readonly List<long> TouchScratch = new List<long>(8); 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 _nextScanAt;
private static float _nextNarrativeAdmissionAt;
private static float _roleCacheAt = -999f; private static float _roleCacheAt = -999f;
private static bool _dirty = true; private static bool _dirty = true;
private static WorldScanPhase _scanPhase = WorldScanPhase.None; private static WorldScanPhase _scanPhase = WorldScanPhase.None;
@ -77,7 +80,9 @@ public static class LifeSagaRoster
PlotAuthorCache.Clear(); PlotAuthorCache.Clear();
SpeciesCountCache.Clear(); SpeciesCountCache.Clear();
McOrbitCache.Clear(); McOrbitCache.Clear();
NarrativeChallengerScratch.Clear();
_nextScanAt = 0f; _nextScanAt = 0f;
_nextNarrativeAdmissionAt = 0f;
_roleCacheAt = -999f; _roleCacheAt = -999f;
_scanPhase = WorldScanPhase.None; _scanPhase = WorldScanPhase.None;
_scanIndex = 0; _scanIndex = 0;
@ -699,156 +704,16 @@ public static class LifeSagaRoster
s.TouchedAt = now; s.TouchedAt = now;
s.Heat = Mathf.Min(HeatCap, s.Heat + 1.5f); s.Heat = Mathf.Min(HeatCap, s.Heat + 1.5f);
_dirty = true; _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) public static void Tick(float now)
{ {
if (now >= _nextNarrativeAdmissionAt)
{
_nextNarrativeAdmissionAt = now + 1f;
ConsiderEmergingStories(now);
}
if (_scanPhase != WorldScanPhase.None) if (_scanPhase != WorldScanPhase.None)
{ {
ContinueAmortizedWorldScan(now); ContinueAmortizedWorldScan(now);
@ -1086,9 +951,13 @@ public static class LifeSagaRoster
/// <summary> /// <summary>
/// Challenge Cap with a living unit. Admission roles always may compete; /// 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> /// </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) if (unitId == 0)
{ {
@ -1117,7 +986,7 @@ public static class LifeSagaRoster
EnsureRoleCache(now, force: false); EnsureRoleCache(now, force: false);
bool admission = IsAdmissionCandidate(actor); bool admission = IsAdmissionCandidate(actor);
if (!admission && !hardArcAdmit) if (!admission && !emergingStoryAdmit)
{ {
return false; return false;
} }
@ -1125,7 +994,8 @@ public static class LifeSagaRoster
LifeSagaSlot neu = BuildSlot( LifeSagaSlot neu = BuildSlot(
actor, actor,
now, now,
hardArcAdmit && !admission ? LifeSagaAdmissionReason.HardArc : LifeSagaAdmissionReason.None); emergingStoryAdmit && !admission ? LifeSagaAdmissionReason.EmergingStory
: LifeSagaAdmissionReason.None);
neu.Heat = Mathf.Min(HeatCap, Mathf.Max(0f, heat)); neu.Heat = Mathf.Min(HeatCap, Mathf.Max(0f, heat));
Scratch.Clear(); Scratch.Clear();
PreviousIds.Clear(); PreviousIds.Clear();
@ -1150,6 +1020,22 @@ public static class LifeSagaRoster
return IsMc(unitId); 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) private static void NoteUnit(long unitId, float heat, float now)
{ {
LifeSagaSlot s = Get(unitId); 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) if (s.GameFavorite)
{ {
score += 50f; score += 50f;
@ -2400,19 +2287,6 @@ public static class LifeSagaRoster
return LifeSagaAdmissionReason.NotableLife; 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) private static float BaseInterest(Actor actor)
{ {
float score = 1f; float score = 1f;
@ -2482,34 +2356,6 @@ public static class LifeSagaRoster
return score; 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) private static string CleanChapterLine(string line)
{ {
if (string.IsNullOrEmpty(line)) if (string.IsNullOrEmpty(line))
@ -2620,8 +2466,6 @@ public sealed class LifeSagaSlot
public float TouchedAt; public float TouchedAt;
public float LastChapterAt; public float LastChapterAt;
public LifeSagaAdmissionReason AdmissionReason; public LifeSagaAdmissionReason AdmissionReason;
public StoryArcKind AdmissionArcKind;
public string AdmissionContext = "";
public readonly List<LifeSagaChapter> Chapters = public readonly List<LifeSagaChapter> Chapters =
new List<LifeSagaChapter>(LifeSagaRoster.MaxChapters); new List<LifeSagaChapter>(LifeSagaRoster.MaxChapters);
@ -2650,7 +2494,7 @@ public enum LifeSagaAdmissionReason
NotableKills, NotableKills,
NotableRenown, NotableRenown,
NotableLife, NotableLife,
HardArc EmergingStory
} }
public enum LifeSagaChapterKind public enum LifeSagaChapterKind

View file

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

View file

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

View file

@ -211,12 +211,6 @@ public static class StoryPlanner
_active.HardHold = hardHold || _active.HardHold; _active.HardHold = hardHold || _active.HardHold;
NoteCastFrom(current, _active); NoteCastFrom(current, _active);
StampHeadline(_active, current); 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) => private static bool IsCombatKind(StoryArcKind kind) =>
@ -355,7 +349,6 @@ public static class StoryPlanner
_coldHookFired = false; _coldHookFired = false;
_coldHookClimaxKey = ""; _coldHookClimaxKey = "";
_coldHookAnchor = ""; _coldHookAnchor = "";
CausalHeat.Clear();
// LifeSagaRoster survives short-arc Clear (browse / brief idle-off). // LifeSagaRoster survives short-arc Clear (browse / brief idle-off).
} }
@ -752,12 +745,6 @@ public static class StoryPlanner
return; return;
} }
CausalHeat.NoteFeatured(next.SubjectId);
if (next.RelatedId != 0)
{
CausalHeat.NoteFeatured(next.RelatedId, 0.6f);
}
LifeSagaRoster.NoteFeatured(next); LifeSagaRoster.NoteFeatured(next);
if (IsStoryAssetCandidate(next)) if (IsStoryAssetCandidate(next))
@ -797,7 +784,6 @@ public static class StoryPlanner
} }
NoteCastFrom(next, _active); NoteCastFrom(next, _active);
LifeSagaRoster.StampChapterFromArc(_active, next, now);
} }
return; return;
@ -808,11 +794,6 @@ public static class StoryPlanner
if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold)) if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
{ {
BeginOrRefreshArc(next, kind, hardHold, now); BeginOrRefreshArc(next, kind, hardHold, now);
if (_active != null && _active.IsActive)
{
LifeSagaRoster.StampChapterFromArc(_active, next, now);
}
return; return;
} }
@ -1420,7 +1401,6 @@ public static class StoryPlanner
_coldHookFired = false; _coldHookFired = false;
_coldHookClimaxKey = ""; _coldHookClimaxKey = "";
_coldHookAnchor = ""; _coldHookAnchor = "";
CausalHeat.NoteCast(_active.CastIds);
TrimParkedBoard(); TrimParkedBoard();
} }
@ -2183,7 +2163,6 @@ public static class StoryPlanner
MaxWatch = registered.MaxWatch, MaxWatch = registered.MaxWatch,
SynthesizeIfMissing = false SynthesizeIfMissing = false
}); });
CausalHeat.NoteFeatured(followId);
return true; return true;
} }
@ -2194,139 +2173,9 @@ public static class StoryPlanner
return; return;
} }
// Crisis chapter owns the closer - do not also fire a related soft epilogue. // Narrative threads carry consequences forward. The old synthetic related-character
if (_crisis != null && _crisis.IsLive) // closer duplicated that responsibility and generated generic unsupported epilogues.
{ arc.Advance(StoryPhase.Done, now);
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);
} }
private static Actor ResolveAftermathFollow(InterestCandidate climax) private static Actor ResolveAftermathFollow(InterestCandidate climax)
@ -3134,10 +2983,6 @@ public static class StoryPlanner
EpilogueInjected = false EpilogueInjected = false
}; };
StampCrisisTheater(_crisis, InterestDirector.CurrentCandidate); StampCrisisTheater(_crisis, InterestDirector.CurrentCandidate);
if (followId != 0)
{
CausalHeat.NoteFeatured(followId, 1.1f);
}
} }
private static void StampCrisisTheater(CrisisChapter crisis, InterestCandidate tip) private static void StampCrisisTheater(CrisisChapter crisis, InterestCandidate tip)
@ -3474,7 +3319,6 @@ public static class StoryPlanner
} }
crisis.FollowId = followId; crisis.FollowId = followId;
CausalHeat.NoteFeatured(followId, 1.2f);
return true; return true;
} }
} }

View file

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

View file

@ -10,9 +10,11 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
## What it follows ## What it follows
The camera follows **ranked events** from live feeds (wars, fights, relationships, buildings, statuses, plots, …). The camera follows the developing lives and relationships of a small emerging cast. A story
`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending. scheduler chooses developments that advance an active character thread, yields to critical world
Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job). 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). 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. 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 - `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML
- `scripts/verify-nml.sh` - load verification helper - `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 ## Deploy

View file

@ -11,13 +11,15 @@ Story commitment (climax → aftermath → epilogue) is owned by [`StoryPlanner`
Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only. Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only.
Discrete / status / WorldLog: `CreatesInterest` = A gate. 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). 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`). 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`). `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: The focused unit must be a tip principal:
- Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff). - Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff).
- Pair-locked duels: PairOwner/PairPartner only. - 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. - Cap stays 10; order follows Effective = Standing + Heat + pin bonuses.
- Prefer and WorldBox favorites are hard pins and fully count toward Cap. - Prefer and WorldBox favorites are hard pins and fully count toward Cap.
- Admission-role units always compete on the periodic scan. - 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). - Ordinary non-notables challenge the Cap through decaying story potential earned from confirmed
- 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. 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. - Soft `NoteFeatured` heats existing MCs only.
- Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash. - Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash.
## Camera (soft) ## Camera (soft)
- No rail camera lock. Prefer is a toggle soft-favorite for idle scoring. - 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). - `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). - 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. - 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 | | 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`. 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). Beat other resolves `RelatedId` then `PairPartnerId` (no living-name parse).
Status banners still own/suppress Beat; Identity + Context keep updating. Status banners still own/suppress Beat; Identity + Context keep updating.
Outside hover, caption Identity/Beat/Context track camera focus. During Saga hover, the 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 ## Files
- `IdleSpectator/Story/StoryPlanner.cs` - short-arc ownership, cold-hook, inject, spine, crisis - `IdleSpectator/Narrative/StoryScheduler.cs` - narrative episode and camera-story ownership
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + chapter stamps - `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/SagaProse.cs` - player-facing relation/stake/title English
- `IdleSpectator/Story/CaptionComposer.cs` - Identity + Beat + Context - `IdleSpectator/Story/CaptionComposer.cs` - Identity + Beat + Context
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - rail Prefer + hover - `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - rail Prefer + hover
- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state - `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels - `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
- `IdleSpectator/WatchCaption.cs` - character beat caption + ContextLine - `IdleSpectator/WatchCaption.cs` - character beat caption + ContextLine

View file

@ -82,9 +82,10 @@ drops = []
bads = [] bads = []
scenes = [] scenes = []
states = [] states = []
first_active_line = -1
last = None last = None
for ln in lines: for line_index, ln in enumerate(lines):
if harness_noise(ln): if harness_noise(ln):
continue continue
if "IdleSpectator" not in ln: if "IdleSpectator" not in ln:
@ -105,6 +106,8 @@ for ln in lines:
scenes.append(ln.split("Scene end", 1)[1].strip()) scenes.append(ln.split("Scene end", 1)[1].strip())
elif "[STATE]" in ln: elif "[STATE]" in ln:
states.append(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: if last is not None and last.get("unit") is None and "unit=" in ln:
last["unit"] = ln.split("unit=", 1)[1].strip() last["unit"] = ln.split("unit=", 1)[1].strip()
@ -113,6 +116,7 @@ for d in drops:
drop_kinds[d.split(":", 1)[0].strip()] += 1 drop_kinds[d.split(":", 1)[0].strip()] += 1
focus_true = focus_false = 0 focus_true = focus_false = 0
idle_true = idle_false = 0
for s in states: for s in states:
m = re.search(r"focus=(True|False)", s) m = re.search(r"focus=(True|False)", s)
if not m: if not m:
@ -121,6 +125,12 @@ for s in states:
focus_false += 1 focus_false += 1
else: else:
focus_true += 1 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: def bucket(t: str) -> str:
tl = t.lower() tl = t.lower()
@ -150,6 +160,7 @@ def bucket(t: str) -> str:
return "other" return "other"
buckets = Counter(bucket(t) for t in watching) buckets = Counter(bucket(t) for t in watching)
bucket_sequence = [bucket(t) for t in watching]
scene_reasons = Counter() scene_reasons = Counter()
for s in scenes: for s in scenes:
m = re.match(r"\(([^)]+)\)", s) m = re.match(r"\(([^)]+)\)", s)
@ -186,6 +197,9 @@ print()
print(f"Watching cuts: {len(watching)}") print(f"Watching cuts: {len(watching)}")
print(f"Focus BADs: {no_focus_bads} (all BADs={len(bads)})") print(f"Focus BADs: {no_focus_bads} (all BADs={len(bads)})")
print(f"focus True/False: {focus_true}/{focus_false}") 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"identity_filter: {identity}")
print(f"unpresentable: {unpresentable}") print(f"unpresentable: {unpresentable}")
print(f"placeholder tips: {placeholder}") print(f"placeholder tips: {placeholder}")
@ -228,23 +242,52 @@ genesis_intent = buckets.get("decision_genesis_intent", 0)
duel_n = buckets.get("combat_duel", 0) duel_n = buckets.get("combat_duel", 0)
multi_n = buckets.get("combat_multi", 0) multi_n = buckets.get("combat_multi", 0)
combat_total = duel_n + multi_n + buckets.get("combat_other", 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: if genesis_intent:
print(" NOTE: decision_genesis_intent tips should be 0 after 0.28.28 (meta outcomes only).") 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: 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.") 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() 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 = ( ok = (
no_focus_bads == 0 sample_valid
and no_focus_bads == 0
and focus_false == 0 and focus_false == 0
and idle_false == 0
and identity == 0 and identity == 0
and placeholder == 0 and placeholder == 0
and len(veg) == 0 and len(veg) == 0
and len(mismatches) == 0 and len(mismatches) == 0
and len(sleep_lag) == 0 and len(sleep_lag) == 0
and genesis_intent == 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") print("VERDICT:", "PASS" if ok else "REVIEW")
sys.exit(0 if ok else 1) sys.exit(0 if ok else 1)
PY PY