diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 051ff8a..b471725 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -1359,6 +1359,24 @@ public static class AgentHarness
break;
}
+ case "focus_partner":
+ {
+ Actor partner = _happinessPartner;
+ bool ok = partner != null && partner.isAlive();
+ if (ok)
+ {
+ CameraDirector.FocusUnit(partner);
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: ok ? $"focusPartner={SafeName(partner)}" : "no partner");
+ break;
+ }
+
case "happiness_bond_lovers":
{
Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@@ -2954,6 +2972,99 @@ public static class AgentHarness
break;
}
+ case "saga_force_admit_partner":
+ {
+ Actor partner = _happinessPartner;
+ long id = EventFeedUtil.SafeId(partner);
+ bool ok = LifeSagaRoster.HarnessForceAdmit(partner);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: $"admitPartner id={id} roster={LifeSagaRoster.Count} on={ok}");
+ break;
+ }
+
+ case "saga_note_encounters":
+ {
+ Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ Actor b = _happinessPartner;
+ int times = 3;
+ if (!string.IsNullOrEmpty(cmd.value) && int.TryParse(cmd.value.Trim(), out int parsed))
+ {
+ times = Mathf.Max(1, parsed);
+ }
+
+ bool ok = a != null && b != null && a != b;
+ if (ok)
+ {
+ // Bypass cooldown by clearing pair cool via Clear would wipe memory - call with
+ // distinct provenance and temporarily force by calling NoteCombatEncounter
+ // after clearing only PairEncounterCooldown through repeated ClearSession is bad.
+ // Use EarnRival path for rematch: clear cooldown by noting with spaced calls.
+ for (int i = 0; i < times; i++)
+ {
+ LifeSagaMemory.HarnessNoteCombatEncounter(a, b, "harness_rematch:" + i);
+ }
+
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: $"encounters={times} a={SafeName(a)} b={SafeName(b)}");
+ break;
+ }
+
+ case "saga_record_kill_partner":
+ {
+ Actor killer = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ Actor victim = _happinessPartner;
+ bool ok = killer != null && victim != null && killer != victim;
+ if (ok)
+ {
+ LifeSagaMemory.RecordKill(killer, victim, "harness_kill");
+ LifeSagaMemory.RecordDeath(victim, killer, "harness", "harness_death");
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: $"kill {SafeName(killer)} -> {SafeName(victim)}");
+ break;
+ }
+
+ case "interest_bind_related_partner":
+ {
+ InterestCandidate cur = InterestDirector.CurrentCandidate;
+ Actor partner = _happinessPartner;
+ bool ok = cur != null && partner != null && partner.isAlive();
+ if (ok)
+ {
+ cur.RelatedUnit = partner;
+ cur.RelatedId = EventFeedUtil.SafeId(partner);
+ InterestScoring.RecalcTotal(cur);
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok,
+ detail: $"bind related={SafeName(partner)} tip={cur?.Label} score={cur?.TotalScore:0.#}");
+ break;
+ }
+
case "saga_stamp_chapter_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@@ -8476,6 +8587,60 @@ public static class AgentHarness
$"preferPip={LifeSagaRail.LastPreferPipVisible} want={want} activeLit={LifeSagaRail.LastActiveHighlight}";
break;
}
+ case "saga_rail_involved_count":
+ {
+ LifeSagaRail.Refresh();
+ int want = 1;
+ if (!string.IsNullOrEmpty(cmd.value) && int.TryParse(cmd.value.Trim(), out int parsed))
+ {
+ want = parsed;
+ }
+
+ pass = LifeSagaRail.LastInvolvedHighlightCount >= want;
+ detail =
+ $"involvedLit={LifeSagaRail.LastInvolvedHighlightCount} want>={want} active={LifeSagaRail.LastActiveHighlight}";
+ break;
+ }
+ case "saga_rival_focus":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ long id = EventFeedUtil.SafeId(focus);
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = LifeSagaMemory.TryGetEarnedRival(id, out LifeSagaFact rival) && rival != null;
+ pass = have == want;
+ detail = $"rival focus={SafeName(focus)} have={have} want={want} note={rival?.Note}";
+ break;
+ }
+ case "saga_crossover_focus":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ long id = EventFeedUtil.SafeId(focus);
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = false;
+ var facts = LifeSagaMemory.FactsFor(id);
+ for (int i = 0; i < facts.Count; i++)
+ {
+ if (facts[i] != null && facts[i].McCrossover)
+ {
+ have = true;
+ break;
+ }
+ }
+
+ pass = have == want;
+ detail = $"crossover focus={SafeName(focus)} have={have} want={want} facts={facts.Count}";
+ break;
+ }
+ case "saga_combat_weight_beats_partner":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ Actor partner = _happinessPartner;
+ float wf = WorldActivityScanner.CombatFocusWeight(focus);
+ float wp = WorldActivityScanner.CombatFocusWeight(partner);
+ pass = focus != null && partner != null && wf > wp;
+ detail = $"focusW={wf:0.#} partnerW={wp:0.#} focus={SafeName(focus)} partner={SafeName(partner)}";
+ break;
+ }
case "saga_snapshot_layout":
{
int rows = LifeSagaRail.LastHoverRowCount;
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 6a4e747..c407a93 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -155,6 +155,22 @@ internal static class HarnessScenarios
return SagaReplaceOnDeath();
case "saga_prefer_soft_bias":
return SagaPreferSoftBias();
+ case "saga_wins_moderate_gap":
+ return SagaWinsModerateGap();
+ case "saga_combat_follow_mc":
+ return SagaCombatFollowMc();
+ case "saga_cast_soft_bias":
+ return SagaCastSoftBias();
+ case "saga_cross_mc_light":
+ return SagaCrossMcLight();
+ case "saga_cross_mc_kill":
+ return SagaCrossMcKill();
+ case "saga_rival_not_single_kill":
+ return SagaRivalNotSingleKill();
+ case "saga_rival_rematch":
+ return SagaRivalRematch();
+ case "saga_prefer_cuts_ambient":
+ return SagaPreferCutsAmbient();
case "saga_overview_has_mc":
return SagaOverviewHasMc();
case "saga_snapshot_popover":
@@ -363,6 +379,14 @@ internal static class HarnessScenarios
Nested("reg_story_family", "story_family_variety"),
Nested("reg_saga_camera", "saga_camera_prefers_mc"),
Nested("reg_saga_prefer", "saga_prefer_soft_bias"),
+ Nested("reg_saga_moderate_gap", "saga_wins_moderate_gap"),
+ Nested("reg_saga_combat_follow", "saga_combat_follow_mc"),
+ Nested("reg_saga_cast_bias", "saga_cast_soft_bias"),
+ Nested("reg_saga_cross_light", "saga_cross_mc_light"),
+ Nested("reg_saga_cross_kill", "saga_cross_mc_kill"),
+ Nested("reg_saga_rival_kill", "saga_rival_not_single_kill"),
+ Nested("reg_saga_rival_rematch", "saga_rival_rematch"),
+ Nested("reg_saga_ambient_cut", "saga_prefer_cuts_ambient"),
Nested("reg_saga_overview", "saga_overview_has_mc"),
Nested("reg_saga_snapshot", "saga_adaptive_dossier"),
Nested("reg_saga_memory", "saga_memory_survives"),
@@ -2963,6 +2987,232 @@ internal static class HarnessScenarios
};
}
+ /// Prefer/MC tip wins when a stranger is moderately hotter (inside sagaNearTieEpsilon).
+ private static List SagaWinsModerateGap()
+ {
+ return new List
+ {
+ Step("smg0", "dismiss_windows"),
+ Step("smg1", "wait_world"),
+ Step("smg2", "set_setting", expect: "enabled", value: "true"),
+ Step("smg3", "fast_timing", value: "true"),
+ Step("smg4", "spawn", asset: "human", count: 2),
+ Step("smg5", "spectator", value: "off"),
+ Step("smg6", "spectator", value: "on"),
+ Step("smg7", "pick_unit", asset: "human"),
+ Step("smg8", "focus", asset: "human"),
+ Step("smg9", "interest_end_session"),
+ Step("smg10", "interest_saga_clear"),
+ Step("smg11", "saga_force_admit_focus"),
+ Step("smg12", "assert", expect: "saga_has_focus"),
+ Step("smg13", "interest_expire_pending", value: ""),
+ // Stranger hotter by ~15-20 after RecalcTotal; sagaNearTieEpsilon (28) still picks MC.
+ Step("smg20", "interest_inject", asset: "human", label: "ModGapMc", tier: "Action",
+ expect: "smg_mc", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
+ Step("smg21", "interest_inject", asset: "human", label: "ModGapStranger", tier: "Action",
+ expect: "smg_stranger", value: "lead=event;evt=78;char=5;force=false;ttl=60;pick=other"),
+ Step("smg22", "director_run", wait: 1.2f),
+ Step("smg23", "assert", expect: "tip_contains", value: "ModGapMc"),
+ Step("smg24", "assert", expect: "tip_not_contains", value: "ModGapStranger"),
+ Step("smg90", "fast_timing", value: "false"),
+ Step("smg99", "snapshot"),
+ };
+ }
+
+ /// MC CombatFocusWeight beats a non-MC partner (mid-fight follow bias).
+ private static List SagaCombatFollowMc()
+ {
+ return new List
+ {
+ Step("scf0", "dismiss_windows"),
+ Step("scf1", "wait_world"),
+ Step("scf2", "set_setting", expect: "enabled", value: "true"),
+ Step("scf3", "spawn", asset: "human", count: 2),
+ Step("scf4", "spectator", value: "off"),
+ Step("scf5", "spectator", value: "on"),
+ // Partner first (non-MC), then admit a different focus as MC.
+ Step("scf6", "pick_unit", asset: "human"),
+ Step("scf7", "happiness_remember_partner"),
+ Step("scf8", "pick_unit", asset: "human", value: "other"),
+ Step("scf9", "focus", asset: "human"),
+ Step("scf10", "interest_saga_clear"),
+ Step("scf11", "saga_force_admit_focus"),
+ Step("scf12", "assert", expect: "saga_has_focus"),
+ Step("scf13", "assert", expect: "saga_combat_weight_beats_partner"),
+ Step("scf99", "snapshot"),
+ };
+ }
+
+ /// MC lover tip beats an equal stranger tip via cast soft bias.
+ private static List SagaCastSoftBias()
+ {
+ return new List
+ {
+ Step("scb0", "dismiss_windows"),
+ Step("scb1", "wait_world"),
+ Step("scb2", "set_setting", expect: "enabled", value: "true"),
+ Step("scb3", "fast_timing", value: "true"),
+ Step("scb4", "spawn", asset: "human", count: 3),
+ Step("scb5", "spectator", value: "off"),
+ Step("scb6", "spectator", value: "on"),
+ // Remember lover candidate first, then admit a different MC and bond them.
+ Step("scb7", "pick_unit", asset: "human"),
+ Step("scb8", "happiness_remember_partner"),
+ Step("scb9", "pick_unit", asset: "human", value: "other"),
+ Step("scb10", "focus", asset: "human"),
+ Step("scb11", "interest_end_session"),
+ Step("scb12", "interest_saga_clear"),
+ Step("scb13", "saga_force_admit_focus"),
+ Step("scb14", "happiness_bond_lovers"),
+ Step("scb15", "interest_expire_pending", value: ""),
+ Step("scb20", "focus_partner"),
+ Step("scb21", "interest_inject", asset: "human", label: "CastLoverWin", tier: "Action",
+ expect: "scb_cast", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
+ Step("scb22", "interest_inject", asset: "human", label: "CastStrangerLose", tier: "Action",
+ expect: "scb_stranger", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"),
+ Step("scb23", "director_run", wait: 1.2f),
+ Step("scb24", "assert", expect: "tip_contains", value: "CastLoverWin"),
+ Step("scb25", "assert", expect: "tip_not_contains", value: "CastStrangerLose"),
+ Step("scb90", "fast_timing", value: "false"),
+ Step("scb99", "snapshot"),
+ };
+ }
+
+ /// Two admitted MCs on one tip light Active + Involved rail chrome.
+ private static List SagaCrossMcLight()
+ {
+ return new List
+ {
+ Step("scl0", "dismiss_windows"),
+ Step("scl1", "wait_world"),
+ Step("scl2", "set_setting", expect: "enabled", value: "true"),
+ Step("scl3", "set_setting", expect: "show_dossier_caption", value: "true"),
+ Step("scl4", "spawn", asset: "human", count: 2),
+ Step("scl5", "spectator", value: "off"),
+ Step("scl6", "spectator", value: "on"),
+ Step("scl7", "pick_unit", asset: "human"),
+ Step("scl8", "happiness_remember_partner"),
+ Step("scl9", "pick_unit", asset: "human", value: "other"),
+ Step("scl10", "focus", asset: "human"),
+ Step("scl11", "interest_end_session"),
+ Step("scl12", "interest_saga_clear"),
+ Step("scl13", "saga_force_admit_focus"),
+ Step("scl14", "saga_force_admit_partner"),
+ Step("scl15", "interest_expire_pending", value: ""),
+ Step("scl20", "interest_inject", asset: "human", label: "CrossMcTip", tier: "Action",
+ expect: "scl_tip", value: "lead=event;evt=60;char=5;force=true;ttl=60"),
+ Step("scl21", "interest_bind_related_partner"),
+ Step("scl22", "wait", wait: 0.4f),
+ Step("scl23", "assert", expect: "saga_rail_active_highlight"),
+ Step("scl24", "assert", expect: "saga_rail_involved_count", value: "1"),
+ Step("scl99", "snapshot"),
+ };
+ }
+
+ /// MC kills MC → both memories hold McCrossover facts.
+ private static List SagaCrossMcKill()
+ {
+ return new List
+ {
+ Step("sck0", "dismiss_windows"),
+ Step("sck1", "wait_world"),
+ Step("sck2", "set_setting", expect: "enabled", value: "true"),
+ Step("sck3", "spawn", asset: "human", count: 2),
+ Step("sck4", "spectator", value: "off"),
+ Step("sck5", "spectator", value: "on"),
+ Step("sck6", "pick_unit", asset: "human"),
+ Step("sck7", "happiness_remember_partner"),
+ Step("sck8", "pick_unit", asset: "human", value: "other"),
+ Step("sck9", "focus", asset: "human"),
+ Step("sck10", "interest_saga_clear"),
+ Step("sck11", "saga_force_admit_focus"),
+ Step("sck12", "saga_force_admit_partner"),
+ Step("sck13", "saga_record_kill_partner"),
+ Step("sck14", "assert", expect: "saga_crossover_focus"),
+ Step("sck15", "assert", expect: "saga_rival_focus", value: "false"),
+ Step("sck99", "snapshot"),
+ };
+ }
+
+ /// One kill+death cycle must not earn RivalEarned.
+ private static List SagaRivalNotSingleKill()
+ {
+ return new List
+ {
+ Step("srk0", "dismiss_windows"),
+ Step("srk1", "wait_world"),
+ Step("srk2", "set_setting", expect: "enabled", value: "true"),
+ Step("srk3", "spawn", asset: "human", count: 2),
+ Step("srk4", "spectator", value: "off"),
+ Step("srk5", "spectator", value: "on"),
+ Step("srk6", "pick_unit", asset: "human"),
+ Step("srk7", "happiness_remember_partner"),
+ Step("srk8", "pick_unit", asset: "human", value: "other"),
+ Step("srk9", "focus", asset: "human"),
+ Step("srk10", "interest_saga_clear"),
+ Step("srk11", "saga_force_admit_focus"),
+ Step("srk12", "saga_record_kill_partner"),
+ Step("srk13", "assert", expect: "saga_rival_focus", value: "false"),
+ Step("srk99", "snapshot"),
+ };
+ }
+
+ /// Threshold living rematches earn RivalEarned.
+ private static List SagaRivalRematch()
+ {
+ return new List
+ {
+ Step("srr0", "dismiss_windows"),
+ Step("srr1", "wait_world"),
+ Step("srr2", "set_setting", expect: "enabled", value: "true"),
+ Step("srr3", "spawn", asset: "human", count: 2),
+ Step("srr4", "spectator", value: "off"),
+ Step("srr5", "spectator", value: "on"),
+ Step("srr6", "pick_unit", asset: "human"),
+ Step("srr7", "happiness_remember_partner"),
+ Step("srr8", "pick_unit", asset: "human", value: "other"),
+ Step("srr9", "focus", asset: "human"),
+ Step("srr10", "interest_saga_clear"),
+ Step("srr11", "saga_force_admit_focus"),
+ Step("srr12", "saga_note_encounters", value: "3"),
+ Step("srr13", "assert", expect: "saga_rival_focus"),
+ Step("srr99", "snapshot"),
+ };
+ }
+
+ /// Prefer tip cuts an ambient/fill hold without waiting for full margin.
+ private static List SagaPreferCutsAmbient()
+ {
+ return new List
+ {
+ Step("spa0", "dismiss_windows"),
+ Step("spa1", "wait_world"),
+ Step("spa2", "set_setting", expect: "enabled", value: "true"),
+ Step("spa3", "fast_timing", value: "true"),
+ Step("spa4", "spawn", asset: "human", count: 2),
+ Step("spa5", "spectator", value: "off"),
+ Step("spa6", "spectator", value: "on"),
+ Step("spa7", "pick_unit", asset: "human"),
+ Step("spa8", "focus", asset: "human"),
+ Step("spa9", "interest_end_session"),
+ Step("spa10", "interest_saga_clear"),
+ Step("spa11", "saga_force_admit_focus"),
+ Step("spa12", "saga_prefer_focus"),
+ Step("spa13", "interest_expire_pending", value: ""),
+ // Low fill-band ambient tip first.
+ Step("spa20", "interest_inject", asset: "human", label: "AmbientFillHold", tier: "Curiosity",
+ expect: "spa_fill", value: "lead=character;evt=12;char=8;force=true;ttl=60;pick=other"),
+ Step("spa21", "director_run", wait: 0.6f),
+ Step("spa22", "assert", expect: "tip_contains", value: "AmbientFillHold"),
+ Step("spa23", "interest_inject", asset: "human", label: "PreferCutsAmbient", tier: "Action",
+ expect: "spa_prefer", value: "lead=event;evt=50;char=5;force=false;ttl=60"),
+ Step("spa24", "director_run", wait: 1.0f),
+ Step("spa25", "assert", expect: "tip_contains", value: "PreferCutsAmbient"),
+ Step("spa90", "fast_timing", value: "false"),
+ Step("spa99", "snapshot"),
+ };
+ }
+
///
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
/// no planner jargon / trait descs / Chronicle dumps.
diff --git a/IdleSpectator/InterestDirector.StickyMaintain.cs b/IdleSpectator/InterestDirector.StickyMaintain.cs
index 0277fa3..2fedb75 100644
--- a/IdleSpectator/InterestDirector.StickyMaintain.cs
+++ b/IdleSpectator/InterestDirector.StickyMaintain.cs
@@ -585,6 +585,14 @@ public static partial class InterestDirector
// Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo).
StoryPlanner.SyncActiveClimax(_current);
+
+ // Living rematch counter for rival earning (deduped inside NoteCombatEncounter).
+ // Kill/death paths must never call this.
+ if (best != null && foe != null)
+ {
+ LifeSagaMemory.NoteCombatEncounter(best, foe, "sticky_combat");
+ }
+
return true;
}
@@ -658,6 +666,28 @@ public static partial class InterestDirector
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
}
+ // Opposing war camps: notable rivalry when both principals are living.
+ if (_current.FollowUnit != null
+ && _current.RelatedUnit != null
+ && !string.IsNullOrEmpty(_current.CorrelationKey))
+ {
+ LifeSagaMemory.NoteWarOpposition(
+ _current.FollowUnit,
+ _current.RelatedUnit,
+ _current.CorrelationKey,
+ "war_front_maintain");
+ }
+ else if (_current.FollowUnit != null
+ && _current.RelatedUnit != null
+ && !string.IsNullOrEmpty(_current.AssetId))
+ {
+ LifeSagaMemory.NoteWarOpposition(
+ _current.FollowUnit,
+ _current.RelatedUnit,
+ _current.AssetId + ":" + _current.SubjectId,
+ "war_front_maintain");
+ }
+
string label = EventReason.WarFront(held);
if (string.IsNullOrEmpty(label))
{
@@ -782,6 +812,17 @@ public static partial class InterestDirector
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
}
+ if (_current.FollowUnit != null
+ && _current.RelatedUnit != null
+ && !string.IsNullOrEmpty(_current.CorrelationKey))
+ {
+ LifeSagaMemory.NotePlotOpposition(
+ _current.FollowUnit,
+ _current.RelatedUnit,
+ _current.CorrelationKey,
+ "plot_cell_maintain");
+ }
+
// Plot stickies are groups only - never "Plot - Plotters (1)" / (0).
int plotters = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0;
if (plotters < 2)
diff --git a/IdleSpectator/InterestDirector.SwitchPolicy.cs b/IdleSpectator/InterestDirector.SwitchPolicy.cs
index 568835b..a96eefc 100644
--- a/IdleSpectator/InterestDirector.SwitchPolicy.cs
+++ b/IdleSpectator/InterestDirector.SwitchPolicy.cs
@@ -165,6 +165,23 @@ public static partial class InterestDirector
return true;
}
+ // Prefer / MC: cut character-fill or soft-quiet linger without full score margin.
+ // Never weakens sticky combat, war, crisis, or short-arc hard holds.
+ if ((curFill || StoryPlanner.SoftFillQuietActive)
+ && !sticky
+ && !StoryPlanner.CrisisActive
+ && !CombatFightIsHot(_current, now)
+ && _current.Completion != InterestCompletionKind.WarFront
+ && !nextFill
+ && !IsAmbientShot(candidate)
+ && (LifeSagaRoster.TipTouchesPrefer(candidate) || LifeSagaRoster.TipTouchesMc(candidate)))
+ {
+ InterestDropLog.Record(
+ "saga_ambient_cut",
+ $"cur={_current.Key} next={candidate.Key}");
+ return true;
+ }
+
// Soft family pack: yield to discrete unit events after a short hold.
if (IsSoftStickyCluster(_current)
&& !IsSoftStickyCluster(candidate)
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index 1537ada..cab7dd5 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -2052,6 +2052,12 @@ public static partial class InterestDirector
return true;
}
+ // Prefer / MC life tips may reclaim the camera during soft-fill quiet.
+ if (LifeSagaRoster.TipTouchesPrefer(c) || LifeSagaRoster.TipTouchesMc(c))
+ {
+ return true;
+ }
+
float epicFloor = InterestScoringConfig.Story.crisisDisasterStrengthMin > 0f
? InterestScoringConfig.Story.crisisDisasterStrengthMin
: 95f;
diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs
index 4322a67..dcd23c1 100644
--- a/IdleSpectator/InterestScoringConfig.cs
+++ b/IdleSpectator/InterestScoringConfig.cs
@@ -23,6 +23,11 @@ public class ScoringModelDocument
public class StoryWeights
{
public float nearTieEpsilon = 8f;
+ ///
+ /// Prefer / MC / MC-cast tips may beat a hotter stranger when the gap is below this
+ /// (wider than ).
+ ///
+ public float sagaNearTieEpsilon = 28f;
public float causalHeatHalfLifeSeconds = 180f;
public float causalHeatWeight = 6f;
public float causalRelatedWeight = 3f;
@@ -41,11 +46,17 @@ public class StoryWeights
/// Obsolete ledger bleed (CharacterLedger removed); kept for json compat.
public float ledgerBleedVillage = 0.82f;
/// Life-saga roster soft MC tip bonus.
- public float sagaMcWeight = 6f;
+ public float sagaMcWeight = 12f;
/// Extra soft bonus when the saga slot is Prefer'd.
- public float sagaPreferWeight = 10f;
+ public float sagaPreferWeight = 16f;
/// Extra soft bonus when the unit is a WorldBox favorite.
public float sagaGameFavoriteWeight = 8f;
+ /// Soft bonus when tip touches an MC's story cast (lover/friend/kin/rival).
+ public float sagaCastWeight = 5f;
+ /// Extra tip bonus when subject/related/follow touch two or more roster MCs.
+ public float sagaCrossMcBonus = 6f;
+ /// Living rematch count before EarnRival (kill/death do not count).
+ public int sagaRivalEncounterThreshold = 3;
/// Chapter starvation window before dull non-favorite MCs lose rank.
public float sagaDullSeconds = 300f;
/// Extra FixedDwell beat cool for family chapters (seconds, stacks with base).
diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs
index 060294e..b52dd82 100644
--- a/IdleSpectator/InterestVariety.cs
+++ b/IdleSpectator/InterestVariety.cs
@@ -722,34 +722,29 @@ public static class InterestVariety
epsilon = 8f;
}
+ float sagaEpsilon = InterestScoringConfig.Story.sagaNearTieEpsilon;
+ if (sagaEpsilon <= 0f)
+ {
+ sagaEpsilon = 20f;
+ }
+
+ sagaEpsilon = Mathf.Max(sagaEpsilon, epsilon);
+
+ // Large gap: take #1 unless a Prefer/MC/cast tip sits within the wider saga band.
if (Ranked[0].TotalScore - Ranked[1].TotalScore >= epsilon)
{
+ InterestCandidate sagaCut = PickBestSagaInBand(Ranked, Ranked.Count, sagaEpsilon);
+ if (sagaCut != null)
+ {
+ return sagaCut;
+ }
+
return Ranked[0];
}
- // Near-tie: Prefer'd MC, then any MC, else random among top-N.
- InterestCandidate bestSaga = null;
- int bestRank = -1;
- for (int i = 0; i < topN; i++)
- {
- if (Ranked[0].TotalScore - Ranked[i].TotalScore >= epsilon)
- {
- break;
- }
-
- int rank = LifeSagaRoster.TipTouchesPrefer(Ranked[i])
- ? 2
- : LifeSagaRoster.TipTouchesMc(Ranked[i])
- ? 1
- : 0;
- if (rank > bestRank)
- {
- bestRank = rank;
- bestSaga = Ranked[i];
- }
- }
-
- if (bestSaga != null && bestRank > 0)
+ // Near-tie: Prefer'd MC, then any MC, then MC cast, else random among top-N.
+ InterestCandidate bestSaga = PickBestSagaInBand(Ranked, topN, epsilon);
+ if (bestSaga != null)
{
return bestSaga;
}
@@ -758,6 +753,47 @@ public static class InterestVariety
return Ranked[pick];
}
+ ///
+ /// Prefer (3) > MC (2) > MC cast (1) among candidates within of #1.
+ ///
+ private static InterestCandidate PickBestSagaInBand(
+ List ranked,
+ int limit,
+ float band)
+ {
+ if (ranked == null || ranked.Count == 0 || band < 0f)
+ {
+ return null;
+ }
+
+ int n = Mathf.Min(limit, ranked.Count);
+ float top = ranked[0].TotalScore;
+ InterestCandidate bestSaga = null;
+ int bestRank = 0;
+ for (int i = 0; i < n; i++)
+ {
+ InterestCandidate c = ranked[i];
+ if (c == null)
+ {
+ continue;
+ }
+
+ if (top - c.TotalScore >= band)
+ {
+ break;
+ }
+
+ int rank = LifeSagaRoster.SoftBiasRank(c);
+ if (rank > bestRank)
+ {
+ bestRank = rank;
+ bestSaga = c;
+ }
+ }
+
+ return bestRank > 0 ? bestSaga : null;
+ }
+
public static float EventShare()
{
if (RecentMix.Count == 0)
diff --git a/IdleSpectator/Story/LifeSagaMemory.cs b/IdleSpectator/Story/LifeSagaMemory.cs
index aa623ec..bd0ee38 100644
--- a/IdleSpectator/Story/LifeSagaMemory.cs
+++ b/IdleSpectator/Story/LifeSagaMemory.cs
@@ -13,18 +13,31 @@ public static class LifeSagaMemory
{
public const int MaxFactsPerLife = 48;
public const int MaxLives = 120;
- public const int RivalEncounterThreshold = 2;
+ /// Fallback when scoring-model omits sagaRivalEncounterThreshold.
+ public const int RivalEncounterThresholdDefault = 3;
+ private const float EncounterDedupeSeconds = 28f;
private static readonly Dictionary Lives =
new Dictionary(64);
private static readonly Dictionary PairEncounters =
new Dictionary(64);
+ private static readonly Dictionary PairEncounterCooldown =
+ new Dictionary(64);
private static object _boundWorld;
private static int _worldEpoch;
public static int WorldEpoch => _worldEpoch;
public static int LifeCount => Lives.Count;
+ public static int RivalEncounterThreshold
+ {
+ get
+ {
+ int t = InterestScoringConfig.Story.sagaRivalEncounterThreshold;
+ return t > 0 ? t : RivalEncounterThresholdDefault;
+ }
+ }
+
public static void EnsureWorldBound()
{
object world = null;
@@ -51,6 +64,7 @@ public static class LifeSagaMemory
{
Lives.Clear();
PairEncounters.Clear();
+ PairEncounterCooldown.Clear();
_boundWorld = null;
}
@@ -254,6 +268,7 @@ public static class LifeSagaMemory
MirrorToOther(fact, otherId);
}
+ TryMarkMcCrossover(fact);
return fact;
}
@@ -371,8 +386,13 @@ public static class LifeSagaMemory
strength: 80f,
provenance: provenance);
- NoteCombatEncounter(killer, victim, provenance);
+ // Kill/death must not increment living rematch counters (single kill ≠ rival).
TryMarkKinKillRival(killer, victim);
+ if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId))
+ {
+ // MC slew MC: crossover stake only - not automatic RivalEarned.
+ NoteMcCrossoverHeat(killerId, victimId);
+ }
}
public static void RecordDeath(Actor victim, Actor killer, string attackType, string provenance = "die")
@@ -395,17 +415,52 @@ public static class LifeSagaMemory
if (killer != null)
{
- NoteCombatEncounter(killer, victim, provenance);
+ long killerId = EventFeedUtil.SafeId(killer);
+ if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId))
+ {
+ NoteMcCrossoverHeat(killerId, victimId);
+ }
}
}
+ ///
+ /// Living rematch counter. Call only while both actors are alive (sticky combat / climax).
+ /// Kill and death paths must not call this. Deduped per pair so one scrap cannot spam to threshold.
+ ///
public static void NoteCombatEncounter(Actor a, Actor b, string provenance = "combat")
+ {
+ NoteCombatEncounterCore(a, b, provenance, bypassCooldown: false);
+ }
+
+ /// Harness: count distinct living rematches without waiting out the dedupe window.
+ public static void HarnessNoteCombatEncounter(Actor a, Actor b, string provenance = "harness")
+ {
+ NoteCombatEncounterCore(a, b, provenance, bypassCooldown: true);
+ }
+
+ private static void NoteCombatEncounterCore(
+ Actor a,
+ Actor b,
+ string provenance,
+ bool bypassCooldown)
{
if (a == null || b == null)
{
return;
}
+ try
+ {
+ if (!a.isAlive() || !b.isAlive())
+ {
+ return;
+ }
+ }
+ catch
+ {
+ return;
+ }
+
long idA = EventFeedUtil.SafeId(a);
long idB = EventFeedUtil.SafeId(b);
if (idA == 0 || idB == 0 || idA == idB)
@@ -414,6 +469,16 @@ public static class LifeSagaMemory
}
string pair = PairKey(idA, idB);
+ float now = Time.unscaledTime;
+ if (!bypassCooldown
+ && PairEncounterCooldown.TryGetValue(pair, out float until)
+ && now < until)
+ {
+ return;
+ }
+
+ PairEncounterCooldown[pair] = now + EncounterDedupeSeconds;
+
if (!PairEncounters.TryGetValue(pair, out int count))
{
count = 0;
@@ -431,9 +496,20 @@ public static class LifeSagaMemory
provenance: provenance,
note: "x" + count);
- if (count >= RivalEncounterThreshold)
+ int need = RivalEncounterThreshold;
+ bool bothMc = LifeSagaRoster.IsMc(idA) && LifeSagaRoster.IsMc(idB);
+ // MC↔MC living rematches earn rivalry one encounter sooner (still never from a single kill).
+ if (bothMc)
{
- EarnRival(a, b, "repeated_encounters:" + count, provenance);
+ need = Mathf.Max(2, need - 1);
+ }
+
+ if (count >= need)
+ {
+ string evidence = bothMc
+ ? "mc_rematch:" + count
+ : "repeated_encounters:" + count;
+ EarnRival(a, b, evidence, provenance);
}
}
@@ -446,7 +522,7 @@ public static class LifeSagaMemory
long subjectId = EventFeedUtil.SafeId(subject);
long rivalId = EventFeedUtil.SafeId(rival);
- Record(
+ LifeSagaFact fact = Record(
LifeSagaFactKind.RivalEarned,
subject,
rival,
@@ -454,6 +530,83 @@ public static class LifeSagaMemory
strength: 85f,
provenance: provenance,
note: evidence ?? "");
+ if (fact != null && LifeSagaRoster.IsMc(subjectId) && LifeSagaRoster.IsMc(rivalId))
+ {
+ fact.McCrossover = true;
+ NoteMcCrossoverHeat(subjectId, rivalId);
+ }
+ }
+
+ ///
+ /// Opposing sides of the same war: durable rivalry when both units are known.
+ ///
+ public static void NoteWarOpposition(
+ Actor a,
+ Actor b,
+ string warKey,
+ string provenance = "war_opposition")
+ {
+ if (a == null || b == null || string.IsNullOrEmpty(warKey))
+ {
+ return;
+ }
+
+ try
+ {
+ if (!a.isAlive() || !b.isAlive())
+ {
+ return;
+ }
+ }
+ catch
+ {
+ return;
+ }
+
+ long idA = EventFeedUtil.SafeId(a);
+ long idB = EventFeedUtil.SafeId(b);
+ if (idA == 0 || idB == 0 || idA == idB)
+ {
+ return;
+ }
+
+ EarnRival(a, b, "opposed_in_war:" + warKey, provenance);
+ }
+
+ ///
+ /// Opposing sides of the same plot: durable rivalry when both units are known.
+ ///
+ public static void NotePlotOpposition(
+ Actor a,
+ Actor b,
+ string plotKey,
+ string provenance = "plot_opposition")
+ {
+ if (a == null || b == null || string.IsNullOrEmpty(plotKey))
+ {
+ return;
+ }
+
+ try
+ {
+ if (!a.isAlive() || !b.isAlive())
+ {
+ return;
+ }
+ }
+ catch
+ {
+ return;
+ }
+
+ long idA = EventFeedUtil.SafeId(a);
+ long idB = EventFeedUtil.SafeId(b);
+ if (idA == 0 || idB == 0 || idA == idB)
+ {
+ return;
+ }
+
+ EarnRival(a, b, "opposed_in_plot:" + plotKey, provenance);
}
public static void RecordWar(
@@ -660,17 +813,99 @@ public static class LifeSagaMemory
return false;
}
+ PruneStaleRivals(life);
+ LifeSagaFact bestLiving = null;
+ LifeSagaFact bestAny = null;
for (int i = 0; i < life.Facts.Count; i++)
{
LifeSagaFact f = life.Facts[i];
- if (f != null && f.Kind == LifeSagaFactKind.RivalEarned && f.OtherId != 0)
+ if (f == null || f.Kind != LifeSagaFactKind.RivalEarned || f.OtherId == 0)
{
- rivalFact = f;
- return true;
+ continue;
+ }
+
+ if (!IsCredibleRival(f))
+ {
+ continue;
+ }
+
+ if (bestAny == null || f.Strength > bestAny.Strength || f.At > bestAny.At)
+ {
+ bestAny = f;
+ }
+
+ if (EventFeedUtil.FindAliveById(f.OtherId) != null)
+ {
+ if (bestLiving == null || f.Strength > bestLiving.Strength || f.At > bestLiving.At)
+ {
+ bestLiving = f;
+ }
}
}
- return false;
+ // Cast prefers a living rival; callers that need Legacy can still read FactsFor.
+ rivalFact = bestLiving;
+ return rivalFact != null;
+ }
+
+ /// True when RivalEarned evidence meets the notable bar (not a single kill-cycle).
+ public static bool IsCredibleRival(LifeSagaFact f)
+ {
+ if (f == null || f.Kind != LifeSagaFactKind.RivalEarned)
+ {
+ return false;
+ }
+
+ string note = f.Note ?? "";
+ string prov = f.Provenance ?? "";
+ if (IsKillCycleProvenance(prov)
+ && note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
+ {
+ string num = note.Substring("repeated_encounters:".Length);
+ if (int.TryParse(num, out int count) && count < RivalEncounterThreshold)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsKillCycleProvenance(string provenance)
+ {
+ if (string.IsNullOrEmpty(provenance))
+ {
+ return false;
+ }
+
+ return string.Equals(provenance, "newKillAction", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(provenance, "die", StringComparison.OrdinalIgnoreCase)
+ || provenance.IndexOf("Kill", StringComparison.OrdinalIgnoreCase) >= 0
+ || provenance.IndexOf("death", StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
+ private static void PruneStaleRivals(LifeSagaLifeMemory life)
+ {
+ if (life?.Facts == null)
+ {
+ return;
+ }
+
+ for (int i = life.Facts.Count - 1; i >= 0; i--)
+ {
+ LifeSagaFact f = life.Facts[i];
+ if (f != null
+ && f.Kind == LifeSagaFactKind.RivalEarned
+ && !IsCredibleRival(f))
+ {
+ life.Facts.RemoveAt(i);
+ }
+ }
}
public static LifeSagaFact StrongestUnresolved(long unitId)
@@ -690,7 +925,7 @@ public static class LifeSagaMemory
continue;
}
- if (!IsStakeWorthy(f.Kind))
+ if (!IsStakeWorthy(f))
{
continue;
}
@@ -774,6 +1009,13 @@ public static class LifeSagaMemory
switch (fact.Kind)
{
case LifeSagaFactKind.BondFormed:
+ if (fact.McCrossover)
+ {
+ return string.IsNullOrEmpty(other)
+ ? "Bound to a fellow saga hero"
+ : "Bound to fellow saga hero " + other;
+ }
+
return string.IsNullOrEmpty(other) ? "Found love" : "Bound with " + other;
case LifeSagaFactKind.BondBroken:
return string.IsNullOrEmpty(other) ? "Lost a lover" : "Lost " + other;
@@ -782,10 +1024,57 @@ public static class LifeSagaMemory
case LifeSagaFactKind.ParentChild:
return string.IsNullOrEmpty(other) ? "A child was born" : "Child " + other;
case LifeSagaFactKind.Kill:
+ if (fact.McCrossover)
+ {
+ return string.IsNullOrEmpty(other)
+ ? "Slew a fellow saga hero"
+ : "Slew fellow saga hero " + other;
+ }
+
return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other;
case LifeSagaFactKind.Death:
+ if (fact.McCrossover)
+ {
+ return string.IsNullOrEmpty(other)
+ ? "Fallen to a fellow saga hero"
+ : "Slain by fellow saga hero " + other;
+ }
+
return string.IsNullOrEmpty(other) ? "Died" : "Slain by " + other;
case LifeSagaFactKind.RivalEarned:
+ if (!string.IsNullOrEmpty(fact.Note))
+ {
+ if (fact.Note.StartsWith("opposed_in_war:", StringComparison.Ordinal))
+ {
+ return string.IsNullOrEmpty(other)
+ ? "Opposed in war"
+ : "Opposed " + other + " in war";
+ }
+
+ if (fact.Note.StartsWith("opposed_in_plot:", StringComparison.Ordinal))
+ {
+ return string.IsNullOrEmpty(other)
+ ? "Opposed in a plot"
+ : "Opposed " + other + " in a plot";
+ }
+
+ if (fact.Note.StartsWith("killed_kin", StringComparison.Ordinal)
+ || fact.Note.StartsWith("kin_slain", StringComparison.Ordinal))
+ {
+ return string.IsNullOrEmpty(other)
+ ? "Blood feud over kin"
+ : "Blood feud with " + other;
+ }
+
+ if (fact.Note.StartsWith("mc_rematch:", StringComparison.Ordinal)
+ || fact.Note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
+ {
+ return string.IsNullOrEmpty(other)
+ ? "Clashed again and again"
+ : "Clashed thrice with " + other;
+ }
+ }
+
return string.IsNullOrEmpty(other) ? "Earned a rival" : "Rivalry with " + other;
case LifeSagaFactKind.WarJoin:
return "Entered a war";
@@ -1042,7 +1331,7 @@ public static class LifeSagaMemory
for (int i = 0; i < life.Facts.Count; i++)
{
- if (life.Facts[i] != null && !life.Facts[i].Resolved && IsStakeWorthy(life.Facts[i].Kind))
+ if (life.Facts[i] != null && !life.Facts[i].Resolved && IsStakeWorthy(life.Facts[i]))
{
return 2;
}
@@ -1066,7 +1355,22 @@ public static class LifeSagaMemory
return a <= b ? a + ":" + b : b + ":" + a;
}
- private static bool IsStakeWorthy(LifeSagaFactKind kind)
+ private static bool IsStakeWorthy(LifeSagaFact fact)
+ {
+ if (fact == null)
+ {
+ return false;
+ }
+
+ if (fact.McCrossover)
+ {
+ return IsMcCrossoverKind(fact.Kind) || fact.Kind == LifeSagaFactKind.RivalEarned;
+ }
+
+ return IsStakeWorthyKind(fact.Kind);
+ }
+
+ private static bool IsStakeWorthyKind(LifeSagaFactKind kind)
{
switch (kind)
{
@@ -1085,6 +1389,66 @@ public static class LifeSagaMemory
}
}
+ private static void TryMarkMcCrossover(LifeSagaFact fact)
+ {
+ if (fact == null || fact.OtherId == 0 || fact.SubjectId == 0)
+ {
+ return;
+ }
+
+ if (!IsMcCrossoverKind(fact.Kind))
+ {
+ return;
+ }
+
+ if (!LifeSagaRoster.IsMc(fact.SubjectId) || !LifeSagaRoster.IsMc(fact.OtherId))
+ {
+ return;
+ }
+
+ fact.McCrossover = true;
+ NoteMcCrossoverHeat(fact.SubjectId, fact.OtherId);
+ }
+
+ private static bool IsMcCrossoverKind(LifeSagaFactKind kind)
+ {
+ switch (kind)
+ {
+ case LifeSagaFactKind.Kill:
+ case LifeSagaFactKind.Death:
+ case LifeSagaFactKind.BondFormed:
+ case LifeSagaFactKind.BondBroken:
+ case LifeSagaFactKind.FriendFormed:
+ case LifeSagaFactKind.ParentChild:
+ case LifeSagaFactKind.RivalEarned:
+ case LifeSagaFactKind.HardArcLove:
+ case LifeSagaFactKind.HardArcGrief:
+ case LifeSagaFactKind.HardArcCombat:
+ case LifeSagaFactKind.HardArcWar:
+ case LifeSagaFactKind.HardArcPlot:
+ case LifeSagaFactKind.WarJoin:
+ case LifeSagaFactKind.PlotJoin:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static void NoteMcCrossoverHeat(long a, long b)
+ {
+ if (a != 0)
+ {
+ CausalHeat.NoteFeatured(a, 1.15f);
+ LifeSagaRoster.NoteFeaturedUnit(a, 1.2f);
+ }
+
+ if (b != 0 && b != a)
+ {
+ CausalHeat.NoteFeatured(b, 1.15f);
+ LifeSagaRoster.NoteFeaturedUnit(b, 1.2f);
+ }
+ }
+
private static bool IsLegacyWorthy(LifeSagaFactKind kind)
{
switch (kind)
@@ -1321,6 +1685,8 @@ public sealed class LifeSagaFact
public string PlotKey = "";
public bool Resolved;
public string Note = "";
+ /// Both subject and other were roster MCs when recorded.
+ public bool McCrossover;
}
public sealed class LifeSagaLifeMemory
diff --git a/IdleSpectator/Story/LifeSagaRail.cs b/IdleSpectator/Story/LifeSagaRail.cs
index 96134f9..f9b0fe7 100644
--- a/IdleSpectator/Story/LifeSagaRail.cs
+++ b/IdleSpectator/Story/LifeSagaRail.cs
@@ -20,6 +20,7 @@ public static class LifeSagaRail
private const float FaceRefreshSeconds = 0.28f;
private static readonly List SlotScratch = new List(MaxSlots);
+ private static readonly List InvolvedScratch = new List(8);
private static readonly Slot[] Slots = new Slot[MaxSlots];
private static GameObject _row;
private static RectTransform _rowRt;
@@ -32,6 +33,9 @@ public static class LifeSagaRail
/// Harness: true when the watched MC slot used Active (not Prefer) chrome.
public static bool LastActiveHighlight { get; private set; }
+ /// Harness: count of Involved (non-follow) MC glyphs lit on last Refresh.
+ public static int LastInvolvedHighlightCount { get; private set; }
+
/// Harness: Prefer pip visible on first Prefer'd slot.
public static bool LastPreferPipVisible { get; private set; }
@@ -132,6 +136,7 @@ public static class LifeSagaRail
LastShownCount = 0;
LastTipSample = "";
LastActiveHighlight = false;
+ LastInvolvedHighlightCount = 0;
LastPreferPipVisible = false;
LastHoverRowCount = 0;
LastHoverHeight = 0f;
@@ -175,7 +180,12 @@ public static class LifeSagaRail
LifeSagaRoster.Tick(now);
SlotScratch.Clear();
LifeSagaRoster.CopySlots(SlotScratch);
- string fp = Fingerprint(SlotScratch);
+ long watchId = EventFeedUtil.SafeId(
+ MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
+ InvolvedScratch.Clear();
+ InterestCandidate tip = InterestDirector.CurrentCandidate;
+ LifeSagaRoster.CollectTouchedMcIds(tip, InvolvedScratch);
+ string fp = Fingerprint(SlotScratch, watchId, InvolvedScratch);
bool structureChanged = !string.Equals(fp, _fingerprint, StringComparison.Ordinal)
|| LifeSagaRoster.Dirty;
bool facesDue = now >= _nextFaceRefreshAt;
@@ -191,9 +201,8 @@ public static class LifeSagaRail
LastShownCount = 0;
LastTipSample = "";
LastActiveHighlight = false;
+ LastInvolvedHighlightCount = 0;
LastPreferPipVisible = false;
- long watchId = EventFeedUtil.SafeId(
- MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
float x = 0f;
for (int i = 0; i < Slots.Length; i++)
{
@@ -208,6 +217,7 @@ public static class LifeSagaRail
LifeSagaSlot saga = SlotScratch[i];
bool watching = watchId != 0 && watchId == saga.UnitId;
+ bool involved = !watching && IsInvolved(saga.UnitId);
bool prefer = saga.Prefer || saga.GameFavorite;
slot.UnitId = saga.UnitId;
ApplyFace(slot, saga);
@@ -226,6 +236,12 @@ public static class LifeSagaRail
slot.Bg.color = HudTheme.ValueOrange;
LastActiveHighlight = true;
}
+ else if (involved)
+ {
+ // Softer gold - second saga lit while another MC is followed.
+ slot.Bg.color = new Color(0.92f, 0.72f, 0.28f, 0.92f);
+ LastInvolvedHighlightCount++;
+ }
else if (prefer)
{
slot.Bg.color = new Color(0.45f, 0.38f, 0.22f, 0.95f);
@@ -581,12 +597,37 @@ public static class LifeSagaRail
return "?";
}
- private static string Fingerprint(List board)
+ private static bool IsInvolved(long unitId)
+ {
+ if (unitId == 0)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < InvolvedScratch.Count; i++)
+ {
+ if (InvolvedScratch[i] == unitId)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static string Fingerprint(List board, long watchId, List involved)
{
var sb = new StringBuilder();
- long watchId = EventFeedUtil.SafeId(
- MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
sb.Append(watchId).Append('|');
+ if (involved != null)
+ {
+ for (int i = 0; i < involved.Count; i++)
+ {
+ sb.Append('i').Append(involved[i]).Append(',');
+ }
+ }
+
+ sb.Append('|');
for (int i = 0; i < board.Count; i++)
{
LifeSagaSlot s = board[i];
diff --git a/IdleSpectator/Story/LifeSagaRoster.cs b/IdleSpectator/Story/LifeSagaRoster.cs
index 8f70c89..56076f8 100644
--- a/IdleSpectator/Story/LifeSagaRoster.cs
+++ b/IdleSpectator/Story/LifeSagaRoster.cs
@@ -273,46 +273,58 @@ public static class LifeSagaRoster
}
StoryWeights sw = InterestScoringConfig.Story;
- float mc = sw.sagaMcWeight > 0f ? sw.sagaMcWeight : 6f;
- float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 10f;
+ float mc = sw.sagaMcWeight > 0f ? sw.sagaMcWeight : 12f;
+ float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 16f;
float fav = sw.sagaGameFavoriteWeight > 0f ? sw.sagaGameFavoriteWeight : 8f;
+ float cast = sw.sagaCastWeight > 0f ? sw.sagaCastWeight : 5f;
+ float cross = sw.sagaCrossMcBonus > 0f ? sw.sagaCrossMcBonus : 6f;
- float best = BonusForUnit(c.SubjectId, mc, prefer, fav);
+ float best = BonusForUnit(c.SubjectId, mc, prefer, fav, cast);
if (c.RelatedId != 0)
{
- best = Mathf.Max(best, BonusForUnit(c.RelatedId, mc, prefer, fav) * 0.85f);
+ best = Mathf.Max(best, BonusForUnit(c.RelatedId, mc, prefer, fav, cast) * 0.85f);
}
long followId = EventFeedUtil.SafeId(c.FollowUnit);
if (followId != 0 && followId != c.SubjectId && followId != c.RelatedId)
{
- best = Mathf.Max(best, BonusForUnit(followId, mc, prefer, fav));
+ best = Mathf.Max(best, BonusForUnit(followId, mc, prefer, fav, cast));
}
- float cap = Mathf.Max(prefer, fav) + mc;
+ if (CountTouchedMcs(c) >= 2)
+ {
+ best += cross;
+ }
+
+ float cap = Mathf.Max(prefer, fav) + mc + cross;
return Mathf.Min(best, cap);
}
- private static float BonusForUnit(long unitId, float mc, float prefer, float fav)
+ private static float BonusForUnit(long unitId, float mc, float prefer, float fav, float cast)
{
- LifeSagaSlot s = Get(unitId);
- if (s == null)
+ if (unitId == 0)
{
return 0f;
}
- float bonus = mc;
- if (s.Prefer)
+ LifeSagaSlot s = Get(unitId);
+ if (s != null)
{
- bonus += prefer;
+ float bonus = mc;
+ if (s.Prefer)
+ {
+ bonus += prefer;
+ }
+
+ if (s.GameFavorite)
+ {
+ bonus += fav;
+ }
+
+ return bonus;
}
- if (s.GameFavorite)
- {
- bonus += fav;
- }
-
- return bonus;
+ return IsMcCast(unitId) ? cast : 0f;
}
/// Among candidates, pick Prefer'd MC, else any MC, else null.
@@ -390,12 +402,208 @@ public static class LifeSagaRoster
return IsPrefer(EventFeedUtil.SafeId(c.FollowUnit));
}
- /// Near-tie breaker: Prefer > MC > neither.
+ public static bool TipTouchesMcCast(InterestCandidate c)
+ {
+ if (c == null)
+ {
+ return false;
+ }
+
+ if (IsMcCast(c.SubjectId) || IsMcCast(c.RelatedId))
+ {
+ return true;
+ }
+
+ return IsMcCast(EventFeedUtil.SafeId(c.FollowUnit));
+ }
+
+ /// Prefer=3, MC=2, MC cast=1, else 0.
+ public static int SoftBiasRank(InterestCandidate c)
+ {
+ if (c == null)
+ {
+ return 0;
+ }
+
+ if (TipTouchesPrefer(c))
+ {
+ return 3;
+ }
+
+ if (TipTouchesMc(c))
+ {
+ return 2;
+ }
+
+ return TipTouchesMcCast(c) ? 1 : 0;
+ }
+
+ /// True when unit is in a roster MC's live story orbit (not themselves an MC).
+ public static bool IsMcCast(long unitId)
+ {
+ if (unitId == 0 || IsMc(unitId))
+ {
+ return false;
+ }
+
+ for (int i = 0; i < Slots.Count; i++)
+ {
+ LifeSagaSlot slot = Slots[i];
+ if (slot == null || slot.UnitId == 0 || slot.UnitId == unitId)
+ {
+ continue;
+ }
+
+ if (IsInMcOrbit(slot.UnitId, unitId))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// Near-tie breaker: Prefer > MC > MC cast > neither.
public static int CompareSoftBias(InterestCandidate a, InterestCandidate b)
{
- int ap = TipTouchesPrefer(a) ? 2 : TipTouchesMc(a) ? 1 : 0;
- int bp = TipTouchesPrefer(b) ? 2 : TipTouchesMc(b) ? 1 : 0;
- return bp.CompareTo(ap);
+ return SoftBiasRank(b).CompareTo(SoftBiasRank(a));
+ }
+
+ ///
+ /// Roster MCs touched by the tip for rail involved chrome.
+ /// Bounded to subject/related/follow/pair/short-arc cast - not mass ParticipantIds.
+ ///
+ public static void CollectTouchedMcIds(InterestCandidate tip, List dest)
+ {
+ if (dest == null)
+ {
+ return;
+ }
+
+ dest.Clear();
+ if (tip == null)
+ {
+ return;
+ }
+
+ void Add(long id)
+ {
+ if (id == 0 || !IsMc(id))
+ {
+ return;
+ }
+
+ for (int i = 0; i < dest.Count; i++)
+ {
+ if (dest[i] == id)
+ {
+ return;
+ }
+ }
+
+ dest.Add(id);
+ }
+
+ Add(tip.SubjectId);
+ Add(tip.RelatedId);
+ Add(EventFeedUtil.SafeId(tip.FollowUnit));
+ Add(tip.PairOwnerId);
+ Add(tip.PairPartnerId);
+ Add(tip.TheaterLeadId);
+
+ StoryArc arc = StoryPlanner.Active;
+ if (arc?.CastIds != null)
+ {
+ for (int i = 0; i < arc.CastIds.Count; i++)
+ {
+ Add(arc.CastIds[i]);
+ }
+ }
+ }
+
+ public static int CountTouchedMcs(InterestCandidate tip)
+ {
+ if (tip == null)
+ {
+ return 0;
+ }
+
+ int n = 0;
+ long a = tip.SubjectId;
+ long b = tip.RelatedId;
+ long f = EventFeedUtil.SafeId(tip.FollowUnit);
+ if (IsMc(a))
+ {
+ n++;
+ }
+
+ if (b != 0 && b != a && IsMc(b))
+ {
+ n++;
+ }
+
+ if (f != 0 && f != a && f != b && IsMc(f))
+ {
+ n++;
+ }
+
+ return n;
+ }
+
+ private static bool IsInMcOrbit(long mcId, long otherId)
+ {
+ if (mcId == 0 || otherId == 0 || mcId == otherId)
+ {
+ return false;
+ }
+
+ Actor mc = EventFeedUtil.FindAliveById(mcId);
+ if (mc != null)
+ {
+ Actor lover = ActorRelation.GetLover(mc);
+ if (lover != null && EventFeedUtil.SafeId(lover) == otherId)
+ {
+ return true;
+ }
+
+ Actor friend = ActorRelation.GetBestFriend(mc);
+ if (friend != null && EventFeedUtil.SafeId(friend) == otherId)
+ {
+ return true;
+ }
+
+ try
+ {
+ foreach (Actor parent in ActorRelation.EnumerateParents(mc, 2))
+ {
+ if (EventFeedUtil.SafeId(parent) == otherId)
+ {
+ return true;
+ }
+ }
+
+ foreach (Actor child in ActorRelation.EnumerateChildren(mc, 4))
+ {
+ if (EventFeedUtil.SafeId(child) == otherId)
+ {
+ return true;
+ }
+ }
+ }
+ catch
+ {
+ // ignore relation probe failures
+ }
+ }
+
+ if (LifeSagaMemory.TryGetEarnedRival(mcId, out LifeSagaFact rival)
+ && rival != null
+ && rival.OtherId == otherId)
+ {
+ return true;
+ }
+
+ return false;
}
public static void StampChapter(long unitId, string line, float now = -1f)
@@ -609,6 +817,17 @@ public static class LifeSagaRoster
}
}
+ /// Soft heat bump for an existing roster MC (cross-MC moments).
+ public static void NoteFeaturedUnit(long unitId, float heat = 1f)
+ {
+ if (unitId == 0)
+ {
+ return;
+ }
+
+ NoteUnit(unitId, heat, Time.unscaledTime);
+ }
+
///
/// Challenge Cap with a living unit. Admission roles always may compete;
/// nobodies only when (hard-arc chapter path).
diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs
index 3b52160..b59692f 100644
--- a/IdleSpectator/WorldActivityScanner.cs
+++ b/IdleSpectator/WorldActivityScanner.cs
@@ -640,6 +640,25 @@ public static class WorldActivityScanner
weight += Mathf.Max(35f, spectacle);
}
+ // Soft saga bias: Prefer/MC beat ordinary notables in the same scrap; spectacle still wins.
+ long id = EventFeedUtil.SafeId(actor);
+ StoryWeights sw = InterestScoringConfig.Story;
+ if (LifeSagaRoster.IsPrefer(id))
+ {
+ float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 16f;
+ weight += Mathf.Max(18f, prefer);
+ }
+ else if (LifeSagaRoster.IsMc(id))
+ {
+ float mc = sw.sagaMcWeight > 0f ? sw.sagaMcWeight : 12f;
+ weight += Mathf.Max(12f, mc);
+ }
+ else if (LifeSagaRoster.IsMcCast(id))
+ {
+ float cast = sw.sagaCastWeight > 0f ? sw.sagaCastWeight : 5f;
+ weight += Mathf.Max(6f, cast);
+ }
+
return weight;
}
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index e1e6fe5..7b70e39 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.29.53",
+ "version": "0.29.57",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json
index 031b28d..7c405c4 100644
--- a/IdleSpectator/scoring-model.json
+++ b/IdleSpectator/scoring-model.json
@@ -116,6 +116,7 @@
"story": {
"nearTieEpsilon": 8,
+ "sagaNearTieEpsilon": 28,
"causalHeatHalfLifeSeconds": 180,
"causalHeatWeight": 6,
"causalRelatedWeight": 3,
@@ -131,9 +132,12 @@
"historyDensityWindowSeconds": 600,
"ledgerBleedLifeChapter": 0.4,
"ledgerBleedVillage": 0.82,
- "sagaMcWeight": 6,
- "sagaPreferWeight": 10,
+ "sagaMcWeight": 12,
+ "sagaPreferWeight": 16,
"sagaGameFavoriteWeight": 8,
+ "sagaCastWeight": 5,
+ "sagaCrossMcBonus": 6,
+ "sagaRivalEncounterThreshold": 3,
"sagaDullSeconds": 300,
"familyLifeBeatCooldownSeconds": 90,
"crisisWarParticipantMin": 8,
diff --git a/docs/life-saga.md b/docs/life-saga.md
index 8c5d2e8..e2aa557 100644
--- a/docs/life-saga.md
+++ b/docs/life-saga.md
@@ -44,18 +44,23 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
## Camera (soft)
- No rail camera lock. Prefer is a toggle soft-favorite for idle scoring.
-- Score ladder: crisis ownership > active short-arc ownership > Prefer soft > MC soft > CausalHeat.
-- Near-ties prefer Prefer'd MCs, then any MC.
-- Ambient fill prefers roster MCs over random villagers.
+- Score ladder: crisis ownership > active short-arc ownership > Prefer soft > MC soft > MC cast > CausalHeat.
+- `nearTieEpsilon` (8) picks #1 for ordinary gaps; Prefer/MC/cast may still win within `sagaNearTieEpsilon` (28).
+- Soft ranks: Prefer > MC > MC cast (lover/friend/kin/earned rival of a roster MC).
+- Tips that touch two or more roster MCs get `sagaCrossMcBonus`.
+- Mid-fight follow uses Prefer/MC/cast soft weight in `CombatFocusWeight` (spectacle floors can still win).
+- Ambient / character-fill yields immediately to Prefer/MC tips (not vs crisis/combat/short-arc holds).
+- Soft-fill quiet still suppresses anonymous crumbs; Prefer/MC tips may break quiet.
- Glyph hover acquires a camera read-pause lease: feeds/story/roster continue, but selection and focus repair are deferred until release.
## Rail UX
- Up to 10 slots with live inspect-UI sprites (refreshed ~3x/sec for animation frames).
- Egg forms and tiny/blank atlas crops fall back to the species icon (letter last).
-- Active chrome = camera follow only; Prefer uses a star pip / distinct border.
+- Active chrome = camera follow MC; Involved chrome = other tip-touched MCs (subject/related/follow/pair/short-arc cast only - not mass `ParticipantIds`).
+- Prefer uses a star pip / distinct border.
- Click toggles saga Prefer (does not mutate WorldBox unit favorite).
-- While on the **Saga** tab, click also pins that life as the persistent Saga subject.
+- While on the **Saga** tab, click also pins that life as the persistent Saga subject (panel only; director bias stays Prefer/MC/cast).
- Tabs sit above the cast rail; the rail is a **horizontal strip under the tabs** so Dossier↔Saga body swaps cannot move the glyphs.
- **Dossier** shows the watched unit dossier; **Saga** shows the adaptive saga panel for the watched MC when on the roster.
- If the watched unit is not an MC and nothing is pinned, Saga shows an empty *Pick a life from the rail* state (never a stale first-roster filler).
@@ -71,15 +76,29 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
- Body height sizes to content up to a cap; Legacy beats stack as separate lines (no · soup).
- Dossier story spine (`Kind · Phase`) is hidden when the orange reason already names the same kind and phase is Climax.
- Never show live task/status/trait rows or call a live attack target a rival.
-- Earned rivals require memory evidence (repeated encounters, kin kills, war/plot opposition).
+
+## Earned rivals
+
+Notable only:
+
+- Living rematches (≥ `sagaRivalEncounterThreshold`, default 3) while both are alive (sticky combat hook; kill/death do not count).
+- Kin blood (`killed_kin` / `kin_slain`).
+- War / plot opposition (opposing principals on the same war/plot tip).
+- MC↔MC living rematches earn rivalry one encounter sooner; a single MC kill is crossover Legacy, not automatic RivalEarned.
+- Cast shows living credible rivals only; weak kill-cycle `repeated_encounters` facts are pruned.
- Likely succession is omitted unless a game-authored heir API probe succeeds.
- Pack / kin lenses fill remaining Cast slots with live family peers (`Packmate` / `Kin`) after lover/friend/parents/children/heir/rival.
+## Cross-MC moments
+
+When subject and other are both roster MCs, facts are flagged `McCrossover` (named stake/legacy prose + heat on both).
+Both rail glyphs light (Active + Involved) when the current tip touches multiple MCs.
+
## Soft-fill quiet (AFK hygiene)
After hard story/crisis closers, soft-fill quiet suppresses soft-life crumbs and short interrupt FX (`stunned` / `invincible`).
`FamilyPack` does not break quiet (ambient cluster fill).
-Combat / war / plot / outbreak / earthquake / story beats / epic strength still cut in.
+Combat / war / plot / outbreak / earthquake / story beats / epic strength / Prefer/MC still cut in.
Quiet can refresh briefly when a soft crumb ends (bounded).
## Session
@@ -93,6 +112,14 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- `saga_roster_diversity`
- `saga_camera_prefers_mc`
- `saga_prefer_soft_bias`
+- `saga_wins_moderate_gap`
+- `saga_combat_follow_mc`
+- `saga_cast_soft_bias`
+- `saga_cross_mc_light`
+- `saga_cross_mc_kill`
+- `saga_rival_not_single_kill`
+- `saga_rival_rematch`
+- `saga_prefer_cuts_ambient`
- `saga_overview_has_mc`
- `saga_adaptive_dossier`
- `saga_memory_survives`
diff --git a/docs/scoring-model.md b/docs/scoring-model.md
index f2776ee..25fda57 100644
--- a/docs/scoring-model.md
+++ b/docs/scoring-model.md
@@ -35,4 +35,12 @@ Live combat holds the camera until the scrap goes cold (see who wins); variety p
Edit the `weights` block in scoring-model.json to change live scoring formula knobs.
+Edit the `story` block for StoryPlanner / life-saga soft bias:
+
+- `nearTieEpsilon` / `sagaNearTieEpsilon` - ordinary vs Prefer/MC/cast pick bands
+- `sagaMcWeight` / `sagaPreferWeight` / `sagaCastWeight` / `sagaCrossMcBonus` - tip score soft bias
+- `sagaRivalEncounterThreshold` - living rematches before EarnRival
+
Edit event-catalog.json to change what each event is worth and what it says.
+
+See also: [life-saga.md](life-saga.md), [story-planner.md](story-planner.md).
diff --git a/docs/story-planner.md b/docs/story-planner.md
index f902e31..33d03e1 100644
--- a/docs/story-planner.md
+++ b/docs/story-planner.md
@@ -174,8 +174,10 @@ short interrupt FX (`stunned` / `invincible`), and character-fill ambient briefl
## Selection discipline
- `InterestVariety.Pick` takes `#1` when score gap ≥ `story.nearTieEpsilon` (default 8).
-- Near-ties still roll among top-3.
-- Causal heat + life-saga Prefer/MC soft bias add into `RecalcTotal`.
+- Prefer / MC / MC-cast tips may still win when the gap is below `story.sagaNearTieEpsilon` (default 28).
+- Near-ties among peers still Prefer > MC > cast, else roll among top-3.
+- Causal heat + life-saga Prefer/MC/cast soft bias + cross-MC bonus add into `RecalcTotal`.
+- See [life-saga.md](life-saga.md) for camera / rival / dual-rail contracts.
## Knobs