From 750a303d896dbfee240f6e1649cc2e357a24df86 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Fri, 17 Jul 2026 15:09:19 -0500 Subject: [PATCH] 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 --- IdleSpectator/ActivityStatusProse.cs | 2 +- IdleSpectator/AgentHarness.cs | 225 +++++++++++- .../Events/Feeds/StatusOutbreakFeed.cs | 97 +++++- IdleSpectator/Events/LiveSceneStickyState.cs | 10 + IdleSpectator/Events/StickyScoreboard.cs | 87 +++-- IdleSpectator/HarnessScenarios.cs | 71 +++- IdleSpectator/InterestDirector.cs | 325 +++++++++++++++++- IdleSpectator/InterestScoring.cs | 68 +++- IdleSpectator/InterestScoringConfig.cs | 12 + IdleSpectator/mod.json | 4 +- IdleSpectator/scoring-model.json | 6 + 11 files changed, 850 insertions(+), 57 deletions(-) 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,