worldbox-observer-mod/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs
DazedAnon 366ae45f13 Implement combat isolation and improve combat event handling.
- 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
2026-07-17 14:40:32 -05:00

415 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}