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.