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