Story Beats

This commit is contained in:
DazedAnon 2026-07-21 13:35:07 -05:00
parent 5cdea1930a
commit 954016f4b6
27 changed files with 2309 additions and 37 deletions

View file

@ -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;
}

View file

@ -461,6 +461,80 @@ public static class Chronicle
}
}
/// <summary>
/// Count History rows for <paramref name="subjectId"/> with CreatedAt inside the last
/// <paramref name="windowSeconds"/> (unscaled). Used by Character Ledger density.
/// </summary>
public static int RecentHistoryCount(long subjectId, float windowSeconds)
{
if (subjectId == 0 || windowSeconds <= 0f)
{
return 0;
}
IReadOnlyList<ChronicleEntry> 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;
}
/// <summary>
/// Collect distinct <see cref="ChronicleEntry.OtherId"/> values from subject History.
/// Newest rows first; stops at <paramref name="max"/>.
/// </summary>
public static void EnumerateRelatedIds(long subjectId, List<long> into, int max = 8)
{
if (into == null)
{
return;
}
into.Clear();
if (subjectId == 0 || max <= 0)
{
return;
}
IReadOnlyList<ChronicleEntry> 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);
}
}
}
/// <summary>Newest-first slice for compact dossier HUD.</summary>
public static IReadOnlyList<ChronicleEntry> LatestForSubject(long subjectId, int max)
{

View file

@ -51,6 +51,8 @@ public static class EventCatalogConfig
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Relationship =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Story =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Traits =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> 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<string, DiscreteEventEntry> into) => FillDiscrete(Plots, into);
public static int FillRelationship(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Relationship, into);
public static int FillStory(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Story, into);
public static int FillTraits(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Traits, into);
public static int FillBooks(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Books, into);
public static int FillEras(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Eras, into);

View file

@ -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<string> AuthoredIds
{
get

View file

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Story aftermath / epilogue dials from <c>event-catalog.json</c> <c>story</c>.</summary>
public static partial class EventCatalog
{
public static class Story
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(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<string> 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
};
}
}
}

View file

@ -34,6 +34,7 @@ public static partial class EventCatalog
WorldLog.InvalidateOverlays();
Plot.InvalidateOverlays();
Relationship.InvalidateOverlays();
Story.InvalidateOverlays();
Trait.InvalidateOverlays();
Book.InvalidateOverlays();
Era.InvalidateOverlays();

View file

@ -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
};
}
/// <summary>
/// Combat cold injects story aftermath (survivor tip) when no stronger peer is pending.
/// </summary>
private static List<HarnessCommand> StoryAftermathCombat()
{
return new List<HarnessCommand>
{
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<HarnessCommand> StoryAftermathWar()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Large score gap always picks #1; within epsilon may roll among top-3.</summary>
private static List<HarnessCommand> StoryNearTie()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Combat climax → aftermath ownership; soft ambient must not steal mid-aftermath.</summary>
private static List<HarnessCommand> StoryArcCombat()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Epic preempt during climax; aftermath still available after cold.</summary>
private static List<HarnessCommand> StoryArcPreemptResume()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Featured unit wins a near-tie against a stranger milestone.</summary>
private static List<HarnessCommand> StoryLedgerRevisit()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Sticky WarFront tips stay kingdom-framed across count dips and follow loss.
/// </summary>

View file

@ -298,6 +298,37 @@ public static class InterestDirector
}
_inactiveSince = now;
StoryPlanner.OnClimaxCold(_current);
}
/// <summary>
/// Harness: force WarFront / PlotActive / CombatActive cold so story aftermath can inject.
/// </summary>
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);
}
/// <summary>Harness: clear FollowUnit while keeping RelatedUnit for death handoff.</summary>
@ -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());
}

View file

@ -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;
}

View file

@ -15,6 +15,27 @@ public class ScoringModelDocument
{
public string modVersion;
public ScoringWeights weights;
public StoryWeights story;
}
/// <summary>StoryPlanner knobs from scoring-model.json <c>story</c> block.</summary>
[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;
}
/// <summary>Serializable weight block — field names must match scoring-model.json "weights".</summary>
@ -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).
/// </summary>
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<StoryWeights>(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;

View file

@ -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];
}

View file

@ -0,0 +1,134 @@
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

@ -0,0 +1,240 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Small cast of watched lives. Heat from favorites, focus, CausalHeat, and Chronicle density.
/// </summary>
public static class CharacterLedger
{
private struct LedgerEntry
{
public long UnitId;
public float Heat;
public float TouchedAt;
}
private static readonly List<LedgerEntry> Entries = new List<LedgerEntry>(20);
private static readonly List<int> ScratchIdx = new List<int>(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<long> 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<Actor> 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);
}
}
}

View file

@ -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
}
/// <summary>
/// Short multi-beat story ownership: climax sticky/life scene → aftermath → optional epilogue.
/// </summary>
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;
/// <summary>True when this arc uses hard hold margins (not soft anonymous duels).</summary>
public bool HardHold;
public readonly List<long> CastIds = new List<long>(4);
public readonly Queue<StoryBeat> PendingBeats = new Queue<StoryBeat>(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;
}
}

View file

@ -0,0 +1,15 @@
namespace IdleSpectator;
/// <summary>Queued aftermath / epilogue beat for an active <see cref="StoryArc"/>.</summary>
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;
}

View file

@ -0,0 +1,960 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Owns short multi-beat story commitment above InterestDirector.
/// Never calls MoveCamera - injects / boosts candidates and reports hold policy.
/// </summary>
public static class StoryPlanner
{
private static StoryArc _active;
private static bool _coldHookFired;
private static string _coldHookClimaxKey = "";
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(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);
}
}
}
/// <summary>
/// Called when a climax scene first goes inactive (entering quiet grace).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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));
}

View file

@ -0,0 +1,59 @@
namespace IdleSpectator;
/// <summary>Subject-led orange Labels for synthetic story aftermath / epilogue beats.</summary>
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;
}
}
}

View file

@ -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;
}
/// <summary>

View file

@ -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",

View file

@ -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"
}

View file

@ -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",

View file

@ -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 |

View file

@ -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

1 library_or_hook live_count catalog_mode missing_ids camera_a wire_status tip_quality priority notes
13 spells 12 live+dial 0 6 wired display_names P3 Signal spectacle; 6 FX B
14 powers 339 live+dial 0 41 wired display_names P3 Signal widened 0.28.27; ambient CI=false
15 items 111 live+dial 0 42 wired gains_verb P3 Signal mythril/adamantine/silver/steel/bone
16 genes 47 live+dial 0 7 0 wired display_names P3 warfare/damage/mutagen Signal all B; gain/remove not camera
17 phenotypes 52 live+dial 0 0 wired display_names P3 all B
18 world_laws_library 49 live+dial 0 17 wired display_names P3 dramatic + forever_/cursed/army…
19 subspecies_traits 204 live+dial 0 21 wired display_names P3 dramatic + mutation/death/blood

View file

@ -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.

View file

@ -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 …")

70
docs/story-planner.md Normal file
View file

@ -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 3090s 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

View file

@ -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`) |