diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index e0c8ef1..e0d6919 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2770,6 +2770,32 @@ public static class AgentHarness break; } + case "interest_variety_push_arc": + { + // value = arc key (or short "combat:duel"), label/count = times + string arc = (cmd.value ?? "").Trim(); + if (!string.IsNullOrEmpty(arc) && !arc.StartsWith("arc:", StringComparison.OrdinalIgnoreCase)) + { + arc = "arc:" + arc; + } + + int times = cmd.count > 0 ? cmd.count : ParseInt(cmd.label, 8); + if (string.IsNullOrEmpty(arc)) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no arc"); + break; + } + + InterestVariety.HarnessPushArc(arc, times); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"pushed={arc} x{times} window={InterestVariety.ArcWindowCount}"); + break; + } + case "scoring_reload": { bool ok = InterestScoringConfig.Reload(); @@ -2855,6 +2881,27 @@ public static class AgentHarness Emit(cmd, ok: true, detail: $"released key={InterestDirector.CurrentKey} active={InterestDirector.CurrentIsActive}"); break; + case "interest_mark_combat_cold": + { + // Drop attack/combat signals so live path cannot refresh hot; force-cold gates the hold. + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene != null) + { + ClearAttackTarget(scene.FollowUnit); + ClearAttackTarget(scene.RelatedUnit); + TryClearCombatTask(scene.FollowUnit); + TryClearCombatTask(scene.RelatedUnit); + } + + InterestDirector.HarnessMarkCombatCold(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"combat_cold key={InterestDirector.CurrentKey} tip='{CameraDirector.LastWatchLabel}' active={InterestDirector.CurrentIsActive}"); + break; + } + case "interest_mark_inactive": { float ago = ParseFloat(cmd.value, 1f); diff --git a/IdleSpectator/Events/Patches/BuildingEventPatches.cs b/IdleSpectator/Events/Patches/BuildingEventPatches.cs index 95dc68b..021061e 100644 --- a/IdleSpectator/Events/Patches/BuildingEventPatches.cs +++ b/IdleSpectator/Events/Patches/BuildingEventPatches.cs @@ -1,3 +1,4 @@ +using System; using ai.behaviours; using HarmonyLib; using UnityEngine; @@ -37,7 +38,13 @@ public static class BuildingEventPatches } string label = EventReason.BuildingEat(pActor, food); - Emit("building_consume", 78f, pActor, label); + bool fruitForage = IsFruitForage(food); + // Fruit bushes are high-rate ambient - fill-band strength so they do not crowd combat/story. + // Real settlement eats (granary/etc.) stay camera-competitive. + float strength = fruitForage ? 42f : 78f; + string category = fruitForage ? "Forage" : "Spectacle"; + string assetId = fruitForage ? "building_consume_fruit" : "building_consume"; + Emit(assetId, strength, category, pActor, label); } [HarmonyPatch(typeof(Building), "startDestroyBuilding")] @@ -102,7 +109,25 @@ public static class BuildingEventPatches maxWatch: spectacle ? 28f : 18f); } - private static void Emit(string eventId, float strength, Actor actor, string label) + private static bool IsFruitForage(Building food) + { + if (food?.asset == null) + { + return false; + } + + try + { + string type = food.asset.type ?? ""; + return string.Equals(type, "type_fruits", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static void Emit(string eventId, float strength, string category, Actor actor, string label) { if (!AgentHarness.LiveFeedsAllowed) { @@ -113,7 +138,7 @@ public static class BuildingEventPatches EventFeedUtil.Register( key, "building", - "Spectacle", + string.IsNullOrEmpty(category) ? "Spectacle" : category, label, eventId, strength, diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index cebc549..1e5a854 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -59,9 +59,11 @@ internal static class HarnessScenarios case "sticky_variety_valve": case "variety_valve": return StickyVarietyValve(); + case "combat_hold_until_end": + case "combat_hold": case "soft_duel_yield": case "duel_yield": - return SoftDuelYield(); + return CombatHoldUntilEnd(); case "war_front_sticky": case "war_sticky": return WarFrontSticky(); @@ -218,7 +220,7 @@ internal static class HarnessScenarios Nested("reg_director_gaps", "director_gaps"), Nested("reg_action_priority", "director_action_priority"), Nested("reg_variety_valve", "sticky_variety_valve"), - Nested("reg_soft_duel_yield", "soft_duel_yield"), + Nested("reg_combat_hold", "combat_hold_until_end"), Nested("reg_combat_stability", "combat_stability_live"), Nested("reg_discovery", "discovery"), Nested("reg_worldlog", "world_log"), @@ -1316,7 +1318,7 @@ internal static class HarnessScenarios } /// - /// After sticky combat matures, a Story/lover peer may cut in via the variety valve. + /// Live Mass combat holds through story peers; after the scrap goes cold, variety valve may cut. /// private static List StickyVarietyValve() { @@ -1344,9 +1346,19 @@ internal static class HarnessScenarios expect: "valve_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"), Step("sv24b", "assert", expect: "pending_contains", value: "valve_lover"), Step("sv25", "director_run", wait: 1.2f), - Step("sv26", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"), - Step("sv27", "assert", expect: "tip_not_contains", value: "Mass -"), - Step("sv28", "assert", expect: "tip_asset", value: "set_lover"), + // Live scrap: hold through the peer (see who wins). + Step("sv26", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), + Step("sv27", "assert", expect: "tip_not_contains", value: "falls in love"), + + // Fight over → story peer may cut. Clear discovery noise so the lover is the peer under test. + Step("sv28", "interest_expire_pending", value: ""), + Step("sv29", "interest_inject", asset: "human", label: "falls in love", tier: "Action", + expect: "valve_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"), + Step("sv30", "interest_mark_combat_cold"), + Step("sv31", "director_run", wait: 1.2f), + Step("sv32", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"), + Step("sv33", "assert", expect: "tip_not_contains", value: "Mass -"), + Step("sv34", "assert", expect: "tip_asset", value: "set_lover"), Step("sv90", "fast_timing", value: "false"), Step("sv99", "snapshot"), @@ -1354,49 +1366,75 @@ internal static class HarnessScenarios } /// - /// Anonymous pair Duels are soft sticky: after a short hold, a Story/lover peer may cut in - /// without needing Mass-tier sticky margin. + /// Live Duels hold through ordinary peers; catalog epic-world urgency may cut mid-fight; + /// after the scrap goes cold, Story peers may cut. /// - private static List SoftDuelYield() + private static List CombatHoldUntilEnd() { return new List { - Step("sd0", "dismiss_windows"), - Step("sd1", "wait_world"), - Step("sd2", "set_setting", expect: "enabled", value: "true"), - Step("sd3", "fast_timing", value: "true"), - Step("sd4", "spawn", asset: "human", count: 1), - Step("sd5", "spawn", asset: "wolf", count: 1), - Step("sd6", "spectator", value: "off"), - Step("sd7", "spectator", value: "on"), - Step("sd8", "pick_unit", asset: "human"), - Step("sd9", "focus", asset: "human"), - Step("sd10", "interest_end_session"), + Step("ch0", "dismiss_windows"), + Step("ch1", "wait_world"), + Step("ch2", "set_setting", expect: "enabled", value: "true"), + Step("ch3", "fast_timing", value: "true"), + Step("ch4", "spawn", asset: "human", count: 1), + Step("ch5", "spawn", asset: "wolf", count: 1), + Step("ch6", "spectator", value: "off"), + Step("ch7", "spectator", value: "on"), + Step("ch8", "pick_unit", asset: "human"), + Step("ch9", "focus", asset: "human"), + Step("ch10", "interest_end_session"), - // Do not combat_wire_attack_sides - that absorbs leftover same-species fighters into Battle. - // "no_attack" in expect skips attack wiring (keeps pair alive); tip still uses Duel framing. - Step("sd20", "interest_combat_session", asset: "human", value: "wolf", - expect: "sd_pack no_attack"), - Step("sd21b", "status_apply", asset: "human", value: "invincible"), - Step("sd21c", "status_apply", asset: "wolf", value: "invincible"), - Step("sd22", "assert", expect: "tip_matches_any", value: "Duel -"), - Step("sd23", "assert", expect: "tip_not_contains", value: "Mass -"), - Step("sd24", "assert", expect: "tip_not_contains", value: "Battle -"), - Step("sd25", "assert", expect: "tip_not_contains", value: "Skirmish -"), + // Wire attack so CombatStillActive stays hot; invincible so the pair survives the assert. + // Isolate so leftover world packs cannot escalate a 1v1 into Battle mid-hold. + Step("ch20", "interest_combat_session", asset: "human", value: "wolf", expect: "ch_pack"), + Step("ch20b", "combat_isolate_pair", value: "20"), + Step("ch21b", "status_apply", asset: "human", value: "invincible"), + Step("ch21c", "status_apply", asset: "wolf", value: "invincible"), + Step("ch22", "assert", expect: "tip_matches_any", value: "Duel -"), + Step("ch23", "assert", expect: "tip_not_contains", value: "Mass -"), - Step("sd29", "interest_expire_pending", value: ""), - Step("sd30", "age_current", wait: 9f), - Step("sd30b", "combat_maintain_focus"), - Step("sd31", "interest_inject", asset: "human", label: "falls in love", tier: "Action", - expect: "sd_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"), - Step("sd32", "assert", expect: "pending_contains", value: "sd_lover"), - Step("sd33", "director_run", wait: 1.2f), - Step("sd34", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"), - Step("sd35", "assert", expect: "tip_not_contains", value: "Duel -"), - Step("sd36", "assert", expect: "tip_asset", value: "set_lover"), + Step("ch29", "interest_expire_pending", value: ""), + Step("ch30", "age_current", wait: 9f), + Step("ch30b", "combat_maintain_focus"), + Step("ch30c", "assert", expect: "tip_matches_any", value: "Duel -"), + // Ordinary Story/Action peer: hold the scrap. + Step("ch31", "interest_inject", asset: "human", label: "falls in love", tier: "Action", + expect: "ch_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"), + Step("ch32", "assert", expect: "pending_contains", value: "ch_lover"), + Step("ch33", "director_run", wait: 1.2f), + Step("ch34", "assert", expect: "tip_matches_any", value: "Duel -"), + Step("ch35", "assert", expect: "tip_not_contains", value: "falls in love"), - Step("sd90", "fast_timing", value: "false"), - Step("sd99", "snapshot"), + // Extremely urgent (catalog epic-world EventStrength) may cut mid-fight. + Step("ch36", "interest_expire_pending", value: "ch_lover"), + Step("ch37", "interest_inject", asset: "human", label: "UrgentCut", tier: "Epic", + expect: "ch_urgent", value: "lead=event;evt=150;char=10;force=false;ttl=60"), + Step("ch38", "assert", expect: "pending_contains", value: "ch_urgent"), + Step("ch39", "director_run", wait: 1.2f), + Step("ch39b", "assert", expect: "tip_contains", value: "UrgentCut"), + Step("ch39c", "assert", expect: "tip_not_contains", value: "Duel -"), + + // Fresh scrap: after cold, ordinary peers may cut again. + Step("ch40", "interest_end_session"), + Step("ch41", "interest_expire_pending", value: ""), + Step("ch42", "interest_combat_session", asset: "human", value: "wolf", expect: "ch_pack2"), + Step("ch42b", "combat_isolate_pair", value: "20"), + Step("ch42c", "status_apply", asset: "human", value: "invincible"), + Step("ch42d", "status_apply", asset: "wolf", value: "invincible"), + Step("ch43", "assert", expect: "tip_matches_any", value: "Duel -"), + Step("ch44", "interest_inject", asset: "human", label: "falls in love", tier: "Action", + expect: "ch_lover2", value: "lead=event;evt=78;char=10;force=false;ttl=60"), + Step("ch45", "director_run", wait: 1.2f), + Step("ch46", "assert", expect: "tip_matches_any", value: "Duel -"), + Step("ch47", "interest_mark_combat_cold"), + Step("ch48", "director_run", wait: 1.2f), + Step("ch49", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"), + Step("ch50", "assert", expect: "tip_not_contains", value: "Duel -"), + Step("ch51", "assert", expect: "tip_asset", value: "set_lover"), + + Step("ch90", "fast_timing", value: "false"), + Step("ch99", "snapshot"), }; } @@ -1952,6 +1990,16 @@ internal static class HarnessScenarios value: "lead=event;evt=78;char=10"), Step("ap56", "assert", expect: "interest_score_order", value: "fresh_story", label: "repeat_duel"), + // Rolling rarity: flood the window with duels → a rare spectacle outranks another duel. + Step("ap60", "interest_variety_clear"), + Step("ap60b", "interest_expire_pending", value: ""), + Step("ap61", "interest_variety_push_arc", value: "combat:duel", count: 12), + Step("ap62", "interest_inject", asset: "sheep", label: "CommonDuel", tier: "Action", expect: "common_duel", + value: "lead=event;evt=100;char=5;fighters=2;notables=0"), + Step("ap63", "interest_inject", asset: "sheep", label: "RareSpell", tier: "Action", expect: "rare_spell", + value: "lead=event;evt=86;char=10"), + Step("ap64", "assert", expect: "interest_score_order", value: "rare_spell", label: "common_duel"), + // Cold-boot focus gaps from welcome/dismiss are unrelated to score order. Step("ap89", "reset_counters"), Step("ap90", "assert", expect: "no_bad"), diff --git a/IdleSpectator/InterestCompletion.cs b/IdleSpectator/InterestCompletion.cs index b7fe819..9d97f31 100644 --- a/IdleSpectator/InterestCompletion.cs +++ b/IdleSpectator/InterestCompletion.cs @@ -89,6 +89,11 @@ public static class InterestCompletion private static bool CombatStillActive(InterestCandidate c) { + if (InterestDirector.HarnessCombatForcedCold) + { + return false; + } + Actor unit = c.FollowUnit; if (unit == null || !unit.isAlive()) { diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 641e77a..ff885bd 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -47,6 +47,11 @@ public static class InterestDirector private static float _lastCombatFocusAt = -999f; /// One Story/lover/discovery cut per sticky combat/war hold via the variety valve. private static bool _varietyValveUsed; + /// + /// Harness-only: treat the current CombatActive scene as cold so peers can cut after a fight + /// without waiting for WorldBox to drop attack_target / combat tasks. + /// + private static bool _harnessCombatForcedCold; private const float CombatFocusThrottleSeconds = 1.25f; /// Large Mass sticky scenes can maintain less often - roster work is heavier. private const float CombatFocusThrottleLargeSeconds = 2.0f; @@ -268,6 +273,33 @@ public static class InterestDirector _inactiveSince = now - Mathf.Max(0f, inactiveSeconds); } + /// + /// Harness: force the current combat scene cold (fight over) without ending the session, + /// so quiet-grace / variety-valve peers may cut after a live hold. + /// + /// Harness: current CombatActive is forced cold until the next scene switch. + public static bool HarnessCombatForcedCold => _harnessCombatForcedCold; + + public static void HarnessMarkCombatCold() + { + if (_current == null || _current.Completion != InterestCompletionKind.CombatActive) + { + return; + } + + float now = Time.unscaledTime; + _harnessCombatForcedCold = true; + _current.ForceActive = false; + _current.LastSeenAt = now - 60f; + // Skip HasLiveCombatNear sticky path while proving post-fight cuts. + if (string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)) + { + _current.AssetId = "live_combat"; + } + + _inactiveSince = now; + } + /// Harness: clear FollowUnit while keeping RelatedUnit for death handoff. public static bool HarnessClearFollowForHandoff(Actor related) { @@ -439,8 +471,9 @@ public static class InterestDirector return; } - // Max cap ends the scene even if still "active". - if (onCurrent >= MaxWatchFor(_current)) + // Max cap ends the scene even if still "active" - except live combat, which + // holds until the scrap goes cold so the viewer can see who wins. + if (onCurrent >= MaxWatchFor(_current) && !CombatFightIsHot(_current, now)) { EndCurrent(now, reason: "max_cap"); return; @@ -3217,6 +3250,7 @@ public static class InterestDirector _interrupted = null; _current = null; + _harnessCombatForcedCold = false; _inactiveSince = -999f; EnsureIdleFocus(now); } @@ -3542,9 +3576,7 @@ public static class InterestDirector switch (current.Completion) { case InterestCompletionKind.CombatActive: - classCap = IsSoftStickyCombat(current) - ? (w.maxWatchDuel > 0f ? w.maxWatchDuel : 24f) - : w.maxWatchCombat; + classCap = w.maxWatchCombat; break; case InterestCompletionKind.WarFront: case InterestCompletionKind.PlotActive: @@ -3580,15 +3612,9 @@ public static class InterestDirector float cap = classCap; if (current.MaxWatch > 0f) { - // Soft pair Duels: honor maxWatchDuel - scanner/harness often stamp MaxWatch=60. - if (current.Completion == InterestCompletionKind.CombatActive - && IsSoftStickyCombat(current)) - { - cap = classCap; - } // Sticky live holds use the class MaxWatch (e.g. combat 60s) - do not let a // short candidate MaxWatch max_cap the fight while it is still active. - else if (current.Completion == InterestCompletionKind.CombatActive + if (current.Completion == InterestCompletionKind.CombatActive || current.Completion == InterestCompletionKind.WarFront || current.Completion == InterestCompletionKind.PlotActive || current.Completion == InterestCompletionKind.StatusPhase @@ -3701,6 +3727,27 @@ public static class InterestDirector } float now = Time.unscaledTime; + + // Live fight: hold until cold (see who wins), unless something extremely urgent cuts in. + // Same-arc theater refresh / absorb still allowed. + if (CombatFightIsHot(_current, now) + && !IsSameStoryArc(_current, candidate) + && candidate.Completion != InterestCompletionKind.CombatActive) + { + if (IsCombatUrgentPeer(_current, candidate)) + { + InterestDropLog.Record( + "combat_urgent_cut", + $"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}"); + return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false); + } + + InterestDropLog.Record( + "combat_hold", + $"cur={_current.Key} next={candidate.Key}"); + return false; + } + bool inGrace = InQuietGrace; if (inGrace) { @@ -3771,33 +3818,17 @@ public static class InterestDirector return true; } - // Soft family pack / thin Duel: yield to discrete unit events after a short hold. + // Soft family pack: yield to discrete unit events after a short hold. if (IsSoftStickyCluster(_current) && !IsSoftStickyCluster(candidate) && !nextFill && !IsAmbientShot(candidate) - && onCurrent >= SoftYieldSecondsFor(_current) + && onCurrent >= SoftClusterYieldSeconds && nextScore >= curScore - w.rotateSlack) { return true; } - // Soft Duel class gate: notice-tier Story peers cut after SoftCombatYieldSeconds. - // Harness combat sessions seed EventStrength ~120 so score-slack yield alone never clears; - // live anon Duels with VisualConfidence can sit in the same band. - if (IsSoftStickyCombat(_current) - && IsVarietyValveCandidate(candidate) - && !nextFill - && !IsAmbientShot(candidate) - && onCurrent >= SoftCombatYieldSeconds - && (nextScore >= w.noticeScoreMin || candidate.EventStrength >= w.noticeScoreMin)) - { - InterestDropLog.Record( - "soft_duel_yield", - $"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}"); - return true; - } - // Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut. // Score-margin alone can never clear Mass (~150) with lover (~78); use class gate. if (varietyValve && !nextFill && !IsAmbientShot(candidate)) @@ -3869,63 +3900,66 @@ public static class InterestDirector /// Soft multi-actor holds that must yield to discrete unit events. private const float SoftClusterYieldSeconds = 4f; - /// Thin Duel soft-yield - longer than pack so scraps still read, shorter than hard sticky. - private const float SoftCombatYieldSeconds = 8f; - private static bool IsSoftStickyCluster(InterestCandidate c) => - c != null - && (c.Completion == InterestCompletionKind.FamilyPack || IsSoftStickyCombat(c)); + c != null && c.Completion == InterestCompletionKind.FamilyPack; + + /// True while a CombatActive scene still has a live scrap (incl. hysteresis). + private static bool CombatFightIsHot(InterestCandidate scene, float now) + { + if (scene == null || scene.Completion != InterestCompletionKind.CombatActive) + { + return false; + } + + if (_harnessCombatForcedCold && scene == _current) + { + return false; + } + + return InterestCompletion.IsActive(scene, _currentStartedAt, now); + } /// - /// Pair scrap without two notables - soft sticky (normal cut-in, short MaxWatch, early valve). - /// Hard sticky reserved for Skirmish+ (live_battle), larger rosters, or notable-pair Duels. - /// Do not use ContestedFighters here: lopsided Mass tips can stamp sides as 1v1 while the roster is large. + /// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world + /// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers). /// - private static bool IsSoftStickyCombat(InterestCandidate c) + private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next) { - if (c == null || c.Completion != InterestCompletionKind.CombatActive) + if (current == null || next == null) { return false; } - // Ensemble framing (Skirmish/Battle/Mass) always hard-holds. - if (string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (c.NotableParticipantCount >= 2) + if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next)) { return false; } ScoringWeights w = InterestScoringConfig.W; - int sideSum = Math.Max(0, c.CombatSideACount) + Math.Max(0, c.CombatSideBCount); - int size = Math.Max(Math.Max(0, c.ParticipantCount), sideSum); - int skirmishFloor = Math.Max(3, w.skirmishMinFighters); - if (size >= skirmishFloor) + float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f; + if (next.TotalScore >= current.TotalScore + stickyMargin) { - return false; + return true; } - int duelMax = Math.Max(1, w.duelMaxFighters); - // size 0 during attack_target gaps: keep soft if we never escalated past a pair. - if (size == 0) - { - return c.CombatPeakParticipants <= duelMax; - } - - return size <= duelMax; + float epicMin = w.combatUrgentEventStrengthMin > 0f + ? w.combatUrgentEventStrengthMin + : 95f; + float urgentMargin = w.combatUrgentCutInMargin > 0f + ? w.combatUrgentCutInMargin + : 20f; + return next.EventStrength >= epicMin + && next.TotalScore >= current.TotalScore + urgentMargin; } - private static float SoftYieldSecondsFor(InterestCandidate c) => - IsSoftStickyCombat(c) ? SoftCombatYieldSeconds : SoftClusterYieldSeconds; - /// /// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in. + /// Live CombatActive scraps never open the valve - hold until the fight goes cold. /// private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) => - !_varietyValveUsed && VarietyValveTimeReady(scene, onCurrent); + !_varietyValveUsed + && VarietyValveTimeReady(scene, onCurrent) + && !CombatFightIsHot(scene, Time.unscaledTime); private static bool VarietyValveTimeReady(InterestCandidate scene, float onCurrent) { @@ -3937,11 +3971,7 @@ public static class InterestDirector } ScoringWeights w = InterestScoringConfig.W; - float after = IsSoftStickyCombat(scene) - ? (w.stickyVarietyValveAfterSecondsPair > 0f - ? w.stickyVarietyValveAfterSecondsPair - : 8f) - : (w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f); + float after = w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f; if (onCurrent >= after) { return true; @@ -4472,6 +4502,7 @@ public static class InterestDirector _currentStartedAt = now; _lastSwitchAt = now; _inactiveSince = -999f; + _harnessCombatForcedCold = false; if (!InterestScoring.IsFillScore(next.TotalScore)) { _lastInterestingAt = now; diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs index 58a02b7..d2d2ae4 100644 --- a/IdleSpectator/InterestScoring.cs +++ b/IdleSpectator/InterestScoring.cs @@ -158,14 +158,28 @@ public static class InterestScoring } float repeatPenalty = InterestVariety.RepeatArcPenalty(c); + float frequencyAdjust = InterestVariety.FrequencyAdjust(c); c.TotalScore = c.EventStrength + scaleBonus + c.CharacterSignificance * charWeight + c.VisualConfidence * w.visualMultiplier + c.Novelty * w.noveltyMultiplier - - repeatPenalty; - c.ScoreDetail = - $"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}" - + (repeatPenalty > 0.05f ? $" rpt=-{repeatPenalty:0.#}" : ""); + - repeatPenalty + + frequencyAdjust; + string detail = + $"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}"; + if (repeatPenalty > 0.05f) + { + detail += $" rpt=-{repeatPenalty:0.#}"; + } + + if (Mathf.Abs(frequencyAdjust) > 0.05f) + { + detail += " freq=" + + (frequencyAdjust >= 0f ? "+" : "") + + frequencyAdjust.ToString("0.#"); + } + + c.ScoreDetail = detail; } /// diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index d36c410..d18fb64 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -30,6 +30,16 @@ public class ScoringWeights public float cutInMargin = 35f; /// Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch). public float stickyCutInMargin = 50f; + /// + /// EventStrength floor for mid-fight urgent cuts (catalog epic-world band, 95-100). + /// Disasters / kingdom fall may interrupt a live scrap; lovers and daily Action may not. + /// + public float combatUrgentEventStrengthMin = 95f; + /// + /// Score margin over the live fight required when EventStrength clears + /// (smaller than stickyCutInMargin). + /// + public float combatUrgentCutInMargin = 20f; /// After this many seconds on sticky combat/war, variety-valve peers may cut in. public float stickyVarietyValveAfterSeconds = 16f; /// @@ -143,6 +153,18 @@ public class ScoringWeights public float repeatArcPenaltyPer = 24f; /// Cap on stacked repeat-arc penalty. public float repeatArcPenaltyCap = 72f; + /// Rolling tip window for rarity/commonality score adjust. + public int arcFrequencyWindow = 16; + /// Soft per-arc share target inside the window (common arcs above this lose score). + public float arcFairShare = 0.12f; + /// Max TotalScore penalty when an arc saturates the window. + public float arcOverSharePenaltyMax = 48f; + /// Max TotalScore boost when an arc is absent from the window (rare). + public float arcUnderShareBoostMax = 22f; + /// Soft share cap for all combat arcs combined (duel + multi). + public float arcCombatShareTarget = 0.35f; + /// Extra penalty when combat share exceeds . + public float arcCombatOverSharePenaltyMax = 36f; public int enrichTopK = 8; public float metaCacheTtl = 2f; } diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index f442dde..d73cfa1 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -6,8 +6,8 @@ namespace IdleSpectator; /// /// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties, -/// hard FixedDwell beat cooldowns, and consecutive variety-arc score drop-off -/// (same arc loses TotalScore until a different arc is shown). +/// hard FixedDwell beat cooldowns, consecutive arc drop-off, and a rolling +/// rarity window (common arcs lose score; rare/absent arcs gain score). /// public static class InterestVariety { @@ -18,6 +18,7 @@ public static class InterestVariety Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds); private static readonly List RecentMix = new List(32); + private static readonly List RecentArcs = new List(32); private static readonly Dictionary CooldownUntil = new Dictionary(64); private static readonly System.Random Rng = new System.Random(); @@ -37,15 +38,34 @@ public static class InterestVariety /// Harness/debug: consecutive shows of . public static int ArcStreak => _arcStreak; + /// Harness/debug: tips recorded in the rarity window. + public static int ArcWindowCount => RecentArcs.Count; + public static void Clear() { RecentMix.Clear(); + RecentArcs.Clear(); CooldownUntil.Clear(); LastPickHadBothPools = false; _lastArcKey = ""; _arcStreak = 0; } + /// Harness: push an arc into the rarity window without a full selection. + public static void HarnessPushArc(string arcKey, int times = 1) + { + if (string.IsNullOrEmpty(arcKey)) + { + return; + } + + int n = Mathf.Max(1, times); + for (int i = 0; i < n; i++) + { + PushArc(arcKey); + } + } + /// Harness: seed the rolling mix meter without touching cooldowns. public static void HarnessSeedMix(int eventLedCount, int characterLedCount) { @@ -158,6 +178,85 @@ public static class InterestVariety return Mathf.Min(cap, _arcStreak * per); } + /// + /// Rolling rarity: arcs over-represented in the recent tip window lose score; + /// absent/rare arcs gain score. Combat as a family gets an extra monopoly penalty. + /// + public static float FrequencyAdjust(InterestCandidate c) + { + if (c == null || RecentArcs.Count == 0) + { + return 0f; + } + + string arc = VarietyArcKey(c); + if (string.IsNullOrEmpty(arc)) + { + return 0f; + } + + ScoringWeights w = InterestScoringConfig.W; + int window = Mathf.Max(4, w.arcFrequencyWindow > 0 ? w.arcFrequencyWindow : 16); + int sample = Mathf.Min(RecentArcs.Count, window); + if (sample <= 0) + { + return 0f; + } + + int arcCount = 0; + int combatCount = 0; + int start = RecentArcs.Count - sample; + for (int i = start; i < RecentArcs.Count; i++) + { + string a = RecentArcs[i]; + if (string.Equals(a, arc, StringComparison.OrdinalIgnoreCase)) + { + arcCount++; + } + + if (IsCombatArc(a)) + { + combatCount++; + } + } + + float share = arcCount / (float)sample; + float fair = w.arcFairShare > 0.02f ? w.arcFairShare : 0.12f; + float overMax = w.arcOverSharePenaltyMax > 0f ? w.arcOverSharePenaltyMax : 48f; + float underMax = w.arcUnderShareBoostMax > 0f ? w.arcUnderShareBoostMax : 22f; + + float adjust = 0f; + if (share > fair) + { + float t = Mathf.Clamp01((share - fair) / Mathf.Max(0.05f, 1f - fair)); + adjust -= t * overMax; + } + else + { + float t = Mathf.Clamp01((fair - share) / fair); + adjust += t * underMax; + } + + if (IsCombatArc(arc)) + { + float combatShare = combatCount / (float)sample; + float combatTarget = w.arcCombatShareTarget > 0.05f ? w.arcCombatShareTarget : 0.35f; + float combatPen = w.arcCombatOverSharePenaltyMax > 0f ? w.arcCombatOverSharePenaltyMax : 36f; + if (combatShare > combatTarget) + { + float t = Mathf.Clamp01((combatShare - combatTarget) / Mathf.Max(0.05f, 1f - combatTarget)); + adjust -= t * combatPen; + } + } + + return adjust; + } + + private static bool IsCombatArc(string arc) => + !string.IsNullOrEmpty(arc) + && (arc.StartsWith("arc:combat:", StringComparison.OrdinalIgnoreCase) + || string.Equals(arc, "arc:war", StringComparison.OrdinalIgnoreCase)); + /// /// Class-level arc for repeat drop-off (not per unit / pair id). /// Duels share one arc so back-to-back scraps lose score; Mass/Battle share another. @@ -175,6 +274,14 @@ public static class InterestVariety return "arc:war"; } + if (string.Equals(c.Category, "Forage", StringComparison.OrdinalIgnoreCase) + || string.Equals(c.AssetId, "building_consume_fruit", StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(c.Label) + && c.Label.IndexOf("foraging", StringComparison.OrdinalIgnoreCase) >= 0)) + { + return "arc:forage"; + } + if (c.Completion == InterestCompletionKind.PlotActive || string.Equals(c.Category, "Plot", StringComparison.OrdinalIgnoreCase)) { @@ -287,6 +394,25 @@ public static class InterestVariety _lastArcKey = arc; _arcStreak = 1; } + + PushArc(arc); + } + + private static void PushArc(string arc) + { + if (string.IsNullOrEmpty(arc)) + { + return; + } + + RecentArcs.Add(arc); + int window = Mathf.Max(4, InterestScoringConfig.W.arcFrequencyWindow > 0 + ? InterestScoringConfig.W.arcFrequencyWindow + : 16); + while (RecentArcs.Count > window) + { + RecentArcs.RemoveAt(0); + } } /// diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 824c8f3..dc0fa8b 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.28.34", + "version": "0.28.38", "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 fcdf78f..fdcaff0 100644 --- a/IdleSpectator/scoring-model.json +++ b/IdleSpectator/scoring-model.json @@ -1,7 +1,7 @@ { - "modVersion": "0.28.34", + "modVersion": "0.28.38", "title": "IdleSpectator interest scoring model", - "updated": "2026-07-17", + "updated": "2026-07-18", "note": "Edit weights below - InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).", "weights": { @@ -12,6 +12,8 @@ "cutInMargin": 35, "stickyCutInMargin": 50, + "combatUrgentEventStrengthMin": 95, + "combatUrgentCutInMargin": 20, "stickyVarietyValveAfterSeconds": 16, "stickyVarietyValveAfterSecondsPair": 8, "stickyVarietyValveMaxWatchFrac": 0.4, @@ -100,6 +102,12 @@ "fixedDwellBeatCooldownSeconds": 45, "repeatArcPenaltyPer": 24, "repeatArcPenaltyCap": 72, + "arcFrequencyWindow": 16, + "arcFairShare": 0.12, + "arcOverSharePenaltyMax": 48, + "arcUnderShareBoostMax": 22, + "arcCombatShareTarget": 0.35, + "arcCombatOverSharePenaltyMax": 36, "enrichTopK": 8, "metaCacheTtl": 2 }, @@ -117,11 +125,13 @@ "Actions matter much more than characters.", "Same action intensity → more important character wins (tie-break).", "Multi-person fights outrank anonymous 1v1s.", - "Anonymous / single-notable Duels are soft sticky (short MaxWatch, early variety valve).", - "Hard sticky combat is reserved for Skirmish+ or notable-pair Duels.", + "Live CombatActive holds the camera until the scrap goes cold (see who wins).", + "Extremely urgent peers (EventStrength >= combatUrgentEventStrengthMin, or stickyCutInMargin) may cut mid-fight.", "Extremely notable 1v1s (kings/favorites) can beat nameless melees.", "Founding tips fire on meta outcomes (new_city / new_kingdom), not decision intent.", "Consecutive same variety-arc shows stack a score penalty until a different arc is shown.", + "Rolling window: over-shown arcs lose score; absent/rare arcs gain score (rarer feels hotter).", + "Combat share above arcCombatShareTarget gets an extra monopoly penalty.", "TotalScore is the sole ranking signal; typed rules (combat vs vignette) gate interrupts." ], "pipeline": [ diff --git a/docs/scoring-model.md b/docs/scoring-model.md index d4023c0..68826fd 100644 --- a/docs/scoring-model.md +++ b/docs/scoring-model.md @@ -25,6 +25,12 @@ Founding tips come from `new_city` / `new_kingdom` meta outcomes, not decision i Consecutive shows of the same variety arc (e.g. duel → duel) subtract `repeatArcPenaltyPer` from TotalScore (capped), resetting when a different arc is shown. +A rolling tip window also adjusts score by frequency: over-shown arcs lose up to `arcOverSharePenaltyMax`, rare/absent arcs gain up to `arcUnderShareBoostMax`, with an extra combat monopoly penalty above `arcCombatShareTarget`. + +Fruit-bush foraging is fill-band strength so it does not crowd the camera. + +Live combat holds the camera until the scrap goes cold (see who wins); variety peers cut after the fight ends. + Edit the `weights` block in scoring-model.json to change live scoring formula knobs. Edit event-catalog.json to change what each event is worth and what it says.