diff --git a/IdleSpectator/ActivityStatusProse.cs b/IdleSpectator/ActivityStatusProse.cs
index 62f05e1..faf019d 100644
--- a/IdleSpectator/ActivityStatusProse.cs
+++ b/IdleSpectator/ActivityStatusProse.cs
@@ -25,7 +25,7 @@ public static class ActivityStatusProse
["cursed"] = ("Is cursed", "Shakes off the curse"),
["dash"] = ("Dashes forward", "Ends the dash"),
["dodge"] = ("Dodges aside", "Stops dodging"),
- ["drowning"] = ("Starts drowning", "Gets air again"),
+ ["drowning"] = ("is drowning", "Gets air again"),
["egg"] = ("Becomes an egg", "Hatches from an egg"),
["enchanted"] = ("Becomes enchanted", "Loses the enchantment"),
["fell_in_love"] = ("Falls in love", "Lets the infatuation pass"),
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 1334ebf..3db33e3 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -2318,6 +2318,10 @@ public static class AgentHarness
DoCombatWireAttackSides(cmd);
break;
+ case "combat_park_bystander_follow":
+ DoCombatParkBystanderFollow(cmd);
+ break;
+
case "interest_variety_clear":
InterestVariety.Clear();
_cmdOk++;
@@ -3543,6 +3547,7 @@ public static class AgentHarness
float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f);
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed);
string label = string.IsNullOrEmpty(cmd.label) ? ("Inject " + (cmd.tier ?? "scene")) : cmd.label;
+ label = EventPresentability.EnsureSubjectLedLabel(follow, label);
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? label.Replace(" ", "_") : cmd.expect;
ParseInterestInjectOptions(
cmd.value,
@@ -3615,6 +3620,29 @@ public static class AgentHarness
InterestScoring.RecalcTotal(c);
}
+ ParseInjectSideCounts(cmd.value, out int sideA, out int sideB);
+ if (sideA > 0 || sideB > 0)
+ {
+ c.CombatSideACount = sideA;
+ c.CombatSideBCount = sideB;
+ c.CombatSideFrame = EnsembleFrame.SpeciesVsSpecies;
+ if (c.ParticipantCount < sideA + sideB)
+ {
+ c.ParticipantCount = sideA + sideB;
+ }
+
+ c.Category = "Combat";
+ InterestScoring.RecalcTotal(c);
+ }
+
+ if (!string.IsNullOrEmpty(cmd.expect)
+ && (cmd.expect.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
+ || cmd.expect.IndexOf("set_lover", StringComparison.OrdinalIgnoreCase) >= 0))
+ {
+ c.AssetId = "set_lover";
+ c.Category = "Relationship";
+ }
+
if (force)
{
InterestDirector.HarnessForceSession(c);
@@ -3624,7 +3652,39 @@ public static class AgentHarness
Emit(
cmd,
ok: true,
- detail: $"injected key={c.Key} score={c.TotalScore:0.#} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={c.ParticipantCount}/{c.NotableParticipantCount} score={c.TotalScore:0.#} pending={InterestRegistry.PendingCount}");
+ detail: $"injected key={c.Key} score={c.TotalScore:0.#} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={c.ParticipantCount}/{c.NotableParticipantCount} sides={c.CombatSideACount}/{c.CombatSideBCount} pending={InterestRegistry.PendingCount}");
+ }
+
+ private static void ParseInjectSideCounts(string raw, out int sideA, out int sideB)
+ {
+ sideA = 0;
+ sideB = 0;
+ if (string.IsNullOrEmpty(raw))
+ {
+ return;
+ }
+
+ string[] parts = raw.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries);
+ for (int i = 0; i < parts.Length; i++)
+ {
+ string p = parts[i].Trim();
+ int eq = p.IndexOf('=');
+ if (eq <= 0)
+ {
+ continue;
+ }
+
+ string key = p.Substring(0, eq).Trim().ToLowerInvariant();
+ string val = p.Substring(eq + 1).Trim();
+ if (key == "sidea" || key == "a")
+ {
+ int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out sideA);
+ }
+ else if (key == "sideb" || key == "b")
+ {
+ int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out sideB);
+ }
+ }
}
private static void ParseInterestInjectOptions(
@@ -5201,6 +5261,142 @@ public static class AgentHarness
detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
}
+ ///
+ /// Spawn a non-fighting same-side unit, park combat Follow on them (rostered bystander),
+ /// while peers keep fighting. Maintain must retarget off the chore unit.
+ ///
+ private static void DoCombatParkBystanderFollow(HarnessCommand cmd)
+ {
+ InterestCandidate scene = InterestDirector.CurrentCandidate;
+ if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
+ {
+ _cmdFail++;
+ Emit(cmd, ok: false, detail: "no_combat_scene");
+ return;
+ }
+
+ Actor anchor = scene.FollowUnit;
+ if (anchor == null || !anchor.isAlive())
+ {
+ anchor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ }
+
+ if (anchor == null || !anchor.isAlive())
+ {
+ _cmdFail++;
+ Emit(cmd, ok: false, detail: "no_anchor");
+ return;
+ }
+
+ string sideA = string.IsNullOrEmpty(cmd.asset) ? "human" : cmd.asset.Trim();
+ // Spawn a bit off the scrap so wolves do not instantly re-acquire the bystander.
+ Actor bystander = SpawnAssetNear(sideA, anchor.current_position, 6f);
+ if (bystander == null || !bystander.isAlive())
+ {
+ _cmdFail++;
+ Emit(cmd, ok: false, detail: "spawn_failed");
+ return;
+ }
+
+ StatusGameApi.TryAddStatus(bystander, "invincible", 12f);
+ StatusGameApi.TryAddStatus(bystander, "sleeping", 8f);
+ ClearAttackTarget(bystander);
+ TryClearCombatTask(bystander);
+ ClearAttackTarget(bystander);
+
+ long id = EventFeedUtil.SafeId(bystander);
+ if (id != 0 && scene.Sticky != null)
+ {
+ if (!scene.Sticky.SideAIds.Contains(id))
+ {
+ scene.Sticky.SideAIds.Add(id);
+ }
+
+ scene.Sticky.SideACount = Math.Max(scene.Sticky.SideACount, scene.Sticky.SideAIds.Count);
+ }
+
+ scene.FollowUnit = bystander;
+ scene.SubjectId = id;
+ try
+ {
+ scene.Position = bystander.current_position;
+ }
+ catch
+ {
+ // keep
+ }
+
+ CameraDirector.FocusUnit(bystander);
+ bool fighting = LiveEnsemble.IsCombatParticipant(bystander);
+ _cmdOk++;
+ Emit(
+ cmd,
+ ok: true,
+ detail: $"parked={SafeName(bystander)} fighting={fighting} tip='{CameraDirector.LastWatchLabel}'");
+ }
+
+ private static void ClearAttackTarget(Actor unit)
+ {
+ if (unit == null)
+ {
+ return;
+ }
+
+ try
+ {
+ var mi = typeof(Actor).GetMethod(
+ "setAttackTarget",
+ System.Reflection.BindingFlags.Instance
+ | System.Reflection.BindingFlags.Public
+ | System.Reflection.BindingFlags.NonPublic);
+ if (mi != null)
+ {
+ mi.Invoke(unit, new object[] { null });
+ return;
+ }
+
+ var prop = typeof(Actor).GetProperty(
+ "attack_target",
+ System.Reflection.BindingFlags.Instance
+ | System.Reflection.BindingFlags.Public
+ | System.Reflection.BindingFlags.NonPublic);
+ prop?.SetValue(unit, null, null);
+ }
+ catch
+ {
+ // best-effort
+ }
+ }
+
+ private static void TryClearCombatTask(Actor unit)
+ {
+ if (unit == null)
+ {
+ return;
+ }
+
+ try
+ {
+ if (!unit.hasTask() || unit.ai?.task == null || !unit.ai.task.in_combat)
+ {
+ return;
+ }
+
+ unit.setTask((string)null);
+ }
+ catch
+ {
+ try
+ {
+ unit.setTask("");
+ }
+ catch
+ {
+ // best-effort
+ }
+ }
+ }
+
///
/// Kill living units near the combat pair so ambient packs cannot escalate a 1v1 Duel tip.
/// value = radius (default 18). Keeps Follow + Related (+ optional PairOwner/Partner ids).
@@ -5607,6 +5803,15 @@ public static class AgentHarness
$"idle={idle} focus={focus} powerBar={bar} bad={StateProbe.BadEventCount}";
break;
}
+ case "tip_asset":
+ {
+ string want = (cmd.value ?? cmd.asset ?? "").Trim();
+ string tipAsset = CameraDirector.LastWatchAssetId ?? "";
+ pass = !string.IsNullOrEmpty(want)
+ && tipAsset.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
+ detail = $"tipAsset='{tipAsset}' want='{want}'";
+ break;
+ }
case "tip_matches_unit":
{
string tipAsset = CameraDirector.LastWatchAssetId ?? "";
@@ -5630,6 +5835,16 @@ public static class AgentHarness
$"tipAsset={tipAsset} unitAsset={unitAsset} tip={tip}";
break;
}
+ case "focus_fighting":
+ {
+ Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ bool fighting = LiveEnsemble.IsCombatParticipant(unit);
+ bool want = string.IsNullOrEmpty(cmd.value)
+ || !string.Equals(cmd.value, "false", StringComparison.OrdinalIgnoreCase);
+ pass = fighting == want;
+ detail = $"focus={SafeName(unit)} fighting={fighting} want={want}";
+ break;
+ }
case "tip_matches_focus":
{
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@@ -5907,6 +6122,14 @@ public static class AgentHarness
detail = $"interest_pending={have} want={want}";
break;
}
+ case "pending_contains":
+ {
+ string needle = (cmd.value ?? cmd.label ?? "").Trim();
+ int have = InterestRegistry.CountKeysContaining(needle);
+ pass = !string.IsNullOrEmpty(needle) && have > 0;
+ detail = $"needle='{needle}' matches={have} pending={InterestRegistry.PendingCount}";
+ break;
+ }
case "scoring_config_loaded":
{
bool want = string.IsNullOrEmpty(cmd.value) || ParseBool(cmd.value, true);
diff --git a/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs b/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs
index 1188e92..b02e2ae 100644
--- a/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs
+++ b/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs
@@ -66,9 +66,11 @@ public static class StatusOutbreakFeed
}
private static float _lastTickAt = -999f;
+ private static float _lastLethalTickAt = -999f;
private static List _sampleStatusIds;
private static float _sampleStatusIdsAt = -999f;
private const float OutbreakTickSeconds = 2.5f;
+ private const float LethalNearFocusTickSeconds = 1.5f;
private const float StatusIdCacheSeconds = 30f;
public static void Tick()
@@ -78,13 +80,16 @@ public static class StatusOutbreakFeed
return;
}
- // Sticky combat already owns the shot - do not walk every unit × status id.
+ // Sticky combat owns the shot - skip the heavy full-world sample.
+ // Still allow lethal affliction outbreaks (drowning / burning / …) near focus.
InterestCandidate current = InterestDirector.CurrentCandidate;
- if (current != null
- && current.Completion == InterestCompletionKind.CombatActive
- && InterestDirector.CurrentIsActive
- && current.ParticipantCount >= 3)
+ bool combatOwns = current != null
+ && current.Completion == InterestCompletionKind.CombatActive
+ && InterestDirector.CurrentIsActive
+ && current.ParticipantCount >= 3;
+ if (combatOwns)
{
+ TickLethalNearFocus(current);
return;
}
@@ -143,6 +148,88 @@ public static class StatusOutbreakFeed
}
}
+ ///
+ /// Light combat-time path: only scan HotStatusIds near the current fight focus.
+ ///
+ private static void TickLethalNearFocus(InterestCandidate combat)
+ {
+ float now = Time.unscaledTime;
+ if (now - _lastLethalTickAt < LethalNearFocusTickSeconds)
+ {
+ return;
+ }
+
+ _lastLethalTickAt = now;
+ Actor focus = combat?.FollowUnit;
+ if (focus == null || !focus.isAlive())
+ {
+ return;
+ }
+
+ Vector2 pos;
+ try
+ {
+ pos = focus.current_position;
+ }
+ catch
+ {
+ return;
+ }
+
+ float r2 = StickyScoreboard.StatusOutbreakRadius * StickyScoreboard.StatusOutbreakRadius;
+ int registered = 0;
+ var seenStatus = new HashSet(StringComparer.OrdinalIgnoreCase);
+ for (int h = 0; h < HotStatusIds.Length && registered < 3; h++)
+ {
+ string statusId = HotStatusIds[h];
+ if (!seenStatus.Add(statusId))
+ {
+ continue;
+ }
+
+ StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId);
+ if (!EventCatalog.IsCameraWorthy(entry) || !IsOutbreakWorthy(statusId, entry))
+ {
+ continue;
+ }
+
+ Actor seed = null;
+ foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
+ {
+ if (actor == null || !actor.isAlive())
+ {
+ continue;
+ }
+
+ try
+ {
+ Vector2 d = actor.current_position - pos;
+ if (d.sqrMagnitude > r2)
+ {
+ continue;
+ }
+ }
+ catch
+ {
+ continue;
+ }
+
+ if (!LiveEnsemble.HasActorStatus(actor, statusId))
+ {
+ continue;
+ }
+
+ seed = actor;
+ break;
+ }
+
+ if (seed != null && TryRegisterOutbreak(seed, statusId, entry))
+ {
+ registered++;
+ }
+ }
+ }
+
private static List GetSampleStatusIds(float now)
{
if (_sampleStatusIds != null && now - _sampleStatusIdsAt < StatusIdCacheSeconds)
diff --git a/IdleSpectator/Events/LiveSceneStickyState.cs b/IdleSpectator/Events/LiveSceneStickyState.cs
index 5bc9347..b15fce4 100644
--- a/IdleSpectator/Events/LiveSceneStickyState.cs
+++ b/IdleSpectator/Events/LiveSceneStickyState.cs
@@ -14,6 +14,10 @@ public sealed class LiveSceneStickyState
public EnsembleFrame Frame = EnsembleFrame.CountOnly;
public int PeakParticipants;
public float ScaleDropSince = -999f;
+ /// Last tip tier presented; held briefly across Skirmish↔Battle↔Mass flaps.
+ public bool HasPresentedScale;
+ public EnsembleScale PresentedScale;
+ public float ScaleChangeSince = -999f;
public string SideAKey = "";
public string SideADisplay = "";
@@ -47,6 +51,9 @@ public sealed class LiveSceneStickyState
Frame = EnsembleFrame.CountOnly;
PeakParticipants = 0;
ScaleDropSince = -999f;
+ HasPresentedScale = false;
+ PresentedScale = EnsembleScale.Pair;
+ ScaleChangeSince = -999f;
SideAKey = "";
SideADisplay = "";
SideAKingdom = "";
@@ -71,6 +78,9 @@ public sealed class LiveSceneStickyState
other.Frame = Frame;
other.PeakParticipants = PeakParticipants;
other.ScaleDropSince = ScaleDropSince;
+ other.HasPresentedScale = HasPresentedScale;
+ other.PresentedScale = PresentedScale;
+ other.ScaleChangeSince = ScaleChangeSince;
other.SideAKey = SideAKey;
other.SideADisplay = SideADisplay;
other.SideAKingdom = SideAKingdom;
diff --git a/IdleSpectator/Events/StickyScoreboard.cs b/IdleSpectator/Events/StickyScoreboard.cs
index 54f8458..b5d7cfb 100644
--- a/IdleSpectator/Events/StickyScoreboard.cs
+++ b/IdleSpectator/Events/StickyScoreboard.cs
@@ -63,7 +63,9 @@ namespace IdleSpectator;
}
LiveSceneStickyState sticky = scene.Sticky;
- bool preferCombat = sticky.Kind == EnsembleKind.Combat;
+ // Combat + war fronts: prefer units still in the scrap over chore bystanders.
+ bool preferCombat = sticky.Kind == EnsembleKind.Combat
+ || sticky.Kind == EnsembleKind.WarFront;
bool plotLike = sticky.Kind == EnsembleKind.PlotCell;
bool familyLike = sticky.Kind == EnsembleKind.FamilyPack;
bool outbreakLike = sticky.Kind == EnsembleKind.StatusOutbreak;
@@ -139,7 +141,8 @@ namespace IdleSpectator;
}
///
- /// Hold elevated scale briefly after participant count dips.
+ /// Hold presented tip tier across brief Skirmish↔Battle↔Mass flaps (up and down).
+ /// Peak still tracks max fighters for headcount / Mass band recovery.
///
public static void StabilizeScale(LiveSceneStickyState sticky, LiveEnsemble ensemble, float now)
{
@@ -152,38 +155,56 @@ namespace IdleSpectator;
if (n > sticky.PeakParticipants)
{
sticky.PeakParticipants = n;
- sticky.ScaleDropSince = -999f;
}
- int peak = Math.Max(sticky.PeakParticipants, n);
EnsembleScale live = LiveEnsemble.ScaleForCount(n);
- EnsembleScale held = LiveEnsemble.ScaleForCount(peak);
- if (held > live)
+ if (!sticky.HasPresentedScale)
{
- if (sticky.ScaleDropSince < 0f)
- {
- sticky.ScaleDropSince = now;
- }
-
- if (now - sticky.ScaleDropSince < ScaleHoldSeconds)
- {
- ensemble.Scale = held;
- if (ensemble.Frame == EnsembleFrame.NamedPair)
- {
- ensemble.Frame = EnsembleFrame.CountOnly;
- }
-
- return;
- }
-
- sticky.PeakParticipants = n;
+ sticky.HasPresentedScale = true;
+ sticky.PresentedScale = live;
+ sticky.ScaleChangeSince = -999f;
sticky.ScaleDropSince = -999f;
ensemble.Scale = live;
+ if (ensemble.Frame == EnsembleFrame.NamedPair && live > EnsembleScale.Pair)
+ {
+ ensemble.Frame = EnsembleFrame.CountOnly;
+ }
+
return;
}
+ if (live == sticky.PresentedScale)
+ {
+ sticky.ScaleChangeSince = -999f;
+ sticky.ScaleDropSince = -999f;
+ ensemble.Scale = sticky.PresentedScale;
+ return;
+ }
+
+ if (sticky.ScaleChangeSince < 0f)
+ {
+ sticky.ScaleChangeSince = now;
+ }
+
+ if (now - sticky.ScaleChangeSince < ScaleHoldSeconds)
+ {
+ ensemble.Scale = sticky.PresentedScale;
+ if (ensemble.Frame == EnsembleFrame.NamedPair && sticky.PresentedScale > EnsembleScale.Pair)
+ {
+ ensemble.Frame = EnsembleFrame.CountOnly;
+ }
+
+ return;
+ }
+
+ sticky.PresentedScale = live;
+ sticky.ScaleChangeSince = -999f;
sticky.ScaleDropSince = -999f;
ensemble.Scale = live;
+ if (ensemble.Frame == EnsembleFrame.NamedPair && live > EnsembleScale.Pair)
+ {
+ ensemble.Frame = EnsembleFrame.CountOnly;
+ }
}
///
@@ -299,6 +320,9 @@ namespace IdleSpectator;
sticky.SideBCount = Math.Max(0, countB);
if (warLike)
{
+ // Counts stay kingdom-wide; focus picks prefer frontline fighters.
+ bestA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: true) ?? bestA;
+ bestB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: true) ?? bestB;
LiveEnsemble.TryApplyKingdomPopulationCounts(sticky);
}
}
@@ -345,7 +369,7 @@ namespace IdleSpectator;
if (bestA != null
&& (ensemble.Focus == null
- || (sticky.Kind == EnsembleKind.Combat
+ || ((sticky.Kind == EnsembleKind.Combat || sticky.Kind == EnsembleKind.WarFront)
&& !LiveEnsemble.IsCombatParticipant(ensemble.Focus))))
{
ensemble.Focus = bestA;
@@ -355,6 +379,21 @@ namespace IdleSpectator;
{
ensemble.Related = bestB;
}
+
+ // War / combat: if Focus is still a chore bystander, swap to a live fighter on either side.
+ if ((sticky.Kind == EnsembleKind.Combat || sticky.Kind == EnsembleKind.WarFront)
+ && ensemble.Focus != null
+ && !LiveEnsemble.IsCombatParticipant(ensemble.Focus))
+ {
+ Actor fighterA = LiveEnsemble.IsCombatParticipant(bestA) ? bestA : null;
+ Actor fighterB = LiveEnsemble.IsCombatParticipant(bestB) ? bestB : null;
+ Actor fighter = fighterA ?? fighterB;
+ if (fighter != null)
+ {
+ ensemble.Related = fighter == bestA ? bestB : bestA;
+ ensemble.Focus = fighter;
+ }
+ }
}
/// Lock sticky keys from a live opposing collective ensemble (once).
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 63b5c9c..5f46bc8 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -56,6 +56,9 @@ internal static class HarnessScenarios
case "combat_stability_live":
case "combat_live_proof":
return CombatStabilityLive();
+ case "sticky_variety_valve":
+ case "variety_valve":
+ return StickyVarietyValve();
case "war_front_sticky":
case "war_sticky":
return WarFrontSticky();
@@ -206,6 +209,8 @@ internal static class HarnessScenarios
Nested("reg_interest_happiness", "interest_happiness"),
Nested("reg_director_gaps", "director_gaps"),
Nested("reg_action_priority", "director_action_priority"),
+ Nested("reg_variety_valve", "sticky_variety_valve"),
+ Nested("reg_combat_stability", "combat_stability_live"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_presentability", "camera_presentability"),
@@ -1194,6 +1199,7 @@ internal static class HarnessScenarios
Step("csl37", "assert", expect: "tip_matches_any", value: " vs "),
// Wire both camps → counts climb back up on the same sticky frame.
+ Step("csl39", "combat_maintain_focus"),
Step("csl40", "combat_wire_attack_sides", asset: "human", value: "wolf"),
Step("csl41", "wait", value: "0.5"),
Step("csl42", "combat_maintain_focus"),
@@ -1205,6 +1211,17 @@ internal static class HarnessScenarios
Step("csl46", "assert", expect: "unit_asset", value: "human|wolf"),
Step("csl47", "assert", expect: "dossier_matches_focus"),
+ // Chore bystander: park Follow on a sleeping rostered unit; maintain must retarget.
+ Step("csl48", "combat_park_bystander_follow", asset: "human", value: "wolf"),
+ Step("csl48w", "wait", value: "0.2"),
+ Step("csl48a", "assert", expect: "focus_fighting", value: "false"),
+ Step("csl48b", "combat_maintain_focus"),
+ Step("csl48c", "wait", value: "0.35"),
+ Step("csl48d", "combat_maintain_focus"),
+ Step("csl48e", "assert", expect: "focus_fighting", value: "true"),
+ Step("csl48f", "assert", expect: "unit_asset", value: "human|wolf"),
+ Step("csl48g", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -|fighting| vs "),
+
// Sleeper bystander must not stick the camera.
Step("csl50", "spawn", asset: "bear", count: 1),
Step("csl51", "focus", asset: "bear"),
@@ -1226,6 +1243,44 @@ internal static class HarnessScenarios
};
}
+ ///
+ /// After sticky combat matures, a Story/lover peer may cut in via the variety valve.
+ ///
+ private static List StickyVarietyValve()
+ {
+ return new List
+ {
+ Step("sv0", "dismiss_windows"),
+ Step("sv1", "wait_world"),
+ Step("sv2", "set_setting", expect: "enabled", value: "true"),
+ Step("sv3", "fast_timing", value: "true"),
+ Step("sv4", "spawn", asset: "human", count: 1),
+ Step("sv5", "spawn", asset: "wolf", count: 1),
+ Step("sv6", "spectator", value: "off"),
+ Step("sv7", "spectator", value: "on"),
+ Step("sv8", "pick_unit", asset: "human"),
+ Step("sv9", "focus", asset: "human"),
+ Step("sv10", "interest_end_session"),
+
+ Step("sv20", "interest_combat_session", asset: "human", value: "wolf", expect: "sv_pack"),
+ Step("sv21", "combat_ensemble_escalate", value: "8"),
+ Step("sv22", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
+ Step("sv22b", "combat_wire_attack_sides", asset: "human", value: "wolf"),
+ Step("sv22c", "interest_expire_pending", value: ""),
+ Step("sv23", "age_current", wait: 21f),
+ Step("sv24", "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("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"),
+
+ Step("sv90", "fast_timing", value: "false"),
+ Step("sv99", "snapshot"),
+ };
+ }
+
///
/// Sticky WarFront tips stay kingdom-framed across count dips and follow loss.
///
@@ -1744,14 +1799,22 @@ internal static class HarnessScenarios
value: "lead=event;evt=55;char=40;fighters=2;notables=2"),
Step("ap33", "assert", expect: "interest_score_order", value: "duel_kings", label: "melee_noname"),
- // Combat Action margin-cuts celebrity Story vignette (instant).
+ // Balanced contested scrap outranks extreme lopsided pack headcount.
+ Step("ap34", "interest_expire_pending", value: ""),
+ Step("ap35", "interest_inject", asset: "sheep", label: "LopsidedPack", tier: "Action", expect: "lopsided_pack",
+ value: "lead=event;evt=88;char=5;fighters=39;notables=0;sideA=38;sideB=1"),
+ Step("ap36", "interest_inject", asset: "sheep", label: "BalancedScrap", tier: "Action", expect: "balanced_scrap",
+ value: "lead=event;evt=88;char=5;fighters=12;notables=0;sideA=6;sideB=6"),
+ Step("ap37", "assert", expect: "interest_score_order", value: "balanced_scrap", label: "lopsided_pack"),
+
+ // Combat Action force-session replaces celebrity Story vignette.
Step("ap40", "interest_expire_pending", value: ""),
Step("ap41", "interest_force_session", asset: "sheep", label: "KingStroll", tier: "Story", expect: "king_stroll"),
- Step("ap42", "interest_inject", asset: "sheep", label: "RealFight", tier: "Action", expect: "real_fight",
+ Step("ap42", "assert", expect: "tip_contains", value: "KingStroll"),
+ Step("ap43", "interest_inject", asset: "sheep", label: "RealFight", tier: "Action", expect: "real_fight",
value: "lead=event;evt=120;char=10;fighters=4;notables=1;force=true"),
- Step("ap43", "director_run", wait: 0.8f),
Step("ap44", "assert", expect: "tip_contains", value: "RealFight"),
- Step("ap45", "assert", expect: "tip_contains", value: "RealFight"),
+ Step("ap45", "assert", expect: "tip_not_contains", value: "KingStroll"),
// Cold-boot focus gaps from welcome/dismiss are unrelated to score order.
Step("ap89", "reset_counters"),
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index ad3e8ad..d28471c 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -45,6 +45,8 @@ public static class InterestDirector
private static float _enabledAt = -999f;
private static float _inactiveSince = -999f;
private static float _lastCombatFocusAt = -999f;
+ /// One Story/lover/discovery cut per sticky combat/war hold via the variety valve.
+ private static bool _varietyValveUsed;
private const float CombatFocusThrottleSeconds = 1.25f;
/// Large Mass sticky scenes can maintain less often - roster work is heavier.
private const float CombatFocusThrottleLargeSeconds = 2.0f;
@@ -711,7 +713,15 @@ public static class InterestDirector
priorParticipantIds: _current.ParticipantIds,
newcomerBonus: 8f);
int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2;
- if (!ShouldEscalateNamedPair(_current, escalateProbe, probeFighters))
+ bool focusFighting = LiveEnsemble.IsCombatParticipant(ownedFocus);
+ bool relatedFighting = LiveEnsemble.IsCombatParticipant(ownedRelated);
+ // Both left the scrap (chores / idle): do not hold a Duel camera on them.
+ // Keep durable A vs B order while either still fights (no tip flip on attack gaps).
+ if (!focusFighting && !relatedFighting)
+ {
+ // Fall through to sticky rebuild / live fighter pick.
+ }
+ else if (!ShouldEscalateNamedPair(_current, escalateProbe, probeFighters))
{
string pairLabel = "";
Actor holdFocus = ownedFocus;
@@ -1011,30 +1021,43 @@ public static class InterestDirector
_current.PairPartnerId = 0;
}
- // Never stick on a sleeper / bystander unless they are on the sticky combat roster.
+ // Prefer live fighters over rostered bystanders (Idle / Following / Breeding / chores).
bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
- bool curRostered = StickyScoreboard.IsOnRoster(_current, curFollow);
- if (!LiveEnsemble.IsCombatParticipant(best))
+ bool bestFighting = LiveEnsemble.IsCombatParticipant(best);
+ if (!curFighting)
{
- if (curFighting || curRostered)
+ if (bestFighting)
{
- best = curFollow;
foe = ResolveAttackFoe(best) ?? foe;
}
+ else if (TryRetargetCombatFollow(_current, out Actor fighter, out Actor fighterFoe))
+ {
+ best = fighter;
+ foe = fighterFoe ?? foe;
+ if (ensemble != null)
+ {
+ label = EventReason.Combat(ensemble);
+ }
+ }
+ else if (curFollow != null && curFollow.isAlive())
+ {
+ // Nobody fighting - keep ownership rather than jump to a random sleeper.
+ best = curFollow;
+ foe = ResolveAttackFoe(best) ?? curRelated ?? foe;
+ }
}
- else if ((curFighting || curRostered)
- && best != curFollow
+ else if (!bestFighting)
+ {
+ best = curFollow;
+ foe = ResolveAttackFoe(best) ?? foe;
+ }
+ else if (best != curFollow
&& curFollow != null
&& curFollow.isAlive()
&& !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
{
float curW = LiveEnsemble.FocusWeight(
curFollow, _current.ParticipantIds, newcomerBonus: 0f);
- if (curRostered && !curFighting)
- {
- curW += 20f;
- }
-
float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
if (newW < curW + CombatFocusSwitchMargin)
{
@@ -1766,6 +1789,60 @@ public static class InterestDirector
return false;
}
+ ///
+ /// Pick a living sticky-roster fighter when the current follow left the scrap.
+ ///
+ private static bool TryRetargetCombatFollow(
+ InterestCandidate scene,
+ out Actor fighter,
+ out Actor foe)
+ {
+ fighter = null;
+ foe = null;
+ if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
+ {
+ return false;
+ }
+
+ LiveSceneStickyState sticky = scene.Sticky;
+ Actor pickA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: true);
+ Actor pickB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: true);
+ bool aFighting = LiveEnsemble.IsCombatParticipant(pickA);
+ bool bFighting = LiveEnsemble.IsCombatParticipant(pickB);
+ if (!aFighting && !bFighting)
+ {
+ return false;
+ }
+
+ if (aFighting && bFighting)
+ {
+ float wA = LiveEnsemble.FocusWeight(pickA, scene.ParticipantIds, 0f);
+ float wB = LiveEnsemble.FocusWeight(pickB, scene.ParticipantIds, 0f);
+ if (wB > wA)
+ {
+ fighter = pickB;
+ foe = pickA;
+ }
+ else
+ {
+ fighter = pickA;
+ foe = pickB;
+ }
+ }
+ else if (aFighting)
+ {
+ fighter = pickA;
+ foe = pickB;
+ }
+ else
+ {
+ fighter = pickB;
+ foe = pickA;
+ }
+
+ return fighter != null && fighter.isAlive();
+ }
+
///
/// Allow leaving NamedPair only when a real sided multi owns the scrap
/// (species/kingdom camps with 3+ fighters), never from ambient radius noise.
@@ -3095,11 +3172,25 @@ public static class InterestDirector
{
float graceCur = _current.TotalScore;
float graceNext = candidate.TotalScore;
+ float onCurrentGrace = now - _currentStartedAt;
float graceMargin = Mathf.Max(
InterestScoringConfig.W.cutInMargin,
InterestScoringConfig.W.stickyCutInMargin > 0f
? InterestScoringConfig.W.stickyCutInMargin
: 50f);
+ if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate))
+ {
+ float valveMargin = InterestScoringConfig.W.stickyVarietyValveMargin > 0f
+ ? InterestScoringConfig.W.stickyVarietyValveMargin
+ : 20f;
+ graceMargin = Mathf.Min(graceMargin, valveMargin);
+ if (graceNext >= graceCur - InterestScoringConfig.W.rotateSlack
+ && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
+ {
+ return true;
+ }
+ }
+
return graceNext >= graceCur + graceMargin
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
}
@@ -3123,6 +3214,13 @@ public static class InterestDirector
float cutMargin = sticky
? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f)
: w.cutInMargin;
+ bool varietyValve = VarietyValveOpen(_current, onCurrent)
+ && IsVarietyValveCandidate(candidate);
+ if (varietyValve)
+ {
+ float valveMargin = w.stickyVarietyValveMargin > 0f ? w.stickyVarietyValveMargin : 20f;
+ cutMargin = Mathf.Min(cutMargin, valveMargin);
+ }
// Ambient fill: any real event may take the camera immediately (no min dwell).
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
@@ -3141,6 +3239,23 @@ public static class InterestDirector
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))
+ {
+ bool hotEnough = InterestScoring.IsHotScore(nextScore)
+ || nextScore >= curScore - w.rotateSlack;
+ bool storyEnough = nextScore >= w.noticeScoreMin
+ || candidate.EventStrength >= w.noticeScoreMin;
+ if (hotEnough || storyEnough)
+ {
+ InterestDropLog.Record(
+ "variety_valve",
+ $"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}");
+ return true;
+ }
+ }
+
// Same-arc refresh (combat/hatch/grief keys) may replace without full margin.
if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack)
{
@@ -3159,7 +3274,8 @@ public static class InterestDirector
{
InterestDropLog.Record(
"below_margin",
- $"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}");
+ $"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"
+ + (varietyValve ? " valve" : ""));
}
// Fill never cuts a protected non-fill session without margin (already failed above).
@@ -3197,6 +3313,140 @@ public static class InterestDirector
private static bool IsSoftStickyCluster(InterestCandidate c) =>
c != null && c.Completion == InterestCompletionKind.FamilyPack;
+ ///
+ /// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
+ ///
+ private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) =>
+ !_varietyValveUsed && VarietyValveTimeReady(scene, onCurrent);
+
+ private static bool VarietyValveTimeReady(InterestCandidate scene, float onCurrent)
+ {
+ if (scene == null
+ || (scene.Completion != InterestCompletionKind.CombatActive
+ && scene.Completion != InterestCompletionKind.WarFront))
+ {
+ return false;
+ }
+
+ ScoringWeights w = InterestScoringConfig.W;
+ float after = w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 20f;
+ if (onCurrent >= after)
+ {
+ return true;
+ }
+
+ float maxW = MaxWatchFor(scene);
+ float frac = w.stickyVarietyValveMaxWatchFrac > 0f ? w.stickyVarietyValveMaxWatchFrac : 0.5f;
+ return maxW > 0f && onCurrent >= maxW * frac;
+ }
+
+ ///
+ /// Peers allowed through the sticky variety valve (class rules, not tip strings).
+ ///
+ private static bool IsVarietyValveCandidate(InterestCandidate c)
+ {
+ if (c == null || InterestScoring.IsFillScore(c.TotalScore) || IsAmbientShot(c))
+ {
+ return false;
+ }
+
+ // Never treat another fight/war as a "variety" peer - those use sticky margin / same-arc.
+ if (c.Completion == InterestCompletionKind.CombatActive
+ || c.Completion == InterestCompletionKind.WarFront
+ || string.Equals(c.Category, "Combat", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(c.AssetId, "live_combat", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(c.AssetId, "live_war", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (c.Completion == InterestCompletionKind.PlotActive
+ || c.Completion == InterestCompletionKind.StatusOutbreak)
+ {
+ return true;
+ }
+
+ if (InterestScoring.IsHotScore(c.TotalScore)
+ || c.EventStrength >= InterestScoringConfig.W.hotScoreMin)
+ {
+ return true;
+ }
+
+ if (c.EventStrength >= InterestScoringConfig.W.noticeScoreMin
+ && c.LeadKind == InterestLeadKind.EventLed)
+ {
+ return true;
+ }
+
+ if (IsParkableLifeBeat(c))
+ {
+ return true;
+ }
+
+ if (c.Completion == InterestCompletionKind.StatusPhase
+ && IsLethalStatusCandidate(c))
+ {
+ return true;
+ }
+
+ // New-species discovery Curiosity.
+ if (!string.IsNullOrEmpty(c.Key)
+ && c.Key.StartsWith("species:", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ string label = c.Label ?? "";
+ return label.StartsWith("New species:", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsParkableLifeBeat(InterestCandidate c)
+ {
+ if (c == null)
+ {
+ return false;
+ }
+
+ string asset = c.AssetId ?? "";
+ string key = c.Key ?? "";
+ string happiness = c.HappinessEffectId ?? "";
+ if (asset.Equals("set_lover", StringComparison.OrdinalIgnoreCase)
+ || asset.Equals("clear_lover", StringComparison.OrdinalIgnoreCase)
+ || asset.Equals("find_lover", StringComparison.OrdinalIgnoreCase)
+ || asset.Equals("fallen_in_love", StringComparison.OrdinalIgnoreCase)
+ || happiness.Equals("fallen_in_love", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return key.StartsWith("rel:set_lover", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("rel:clear_lover", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("rel:find_lover", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsLethalStatusCandidate(InterestCandidate c)
+ {
+ if (c == null)
+ {
+ return false;
+ }
+
+ string id = c.StatusId ?? c.AssetId ?? "";
+ if (string.IsNullOrEmpty(id))
+ {
+ return false;
+ }
+
+ return id.IndexOf("drown", StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("burn", StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("poison", StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("frozen", StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("curse", StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("infect", StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
private static bool IsStickyStoryScene(InterestCandidate c)
{
if (c == null)
@@ -3301,8 +3551,17 @@ public static class InterestDirector
}
// Purge moments that fired before the current scene started (stale queue).
+ // Parkable life beats may still wait for the variety valve under combat/war.
if (_current != null && c.CreatedAt < _currentStartedAt - 0.05f)
{
+ if (IsParkableLifeBeat(c)
+ && (_current.Completion == InterestCompletionKind.CombatActive
+ || _current.Completion == InterestCompletionKind.WarFront)
+ && now - c.CreatedAt < 45f)
+ {
+ return true;
+ }
+
if (c.Completion == InterestCompletionKind.FixedDwell
|| c.Completion == InterestCompletionKind.Manual)
{
@@ -3335,6 +3594,15 @@ public static class InterestDirector
return true;
}
+ if (IsParkableLifeBeat(c)
+ && _current != null
+ && (_current.Completion == InterestCompletionKind.CombatActive
+ || _current.Completion == InterestCompletionKind.WarFront)
+ && age < 45f)
+ {
+ return true;
+ }
+
float maxW = c.MaxWatch > 0f ? c.MaxWatch : InterestScoringConfig.W.maxWatchMoment;
return age < maxW * 0.5f;
default:
@@ -3355,11 +3623,21 @@ public static class InterestDirector
}
// Queued under an active hold: short TTL for FixedDwell moments.
+ // Life beats (lover / bond) park longer under sticky combat/war so the variety
+ // valve can still cut them in instead of soft-dropping forever.
if (_current != null
&& CurrentIsActive
&& (c.Completion == InterestCompletionKind.FixedDwell || c.Completion == InterestCompletionKind.Manual)
&& now - c.CreatedAt > 8f)
{
+ bool parkLife = IsParkableLifeBeat(c)
+ && (_current.Completion == InterestCompletionKind.CombatActive
+ || _current.Completion == InterestCompletionKind.WarFront);
+ if (parkLife)
+ {
+ return now - c.CreatedAt > 45f;
+ }
+
return true;
}
@@ -3388,6 +3666,25 @@ public static class InterestDirector
_interrupted = _current.CloneShallow();
}
+ // Consume the once-per-hold variety valve when leaving sticky combat/war for a peer story.
+ bool leavingStickyCombat = _current != null
+ && (_current.Completion == InterestCompletionKind.CombatActive
+ || _current.Completion == InterestCompletionKind.WarFront);
+ bool nextIsCombatTheater = next.Completion == InterestCompletionKind.CombatActive
+ || next.Completion == InterestCompletionKind.WarFront;
+ if (leavingStickyCombat
+ && !nextIsCombatTheater
+ && IsVarietyValveCandidate(next)
+ && VarietyValveTimeReady(_current, now - _currentStartedAt))
+ {
+ _varietyValveUsed = true;
+ }
+
+ if (nextIsCombatTheater && !leavingStickyCombat)
+ {
+ _varietyValveUsed = false;
+ }
+
_current = next;
_currentStartedAt = now;
_lastSwitchAt = now;
diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs
index 3ef8e87..91929ea 100644
--- a/IdleSpectator/InterestScoring.cs
+++ b/IdleSpectator/InterestScoring.cs
@@ -114,12 +114,20 @@ public static class InterestScoring
|| string.Equals(c.Category, "Combat", System.StringComparison.OrdinalIgnoreCase);
if (crowdScale)
{
- if (fighters >= w.massFighterThreshold)
+ int sideA = Mathf.Max(0, c.CombatSideACount);
+ int sideB = Mathf.Max(0, c.CombatSideBCount);
+ int effectiveFighters = ContestedFighters(fighters, sideA, sideB);
+ float balanceMul = FightBalanceMultiplier(sideA, sideB, w);
+
+ if (effectiveFighters >= w.massFighterThreshold)
{
- scaleBonus += w.massBaseBonus
- + Mathf.Min(w.massExtraCap, (fighters - w.massFighterThreshold) * w.massPerExtraFighter);
+ scaleBonus += (w.massBaseBonus
+ + Mathf.Min(
+ w.massExtraCap,
+ (effectiveFighters - w.massFighterThreshold) * w.massPerExtraFighter))
+ * balanceMul;
}
- else if (fighters > 0 && fighters <= w.duelMaxFighters)
+ else if (effectiveFighters > 0 && effectiveFighters <= w.duelMaxFighters)
{
if (notables >= 2)
{
@@ -135,9 +143,17 @@ public static class InterestScoring
}
}
- if (notables > 0 && fighters >= w.skirmishMinFighters)
+ if (notables > 0 && effectiveFighters >= w.skirmishMinFighters)
{
- scaleBonus += Mathf.Min(w.notableSkirmishCap, notables * w.notableSkirmishPer);
+ scaleBonus += Mathf.Min(w.notableSkirmishCap, notables * w.notableSkirmishPer) * balanceMul;
+ }
+
+ if (c.Completion == InterestCompletionKind.WarFront
+ && sideA >= 2
+ && sideB >= 2
+ && w.warFrontBalanceBonus > 0f)
+ {
+ scaleBonus += w.warFrontBalanceBonus * balanceMul;
}
}
@@ -149,6 +165,46 @@ public static class InterestScoring
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
}
+ ///
+ /// Contested size: 2× minority side when both camps exist (38v1 → 2; 6v6 → 12).
+ ///
+ public static int ContestedFighters(int fighters, int sideA, int sideB)
+ {
+ if (sideA >= 1 && sideB >= 1)
+ {
+ return Mathf.Max(2, 2 * Mathf.Min(sideA, sideB));
+ }
+
+ return Mathf.Max(0, fighters);
+ }
+
+ ///
+ /// 1 when sides are balanced; approaches when lopsided.
+ ///
+ public static float FightBalanceMultiplier(int sideA, int sideB, ScoringWeights w)
+ {
+ if (w == null)
+ {
+ w = InterestScoringConfig.W;
+ }
+
+ if (sideA < 1 || sideB < 1)
+ {
+ return 1f;
+ }
+
+ float balance = (float)Mathf.Min(sideA, sideB) / Mathf.Max(sideA, sideB);
+ float fullAt = w.fightBalanceFullAt > 0.05f ? w.fightBalanceFullAt : 0.55f;
+ float floor = Mathf.Clamp01(w.fightBalanceLopsidedFloor);
+ if (balance >= fullAt)
+ {
+ return 1f;
+ }
+
+ float t = Mathf.Clamp01(balance / fullAt);
+ return Mathf.Lerp(floor, 1f, t);
+ }
+
public static InterestMetadataSnapshot GetOrBuildMeta(Actor actor, float now)
{
if (actor == null)
diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs
index 8ab1a4a..de12876 100644
--- a/IdleSpectator/InterestScoringConfig.cs
+++ b/IdleSpectator/InterestScoringConfig.cs
@@ -30,6 +30,18 @@ public class ScoringWeights
public float cutInMargin = 35f;
/// Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch).
public float stickyCutInMargin = 50f;
+ /// After this many seconds on sticky combat/war, variety-valve peers may cut in.
+ public float stickyVarietyValveAfterSeconds = 20f;
+ /// Also open the valve after this fraction of MaxWatch (0.5 → 30s of a 60s combat).
+ public float stickyVarietyValveMaxWatchFrac = 0.5f;
+ /// Cut-in margin while the variety valve is open (between rotateSlack and stickyCutInMargin).
+ public float stickyVarietyValveMargin = 20f;
+ /// Lopsided fight scale multiplier floor (38v1 → near this; 1v1 balanced → 1).
+ public float fightBalanceLopsidedFloor = 0.28f;
+ /// Balance ratio (min/max sides) at which the fight gets full scale bonus.
+ public float fightBalanceFullAt = 0.55f;
+ /// Small bonus when WarFront has two material kingdom sides.
+ public float warFrontBalanceBonus = 12f;
public float rotateSlack = 12f;
public float fillScoreMax = 55f;
public float noticeScoreMin = 70f;
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index c8e4400..5adb4df 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.26.7",
- "description": "AFK Idle Spectator (I) + Lore (L). Fix Duel→Battle escalate when packs join.",
+ "version": "0.27.6",
+ "description": "AFK Idle Spectator (I) + Lore (L). Variety valve, tip hysteresis, fight balance, lethal/lover parks.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json
index 93d0a1c..7532e7d 100644
--- a/IdleSpectator/scoring-model.json
+++ b/IdleSpectator/scoring-model.json
@@ -12,6 +12,12 @@
"cutInMargin": 35,
"stickyCutInMargin": 50,
+ "stickyVarietyValveAfterSeconds": 20,
+ "stickyVarietyValveMaxWatchFrac": 0.5,
+ "stickyVarietyValveMargin": 20,
+ "fightBalanceLopsidedFloor": 0.28,
+ "fightBalanceFullAt": 0.55,
+ "warFrontBalanceBonus": 12,
"rotateSlack": 12,
"fillScoreMax": 55,
"noticeScoreMin": 70,