From 954016f4b6a6adbc4e66eec46cda3bc92146d2c6 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 21 Jul 2026 13:35:07 -0500 Subject: [PATCH] Story Beats --- IdleSpectator/AgentHarness.cs | 108 +- IdleSpectator/Chronicle.cs | 74 ++ IdleSpectator/EventCatalogConfig.cs | 8 +- .../Events/Catalogs/EventCatalog.Libraries.cs | 17 +- .../Events/Catalogs/EventCatalog.Story.cs | 62 ++ IdleSpectator/Events/EventCatalog.cs | 1 + IdleSpectator/HarnessScenarios.cs | 223 ++++ IdleSpectator/InterestDirector.cs | 79 +- IdleSpectator/InterestScoring.cs | 23 +- IdleSpectator/InterestScoringConfig.cs | 52 +- IdleSpectator/InterestVariety.cs | 44 +- IdleSpectator/Story/CausalHeat.cs | 134 +++ IdleSpectator/Story/CharacterLedger.cs | 240 +++++ IdleSpectator/Story/StoryArc.cs | 78 ++ IdleSpectator/Story/StoryBeat.cs | 15 + IdleSpectator/Story/StoryPlanner.cs | 960 ++++++++++++++++++ IdleSpectator/Story/StoryReason.cs | 59 ++ IdleSpectator/WorldActivityScanner.cs | 17 +- IdleSpectator/event-catalog.json | 44 + IdleSpectator/mod.json | 2 +- IdleSpectator/scoring-model.json | 26 +- docs/event-catalog-discovery.md | 2 +- docs/event-catalog-discovery.tsv | 2 +- docs/event-reason.md | 2 + docs/scoring-model.md | 2 + docs/story-planner.md | 70 ++ docs/world-event-inventory.md | 2 +- 27 files changed, 2309 insertions(+), 37 deletions(-) create mode 100644 IdleSpectator/Events/Catalogs/EventCatalog.Story.cs create mode 100644 IdleSpectator/Story/CausalHeat.cs create mode 100644 IdleSpectator/Story/CharacterLedger.cs create mode 100644 IdleSpectator/Story/StoryArc.cs create mode 100644 IdleSpectator/Story/StoryBeat.cs create mode 100644 IdleSpectator/Story/StoryPlanner.cs create mode 100644 IdleSpectator/Story/StoryReason.cs create mode 100644 docs/story-planner.md 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`) |