- 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
502 lines
15 KiB
C#
502 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Upgrades / registers sticky status outbreaks when multiple nearby units share a
|
|
/// lasting affliction / disease / spectacle status. Stickies require a real group (2+).
|
|
/// Brief combat FX (stun, silence, shield, …) and social/emotion statuses stay unit tips.
|
|
/// </summary>
|
|
public static class StatusOutbreakFeed
|
|
{
|
|
/// <summary>
|
|
/// Authored affliction cluster inventory sampled by <see cref="Tick"/>.
|
|
/// Live ids may also pass via <see cref="LooksLikeAfflictionCluster"/> tokens.
|
|
/// </summary>
|
|
private static readonly string[] HotStatusIds =
|
|
{
|
|
"cursed", "poisoned", "burning", "plague", "possessed", "ash_fever",
|
|
"frozen", "infected", "sick", "rage", "drowning"
|
|
};
|
|
|
|
public static void TryUpgradeFromEmit(Actor subject, string statusId, InterestCandidate registered)
|
|
{
|
|
if (!AgentHarness.LiveFeedsAllowed
|
|
|| subject == null
|
|
|| !subject.isAlive()
|
|
|| registered == null
|
|
|| string.IsNullOrEmpty(statusId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId);
|
|
if (!IsOutbreakWorthy(statusId, entry))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Vector2 pos;
|
|
try
|
|
{
|
|
Vector3 p = subject.current_position;
|
|
pos = new Vector2(p.x, p.y);
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!LiveEnsemble.TryBuildStatusOutbreak(
|
|
statusId,
|
|
pos,
|
|
StickyScoreboard.StatusOutbreakRadius,
|
|
out LiveEnsemble ensemble,
|
|
preferFocus: subject)
|
|
|| ensemble == null
|
|
|| ensemble.SideA == null
|
|
|| ensemble.SideA.Count < 2)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ApplySticky(registered, ensemble, statusId, pos);
|
|
}
|
|
|
|
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()
|
|
{
|
|
if (!AgentHarness.LiveFeedsAllowed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Sticky combat owns the shot - skip the heavy full-world sample.
|
|
// Still allow lethal affliction outbreaks (drowning / burning / …) near focus.
|
|
InterestCandidate current = InterestDirector.CurrentCandidate;
|
|
bool combatOwns = current != null
|
|
&& current.Completion == InterestCompletionKind.CombatActive
|
|
&& InterestDirector.CurrentIsActive
|
|
&& current.ParticipantCount >= 3;
|
|
if (combatOwns)
|
|
{
|
|
TickLethalNearFocus(current);
|
|
return;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
if (now - _lastTickAt < OutbreakTickSeconds)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_lastTickAt = now;
|
|
|
|
// Sample live affliction statuses from living units (HotStatusIds + discovered ids).
|
|
int registered = 0;
|
|
var seenStatus = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
List<string> sampleIds = GetSampleStatusIds(now);
|
|
int checkedUnits = 0;
|
|
const int maxUnitsToProbe = 80;
|
|
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || !actor.isAlive() || registered >= 6)
|
|
{
|
|
break;
|
|
}
|
|
|
|
checkedUnits++;
|
|
if (checkedUnits > maxUnitsToProbe)
|
|
{
|
|
break;
|
|
}
|
|
|
|
for (int s = 0; s < sampleIds.Count; s++)
|
|
{
|
|
string statusId = sampleIds[s];
|
|
if (!LiveEnsemble.HasActorStatus(actor, statusId) || !seenStatus.Add(statusId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId);
|
|
if (!EventCatalog.IsCameraWorthy(entry) || !IsOutbreakWorthy(statusId, entry))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (TryRegisterOutbreak(actor, statusId, entry))
|
|
{
|
|
registered++;
|
|
}
|
|
|
|
if (registered >= 6)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
return _sampleStatusIds;
|
|
}
|
|
|
|
var sampleIds = new List<string>(HotStatusIds.Length + 16);
|
|
sampleIds.AddRange(HotStatusIds);
|
|
List<string> liveIds = ActivityAssetCatalog.EnumerateLiveStatusIds();
|
|
for (int i = 0; i < liveIds.Count; i++)
|
|
{
|
|
string id = liveIds[i];
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bool already = false;
|
|
for (int j = 0; j < sampleIds.Count; j++)
|
|
{
|
|
if (sampleIds[j].Equals(id, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
already = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!already && IsOutbreakWorthy(id))
|
|
{
|
|
sampleIds.Add(id);
|
|
}
|
|
}
|
|
|
|
_sampleStatusIds = sampleIds;
|
|
_sampleStatusIdsAt = now;
|
|
return sampleIds;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Outbreak tips are lasting affliction / disease / spectacle clusters (2+ nearby).
|
|
/// Never love/emotion/ambient crowds, and never brief combat interrupt FX
|
|
/// (stun, confuse, silence, shield, dodge, recovery_*, …) even if StatusTransformation.
|
|
/// Solo carriers stay unit StatusPhase tips until a group forms.
|
|
/// </summary>
|
|
public static bool IsOutbreakWorthy(string statusId, StatusInterestEntry entry = null)
|
|
{
|
|
if (string.IsNullOrEmpty(statusId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string id = statusId.Trim();
|
|
StatusInterestEntry e = entry ?? EventCatalog.Status.GetOrFallback(id);
|
|
string cat = (e?.Category ?? "").Trim();
|
|
|
|
// Social / life / ambient beats stay unit-led tips even when many share them.
|
|
if (cat.Equals("Relationship", StringComparison.OrdinalIgnoreCase)
|
|
|| cat.Equals("Emotion", StringComparison.OrdinalIgnoreCase)
|
|
|| cat.Equals("LifeChapter", StringComparison.OrdinalIgnoreCase)
|
|
|| cat.Equals("StatusAmbient", StringComparison.OrdinalIgnoreCase)
|
|
|| cat.Equals("Grief", StringComparison.OrdinalIgnoreCase)
|
|
|| cat.Equals("Social", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Brief combat / spell FX - unit StatusPhase only, never sticky Outbreak.
|
|
// Token deny + live StatusAsset duration (short timers are interrupt FX).
|
|
if (IsBriefCombatOrSpellFx(id))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (int i = 0; i < HotStatusIds.Length; i++)
|
|
{
|
|
if (HotStatusIds[i].Equals(id, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Live-discovered disease / affliction ids not yet authored in HotStatusIds.
|
|
return LooksLikeAfflictionCluster(id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Combat interrupt / recovery / shield FX that fire in packs during battles
|
|
/// but are not lasting affliction stories. Uses id tokens plus live asset duration.
|
|
/// </summary>
|
|
public static bool IsBriefCombatOrSpellFx(string statusId)
|
|
{
|
|
if (string.IsNullOrEmpty(statusId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string s = statusId.Trim().ToLowerInvariant();
|
|
// Lasting afflictions keep outbreak eligibility even when short-lived in water/combat.
|
|
if (LooksLikeAfflictionClusterTokensOnly(s))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (s.StartsWith("recovery_", StringComparison.Ordinal)
|
|
|| s.StartsWith("spell_", StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (s == "stunned"
|
|
|| s == "confused"
|
|
|| s == "surprised"
|
|
|| s == "shield"
|
|
|| s == "slowness"
|
|
|| s == "dash"
|
|
|| s == "dodge"
|
|
|| s == "flicked"
|
|
|| s == "cough"
|
|
|| s == "afterglow"
|
|
|| s == "on_guard"
|
|
|| s == "powerup"
|
|
|| s == "invincible"
|
|
|| s == "magnetized"
|
|
|| s.Contains("stun")
|
|
|| s.Contains("confus")
|
|
|| s.Contains("silence")
|
|
|| s.Contains("shield")
|
|
|| s.Contains("dodge")
|
|
|| s.Contains("recovery")
|
|
|| s.Contains("cooldown")
|
|
|| s.Contains("windup")
|
|
|| s.Contains("cast_"))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Live library: very short statuses are combat FX, not outbreak stories.
|
|
try
|
|
{
|
|
StatusAsset asset = ActivityAssetCatalog.TryGetStatusAsset(s);
|
|
if (asset != null && asset.duration > 0f && asset.duration <= 6f)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Asset library unavailable during early boot.
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Token heuristic for live plague/disease/curse-like statuses.</summary>
|
|
public static bool LooksLikeAfflictionCluster(string statusId)
|
|
{
|
|
if (string.IsNullOrEmpty(statusId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string s = statusId.Trim().ToLowerInvariant();
|
|
if (!LooksLikeAfflictionClusterTokensOnly(s))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Affliction tokens win; still reject if the id is also an explicit interrupt FX name.
|
|
return !(s.Contains("stun") || s.Contains("shield") || s.Contains("dodge") || s.Contains("recovery"));
|
|
}
|
|
|
|
private static bool LooksLikeAfflictionClusterTokensOnly(string s)
|
|
{
|
|
if (string.IsNullOrEmpty(s))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return s.Contains("plague")
|
|
|| s.Contains("infect")
|
|
|| s.Contains("poison")
|
|
|| s.Contains("curse")
|
|
|| s.Contains("burn")
|
|
|| s.Contains("drown")
|
|
|| s.Contains("possess")
|
|
|| s.Contains("frozen")
|
|
|| s.Contains("fever")
|
|
|| s.Contains("sick")
|
|
|| s == "rage"
|
|
|| s.Contains("ash_fever");
|
|
}
|
|
|
|
private static bool TryRegisterOutbreak(Actor seed, string statusId, StatusInterestEntry entry)
|
|
{
|
|
Vector2 pos;
|
|
try
|
|
{
|
|
Vector3 p = seed.current_position;
|
|
pos = new Vector2(p.x, p.y);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!LiveEnsemble.TryBuildStatusOutbreak(
|
|
statusId,
|
|
pos,
|
|
StickyScoreboard.StatusOutbreakRadius,
|
|
out LiveEnsemble ensemble,
|
|
preferFocus: seed)
|
|
|| ensemble == null
|
|
|| ensemble.SideA == null
|
|
|| ensemble.SideA.Count < 2)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string key = "status:outbreak:" + statusId;
|
|
string label = EventReason.StatusOutbreak(ensemble);
|
|
if (string.IsNullOrEmpty(label))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
InterestCandidate registered = EventFeedUtil.Register(
|
|
key,
|
|
"status",
|
|
string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category,
|
|
label,
|
|
"live_outbreak",
|
|
Mathf.Max(entry.EventStrength, 84f),
|
|
seed.current_position,
|
|
ensemble.Focus,
|
|
related: ensemble.Related,
|
|
minWatch: 8f,
|
|
maxWatch: InterestScoringConfig.W.maxWatchStatus,
|
|
completion: InterestCompletionKind.StatusOutbreak);
|
|
if (registered == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
registered.StatusId = statusId;
|
|
ApplySticky(registered, ensemble, statusId, pos);
|
|
return true;
|
|
}
|
|
|
|
private static void ApplySticky(
|
|
InterestCandidate registered,
|
|
LiveEnsemble ensemble,
|
|
string statusId,
|
|
Vector2 pos)
|
|
{
|
|
registered.Completion = InterestCompletionKind.StatusOutbreak;
|
|
registered.StatusId = statusId;
|
|
registered.AssetId = "live_outbreak";
|
|
registered.CorrelationKey = "status:outbreak:" + statusId;
|
|
StickyScoreboard.TryLockFromEnsemble(registered.Sticky, ensemble);
|
|
StickyScoreboard.Refresh(
|
|
registered,
|
|
ensemble,
|
|
new Vector3(pos.x, pos.y, 0f),
|
|
StickyScoreboard.StatusOutbreakRadius);
|
|
registered.Label = EventReason.StatusOutbreak(ensemble);
|
|
registered.ParticipantCount = ensemble.ParticipantCount;
|
|
InterestScoring.ScoreCheap(registered);
|
|
}
|
|
}
|