worldbox-observer-mod/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs
DazedAnon 2293274d79 feat(event): enhance event handling and narrative presentation probes
- Introduce new probes for framing approval and family coalescing
- Implement exact presentation replay checks to prevent duplicate events
- Refactor InterestVariety to manage exact replay cooldowns effectively
- Update EventPresentability to improve candidate approval logic
- Enhance InterestFeeds to streamline event registration and labeling
2026-07-23 19:43:29 -05:00

514 lines
16 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;
}
// A group is a different story object from the unit's onset. Publish one
// canonical outbreak card instead of mutating every per-unit status key
// into an identical crowd card. The latter left several changing cards in
// the queue and forced repeated full-world presentation validation.
if (TryRegisterOutbreak(subject, statusId, entry))
{
InterestRegistry.ForceExpireContaining(
"status:" + statusId + ":",
Time.unscaledTime);
}
}
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);
// ApplySticky runs after registry intake. Its generated crowd framing is
// authoritative and ownership-preserving, so retain that approval.
EventPresentability.MarkApproved(registered);
}
}