Story Beats
This commit is contained in:
parent
5cdea1930a
commit
954016f4b6
27 changed files with 2309 additions and 37 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
62
IdleSpectator/Events/Catalogs/EventCatalog.Story.cs
Normal file
62
IdleSpectator/Events/Catalogs/EventCatalog.Story.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ public static partial class EventCatalog
|
|||
WorldLog.InvalidateOverlays();
|
||||
Plot.InvalidateOverlays();
|
||||
Relationship.InvalidateOverlays();
|
||||
Story.InvalidateOverlays();
|
||||
Trait.InvalidateOverlays();
|
||||
Book.InvalidateOverlays();
|
||||
Era.InvalidateOverlays();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
134
IdleSpectator/Story/CausalHeat.cs
Normal file
134
IdleSpectator/Story/CausalHeat.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
240
IdleSpectator/Story/CharacterLedger.cs
Normal file
240
IdleSpectator/Story/CharacterLedger.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
IdleSpectator/Story/StoryArc.cs
Normal file
78
IdleSpectator/Story/StoryArc.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
15
IdleSpectator/Story/StoryBeat.cs
Normal file
15
IdleSpectator/Story/StoryBeat.cs
Normal 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;
|
||||
}
|
||||
960
IdleSpectator/Story/StoryPlanner.cs
Normal file
960
IdleSpectator/Story/StoryPlanner.cs
Normal 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));
|
||||
}
|
||||
59
IdleSpectator/Story/StoryReason.cs
Normal file
59
IdleSpectator/Story/StoryReason.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
70
docs/story-planner.md
Normal 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 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
|
||||
|
|
@ -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`) |
|
||||
|
|
|
|||
Loading…
Reference in a new issue