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:
parent
366ae45f13
commit
750a303d89
11 changed files with 850 additions and 57 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue