using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// 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. /// public static class StatusOutbreakFeed { /// /// Authored affliction cluster inventory sampled by . /// Live ids may also pass via tokens. /// 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 _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(StringComparer.OrdinalIgnoreCase); List 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; } } } } /// /// Light combat-time path: only scan HotStatusIds near the current fight focus. /// 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(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 GetSampleStatusIds(float now) { if (_sampleStatusIds != null && now - _sampleStatusIdsAt < StatusIdCacheSeconds) { return _sampleStatusIds; } var sampleIds = new List(HotStatusIds.Length + 16); sampleIds.AddRange(HotStatusIds); List 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; } /// /// 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. /// 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); } /// /// Combat interrupt / recovery / shield FX that fire in packs during battles /// but are not lasting affliction stories. Uses id tokens plus live asset duration. /// 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; } /// Token heuristic for live plague/disease/curse-like statuses. 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); } }