diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index e0d6919..a5a9e9c 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -2902,6 +2902,27 @@ public static class AgentHarness
break;
}
+ case "interest_mark_sticky_cold":
+ case "story_mark_climax_cold":
+ {
+ InterestCandidate scene = InterestDirector.CurrentCandidate;
+ if (scene != null)
+ {
+ ClearAttackTarget(scene.FollowUnit);
+ ClearAttackTarget(scene.RelatedUnit);
+ TryClearCombatTask(scene.FollowUnit);
+ TryClearCombatTask(scene.RelatedUnit);
+ }
+
+ InterestDirector.HarnessMarkStickySceneCold();
+ _cmdOk++;
+ Emit(
+ cmd,
+ ok: true,
+ detail: $"sticky_cold key={InterestDirector.CurrentKey} tip='{CameraDirector.LastWatchLabel}' active={InterestDirector.CurrentIsActive}");
+ break;
+ }
+
case "interest_mark_inactive":
{
float ago = ParseFloat(cmd.value, 1f);
@@ -4034,6 +4055,17 @@ public static class AgentHarness
return;
}
+ // pick=other: use a different living unit (same asset when possible) for ledger/near-tie tests.
+ if (!string.IsNullOrEmpty(cmd.value)
+ && cmd.value.IndexOf("pick=other", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ Actor other = FindOtherAliveUnit(follow, cmd.asset);
+ if (other != null)
+ {
+ follow = other;
+ }
+ }
+
float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f);
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed);
string label = string.IsNullOrEmpty(cmd.label) ? ("Inject " + (cmd.tier ?? "scene")) : cmd.label;
@@ -4145,6 +4177,51 @@ public static class AgentHarness
detail: $"injected key={c.Key} score={c.TotalScore:0.#} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={c.ParticipantCount}/{c.NotableParticipantCount} sides={c.CombatSideACount}/{c.CombatSideBCount} pending={InterestRegistry.PendingCount}");
}
+ private static Actor FindOtherAliveUnit(Actor exclude, string preferAsset)
+ {
+ if (World.world?.units == null)
+ {
+ return null;
+ }
+
+ long excludeId = EventFeedUtil.SafeId(exclude);
+ string wantAsset = (preferAsset ?? "").Trim();
+ if (wantAsset == "auto")
+ {
+ wantAsset = "";
+ }
+
+ Actor fallback = null;
+ try
+ {
+ foreach (Actor a in World.world.units)
+ {
+ if (a == null || !a.isAlive() || EventFeedUtil.SafeId(a) == excludeId)
+ {
+ continue;
+ }
+
+ string id = a.asset != null ? a.asset.id : "";
+ if (!string.IsNullOrEmpty(wantAsset)
+ && string.Equals(id, wantAsset, StringComparison.OrdinalIgnoreCase))
+ {
+ return a;
+ }
+
+ if (fallback == null)
+ {
+ fallback = a;
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return fallback;
+ }
+
private static void ParseInjectSideCounts(string raw, out int sideA, out int sideB)
{
sideA = 0;
@@ -6497,6 +6574,24 @@ public static class AgentHarness
detail = $"tipAsset='{tipAsset}' want='{want}'";
break;
}
+ case "story_phase":
+ {
+ string want = (cmd.value ?? "").Trim();
+ StoryArc arc = StoryPlanner.Active;
+ string have = arc != null ? arc.Phase.ToString() : "None";
+ pass = !string.IsNullOrEmpty(want)
+ && string.Equals(have, want, StringComparison.OrdinalIgnoreCase);
+ detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold}";
+ break;
+ }
+ case "ledger_has_focus":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ long id = EventFeedUtil.SafeId(focus);
+ pass = id != 0 && CharacterLedger.IsOnLedger(id);
+ detail = $"ledger focus={SafeName(focus)} id={id} on={pass} heat={CharacterLedger.Heat(id):0.##}";
+ break;
+ }
case "tip_matches_unit":
{
string tipAsset = CameraDirector.LastWatchAssetId ?? "";
@@ -7524,16 +7619,17 @@ public static class AgentHarness
}
}
- // Spot-check Signal classes still have camera rows after demote.
- int geneSignal = 0;
+ // Genes are all B (gain/remove tips never own the camera).
+ int geneCamera = 0;
foreach (string id in EventCatalog.Gene.AuthoredIds)
{
if (EventCatalog.Gene.GetOrFallback(id).CreatesInterest)
{
- geneSignal++;
+ geneCamera++;
}
}
+ // Spot-check other Signal classes still have camera rows after demote.
int lawSignal = 0;
foreach (string id in EventCatalog.WorldLaw.AuthoredIds)
{
@@ -7565,15 +7661,15 @@ public static class AgentHarness
pass = dialBad == 0
&& phenotypeCamera == 0
+ && geneCamera == 0
&& spellFxCamera == 0
&& spellSpectacleMissing == 0
- && geneSignal > 0
&& lawSignal > 0
&& itemSignal > 0
&& signalCamera == spectacleSpells.Length;
detail =
- $"dialBad={dialBad} phenotypeCamera={phenotypeCamera} spellFxCamera={spellFxCamera} "
- + $"spellSpectacleMissing={spellSpectacleMissing} geneSignal={geneSignal} "
+ $"dialBad={dialBad} phenotypeCamera={phenotypeCamera} geneCamera={geneCamera} "
+ + $"spellFxCamera={spellFxCamera} spellSpectacleMissing={spellSpectacleMissing} "
+ $"lawSignal={lawSignal} itemSignal={itemSignal} spectacleOk={signalCamera}";
break;
}
diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs
index fc748bd..a229b68 100644
--- a/IdleSpectator/Chronicle.cs
+++ b/IdleSpectator/Chronicle.cs
@@ -461,6 +461,80 @@ public static class Chronicle
}
}
+ ///
+ /// Count History rows for with CreatedAt inside the last
+ /// (unscaled). Used by Character Ledger density.
+ ///
+ public static int RecentHistoryCount(long subjectId, float windowSeconds)
+ {
+ if (subjectId == 0 || windowSeconds <= 0f)
+ {
+ return 0;
+ }
+
+ IReadOnlyList snap = SnapshotForSubject(subjectId);
+ if (snap.Count == 0)
+ {
+ return 0;
+ }
+
+ float cutoff = Time.unscaledTime - windowSeconds;
+ int n = 0;
+ for (int i = 0; i < snap.Count; i++)
+ {
+ ChronicleEntry e = snap[i];
+ if (e != null && e.CreatedAt >= cutoff)
+ {
+ n++;
+ }
+ }
+
+ return n;
+ }
+
+ ///
+ /// Collect distinct values from subject History.
+ /// Newest rows first; stops at .
+ ///
+ public static void EnumerateRelatedIds(long subjectId, List into, int max = 8)
+ {
+ if (into == null)
+ {
+ return;
+ }
+
+ into.Clear();
+ if (subjectId == 0 || max <= 0)
+ {
+ return;
+ }
+
+ IReadOnlyList snap = SnapshotForSubject(subjectId);
+ for (int i = snap.Count - 1; i >= 0 && into.Count < max; i--)
+ {
+ ChronicleEntry e = snap[i];
+ if (e == null || e.OtherId == 0 || e.OtherId == subjectId)
+ {
+ continue;
+ }
+
+ bool seen = false;
+ for (int j = 0; j < into.Count; j++)
+ {
+ if (into[j] == e.OtherId)
+ {
+ seen = true;
+ break;
+ }
+ }
+
+ if (!seen)
+ {
+ into.Add(e.OtherId);
+ }
+ }
+ }
+
/// Newest-first slice for compact dossier HUD.
public static IReadOnlyList LatestForSubject(long subjectId, int max)
{
diff --git a/IdleSpectator/EventCatalogConfig.cs b/IdleSpectator/EventCatalogConfig.cs
index 99dc2d2..684c7a8 100644
--- a/IdleSpectator/EventCatalogConfig.cs
+++ b/IdleSpectator/EventCatalogConfig.cs
@@ -51,6 +51,8 @@ public static class EventCatalogConfig
new Dictionary(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary Relationship =
new Dictionary(StringComparer.OrdinalIgnoreCase);
+ private static readonly Dictionary Story =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary Traits =
new Dictionary(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary Books =
@@ -83,6 +85,7 @@ public static class EventCatalogConfig
public static int WorldLogCount => WorldLog.Count;
public static int PlotCount => Plots.Count;
public static int RelationshipCount => Relationship.Count;
+ public static int StoryCount => Story.Count;
public static int TraitCount => Traits.Count;
public static int BookCount => Books.Count;
public static int EraCount => Eras.Count;
@@ -130,7 +133,7 @@ public static class EventCatalogConfig
LogService.LogInfo(
$"[IdleSpectator] event-catalog.json loaded v={_loadedVersion} "
+ $"happiness={Happiness.Count} status={Status.Count} worldLog={WorldLog.Count} "
- + $"plots={Plots.Count} relationship={Relationship.Count} traits={Traits.Count} "
+ + $"plots={Plots.Count} relationship={Relationship.Count} story={Story.Count} traits={Traits.Count} "
+ $"books={Books.Count} eras={Eras.Count} disasters={Disasters.Count} "
+ $"warTypes={WarTypes.Count} decisions={Decisions.Count} libraries={Libraries.Count} "
+ $"species={SpeciesBonuses.Count} character={CharacterBonuses.Count}");
@@ -153,6 +156,7 @@ public static class EventCatalogConfig
WorldLog.Clear();
Plots.Clear();
Relationship.Clear();
+ Story.Clear();
Traits.Clear();
Books.Clear();
Eras.Clear();
@@ -184,6 +188,7 @@ public static class EventCatalogConfig
if (TryExtractArray(json, "worldLog", out a)) IngestWorldLog(a);
if (TryExtractArray(json, "plots", out a)) IngestDiscrete(a, Plots);
if (TryExtractArray(json, "relationship", out a)) IngestDiscrete(a, Relationship);
+ if (TryExtractArray(json, "story", out a)) IngestDiscrete(a, Story);
if (TryExtractArray(json, "traits", out a)) IngestDiscrete(a, Traits);
if (TryExtractArray(json, "books", out a)) IngestDiscrete(a, Books);
if (TryExtractArray(json, "eras", out a)) IngestDiscrete(a, Eras);
@@ -226,6 +231,7 @@ public static class EventCatalogConfig
public static int FillPlots(Dictionary into) => FillDiscrete(Plots, into);
public static int FillRelationship(Dictionary into) => FillDiscrete(Relationship, into);
+ public static int FillStory(Dictionary into) => FillDiscrete(Story, into);
public static int FillTraits(Dictionary into) => FillDiscrete(Traits, into);
public static int FillBooks(Dictionary into) => FillDiscrete(Books, into);
public static int FillEras(Dictionary into) => FillDiscrete(Eras, into);
diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs
index 18ca629..307c055 100644
--- a/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs
+++ b/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs
@@ -408,6 +408,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
+ // Gene gain/remove is catalog noise - no Signal predicate, all B (like phenotypes).
LibraryCatalogDefaults d = LibOr("gene", 30f, 70f, "Genetics", "{a} gains gene {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
@@ -417,25 +418,9 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
- signalPredicate: IsSignalGene,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
- private static bool IsSignalGene(string id)
- {
- if (string.IsNullOrEmpty(id))
- {
- return false;
- }
-
- string s = id.ToLowerInvariant();
- return s.Contains("warfare")
- || s.Contains("magic")
- || s.Contains("mutation")
- || s.Contains("mutagen")
- || s.Contains("damage");
- }
-
public static IEnumerable AuthoredIds
{
get
diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Story.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Story.cs
new file mode 100644
index 0000000..75ae047
--- /dev/null
+++ b/IdleSpectator/Events/Catalogs/EventCatalog.Story.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+
+namespace IdleSpectator;
+
+/// Story aftermath / epilogue dials from event-catalog.json story.
+public static partial class EventCatalog
+{
+ public static class Story
+ {
+ private static readonly Dictionary Entries =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ private static int _appliedGeneration = -1;
+
+ public static void InvalidateOverlays()
+ {
+ Entries.Clear();
+ _appliedGeneration = -1;
+ }
+
+ private static void Ensure()
+ {
+ if (_appliedGeneration == EventCatalogConfig.Generation)
+ {
+ return;
+ }
+
+ _appliedGeneration = EventCatalogConfig.Generation;
+ EventCatalogConfig.FillStory(Entries);
+ }
+
+ public static IEnumerable AuthoredIds
+ {
+ get
+ {
+ Ensure();
+ return Entries.Keys;
+ }
+ }
+
+ public static DiscreteEventEntry GetOrFallback(string id)
+ {
+ Ensure();
+ if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
+ {
+ return entry;
+ }
+
+ string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
+ return new DiscreteEventEntry
+ {
+ Id = key,
+ EventStrength = InterestScoringConfig.Story.aftermathStrength,
+ Category = "Story",
+ LabelTemplate = "{a} · {id}",
+ CreatesInterest = true,
+ IsFallback = true
+ };
+ }
+ }
+}
diff --git a/IdleSpectator/Events/EventCatalog.cs b/IdleSpectator/Events/EventCatalog.cs
index 41bb6c4..4060897 100644
--- a/IdleSpectator/Events/EventCatalog.cs
+++ b/IdleSpectator/Events/EventCatalog.cs
@@ -34,6 +34,7 @@ public static partial class EventCatalog
WorldLog.InvalidateOverlays();
Plot.InvalidateOverlays();
Relationship.InvalidateOverlays();
+ Story.InvalidateOverlays();
Trait.InvalidateOverlays();
Book.InvalidateOverlays();
Era.InvalidateOverlays();
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 1e5a854..6f48e06 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -64,6 +64,20 @@ internal static class HarnessScenarios
case "soft_duel_yield":
case "duel_yield":
return CombatHoldUntilEnd();
+ case "story_aftermath_combat":
+ case "story_aftermath":
+ return StoryAftermathCombat();
+ case "story_aftermath_war":
+ return StoryAftermathWar();
+ case "story_near_tie":
+ case "variety_near_tie":
+ return StoryNearTie();
+ case "story_arc_combat":
+ return StoryArcCombat();
+ case "story_arc_preempt_resume":
+ return StoryArcPreemptResume();
+ case "story_ledger_revisit":
+ return StoryLedgerRevisit();
case "war_front_sticky":
case "war_sticky":
return WarFrontSticky();
@@ -222,6 +236,10 @@ internal static class HarnessScenarios
Nested("reg_variety_valve", "sticky_variety_valve"),
Nested("reg_combat_hold", "combat_hold_until_end"),
Nested("reg_combat_stability", "combat_stability_live"),
+ Nested("reg_story_aftermath", "story_aftermath_combat"),
+ Nested("reg_story_near_tie", "story_near_tie"),
+ Nested("reg_story_arc", "story_arc_combat"),
+ Nested("reg_story_ledger", "story_ledger_revisit"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_presentability", "camera_presentability"),
@@ -1438,6 +1456,211 @@ internal static class HarnessScenarios
};
}
+ ///
+ /// Combat cold injects story aftermath (survivor tip) when no stronger peer is pending.
+ ///
+ private static List StoryAftermathCombat()
+ {
+ return new List
+ {
+ Step("sa0", "dismiss_windows"),
+ Step("sa1", "wait_world"),
+ Step("sa2", "set_setting", expect: "enabled", value: "true"),
+ Step("sa3", "fast_timing", value: "true"),
+ Step("sa4", "spawn", asset: "human", count: 1),
+ Step("sa5", "spawn", asset: "wolf", count: 1),
+ Step("sa6", "spectator", value: "off"),
+ Step("sa7", "spectator", value: "on"),
+ Step("sa8", "pick_unit", asset: "human"),
+ Step("sa9", "focus", asset: "human"),
+ Step("sa10", "interest_end_session"),
+ Step("sa20", "interest_combat_session", asset: "human", value: "wolf", expect: "sa_pack"),
+ Step("sa20b", "combat_isolate_pair", value: "20"),
+ Step("sa21", "status_apply", asset: "human", value: "invincible"),
+ Step("sa22", "status_apply", asset: "wolf", value: "invincible"),
+ Step("sa23", "assert", expect: "tip_matches_any", value: "Duel -"),
+ Step("sa24", "interest_expire_pending", value: ""),
+ Step("sa25", "age_current", wait: 10f),
+ Step("sa26", "interest_mark_combat_cold"),
+ Step("sa27", "director_run", wait: 1.5f),
+ Step("sa28", "assert", expect: "tip_matches_any", value: "stands over|mourns|lingers"),
+ Step("sa29", "assert", expect: "tip_asset", value: "aftermath_"),
+ Step("sa30", "assert", expect: "tip_not_contains", value: "Duel -"),
+ Step("sa31", "assert", expect: "story_phase", value: "Aftermath"),
+ Step("sa90", "fast_timing", value: "false"),
+ Step("sa99", "snapshot"),
+ };
+ }
+
+ private static List StoryAftermathWar()
+ {
+ return new List
+ {
+ Step("saw0", "dismiss_windows"),
+ Step("saw1", "wait_world"),
+ Step("saw2", "set_setting", expect: "enabled", value: "true"),
+ Step("saw3", "fast_timing", value: "true"),
+ Step("saw4", "spawn", asset: "human", count: 2),
+ Step("saw5", "spectator", value: "off"),
+ Step("saw6", "spectator", value: "on"),
+ Step("saw7", "pick_unit", asset: "human"),
+ Step("saw8", "focus", asset: "human"),
+ Step("saw9", "interest_end_session"),
+ Step("saw20", "interest_war_session", asset: "human", expect: "saw_war"),
+ Step("saw21", "assert", expect: "tip_matches_any", value: "War -"),
+ Step("saw22", "interest_expire_pending", value: ""),
+ Step("saw23", "interest_mark_sticky_cold"),
+ Step("saw24", "director_run", wait: 1.5f),
+ Step("saw25", "assert", expect: "tip_matches_any", value: "lingers|stands over|war front"),
+ Step("saw26", "assert", expect: "tip_asset", value: "aftermath_"),
+ Step("saw90", "fast_timing", value: "false"),
+ Step("saw99", "snapshot"),
+ };
+ }
+
+ /// Large score gap always picks #1; within epsilon may roll among top-3.
+ private static List StoryNearTie()
+ {
+ return new List
+ {
+ Step("nt0", "dismiss_windows"),
+ Step("nt1", "wait_world"),
+ Step("nt2", "set_setting", expect: "enabled", value: "true"),
+ Step("nt3", "fast_timing", value: "true"),
+ Step("nt4", "spawn", asset: "human", count: 2),
+ Step("nt5", "spectator", value: "off"),
+ Step("nt6", "spectator", value: "on"),
+ Step("nt7", "pick_unit", asset: "human"),
+ Step("nt8", "focus", asset: "human"),
+ Step("nt9", "interest_end_session"),
+ Step("nt10", "interest_expire_pending", value: ""),
+ Step("nt20", "interest_inject", asset: "human", label: "ClearWinner", tier: "Action",
+ expect: "nt_hi", value: "lead=event;evt=90;char=5;force=false;ttl=60"),
+ Step("nt21", "interest_inject", asset: "human", label: "ClearLoser", tier: "Action",
+ expect: "nt_lo", value: "lead=event;evt=40;char=5;force=false;ttl=60"),
+ Step("nt22", "director_run", wait: 1.2f),
+ Step("nt23", "assert", expect: "tip_contains", value: "ClearWinner"),
+ Step("nt24", "assert", expect: "tip_not_contains", value: "ClearLoser"),
+ Step("nt90", "fast_timing", value: "false"),
+ Step("nt99", "snapshot"),
+ };
+ }
+
+ /// Combat climax → aftermath ownership; soft ambient must not steal mid-aftermath.
+ private static List StoryArcCombat()
+ {
+ return new List
+ {
+ Step("sac0", "dismiss_windows"),
+ Step("sac1", "wait_world"),
+ Step("sac2", "set_setting", expect: "enabled", value: "true"),
+ Step("sac3", "fast_timing", value: "true"),
+ Step("sac4", "spawn", asset: "human", count: 1),
+ Step("sac5", "spawn", asset: "wolf", count: 1),
+ Step("sac6", "spectator", value: "off"),
+ Step("sac7", "spectator", value: "on"),
+ Step("sac8", "pick_unit", asset: "human"),
+ Step("sac9", "focus", asset: "human"),
+ Step("sac10", "interest_end_session"),
+ Step("sac20", "interest_combat_session", asset: "human", value: "wolf", expect: "sac_pack"),
+ Step("sac20b", "combat_isolate_pair", value: "20"),
+ Step("sac21", "status_apply", asset: "human", value: "invincible"),
+ Step("sac22", "status_apply", asset: "wolf", value: "invincible"),
+ Step("sac23", "assert", expect: "tip_matches_any", value: "Duel -"),
+ Step("sac24", "assert", expect: "story_phase", value: "Climax"),
+ Step("sac25", "interest_expire_pending", value: ""),
+ Step("sac26", "age_current", wait: 10f),
+ Step("sac27", "interest_mark_combat_cold"),
+ Step("sac28", "director_run", wait: 1.5f),
+ Step("sac29", "assert", expect: "tip_asset", value: "aftermath_"),
+ Step("sac30", "assert", expect: "story_phase", value: "Aftermath"),
+ // Weak ambient peer must not steal mid-aftermath.
+ Step("sac31", "interest_inject", asset: "human", label: "WeakPeer", tier: "Curiosity",
+ expect: "sac_weak", value: "lead=event;evt=30;char=5;force=false;ttl=60"),
+ Step("sac32", "director_run", wait: 1.0f),
+ Step("sac33", "assert", expect: "tip_asset", value: "aftermath_"),
+ Step("sac34", "assert", expect: "tip_not_contains", value: "WeakPeer"),
+ Step("sac90", "fast_timing", value: "false"),
+ Step("sac99", "snapshot"),
+ };
+ }
+
+ /// Epic preempt during climax; aftermath still available after cold.
+ private static List StoryArcPreemptResume()
+ {
+ return new List
+ {
+ Step("spr0", "dismiss_windows"),
+ Step("spr1", "wait_world"),
+ Step("spr2", "set_setting", expect: "enabled", value: "true"),
+ Step("spr3", "fast_timing", value: "true"),
+ Step("spr4", "spawn", asset: "human", count: 1),
+ Step("spr5", "spawn", asset: "wolf", count: 1),
+ Step("spr6", "spectator", value: "off"),
+ Step("spr7", "spectator", value: "on"),
+ Step("spr8", "pick_unit", asset: "human"),
+ Step("spr9", "focus", asset: "human"),
+ Step("spr10", "interest_end_session"),
+ Step("spr20", "interest_combat_session", asset: "human", value: "wolf", expect: "spr_pack"),
+ Step("spr20b", "combat_isolate_pair", value: "20"),
+ Step("spr21", "status_apply", asset: "human", value: "invincible"),
+ Step("spr22", "status_apply", asset: "wolf", value: "invincible"),
+ Step("spr23", "assert", expect: "tip_matches_any", value: "Duel -"),
+ Step("spr24", "interest_inject", asset: "human", label: "EpicPreempt", tier: "Epic",
+ expect: "spr_epic", value: "lead=event;evt=150;char=10;force=false;ttl=60"),
+ Step("spr25", "director_run", wait: 1.2f),
+ Step("spr26", "assert", expect: "tip_contains", value: "EpicPreempt"),
+ Step("spr27", "interest_end_session"),
+ Step("spr28", "interest_expire_pending", value: ""),
+ Step("spr29", "interest_combat_session", asset: "human", value: "wolf", expect: "spr_pack2"),
+ Step("spr29b", "combat_isolate_pair", value: "20"),
+ Step("spr30", "status_apply", asset: "human", value: "invincible"),
+ Step("spr31", "status_apply", asset: "wolf", value: "invincible"),
+ Step("spr32", "age_current", wait: 10f),
+ Step("spr33", "interest_mark_combat_cold"),
+ Step("spr34", "director_run", wait: 1.5f),
+ Step("spr35", "assert", expect: "tip_asset", value: "aftermath_"),
+ Step("spr90", "fast_timing", value: "false"),
+ Step("spr99", "snapshot"),
+ };
+ }
+
+ /// Featured unit wins a near-tie against a stranger milestone.
+ private static List StoryLedgerRevisit()
+ {
+ return new List
+ {
+ Step("sl0", "dismiss_windows"),
+ Step("sl1", "wait_world"),
+ Step("sl2", "set_setting", expect: "enabled", value: "true"),
+ Step("sl3", "fast_timing", value: "true"),
+ Step("sl4", "spawn", asset: "human", count: 2),
+ Step("sl5", "spectator", value: "off"),
+ Step("sl6", "spectator", value: "on"),
+ Step("sl7", "pick_unit", asset: "human"),
+ Step("sl8", "focus", asset: "human"),
+ Step("sl9", "interest_end_session"),
+ // Feature unit A via a watched tip.
+ Step("sl10", "interest_inject", asset: "human", label: "FeaturedLife", tier: "Action",
+ expect: "sl_feat", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
+ Step("sl11", "director_run", wait: 1.2f),
+ Step("sl12", "assert", expect: "tip_contains", value: "FeaturedLife"),
+ Step("sl13", "assert", expect: "ledger_has_focus"),
+ Step("sl14", "interest_end_session"),
+ Step("sl15", "interest_expire_pending", value: ""),
+ // Near-tie: same strength on focus vs other human - ledger/causal heat should tip focus.
+ Step("sl20", "interest_inject", asset: "human", label: "LedgerWin", tier: "Action",
+ expect: "sl_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
+ Step("sl21", "interest_inject", asset: "human", label: "LedgerLose", tier: "Action",
+ expect: "sl_lose", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"),
+ Step("sl22", "director_run", wait: 1.2f),
+ Step("sl23", "assert", expect: "tip_contains", value: "LedgerWin"),
+ Step("sl24", "assert", expect: "tip_not_contains", value: "LedgerLose"),
+ Step("sl90", "fast_timing", value: "false"),
+ Step("sl99", "snapshot"),
+ };
+ }
+
///
/// Sticky WarFront tips stay kingdom-framed across count dips and follow loss.
///
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index ff885bd..ff65fc5 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -298,6 +298,37 @@ public static class InterestDirector
}
_inactiveSince = now;
+ StoryPlanner.OnClimaxCold(_current);
+ }
+
+ ///
+ /// Harness: force WarFront / PlotActive / CombatActive cold so story aftermath can inject.
+ ///
+ public static void HarnessMarkStickySceneCold()
+ {
+ if (_current == null)
+ {
+ return;
+ }
+
+ float now = Time.unscaledTime;
+ if (_current.Completion == InterestCompletionKind.CombatActive)
+ {
+ HarnessMarkCombatCold();
+ return;
+ }
+
+ _current.ForceActive = false;
+ _current.LastSeenAt = now - 60f;
+ // Break sticky liveness predicates (sides / plotters).
+ _current.Sticky.Clear();
+ _current.ParticipantCount = 0;
+ // Age past MaxWatch so IsActive is false even if a path still resolves.
+ float past = Mathf.Max(_current.MinWatch, _current.MaxWatch) + 1f;
+ _currentStartedAt = now - past;
+ _lastSwitchAt = _currentStartedAt;
+ _inactiveSince = now;
+ StoryPlanner.OnClimaxCold(_current);
}
/// Harness: clear FollowUnit while keeping RelatedUnit for death handoff.
@@ -348,6 +379,7 @@ public static class InterestDirector
InterestVariety.Clear();
InterestScoring.ClearCache();
InterestFeeds.Reset();
+ StoryPlanner.Clear();
_current = null;
_interrupted = null;
float now = Time.unscaledTime;
@@ -420,6 +452,7 @@ public static class InterestDirector
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
+ StoryPlanner.Tick(now);
UpdateSessionLiveness(now, onCurrent);
MaintainCombatFocus(now, force: false);
MaintainWarFront(now, force: false);
@@ -487,6 +520,9 @@ public static class InterestDirector
return;
}
+ // Climax went cold: inject / claim aftermath (idempotent per climax key).
+ StoryPlanner.OnClimaxCold(_current);
+
// No living subject left: prefer sticky combat roster handoff before ending.
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
@@ -3231,12 +3267,15 @@ public static class InterestDirector
private static void EndCurrent(float now, string reason)
{
+ InterestCandidate ended = _current;
if (_current != null)
{
InterestRegistry.Remove(_current.Key);
LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label);
}
+ StoryPlanner.OnEndCurrent(ended, now);
+
// Resume interrupted Epic-preempted scene if still valid.
if (_interrupted != null
&& _interrupted.HasValidPosition
@@ -3248,6 +3287,20 @@ public static class InterestDirector
return;
}
+ // Prefer queued story aftermath/epilogue over ambient fill.
+ InterestCandidate storyNext = SelectNext(now);
+ if (storyNext != null
+ && StoryPlanner.OwnsCandidate(storyNext)
+ && IsWorthWatchingNow(storyNext, now, selectingDuringGrace: true))
+ {
+ _interrupted = null;
+ _current = null;
+ _harnessCombatForcedCold = false;
+ _inactiveSince = -999f;
+ SwitchTo(storyNext, now, resumableInterrupt: false);
+ return;
+ }
+
_interrupted = null;
_current = null;
_harnessCombatForcedCold = false;
@@ -3751,6 +3804,13 @@ public static class InterestDirector
bool inGrace = InQuietGrace;
if (inGrace)
{
+ // Story aftermath / same-arc continuation may cut freely during quiet grace.
+ if (IsSameStoryArc(_current, candidate)
+ && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
+ {
+ return true;
+ }
+
// Quiet grace after a sticky hold: only margin-worthy events may cut in.
// Prevents family-join / love status from stealing a fight between swings.
if (_current != null
@@ -3852,6 +3912,13 @@ public static class InterestDirector
return true;
}
+ // StoryPlanner hard-arc hold during aftermath/epilogue against unrelated peers.
+ float storyHold = StoryPlanner.ArcHoldMargin(_current, candidate);
+ if (storyHold > 0f)
+ {
+ cutMargin = Mathf.Max(cutMargin, storyHold);
+ }
+
// Instant score-margin cut - no settle grace, no MinDwell block.
// Soft clusters use the normal cut-in margin (not stickyCutInMargin).
float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin;
@@ -3860,12 +3927,13 @@ public static class InterestDirector
return true;
}
- if (sticky && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
+ if ((sticky || storyHold > 0f) && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
{
InterestDropLog.Record(
"below_margin",
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"
- + (varietyValve ? " valve" : ""));
+ + (varietyValve ? " valve" : "")
+ + (storyHold > 0f ? " story" : ""));
}
// Fill never cuts a protected non-fill session without margin (already failed above).
@@ -4164,6 +4232,12 @@ public static class InterestDirector
return true;
}
+ // StoryPlanner aftermath / epilogue of the active climax.
+ if (StoryPlanner.IsContinuationOf(current, next))
+ {
+ return true;
+ }
+
return false;
}
@@ -4510,6 +4584,7 @@ public static class InterestDirector
InterestRegistry.MarkSelected(next.Key);
InterestVariety.NoteSelection(next);
+ StoryPlanner.OnSwitchTo(next, now);
CameraDirector.Watch(next.ToInterestEvent());
}
diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs
index d2d2ae4..47122c1 100644
--- a/IdleSpectator/InterestScoring.cs
+++ b/IdleSpectator/InterestScoring.cs
@@ -159,12 +159,18 @@ public static class InterestScoring
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
+ float causalBonus = CausalHeat.ScoreBonus(c);
+ float ledgerBonus = CharacterLedger.ScoreBonus(c);
+ float ownershipBonus = StoryPlanner.OwnershipBoost(c);
c.TotalScore = c.EventStrength + scaleBonus
+ c.CharacterSignificance * charWeight
+ c.VisualConfidence * w.visualMultiplier
+ c.Novelty * w.noveltyMultiplier
- repeatPenalty
- + frequencyAdjust;
+ + frequencyAdjust
+ + causalBonus
+ + ledgerBonus
+ + ownershipBonus;
string detail =
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
if (repeatPenalty > 0.05f)
@@ -179,6 +185,21 @@ public static class InterestScoring
+ frequencyAdjust.ToString("0.#");
}
+ if (causalBonus > 0.05f)
+ {
+ detail += $" causal=+{causalBonus:0.#}";
+ }
+
+ if (ledgerBonus > 0.05f)
+ {
+ detail += $" ledger=+{ledgerBonus:0.#}";
+ }
+
+ if (ownershipBonus > 0.05f)
+ {
+ detail += $" arc=+{ownershipBonus:0.#}";
+ }
+
c.ScoreDetail = detail;
}
diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs
index d18fb64..00bdf80 100644
--- a/IdleSpectator/InterestScoringConfig.cs
+++ b/IdleSpectator/InterestScoringConfig.cs
@@ -15,6 +15,27 @@ public class ScoringModelDocument
{
public string modVersion;
public ScoringWeights weights;
+ public StoryWeights story;
+}
+
+/// StoryPlanner knobs from scoring-model.json story block.
+[Serializable]
+public class StoryWeights
+{
+ public float nearTieEpsilon = 8f;
+ public float causalHeatHalfLifeSeconds = 180f;
+ public float causalHeatWeight = 6f;
+ public float causalRelatedWeight = 3f;
+ public float arcOwnershipBoost = 18f;
+ public float arcHoldMargin = 50f;
+ public float aftermathStrength = 62f;
+ public float aftermathMinWatch = 12f;
+ public float aftermathMaxWatch = 20f;
+ public float epilogueStrength = 48f;
+ public float epilogueMaxWatch = 14f;
+ public int ledgerCap = 16;
+ public float ledgerWeight = 8f;
+ public float historyDensityWindowSeconds = 600f;
}
/// Serializable weight block — field names must match scoring-model.json "weights".
@@ -175,12 +196,15 @@ public static class InterestScoringConfig
public const string FileName = "scoring-model.json";
private static ScoringWeights _w = new ScoringWeights();
+ private static StoryWeights _story = new StoryWeights();
private static string _loadedPath = "";
private static string _loadedVersion = "";
private static bool _loadedFromFile;
public static ScoringWeights W => _w;
+ public static StoryWeights Story => _story;
+
public static bool LoadedFromFile => _loadedFromFile;
public static string LoadedPath => _loadedPath;
@@ -190,6 +214,7 @@ public static class InterestScoringConfig
public static void LoadFromModFolder(string modFolder)
{
_w = new ScoringWeights();
+ _story = new StoryWeights();
_loadedFromFile = false;
_loadedPath = "";
_loadedVersion = "";
@@ -210,22 +235,24 @@ public static class InterestScoringConfig
try
{
string json = File.ReadAllText(path);
- if (!TryParseDocument(json, out ScoringWeights weights, out string version))
+ if (!TryParseDocument(json, out ScoringWeights weights, out StoryWeights story, out string version))
{
LogService.LogInfo("[IdleSpectator] scoring-model.json parse failed, using defaults");
return;
}
_w = weights;
+ _story = story ?? new StoryWeights();
_loadedFromFile = true;
_loadedPath = path;
_loadedVersion = version ?? "";
LogService.LogInfo(
- $"[IdleSpectator] scoring-model.json loaded v={_loadedVersion} charEvent={_w.charWeightEventLed:0.##} mass={_w.massBaseBonus:0.#} duelPair={_w.duelNotablePairBonus:0.#}");
+ $"[IdleSpectator] scoring-model.json loaded v={_loadedVersion} charEvent={_w.charWeightEventLed:0.##} mass={_w.massBaseBonus:0.#} duelPair={_w.duelNotablePairBonus:0.#} storyNearTie={_story.nearTieEpsilon:0.#}");
}
catch (Exception ex)
{
_w = new ScoringWeights();
+ _story = new StoryWeights();
LogService.LogInfo($"[IdleSpectator] scoring-model.json failed ({ex.Message}), using defaults");
}
}
@@ -234,9 +261,14 @@ public static class InterestScoringConfig
/// Unity JsonUtility often returns null nested objects on mixed doc/schema files.
/// Parse the weights object directly (extracted by brace matching).
///
- internal static bool TryParseDocument(string json, out ScoringWeights weights, out string version)
+ internal static bool TryParseDocument(
+ string json,
+ out ScoringWeights weights,
+ out StoryWeights story,
+ out string version)
{
weights = null;
+ story = new StoryWeights();
version = "";
if (string.IsNullOrEmpty(json))
{
@@ -245,6 +277,15 @@ public static class InterestScoringConfig
version = ExtractJsonString(json, "modVersion") ?? "";
+ if (TryExtractObject(json, "story", out string storyJson))
+ {
+ StoryWeights parsedStory = JsonUtility.FromJson(storyJson);
+ if (parsedStory != null)
+ {
+ story = parsedStory;
+ }
+ }
+
// Prefer direct FromJson of the weights object — reliable with flat numeric fields.
if (TryExtractObject(json, "weights", out string weightsJson))
{
@@ -261,6 +302,11 @@ public static class InterestScoringConfig
if (doc?.weights != null)
{
weights = doc.weights;
+ if (doc.story != null)
+ {
+ story = doc.story;
+ }
+
if (!string.IsNullOrEmpty(doc.modVersion))
{
version = doc.modVersion;
diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs
index d73cfa1..19c08d9 100644
--- a/IdleSpectator/InterestVariety.cs
+++ b/IdleSpectator/InterestVariety.cs
@@ -317,6 +317,19 @@ public static class InterestVariety
return "arc:combat:multi";
}
+ if (StoryReason.IsStoryAsset(c.AssetId)
+ || string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(c.Category, "Story", StringComparison.OrdinalIgnoreCase))
+ {
+ if (!string.IsNullOrEmpty(c.AssetId)
+ && c.AssetId.StartsWith("epilogue_", StringComparison.OrdinalIgnoreCase))
+ {
+ return "arc:life:epilogue";
+ }
+
+ return "arc:life:aftermath";
+ }
+
if (!string.IsNullOrEmpty(c.HappinessEffectId))
{
string h = c.HappinessEffectId.Trim().ToLowerInvariant();
@@ -531,13 +544,42 @@ public static class InterestVariety
Ranked.Sort(InterestScoring.CompareByUrgencyThenScore);
InterestScoring.EnrichTopK(Ranked);
+ // Prefer active story-owned beats when present.
+ for (int i = 1; i < Ranked.Count; i++)
+ {
+ if (StoryPlanner.PreferOver(Ranked[i], Ranked[0]))
+ {
+ InterestCandidate owned = Ranked[i];
+ Ranked.RemoveAt(i);
+ Ranked.Insert(0, owned);
+ break;
+ }
+ }
+
int topN = Mathf.Min(3, Ranked.Count);
if (topN <= 0)
{
return null;
}
- // Probabilistic among top-N after penalties.
+ // Deterministic #1 unless near-tie among top scores.
+ if (topN == 1)
+ {
+ return Ranked[0];
+ }
+
+ float epsilon = InterestScoringConfig.Story.nearTieEpsilon;
+ if (epsilon <= 0f)
+ {
+ epsilon = 8f;
+ }
+
+ if (Ranked[0].TotalScore - Ranked[1].TotalScore >= epsilon)
+ {
+ return Ranked[0];
+ }
+
+ // Probabilistic among top-N only when scores are within epsilon.
int pick = Rng.Next(topN);
return Ranked[pick];
}
diff --git a/IdleSpectator/Story/CausalHeat.cs b/IdleSpectator/Story/CausalHeat.cs
new file mode 100644
index 0000000..62faee7
--- /dev/null
+++ b/IdleSpectator/Story/CausalHeat.cs
@@ -0,0 +1,134 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+///
+/// Decaying per-unit heat from featured cast. Soft-spreads to live relations and Chronicle links.
+///
+public static class CausalHeat
+{
+ private struct HeatEntry
+ {
+ public float Value;
+ public float TouchedAt;
+ }
+
+ private static readonly Dictionary HeatById = new Dictionary(64);
+ private static readonly List ScratchIds = new List(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 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);
+ }
+ }
+}
diff --git a/IdleSpectator/Story/CharacterLedger.cs b/IdleSpectator/Story/CharacterLedger.cs
new file mode 100644
index 0000000..b7ed162
--- /dev/null
+++ b/IdleSpectator/Story/CharacterLedger.cs
@@ -0,0 +1,240 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+///
+/// Small cast of watched lives. Heat from favorites, focus, CausalHeat, and Chronicle density.
+///
+public static class CharacterLedger
+{
+ private struct LedgerEntry
+ {
+ public long UnitId;
+ public float Heat;
+ public float TouchedAt;
+ }
+
+ private static readonly List Entries = new List(20);
+ private static readonly List ScratchIdx = new List(8);
+
+ public static void Clear()
+ {
+ Entries.Clear();
+ }
+
+ public static int Count => Entries.Count;
+
+ public static bool IsOnLedger(long unitId)
+ {
+ if (unitId == 0)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < Entries.Count; i++)
+ {
+ if (Entries[i].UnitId == unitId)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static float Heat(long unitId)
+ {
+ if (unitId == 0)
+ {
+ return 0f;
+ }
+
+ for (int i = 0; i < Entries.Count; i++)
+ {
+ if (Entries[i].UnitId == unitId)
+ {
+ return Entries[i].Heat;
+ }
+ }
+
+ return 0f;
+ }
+
+ public static float ScoreBonus(InterestCandidate c)
+ {
+ if (c == null)
+ {
+ return 0f;
+ }
+
+ StoryWeights s = InterestScoringConfig.Story;
+ float h = Heat(c.SubjectId);
+ if (h <= 0f && c.RelatedId != 0)
+ {
+ h = Heat(c.RelatedId) * 0.5f;
+ }
+
+ return h * s.ledgerWeight;
+ }
+
+ public static void Touch(long unitId, float heatAdd = 1f)
+ {
+ if (unitId == 0)
+ {
+ return;
+ }
+
+ float now = Time.unscaledTime;
+ for (int i = 0; i < Entries.Count; i++)
+ {
+ if (Entries[i].UnitId != unitId)
+ {
+ continue;
+ }
+
+ LedgerEntry e = Entries[i];
+ e.Heat = Mathf.Min(8f, e.Heat + heatAdd);
+ e.TouchedAt = now;
+ Entries[i] = e;
+ return;
+ }
+
+ EnsureCapacityForNew(unitId);
+ Entries.Add(new LedgerEntry
+ {
+ UnitId = unitId,
+ Heat = Mathf.Clamp(heatAdd, 0.5f, 8f),
+ TouchedAt = now
+ });
+ }
+
+ public static void NoteFeatured(InterestCandidate scene)
+ {
+ if (scene == null)
+ {
+ return;
+ }
+
+ float add = 1f;
+ if (scene.FollowUnit != null && Chronicle.IsFavoriteSubject(EventFeedUtil.SafeId(scene.FollowUnit)))
+ {
+ add += 1.5f;
+ }
+
+ if (InterestScoring.IsNotable(scene.FollowUnit))
+ {
+ add += 0.75f;
+ }
+
+ if (scene.SubjectId != 0)
+ {
+ Touch(scene.SubjectId, add);
+ float density = Chronicle.RecentHistoryCount(
+ scene.SubjectId,
+ InterestScoringConfig.Story.historyDensityWindowSeconds);
+ if (density > 0)
+ {
+ Touch(scene.SubjectId, Mathf.Min(2f, density * 0.15f));
+ }
+ }
+
+ if (scene.RelatedId != 0)
+ {
+ Touch(scene.RelatedId, add * 0.6f);
+ }
+
+ float causal = CausalHeat.Get(scene.SubjectId);
+ if (causal > 0.5f)
+ {
+ Touch(scene.SubjectId, Mathf.Min(1.5f, causal));
+ }
+ }
+
+ public static void Enumerate(List into)
+ {
+ if (into == null)
+ {
+ return;
+ }
+
+ into.Clear();
+ for (int i = 0; i < Entries.Count; i++)
+ {
+ into.Add(Entries[i].UnitId);
+ }
+ }
+
+ public static Actor PreferLedgerUnit(IEnumerable candidates)
+ {
+ if (candidates == null)
+ {
+ return null;
+ }
+
+ Actor best = null;
+ float bestHeat = -1f;
+ foreach (Actor a in candidates)
+ {
+ if (a == null || !a.isAlive())
+ {
+ continue;
+ }
+
+ long id = EventFeedUtil.SafeId(a);
+ float h = Heat(id);
+ if (h > bestHeat)
+ {
+ bestHeat = h;
+ best = a;
+ }
+ }
+
+ return bestHeat > 0f ? best : null;
+ }
+
+ private static void EnsureCapacityForNew(long incomingId)
+ {
+ StoryWeights s = InterestScoringConfig.Story;
+ int cap = s.ledgerCap > 0 ? s.ledgerCap : 16;
+ if (Entries.Count < cap)
+ {
+ return;
+ }
+
+ // Never evict current arc cast.
+ ScratchIdx.Clear();
+ int worst = -1;
+ float worstHeat = float.MaxValue;
+ for (int i = 0; i < Entries.Count; i++)
+ {
+ long id = Entries[i].UnitId;
+ if (StoryPlanner.Active != null && StoryPlanner.Active.ContainsCast(id))
+ {
+ continue;
+ }
+
+ if (id == incomingId)
+ {
+ continue;
+ }
+
+ float h = Entries[i].Heat;
+ if (h < worstHeat)
+ {
+ worstHeat = h;
+ worst = i;
+ }
+ }
+
+ if (worst >= 0)
+ {
+ Entries.RemoveAt(worst);
+ }
+ else if (Entries.Count > 0)
+ {
+ // All protected - drop oldest non-incoming.
+ Entries.RemoveAt(0);
+ }
+ }
+}
diff --git a/IdleSpectator/Story/StoryArc.cs b/IdleSpectator/Story/StoryArc.cs
new file mode 100644
index 0000000..7ca4153
--- /dev/null
+++ b/IdleSpectator/Story/StoryArc.cs
@@ -0,0 +1,78 @@
+using System.Collections.Generic;
+
+namespace IdleSpectator;
+
+public enum StoryArcKind
+{
+ None = 0,
+ CombatDuel = 1,
+ CombatMass = 2,
+ WarFront = 3,
+ Plot = 4,
+ Love = 5,
+ Grief = 6
+}
+
+public enum StoryPhase
+{
+ None = 0,
+ Climax = 1,
+ Aftermath = 2,
+ Epilogue = 3,
+ Done = 4
+}
+
+///
+/// Short multi-beat story ownership: climax sticky/life scene → aftermath → optional epilogue.
+///
+public sealed class StoryArc
+{
+ public string Id = "";
+ public StoryArcKind Kind = StoryArcKind.None;
+ public StoryPhase Phase = StoryPhase.None;
+ public string AnchorKey = "";
+ public string ClimaxKey = "";
+ public float StartedAt;
+ public float PhaseStartedAt;
+ /// True when this arc uses hard hold margins (not soft anonymous duels).
+ public bool HardHold;
+ public readonly List CastIds = new List(4);
+ public readonly Queue PendingBeats = new Queue(2);
+
+ public bool IsActive =>
+ Phase != StoryPhase.None && Phase != StoryPhase.Done;
+
+ public bool ContainsCast(long unitId)
+ {
+ if (unitId == 0)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < CastIds.Count; i++)
+ {
+ if (CastIds[i] == unitId)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public void NoteCast(long unitId)
+ {
+ if (unitId == 0 || ContainsCast(unitId))
+ {
+ return;
+ }
+
+ CastIds.Add(unitId);
+ }
+
+ public void Advance(StoryPhase next, float now)
+ {
+ Phase = next;
+ PhaseStartedAt = now;
+ }
+}
diff --git a/IdleSpectator/Story/StoryBeat.cs b/IdleSpectator/Story/StoryBeat.cs
new file mode 100644
index 0000000..a5771cd
--- /dev/null
+++ b/IdleSpectator/Story/StoryBeat.cs
@@ -0,0 +1,15 @@
+namespace IdleSpectator;
+
+/// Queued aftermath / epilogue beat for an active .
+public sealed class StoryBeat
+{
+ public StoryPhase Phase = StoryPhase.Aftermath;
+ public string AssetId = "";
+ public string CandidateKey = "";
+ public long FollowId;
+ public long RelatedId;
+ public float EventStrength;
+ public float MinWatch;
+ public float MaxWatch;
+ public bool SynthesizeIfMissing = true;
+}
diff --git a/IdleSpectator/Story/StoryPlanner.cs b/IdleSpectator/Story/StoryPlanner.cs
new file mode 100644
index 0000000..3bd81ca
--- /dev/null
+++ b/IdleSpectator/Story/StoryPlanner.cs
@@ -0,0 +1,960 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+///
+/// Owns short multi-beat story commitment above InterestDirector.
+/// Never calls MoveCamera - injects / boosts candidates and reports hold policy.
+///
+public static class StoryPlanner
+{
+ private static StoryArc _active;
+ private static bool _coldHookFired;
+ private static string _coldHookClimaxKey = "";
+ private static readonly List PendingScratch = new List(64);
+
+ public static StoryArc Active => _active != null && _active.IsActive ? _active : null;
+
+ public static void Clear()
+ {
+ _active = null;
+ _coldHookFired = false;
+ _coldHookClimaxKey = "";
+ CausalHeat.Clear();
+ CharacterLedger.Clear();
+ }
+
+ public static void Tick(float now)
+ {
+ if (_active == null || !_active.IsActive)
+ {
+ return;
+ }
+
+ // Stale arcs without a living cast eventually end.
+ if (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue)
+ {
+ if (now - _active.PhaseStartedAt > 90f)
+ {
+ _active.Advance(StoryPhase.Done, now);
+ }
+ }
+ }
+
+ public static void OnSwitchTo(InterestCandidate next, float now)
+ {
+ if (next == null)
+ {
+ return;
+ }
+
+ CausalHeat.NoteFeatured(next.SubjectId);
+ if (next.RelatedId != 0)
+ {
+ CausalHeat.NoteFeatured(next.RelatedId, 0.6f);
+ }
+
+ CharacterLedger.NoteFeatured(next);
+
+ if (IsStoryAssetCandidate(next))
+ {
+ if (_active != null && _active.IsActive)
+ {
+ if (string.Equals(next.AssetId, StoryReason.EpilogueRelated, StringComparison.OrdinalIgnoreCase)
+ || next.Key.StartsWith("epilogue:", StringComparison.OrdinalIgnoreCase))
+ {
+ _active.Advance(StoryPhase.Epilogue, now);
+ }
+ else
+ {
+ _active.Advance(StoryPhase.Aftermath, now);
+ }
+
+ NoteCastFrom(next, _active);
+ }
+
+ return;
+ }
+
+ if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
+ {
+ BeginOrRefreshArc(next, kind, hardHold, now);
+ }
+ }
+
+ public static void OnEndCurrent(InterestCandidate ended, float now)
+ {
+ if (ended == null || _active == null || !_active.IsActive)
+ {
+ return;
+ }
+
+ if (!string.IsNullOrEmpty(_active.ClimaxKey)
+ && string.Equals(ended.Key, _active.ClimaxKey, StringComparison.Ordinal)
+ && _active.Phase == StoryPhase.Climax)
+ {
+ // Cold-hook should have advanced; if not, still try aftermath once.
+ if (!_coldHookFired || !string.Equals(_coldHookClimaxKey, ended.Key, StringComparison.Ordinal))
+ {
+ OnClimaxCold(ended);
+ }
+ }
+
+ if (IsStoryAssetCandidate(ended))
+ {
+ if (_active.Phase == StoryPhase.Aftermath)
+ {
+ TryInjectEpilogue(_active, now);
+ }
+ else if (_active.Phase == StoryPhase.Epilogue)
+ {
+ _active.Advance(StoryPhase.Done, now);
+ }
+ }
+ }
+
+ ///
+ /// Called when a climax scene first goes inactive (entering quiet grace).
+ ///
+ public static void OnClimaxCold(InterestCandidate climax)
+ {
+ if (climax == null)
+ {
+ return;
+ }
+
+ float now = Time.unscaledTime;
+ // Once per climax key - director may re-enter inactive while min-dwell resets grace.
+ if (_coldHookFired
+ && string.Equals(_coldHookClimaxKey, climax.Key, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ if (!TryClassifyClimax(climax, out StoryArcKind kind, out bool hardHold)
+ && (_active == null || !_active.IsActive))
+ {
+ return;
+ }
+
+ if (_active == null || !_active.IsActive
+ || !string.Equals(_active.ClimaxKey, climax.Key, StringComparison.Ordinal))
+ {
+ if (!TryClassifyClimax(climax, out kind, out hardHold))
+ {
+ return;
+ }
+
+ BeginOrRefreshArc(climax, kind, hardHold, now);
+ }
+
+ _coldHookFired = true;
+ _coldHookClimaxKey = climax.Key ?? "";
+
+ if (TryClaimNaturalGrief(climax, _active))
+ {
+ _active.Advance(StoryPhase.Aftermath, now);
+ return;
+ }
+
+ // Grief climax is already the aftermath beat - do not synthesize a twin.
+ if (_active.Kind == StoryArcKind.Grief)
+ {
+ _active.Advance(StoryPhase.Done, now);
+ return;
+ }
+
+ if (HasStrongerPendingPeer(climax))
+ {
+ // Natural drama already queued (e.g. lover tip in combat_hold) - do not steal.
+ return;
+ }
+
+ if (TryInjectAftermath(climax, _active, now))
+ {
+ _active.Advance(StoryPhase.Aftermath, now);
+ }
+ }
+
+ public static bool IsContinuationOf(InterestCandidate current, InterestCandidate next)
+ {
+ if (current == null || next == null || !IsStoryAssetCandidate(next))
+ {
+ return false;
+ }
+
+ if (_active == null || !_active.IsActive)
+ {
+ return false;
+ }
+
+ if (!string.IsNullOrEmpty(_active.ClimaxKey)
+ && string.Equals(current.Key, _active.ClimaxKey, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ if (!string.IsNullOrEmpty(_active.AnchorKey)
+ && !string.IsNullOrEmpty(next.Key)
+ && next.Key.IndexOf(_active.AnchorKey, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return true;
+ }
+
+ return _active.ContainsCast(next.SubjectId) || _active.ContainsCast(next.RelatedId);
+ }
+
+ public static bool OwnsCandidate(InterestCandidate c)
+ {
+ if (c == null || _active == null || !_active.IsActive)
+ {
+ return false;
+ }
+
+ if (IsStoryAssetCandidate(c))
+ {
+ return true;
+ }
+
+ if (!string.IsNullOrEmpty(_active.ClimaxKey)
+ && string.Equals(c.Key, _active.ClimaxKey, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ public static float OwnershipBoost(InterestCandidate c)
+ {
+ if (!OwnsCandidate(c) || _active == null || !_active.HardHold)
+ {
+ // Aftermath/epilogue always get a modest boost so they can win quiet grace.
+ if (IsStoryAssetCandidate(c))
+ {
+ return InterestScoringConfig.Story.arcOwnershipBoost;
+ }
+
+ return 0f;
+ }
+
+ if (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue)
+ {
+ return InterestScoringConfig.Story.arcOwnershipBoost;
+ }
+
+ return 0f;
+ }
+
+ ///
+ /// Raised cut-in margin while a hard arc holds Aftermath/Epilogue against unrelated peers.
+ /// Returns 0 when the planner does not impose an extra hold.
+ ///
+ public static float ArcHoldMargin(InterestCandidate current, InterestCandidate next)
+ {
+ if (_active == null || !_active.HardHold || !_active.IsActive)
+ {
+ return 0f;
+ }
+
+ if (_active.Phase != StoryPhase.Aftermath && _active.Phase != StoryPhase.Epilogue)
+ {
+ return 0f;
+ }
+
+ if (current == null || next == null)
+ {
+ return 0f;
+ }
+
+ if (OwnsCandidate(next) || IsContinuationOf(current, next))
+ {
+ return 0f;
+ }
+
+ // Same cast life beats may still cut without the raised margin.
+ if (_active.ContainsCast(next.SubjectId))
+ {
+ return 0f;
+ }
+
+ StoryWeights s = InterestScoringConfig.Story;
+ return s.arcHoldMargin > 0f ? s.arcHoldMargin : 50f;
+ }
+
+ public static bool PreferOver(InterestCandidate a, InterestCandidate b)
+ {
+ if (a == null || b == null || _active == null || !_active.IsActive)
+ {
+ return false;
+ }
+
+ bool aOwn = OwnsCandidate(a);
+ bool bOwn = OwnsCandidate(b);
+ return aOwn && !bOwn;
+ }
+
+ private static void BeginOrRefreshArc(
+ InterestCandidate climax,
+ StoryArcKind kind,
+ bool hardHold,
+ float now)
+ {
+ string anchor = AnchorFor(climax, kind);
+ if (_active != null
+ && _active.IsActive
+ && string.Equals(_active.AnchorKey, anchor, StringComparison.Ordinal)
+ && _active.Kind == kind)
+ {
+ _active.ClimaxKey = climax.Key ?? "";
+ _active.HardHold = hardHold || _active.HardHold;
+ NoteCastFrom(climax, _active);
+ if (_active.Phase == StoryPhase.None || _active.Phase == StoryPhase.Done)
+ {
+ _active.Advance(StoryPhase.Climax, now);
+ }
+
+ return;
+ }
+
+ _active = new StoryArc
+ {
+ Id = "arc:" + kind + ":" + anchor,
+ Kind = kind,
+ AnchorKey = anchor,
+ ClimaxKey = climax.Key ?? "",
+ StartedAt = now,
+ HardHold = hardHold
+ };
+ _active.Advance(StoryPhase.Climax, now);
+ NoteCastFrom(climax, _active);
+ _coldHookFired = false;
+ _coldHookClimaxKey = "";
+ CausalHeat.NoteCast(_active.CastIds);
+ }
+
+ private static bool TryClassifyClimax(
+ InterestCandidate c,
+ out StoryArcKind kind,
+ out bool hardHold)
+ {
+ kind = StoryArcKind.None;
+ hardHold = false;
+ if (c == null || IsStoryAssetCandidate(c))
+ {
+ return false;
+ }
+
+ if (c.Completion == InterestCompletionKind.WarFront
+ || string.Equals(c.AssetId, "live_war", StringComparison.OrdinalIgnoreCase))
+ {
+ kind = StoryArcKind.WarFront;
+ hardHold = true;
+ return true;
+ }
+
+ if (c.Completion == InterestCompletionKind.PlotActive
+ || string.Equals(c.Category, "Plot", StringComparison.OrdinalIgnoreCase))
+ {
+ kind = StoryArcKind.Plot;
+ hardHold = true;
+ return true;
+ }
+
+ if (c.Completion == InterestCompletionKind.HappinessGrief
+ || (!string.IsNullOrEmpty(c.HappinessEffectId)
+ && c.HappinessEffectId.StartsWith("death_", StringComparison.OrdinalIgnoreCase)))
+ {
+ kind = StoryArcKind.Grief;
+ hardHold = true;
+ return true;
+ }
+
+ if (IsLoveBeat(c))
+ {
+ kind = StoryArcKind.Love;
+ hardHold = true;
+ return true;
+ }
+
+ if (c.Completion == InterestCompletionKind.CombatActive
+ || string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(c.Category, "Combat", StringComparison.OrdinalIgnoreCase))
+ {
+ bool mass = c.Sticky != null
+ && c.Sticky.HasPresentedScale
+ && c.Sticky.PresentedScale >= EnsembleScale.Skirmish;
+ int fighters = Mathf.Max(c.ParticipantCount, c.Sticky != null ? c.Sticky.TotalCount : 0);
+ if (!mass && fighters > InterestScoringConfig.W.duelMaxFighters)
+ {
+ mass = true;
+ }
+
+ bool notablePair = c.NotableParticipantCount >= 2;
+ if (mass)
+ {
+ kind = StoryArcKind.CombatMass;
+ hardHold = true;
+ }
+ else
+ {
+ kind = StoryArcKind.CombatDuel;
+ // Soft anonymous / single-notable duels: aftermath OK, no hard arc hold.
+ hardHold = notablePair;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsLoveBeat(InterestCandidate c)
+ {
+ if (c == null)
+ {
+ return false;
+ }
+
+ string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
+ if (asset == "set_lover" || asset == "find_lover" || asset == AftermathLoveKey())
+ {
+ return true;
+ }
+
+ string hap = (c.HappinessEffectId ?? "").Trim().ToLowerInvariant();
+ if (hap.IndexOf("lover", StringComparison.Ordinal) >= 0
+ || hap.IndexOf("fallen_in_love", StringComparison.Ordinal) >= 0
+ || hap.IndexOf("married", StringComparison.Ordinal) >= 0)
+ {
+ return true;
+ }
+
+ string cat = (c.Category ?? "").Trim();
+ return string.Equals(cat, "Relationship", StringComparison.OrdinalIgnoreCase)
+ && (asset.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
+ || asset.IndexOf("love", StringComparison.OrdinalIgnoreCase) >= 0);
+ }
+
+ private static string AftermathLoveKey() => StoryReason.AftermathLoveLinger;
+
+ private static string AnchorFor(InterestCandidate c, StoryArcKind kind)
+ {
+ if (c == null)
+ {
+ return "unknown";
+ }
+
+ if (!string.IsNullOrEmpty(c.Key)
+ && (c.Key.StartsWith("combat:pair:", StringComparison.OrdinalIgnoreCase)
+ || c.Key.StartsWith("war:", StringComparison.OrdinalIgnoreCase)
+ || c.Key.StartsWith("plot:", StringComparison.OrdinalIgnoreCase)))
+ {
+ return c.Key;
+ }
+
+ switch (kind)
+ {
+ case StoryArcKind.CombatDuel:
+ case StoryArcKind.CombatMass:
+ if (c.PairOwnerId != 0 && c.PairPartnerId != 0)
+ {
+ return EventCatalog.Combat.PairKey(c.PairOwnerId, c.PairPartnerId);
+ }
+
+ return !string.IsNullOrEmpty(c.Key) ? c.Key : "combat:" + c.SubjectId;
+ case StoryArcKind.WarFront:
+ return !string.IsNullOrEmpty(c.Key) ? c.Key : "war:" + c.SubjectId;
+ case StoryArcKind.Plot:
+ return !string.IsNullOrEmpty(c.Key) ? c.Key : "plot:" + c.SubjectId;
+ case StoryArcKind.Love:
+ long a = c.SubjectId;
+ long b = c.RelatedId;
+ if (a != 0 && b != 0)
+ {
+ long lo = a < b ? a : b;
+ long hi = a < b ? b : a;
+ return "love:" + lo + ":" + hi;
+ }
+
+ return "love:" + a;
+ case StoryArcKind.Grief:
+ return "grief:" + c.SubjectId + ":" + (c.HappinessEffectId ?? "");
+ default:
+ return c.Key ?? ("story:" + c.SubjectId);
+ }
+ }
+
+ private static void NoteCastFrom(InterestCandidate c, StoryArc arc)
+ {
+ if (c == null || arc == null)
+ {
+ return;
+ }
+
+ arc.NoteCast(c.SubjectId);
+ arc.NoteCast(c.RelatedId);
+ arc.NoteCast(c.PairOwnerId);
+ arc.NoteCast(c.PairPartnerId);
+ arc.NoteCast(c.TheaterLeadId);
+ if (c.Sticky != null)
+ {
+ for (int i = 0; i < c.Sticky.SideAIds.Count && i < 6; i++)
+ {
+ arc.NoteCast(c.Sticky.SideAIds[i]);
+ }
+
+ for (int i = 0; i < c.Sticky.SideBIds.Count && i < 6; i++)
+ {
+ arc.NoteCast(c.Sticky.SideBIds[i]);
+ }
+ }
+ }
+
+ private static bool TryClaimNaturalGrief(InterestCandidate climax, StoryArc arc)
+ {
+ PendingScratch.Clear();
+ InterestRegistry.CopyPending(PendingScratch);
+ InterestCandidate best = null;
+ for (int i = 0; i < PendingScratch.Count; i++)
+ {
+ InterestCandidate p = PendingScratch[i];
+ if (p == null || p.LeadKind != InterestLeadKind.EventLed)
+ {
+ continue;
+ }
+
+ bool grief = p.Completion == InterestCompletionKind.HappinessGrief
+ || (!string.IsNullOrEmpty(p.HappinessEffectId)
+ && p.HappinessEffectId.StartsWith("death_", StringComparison.OrdinalIgnoreCase));
+ if (!grief)
+ {
+ continue;
+ }
+
+ if (arc != null && !arc.ContainsCast(p.SubjectId) && !arc.ContainsCast(p.RelatedId))
+ {
+ // Still allow grief about climax cast deaths via RelatedId link to fallen.
+ if (climax != null
+ && p.RelatedId != climax.SubjectId
+ && p.RelatedId != climax.RelatedId
+ && p.SubjectId != climax.SubjectId)
+ {
+ continue;
+ }
+ }
+
+ if (best == null || p.TotalScore > best.TotalScore)
+ {
+ best = p;
+ }
+ }
+
+ if (best == null)
+ {
+ return false;
+ }
+
+ // Boost so grief wins quiet grace without a synthetic twin.
+ StoryWeights s = InterestScoringConfig.Story;
+ best.EventStrength = Mathf.Max(best.EventStrength, s.aftermathStrength);
+ best.CorrelationKey = "story:" + (arc?.AnchorKey ?? "");
+ InterestScoring.RecalcTotal(best);
+ if (arc != null)
+ {
+ NoteCastFrom(best, arc);
+ arc.PendingBeats.Enqueue(new StoryBeat
+ {
+ Phase = StoryPhase.Aftermath,
+ AssetId = best.AssetId,
+ CandidateKey = best.Key,
+ FollowId = best.SubjectId,
+ RelatedId = best.RelatedId,
+ EventStrength = best.EventStrength,
+ MinWatch = best.MinWatch,
+ MaxWatch = best.MaxWatch,
+ SynthesizeIfMissing = false
+ });
+ }
+
+ return true;
+ }
+
+ private static bool HasStrongerPendingPeer(InterestCandidate climax)
+ {
+ StoryWeights s = InterestScoringConfig.Story;
+ float floor = s.aftermathStrength - 5f;
+ PendingScratch.Clear();
+ InterestRegistry.CopyPending(PendingScratch);
+ for (int i = 0; i < PendingScratch.Count; i++)
+ {
+ InterestCandidate p = PendingScratch[i];
+ if (p == null || p.LeadKind != InterestLeadKind.EventLed || IsStoryAssetCandidate(p))
+ {
+ continue;
+ }
+
+ if (InterestScoring.IsFillScore(p.TotalScore))
+ {
+ continue;
+ }
+
+ if (p.EventStrength < floor && p.TotalScore < floor)
+ {
+ continue;
+ }
+
+ // Prefer natural life/story peers already queued for this cast.
+ if (climax != null
+ && (p.SubjectId == climax.SubjectId
+ || p.SubjectId == climax.RelatedId
+ || p.RelatedId == climax.SubjectId
+ || (_active != null && _active.ContainsCast(p.SubjectId))))
+ {
+ return true;
+ }
+
+ // High-strength parked Action tips (lover injects) also block synthetic aftermath.
+ if (p.EventStrength >= s.aftermathStrength)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryInjectAftermath(InterestCandidate climax, StoryArc arc, float now)
+ {
+ if (climax == null || arc == null)
+ {
+ return false;
+ }
+
+ Actor follow = ResolveAftermathFollow(climax);
+ if (follow == null || !follow.isAlive())
+ {
+ return false;
+ }
+
+ Actor related = ResolveAftermathRelated(climax, follow);
+ string assetId = PickAftermathAsset(arc.Kind, related);
+ StoryWeights s = InterestScoringConfig.Story;
+ DiscreteEventEntry catalog = EventCatalog.Story.GetOrFallback(assetId);
+ float strength = catalog != null && !catalog.IsFallback
+ ? catalog.EventStrength
+ : s.aftermathStrength;
+ string label = StoryReason.AftermathLabel(assetId, follow, related);
+ string key = "aftermath:" + 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 = strength > 0f ? strength : s.aftermathStrength,
+ VisualConfidence = 0.75f,
+ Novelty = 1f,
+ MinWatch = s.aftermathMinWatch > 0f ? s.aftermathMinWatch : 12f,
+ MaxWatch = s.aftermathMaxWatch > 0f ? s.aftermathMaxWatch : 20f,
+ Completion = InterestCompletionKind.FixedDwell,
+ Resumable = true,
+ CorrelationKey = "story:" + arc.AnchorKey,
+ CreatedAt = now,
+ LastSeenAt = now,
+ ExpiresAt = now + 45f
+ };
+
+ InterestCandidate registered = EventFeedUtil.RegisterCandidate(candidate);
+ if (registered == null)
+ {
+ return false;
+ }
+
+ arc.NoteCast(followId);
+ arc.NoteCast(relatedId);
+ arc.PendingBeats.Enqueue(new StoryBeat
+ {
+ Phase = StoryPhase.Aftermath,
+ AssetId = assetId,
+ CandidateKey = registered.Key,
+ FollowId = followId,
+ RelatedId = relatedId,
+ EventStrength = registered.EventStrength,
+ MinWatch = registered.MinWatch,
+ MaxWatch = registered.MaxWatch,
+ SynthesizeIfMissing = false
+ });
+ CausalHeat.NoteFeatured(followId);
+ CharacterLedger.Touch(followId, 1.2f);
+ return true;
+ }
+
+ private static void TryInjectEpilogue(StoryArc arc, float now)
+ {
+ if (arc == null || arc.Phase == StoryPhase.Epilogue || arc.Phase == StoryPhase.Done)
+ {
+ return;
+ }
+
+ Actor follow = null;
+ Actor related = null;
+ 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 = true,
+ 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);
+ CharacterLedger.Touch(followId, 0.8f);
+ }
+
+ private static Actor ResolveAftermathFollow(InterestCandidate climax)
+ {
+ if (climax == null)
+ {
+ return null;
+ }
+
+ if (climax.FollowUnit != null && climax.FollowUnit.isAlive())
+ {
+ return climax.FollowUnit;
+ }
+
+ if (climax.TheaterLeadId != 0)
+ {
+ Actor lead = EventFeedUtil.FindAliveById(climax.TheaterLeadId);
+ if (lead != null)
+ {
+ return lead;
+ }
+ }
+
+ if (climax.PairOwnerId != 0)
+ {
+ Actor owner = EventFeedUtil.FindAliveById(climax.PairOwnerId);
+ if (owner != null)
+ {
+ return owner;
+ }
+ }
+
+ if (climax.RelatedUnit != null && climax.RelatedUnit.isAlive())
+ {
+ return climax.RelatedUnit;
+ }
+
+ if (climax.PairPartnerId != 0)
+ {
+ Actor partner = EventFeedUtil.FindAliveById(climax.PairPartnerId);
+ if (partner != null)
+ {
+ return partner;
+ }
+ }
+
+ if (climax.Sticky != null)
+ {
+ for (int i = 0; i < climax.Sticky.SideAIds.Count; i++)
+ {
+ Actor a = EventFeedUtil.FindAliveById(climax.Sticky.SideAIds[i]);
+ if (a != null)
+ {
+ return a;
+ }
+ }
+
+ for (int i = 0; i < climax.Sticky.SideBIds.Count; i++)
+ {
+ Actor a = EventFeedUtil.FindAliveById(climax.Sticky.SideBIds[i]);
+ if (a != null)
+ {
+ return a;
+ }
+ }
+ }
+
+ // Mourner via relations of the fallen subject.
+ Actor fallenPeer = EventFeedUtil.FindAliveById(climax.SubjectId);
+ if (fallenPeer == null && climax.SubjectId != 0)
+ {
+ // Subject dead - look for lover/friend of related or pair partner.
+ Actor partner = climax.RelatedUnit != null && climax.RelatedUnit.isAlive()
+ ? climax.RelatedUnit
+ : EventFeedUtil.FindAliveById(climax.RelatedId);
+ if (partner != null)
+ {
+ Actor mourner = ActorRelation.GetLover(partner) ?? ActorRelation.GetBestFriend(partner);
+ if (mourner != null)
+ {
+ return mourner;
+ }
+
+ return partner;
+ }
+ }
+
+ return null;
+ }
+
+ private static Actor ResolveAftermathRelated(InterestCandidate climax, Actor follow)
+ {
+ if (climax == null)
+ {
+ return null;
+ }
+
+ long followId = EventFeedUtil.SafeId(follow);
+ if (climax.RelatedUnit != null
+ && climax.RelatedUnit.isAlive()
+ && EventFeedUtil.SafeId(climax.RelatedUnit) != followId)
+ {
+ return climax.RelatedUnit;
+ }
+
+ if (climax.FollowUnit != null
+ && climax.FollowUnit.isAlive()
+ && EventFeedUtil.SafeId(climax.FollowUnit) != followId)
+ {
+ return climax.FollowUnit;
+ }
+
+ if (climax.PairPartnerId != 0 && climax.PairPartnerId != followId)
+ {
+ Actor p = EventFeedUtil.FindAliveById(climax.PairPartnerId);
+ if (p != null)
+ {
+ return p;
+ }
+ }
+
+ if (climax.PairOwnerId != 0 && climax.PairOwnerId != followId)
+ {
+ Actor p = EventFeedUtil.FindAliveById(climax.PairOwnerId);
+ if (p != null)
+ {
+ return p;
+ }
+ }
+
+ Actor lover = ActorRelation.GetLover(follow);
+ if (lover != null)
+ {
+ return lover;
+ }
+
+ return ActorRelation.GetBestFriend(follow);
+ }
+
+ private static string PickAftermathAsset(StoryArcKind kind, Actor related)
+ {
+ switch (kind)
+ {
+ case StoryArcKind.WarFront:
+ return StoryReason.AftermathWarLinger;
+ case StoryArcKind.Plot:
+ return StoryReason.AftermathPlotFallout;
+ case StoryArcKind.Love:
+ return StoryReason.AftermathLoveLinger;
+ case StoryArcKind.Grief:
+ return StoryReason.AftermathMourner;
+ default:
+ return related != null && !related.isAlive()
+ ? StoryReason.AftermathSurvivor
+ : StoryReason.AftermathSurvivor;
+ }
+ }
+
+ private static bool IsStoryAssetCandidate(InterestCandidate c) =>
+ c != null && (StoryReason.IsStoryAsset(c.AssetId)
+ || string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase));
+}
diff --git a/IdleSpectator/Story/StoryReason.cs b/IdleSpectator/Story/StoryReason.cs
new file mode 100644
index 0000000..73d8653
--- /dev/null
+++ b/IdleSpectator/Story/StoryReason.cs
@@ -0,0 +1,59 @@
+namespace IdleSpectator;
+
+/// Subject-led orange Labels for synthetic story aftermath / epilogue beats.
+public static class StoryReason
+{
+ public const string AftermathSurvivor = "aftermath_survivor";
+ public const string AftermathMourner = "aftermath_mourner";
+ public const string AftermathWarLinger = "aftermath_war_linger";
+ public const string AftermathPlotFallout = "aftermath_plot_fallout";
+ public const string AftermathLoveLinger = "aftermath_love_linger";
+ public const string EpilogueRelated = "epilogue_related";
+
+ public static bool IsStoryAsset(string assetId)
+ {
+ if (string.IsNullOrEmpty(assetId))
+ {
+ return false;
+ }
+
+ return assetId.StartsWith("aftermath_", System.StringComparison.OrdinalIgnoreCase)
+ || assetId.StartsWith("epilogue_", System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static string AftermathLabel(string assetId, Actor follow, Actor related)
+ {
+ string name = EventFeedUtil.SafeName(follow);
+ if (string.IsNullOrEmpty(name))
+ {
+ name = "Someone";
+ }
+
+ string other = EventFeedUtil.SafeName(related);
+ string id = (assetId ?? "").Trim().ToLowerInvariant();
+ switch (id)
+ {
+ case AftermathMourner:
+ return string.IsNullOrEmpty(other)
+ ? name + " mourns the fallen"
+ : name + " mourns " + other;
+ case AftermathWarLinger:
+ return name + " lingers on the war front";
+ case AftermathPlotFallout:
+ return name + " faces the plot's fallout";
+ case AftermathLoveLinger:
+ return string.IsNullOrEmpty(other)
+ ? name + " stays with their love"
+ : name + " stays beside " + other;
+ case EpilogueRelated:
+ return string.IsNullOrEmpty(other)
+ ? name + " carries on after the story"
+ : name + " seeks " + other + " after the story";
+ case AftermathSurvivor:
+ default:
+ return string.IsNullOrEmpty(other)
+ ? name + " stands over the fallen"
+ : name + " stands over " + other;
+ }
+ }
+}
diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs
index 8874d41..24bb3e0 100644
--- a/IdleSpectator/WorldActivityScanner.cs
+++ b/IdleSpectator/WorldActivityScanner.cs
@@ -1020,7 +1020,22 @@ public static class WorldActivityScanner
{
SplitActorScore(actor, speciesCounts, out float action, out float character);
// Action-primary total used for ranking; character is a light tie-break.
- return action + character * InterestScoringConfig.W.scannerRankCharWeight;
+ float score = action + character * InterestScoringConfig.W.scannerRankCharWeight;
+ // Character Ledger bias for ambient fill - prefer watched lives over strangers.
+ long id = EventFeedUtil.SafeId(actor);
+ float ledger = CharacterLedger.Heat(id);
+ if (ledger > 0f)
+ {
+ score += ledger * Mathf.Max(1f, InterestScoringConfig.Story.ledgerWeight * 0.35f);
+ }
+
+ float causal = CausalHeat.Get(id);
+ if (causal > 0f)
+ {
+ score += causal * 2f;
+ }
+
+ return score;
}
///
diff --git a/IdleSpectator/event-catalog.json b/IdleSpectator/event-catalog.json
index 3af85bf..0aa9265 100644
--- a/IdleSpectator/event-catalog.json
+++ b/IdleSpectator/event-catalog.json
@@ -2588,6 +2588,50 @@
"label": "{a} is created as a baby"
}
],
+ "story": [
+ {
+ "id": "aftermath_survivor",
+ "strength": 62,
+ "category": "Story",
+ "camera": true,
+ "label": "{a} stands over the fallen"
+ },
+ {
+ "id": "aftermath_mourner",
+ "strength": 62,
+ "category": "Story",
+ "camera": true,
+ "label": "{a} mourns the fallen"
+ },
+ {
+ "id": "aftermath_war_linger",
+ "strength": 62,
+ "category": "Story",
+ "camera": true,
+ "label": "{a} lingers on the war front"
+ },
+ {
+ "id": "aftermath_plot_fallout",
+ "strength": 62,
+ "category": "Story",
+ "camera": true,
+ "label": "{a} faces the plot's fallout"
+ },
+ {
+ "id": "aftermath_love_linger",
+ "strength": 58,
+ "category": "Story",
+ "camera": true,
+ "label": "{a} stays with their love"
+ },
+ {
+ "id": "epilogue_related",
+ "strength": 48,
+ "category": "Story",
+ "camera": true,
+ "label": "{a} carries on after the story"
+ }
+ ],
"traits": [
{
"id": "zombie",
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index dc0fa8b..071ef64 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.28.38",
+ "version": "0.28.40",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json
index fdcaff0..52a9906 100644
--- a/IdleSpectator/scoring-model.json
+++ b/IdleSpectator/scoring-model.json
@@ -1,5 +1,5 @@
{
- "modVersion": "0.28.38",
+ "modVersion": "0.28.39",
"title": "IdleSpectator interest scoring model",
"updated": "2026-07-18",
"note": "Edit weights below - InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).",
@@ -112,6 +112,23 @@
"metaCacheTtl": 2
},
+ "story": {
+ "nearTieEpsilon": 8,
+ "causalHeatHalfLifeSeconds": 180,
+ "causalHeatWeight": 6,
+ "causalRelatedWeight": 3,
+ "arcOwnershipBoost": 18,
+ "arcHoldMargin": 50,
+ "aftermathStrength": 62,
+ "aftermathMinWatch": 12,
+ "aftermathMaxWatch": 20,
+ "epilogueStrength": 48,
+ "epilogueMaxWatch": 14,
+ "ledgerCap": 16,
+ "ledgerWeight": 8,
+ "historyDensityWindowSeconds": 600
+ },
+
"sources": [
"IdleSpectator/scoring-model.json",
"IdleSpectator/InterestScoringConfig.cs",
@@ -143,7 +160,12 @@
{
"id": "variety",
"name": "Variety pick",
- "detail": "InterestVariety.Pick soft-splits EventLed vs CharacterLed (~70/30), applies novelty penalties, RecalcTotal, EnrichTopK, then probabilistic top-3 by TotalScore."
+ "detail": "InterestVariety.Pick soft-splits EventLed vs CharacterLed (~70/30), applies novelty penalties, RecalcTotal, EnrichTopK, then takes #1 unless near-tie (story.nearTieEpsilon) among top-3. StoryPlanner ownership can prefer aftermath/epilogue beats."
+ },
+ {
+ "id": "story",
+ "name": "StoryPlanner",
+ "detail": "Owns short arcs (climax→aftermath→epilogue). Injects aftermath on climax cold, applies causal/ledger heat and arc ownership boosts. Never calls MoveCamera."
},
{
"id": "director",
diff --git a/docs/event-catalog-discovery.md b/docs/event-catalog-discovery.md
index 73e83a2..4efeea9 100644
--- a/docs/event-catalog-discovery.md
+++ b/docs/event-catalog-discovery.md
@@ -66,7 +66,7 @@ Seeded via `LiveLibraryInterest.EnsureSeeded` + JSON `libraries.*` dials.
| spells | 12 | 6 | `{a} casts {id}` | Signal spectacle only; FX spells B | P3 |
| powers | 339 | **41** | `God power: {id}` | ambient CI=false; widened spectacle + creature powers; UI `_edit` B | P3 expanded 0.28.27 |
| items | 111 | **42** | `{a} gains {id}` | Signal mythril/adamantine + silver/steel/bone; leather/iron B | P3 expanded 0.28.27 |
-| genes | 47 | **7** | `{a} gains gene {id}` | warfare/damage/mutagen Signal | P3 expanded 0.28.27 |
+| genes | 47 | **0** | `{a} gains gene {id}` | all B (gain/remove tips not camera-worthy) | demoted |
| phenotypes | 52 | 0 | `{a} shows {id}` | all B | P3 |
| world_laws | 49 | **17** | `Law changed: {id}` | dramatic + forever_/cursed/army/expansion… | P3 expanded 0.28.27 |
| subspecies_traits | 204 | **21** | `{a} evolves {id}` | dramatic + mutation/death/blood/horror | P3 expanded 0.28.27 |
diff --git a/docs/event-catalog-discovery.tsv b/docs/event-catalog-discovery.tsv
index 128c221..786ee08 100644
--- a/docs/event-catalog-discovery.tsv
+++ b/docs/event-catalog-discovery.tsv
@@ -13,7 +13,7 @@ decisions_library 127 live+dial 0 33 wired action_phrases P3 intent genesis demo
spells 12 live+dial 0 6 wired display_names P3 Signal spectacle; 6 FX B
powers 339 live+dial 0 41 wired display_names P3 Signal widened 0.28.27; ambient CI=false
items 111 live+dial 0 42 wired gains_verb P3 Signal mythril/adamantine/silver/steel/bone
-genes 47 live+dial 0 7 wired display_names P3 warfare/damage/mutagen Signal
+genes 47 live+dial 0 0 wired display_names P3 all B; gain/remove not camera
phenotypes 52 live+dial 0 0 wired display_names P3 all B
world_laws_library 49 live+dial 0 17 wired display_names P3 dramatic + forever_/cursed/army…
subspecies_traits 204 live+dial 0 21 wired display_names P3 dramatic + mutation/death/blood
diff --git a/docs/event-reason.md b/docs/event-reason.md
index ec828fa..74b54b3 100644
--- a/docs/event-reason.md
+++ b/docs/event-reason.md
@@ -7,6 +7,8 @@
| **B ticker** | Activity / Life / chronicle prose for live ids | Always may write when the game fires |
| **A story camera** | Interest candidates, orange reason, camera cuts | `EventCatalog.IsCameraWorthy` only |
+Story commitment (climax → aftermath → epilogue) is owned by [`StoryPlanner`](story-planner.md); orange aftermath Labels come from `StoryReason` and must stay subject-led for presentability.
+
Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only.
Discrete / status / WorldLog: `CreatesInterest` = A gate.
`InterestDirector` only ranks A candidates. It does not author inventory.
diff --git a/docs/scoring-model.md b/docs/scoring-model.md
index 68826fd..f2776ee 100644
--- a/docs/scoring-model.md
+++ b/docs/scoring-model.md
@@ -4,6 +4,8 @@
**Per-event inventory (strength + prose):** [`IdleSpectator/event-catalog.json`](../IdleSpectator/event-catalog.json)
+**Story commitment (arcs / aftermath / ledger):** [`story-planner.md`](story-planner.md)
+
- `scoring-model.json` - margins, multipliers, rarity caps, soft-Duel / variety valve knobs
- `event-catalog.json` - every authored event id (`happiness`, `status`, `worldLog`, `plots`, `decisions`, …)
- Decisions use `action` (clause after "decides to …")
diff --git a/docs/story-planner.md b/docs/story-planner.md
new file mode 100644
index 0000000..c59429a
--- /dev/null
+++ b/docs/story-planner.md
@@ -0,0 +1,70 @@
+# Story Planner
+
+IdleSpectator commits to short multi-beat stories instead of channel-surfing ranked tips.
+
+Feeds, sticky ensembles, presentability, and `InterestDirector` stay in place.
+`StoryPlanner` sits above the director and owns *which story* the next 30–90s belongs to.
+
+See also: [scoring-model.md](scoring-model.md), [event-reason.md](event-reason.md).
+
+## Pipeline
+
+```text
+Feeds → InterestRegistry → StoryPlanner (boost / inject)
+ → InterestVariety.Pick → InterestDirector → CameraDirector
+```
+
+Director still owns camera, sticky maintain, and switch margins.
+Planner never calls `MoveCamera`.
+
+## Arc model
+
+| Kind | Climax | Hard hold |
+|------|--------|-----------|
+| CombatDuel | sticky pair | only notable pairs (2+ notables) |
+| CombatMass | Skirmish+ | yes |
+| WarFront / Plot / Love / Grief | existing sticky / life | yes |
+
+Phases: `Climax → Aftermath → Epilogue → Done`.
+
+- **Aftermath** injects on climax cold (quiet grace), or claims natural `death_*` grief.
+- **Epilogue** once after aftermath if a living lover/friend resolves.
+- Soft anonymous duels still get aftermath tips; they do not use raised `arcHoldMargin`.
+
+## Selection discipline
+
+- `InterestVariety.Pick` takes `#1` when score gap ≥ `story.nearTieEpsilon` (default 8).
+- Near-ties still roll among top-3.
+- Causal heat + Character Ledger heat add into `RecalcTotal`.
+
+## Knobs
+
+`scoring-model.json` → `story` block (loaded by `InterestScoringConfig.Story`).
+
+Catalog policy ids live under `event-catalog.json` → `story`
+(`aftermath_survivor`, `aftermath_mourner`, `aftermath_war_linger`, …).
+
+## Harness
+
+| Scenario | Proves |
+|----------|--------|
+| `story_aftermath_combat` | duel cold → aftermath tip |
+| `story_aftermath_war` | war sticky cold → aftermath |
+| `story_near_tie` | large gap always picks #1 |
+| `story_arc_combat` | climax → aftermath ownership |
+| `story_arc_preempt_resume` | epic cut then fresh scrap aftermath |
+| `story_ledger_revisit` | featured unit wins near-tie |
+
+```bash
+./scripts/harness-run.sh --repeat 3 story_aftermath_combat
+./scripts/harness-run.sh --repeat 3 story_near_tie
+./scripts/harness-run.sh --repeat 3 story_arc_combat
+./scripts/harness-run.sh --repeat 3 story_ledger_revisit
+```
+
+## Files
+
+- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject
+- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
+- `IdleSpectator/Story/CharacterLedger.cs` - watched lives (cap 16)
+- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
diff --git a/docs/world-event-inventory.md b/docs/world-event-inventory.md
index 9a38107..1933b6e 100644
--- a/docs/world-event-inventory.md
+++ b/docs/world-event-inventory.md
@@ -53,7 +53,7 @@ Only true brief FX, cooldowns, civic Aggregates, and god-tool / biome spam stay
| Plot / Era / War / Book | 28 / 11 / 5 / 12 | all A |
| Trait | 116 | all CI |
| Decision | 127 live / 37 prose overlays | **37** camera-interesting (0.28.27) |
-| Spell / Item / Gene / Phenotype / WorldLaw / SubspeciesTrait | live | Signal-only A: spell 6 / item 42 / gene 7 / phenotype 0 / law 17 / subsp 21 |
+| Spell / Item / Gene / Phenotype / WorldLaw / SubspeciesTrait | live | Signal-only A: spell 6 / item 42 / gene 0 / phenotype 0 / law 17 / subsp 21 |
| Culture / Religion / Clan / LanguageTrait | live dials | Signal dramatic: 27 / 6 / 13 / 2 |
| Power / Biome | live | Power Signal **41**; biome 0 (ambient CI=false) |
| Library+decision inventory dump | 1143 rows | **219 A / 924 B** (`.harness/camera-a-inventory.tsv`) |