Implement variety valve for combat interactions and enhance scoring.

- Introduce variety valve mechanics for cut-in opportunities during combat
- Improve fight balance calculations and scoring based on participant counts
- Add new scenarios for testing variety valve interactions and combat focus
- Update combat event handling to prioritize live fighters over bystanders
- Increment version to 0.27.6 in mod.json
This commit is contained in:
DazedAnon 2026-07-17 15:09:19 -05:00
parent 366ae45f13
commit 750a303d89
11 changed files with 850 additions and 57 deletions

View file

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

View file

@ -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}");
}
/// <summary>
/// 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.
/// </summary>
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
}
}
}
/// <summary>
/// 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);

View file

@ -66,9 +66,11 @@ public static class StatusOutbreakFeed
}
private static float _lastTickAt = -999f;
private static float _lastLethalTickAt = -999f;
private static List<string> _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
}
}
/// <summary>
/// Light combat-time path: only scan HotStatusIds near the current fight focus.
/// </summary>
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<string>(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<string> GetSampleStatusIds(float now)
{
if (_sampleStatusIds != null && now - _sampleStatusIdsAt < StatusIdCacheSeconds)

View file

@ -14,6 +14,10 @@ public sealed class LiveSceneStickyState
public EnsembleFrame Frame = EnsembleFrame.CountOnly;
public int PeakParticipants;
public float ScaleDropSince = -999f;
/// <summary>Last tip tier presented; held briefly across Skirmish↔Battle↔Mass flaps.</summary>
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;

View file

@ -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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
@ -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;
}
}
}
/// <summary>Lock sticky keys from a live opposing collective ensemble (once).</summary>

View file

@ -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
};
}
/// <summary>
/// After sticky combat matures, a Story/lover peer may cut in via the variety valve.
/// </summary>
private static List<HarnessCommand> StickyVarietyValve()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Sticky WarFront tips stay kingdom-framed across count dips and follow loss.
/// </summary>
@ -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"),

View file

@ -45,6 +45,8 @@ public static class InterestDirector
private static float _enabledAt = -999f;
private static float _inactiveSince = -999f;
private static float _lastCombatFocusAt = -999f;
/// <summary>One Story/lover/discovery cut per sticky combat/war hold via the variety valve.</summary>
private static bool _varietyValveUsed;
private const float CombatFocusThrottleSeconds = 1.25f;
/// <summary>Large Mass sticky scenes can maintain less often - roster work is heavier.</summary>
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;
}
/// <summary>
/// Pick a living sticky-roster fighter when the current follow left the scrap.
/// </summary>
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();
}
/// <summary>
/// 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;
/// <summary>
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
/// </summary>
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;
}
/// <summary>
/// Peers allowed through the sticky variety valve (class rules, not tip strings).
/// </summary>
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;

View file

@ -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.##}";
}
/// <summary>
/// Contested size: 2× minority side when both camps exist (38v1 → 2; 6v6 → 12).
/// </summary>
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);
}
/// <summary>
/// 1 when sides are balanced; approaches <see cref="ScoringWeights.fightBalanceLopsidedFloor"/> when lopsided.
/// </summary>
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)

View file

@ -30,6 +30,18 @@ public class ScoringWeights
public float cutInMargin = 35f;
/// <summary>Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch).</summary>
public float stickyCutInMargin = 50f;
/// <summary>After this many seconds on sticky combat/war, variety-valve peers may cut in.</summary>
public float stickyVarietyValveAfterSeconds = 20f;
/// <summary>Also open the valve after this fraction of MaxWatch (0.5 → 30s of a 60s combat).</summary>
public float stickyVarietyValveMaxWatchFrac = 0.5f;
/// <summary>Cut-in margin while the variety valve is open (between rotateSlack and stickyCutInMargin).</summary>
public float stickyVarietyValveMargin = 20f;
/// <summary>Lopsided fight scale multiplier floor (38v1 → near this; 1v1 balanced → 1).</summary>
public float fightBalanceLopsidedFloor = 0.28f;
/// <summary>Balance ratio (min/max sides) at which the fight gets full scale bonus.</summary>
public float fightBalanceFullAt = 0.55f;
/// <summary>Small bonus when WarFront has two material kingdom sides.</summary>
public float warFrontBalanceBonus = 12f;
public float rotateSlack = 12f;
public float fillScoreMax = 55f;
public float noticeScoreMin = 70f;

View file

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

View file

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