- Add new command for isolating combat pairs to prevent ambient escalation - Enhance combat event logging and participant tracking - Optimize combat focus handling based on participant count - Update harness scenarios to validate new combat interactions - Increment version to 0.26.7 in mod.json
415 lines
13 KiB
C#
415 lines
13 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 List<string> _sampleStatusIds;
|
||
private static float _sampleStatusIdsAt = -999f;
|
||
private const float OutbreakTickSeconds = 2.5f;
|
||
private const float StatusIdCacheSeconds = 30f;
|
||
|
||
public static void Tick()
|
||
{
|
||
if (!AgentHarness.LiveFeedsAllowed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Sticky combat already owns the shot - do not walk every unit × status id.
|
||
InterestCandidate current = InterestDirector.CurrentCandidate;
|
||
if (current != null
|
||
&& current.Completion == InterestCompletionKind.CombatActive
|
||
&& InterestDirector.CurrentIsActive
|
||
&& current.ParticipantCount >= 3)
|
||
{
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|