feat(saga): bias director onto MCs and note cross-MC beats

- Prefer/MC/cast win moderate tip gaps; combat follow and ambient cut-in follow roster
- Dual-light rail when tip touches multiple MCs; record MC↔MC crossover memory
- Earn rivals only from rematches, kin, or war/plot opposition (not single kills)
- Add harness coverage for gap, cast, rival, cross-MC, and ambient cut paths
This commit is contained in:
DazedAnon 2026-07-22 05:22:16 -05:00
parent 6fb62c47d6
commit 3941dc33e5
16 changed files with 1290 additions and 78 deletions

View file

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

View file

@ -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
};
}
/// <summary>Prefer/MC tip wins when a stranger is moderately hotter (inside sagaNearTieEpsilon).</summary>
private static List<HarnessCommand> SagaWinsModerateGap()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>MC CombatFocusWeight beats a non-MC partner (mid-fight follow bias).</summary>
private static List<HarnessCommand> SagaCombatFollowMc()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>MC lover tip beats an equal stranger tip via cast soft bias.</summary>
private static List<HarnessCommand> SagaCastSoftBias()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Two admitted MCs on one tip light Active + Involved rail chrome.</summary>
private static List<HarnessCommand> SagaCrossMcLight()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>MC kills MC → both memories hold McCrossover facts.</summary>
private static List<HarnessCommand> SagaCrossMcKill()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>One kill+death cycle must not earn RivalEarned.</summary>
private static List<HarnessCommand> SagaRivalNotSingleKill()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Threshold living rematches earn RivalEarned.</summary>
private static List<HarnessCommand> SagaRivalRematch()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Prefer tip cuts an ambient/fill hold without waiting for full margin.</summary>
private static List<HarnessCommand> SagaPreferCutsAmbient()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
/// no planner jargon / trait descs / Chronicle dumps.

View file

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

View file

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

View file

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

View file

@ -23,6 +23,11 @@ public class ScoringModelDocument
public class StoryWeights
{
public float nearTieEpsilon = 8f;
/// <summary>
/// Prefer / MC / MC-cast tips may beat a hotter stranger when the gap is below this
/// (wider than <see cref="nearTieEpsilon"/>).
/// </summary>
public float sagaNearTieEpsilon = 28f;
public float causalHeatHalfLifeSeconds = 180f;
public float causalHeatWeight = 6f;
public float causalRelatedWeight = 3f;
@ -41,11 +46,17 @@ public class StoryWeights
/// <summary>Obsolete ledger bleed (CharacterLedger removed); kept for json compat.</summary>
public float ledgerBleedVillage = 0.82f;
/// <summary>Life-saga roster soft MC tip bonus.</summary>
public float sagaMcWeight = 6f;
public float sagaMcWeight = 12f;
/// <summary>Extra soft bonus when the saga slot is Prefer'd.</summary>
public float sagaPreferWeight = 10f;
public float sagaPreferWeight = 16f;
/// <summary>Extra soft bonus when the unit is a WorldBox favorite.</summary>
public float sagaGameFavoriteWeight = 8f;
/// <summary>Soft bonus when tip touches an MC's story cast (lover/friend/kin/rival).</summary>
public float sagaCastWeight = 5f;
/// <summary>Extra tip bonus when subject/related/follow touch two or more roster MCs.</summary>
public float sagaCrossMcBonus = 6f;
/// <summary>Living rematch count before EarnRival (kill/death do not count).</summary>
public int sagaRivalEncounterThreshold = 3;
/// <summary>Chapter starvation window before dull non-favorite MCs lose rank.</summary>
public float sagaDullSeconds = 300f;
/// <summary>Extra FixedDwell beat cool for family chapters (seconds, stacks with base).</summary>

View file

@ -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];
}
/// <summary>
/// Prefer (3) > MC (2) > MC cast (1) among candidates within <paramref name="band"/> of #1.
/// </summary>
private static InterestCandidate PickBestSagaInBand(
List<InterestCandidate> 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)

View file

@ -13,18 +13,31 @@ public static class LifeSagaMemory
{
public const int MaxFactsPerLife = 48;
public const int MaxLives = 120;
public const int RivalEncounterThreshold = 2;
/// <summary>Fallback when scoring-model omits sagaRivalEncounterThreshold.</summary>
public const int RivalEncounterThresholdDefault = 3;
private const float EncounterDedupeSeconds = 28f;
private static readonly Dictionary<long, LifeSagaLifeMemory> Lives =
new Dictionary<long, LifeSagaLifeMemory>(64);
private static readonly Dictionary<string, int> PairEncounters =
new Dictionary<string, int>(64);
private static readonly Dictionary<string, float> PairEncounterCooldown =
new Dictionary<string, float>(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);
}
}
}
/// <summary>
/// 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.
/// </summary>
public static void NoteCombatEncounter(Actor a, Actor b, string provenance = "combat")
{
NoteCombatEncounterCore(a, b, provenance, bypassCooldown: false);
}
/// <summary>Harness: count distinct living rematches without waiting out the dedupe window.</summary>
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);
}
}
/// <summary>
/// Opposing sides of the same war: durable rivalry when both units are known.
/// </summary>
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);
}
/// <summary>
/// Opposing sides of the same plot: durable rivalry when both units are known.
/// </summary>
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;
}
/// <summary>True when RivalEarned evidence meets the notable bar (not a single kill-cycle).</summary>
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 = "";
/// <summary>Both subject and other were roster MCs when recorded.</summary>
public bool McCrossover;
}
public sealed class LifeSagaLifeMemory

View file

@ -20,6 +20,7 @@ public static class LifeSagaRail
private const float FaceRefreshSeconds = 0.28f;
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(MaxSlots);
private static readonly List<long> InvolvedScratch = new List<long>(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
/// <summary>Harness: true when the watched MC slot used Active (not Prefer) chrome.</summary>
public static bool LastActiveHighlight { get; private set; }
/// <summary>Harness: count of Involved (non-follow) MC glyphs lit on last Refresh.</summary>
public static int LastInvolvedHighlightCount { get; private set; }
/// <summary>Harness: Prefer pip visible on first Prefer'd slot.</summary>
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<LifeSagaSlot> 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<LifeSagaSlot> board, long watchId, List<long> 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];

View file

@ -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;
}
/// <summary>Among candidates, pick Prefer'd MC, else any MC, else null.</summary>
@ -390,12 +402,208 @@ public static class LifeSagaRoster
return IsPrefer(EventFeedUtil.SafeId(c.FollowUnit));
}
/// <summary>Near-tie breaker: Prefer > MC > neither.</summary>
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));
}
/// <summary>Prefer=3, MC=2, MC cast=1, else 0.</summary>
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;
}
/// <summary>True when unit is in a roster MC's live story orbit (not themselves an MC).</summary>
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;
}
/// <summary>Near-tie breaker: Prefer > MC > MC cast > neither.</summary>
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));
}
/// <summary>
/// Roster MCs touched by the tip for rail involved chrome.
/// Bounded to subject/related/follow/pair/short-arc cast - not mass ParticipantIds.
/// </summary>
public static void CollectTouchedMcIds(InterestCandidate tip, List<long> 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
}
}
/// <summary>Soft heat bump for an existing roster MC (cross-MC moments).</summary>
public static void NoteFeaturedUnit(long unitId, float heat = 1f)
{
if (unitId == 0)
{
return;
}
NoteUnit(unitId, heat, Time.unscaledTime);
}
/// <summary>
/// Challenge Cap with a living unit. Admission roles always may compete;
/// nobodies only when <paramref name="hardArcAdmit"/> (hard-arc chapter path).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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