using System;
using UnityEngine;
namespace IdleSpectator;
///
/// Kind-agnostic sticky scoreboard: lock sides, enroll members, refresh alive counts,
/// hold elevated scale, promote follow on subject death.
/// Combat, WarFront, PlotCell, FamilyPack, and StatusOutbreak are consumers.
///
public static class StickyScoreboard
{
public const float ScaleHoldSeconds = 2.5f;
public const float WarTheaterRadius = 48f;
public const float PlotTheaterRadius = 36f;
public const float FamilyTheaterRadius = 28f;
public const float StatusOutbreakRadius = 32f;
public static bool HasOpposingSides(InterestCandidate scene) =>
scene?.Sticky != null && scene.Sticky.HasOpposingSides;
public static bool IsOnRoster(InterestCandidate scene, Actor actor)
{
if (scene?.Sticky == null || actor == null || !actor.isAlive())
{
return false;
}
long id = EventFeedUtil.SafeId(actor);
if (id == 0)
{
return false;
}
LiveSceneStickyState sticky = scene.Sticky;
for (int i = 0; i < sticky.SideAIds.Count; i++)
{
if (sticky.SideAIds[i] == id)
{
return true;
}
}
for (int i = 0; i < sticky.SideBIds.Count; i++)
{
if (sticky.SideBIds[i] == id)
{
return true;
}
}
return false;
}
///
/// Promote another living roster member onto the candidate when follow is lost.
///
public static bool TryPromoteFollow(InterestCandidate scene)
{
if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
{
return false;
}
LiveSceneStickyState sticky = scene.Sticky;
// Combat + war fronts: prefer units still in the scrap over chore bystanders.
bool preferCombat = sticky.Kind == EnsembleKind.Combat
|| sticky.Kind == EnsembleKind.WarFront;
bool plotLike = sticky.Kind == EnsembleKind.PlotCell;
bool familyLike = sticky.Kind == EnsembleKind.FamilyPack;
bool outbreakLike = sticky.Kind == EnsembleKind.StatusOutbreak;
bool singleSide = familyLike || outbreakLike;
Actor pickA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: preferCombat);
Actor pickB = singleSide
? null
: LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: preferCombat);
if (pickA == null
&& sticky.Frame == EnsembleFrame.KingdomVsKingdom
&& !LiveEnsemble.IsPlotterSideKey(sticky.SideAKey)
&& !LiveEnsemble.IsFamilyPackSideKey(sticky.SideAKey)
&& !LiveEnsemble.IsStatusOutbreakSideKey(sticky.SideAKey))
{
pickA = LiveEnsemble.PreferKingdomLeader(LiveEnsemble.FindKingdomByKey(sticky.SideAKey));
}
if (pickB == null
&& !singleSide
&& sticky.Frame == EnsembleFrame.KingdomVsKingdom)
{
pickB = LiveEnsemble.PreferKingdomLeader(LiveEnsemble.FindKingdomByKey(sticky.SideBKey));
}
// Family / plot / outbreak prefer Side A.
Actor pick = (plotLike || singleSide) ? (pickA ?? pickB) : (pickA ?? pickB);
if (pick == null || !pick.isAlive())
{
return false;
}
if (!plotLike && !singleSide && pickA != null && pickB != null)
{
if (sticky.SideBCount > sticky.SideACount)
{
pick = pickB;
}
else if (sticky.SideACount > sticky.SideBCount)
{
pick = pickA;
}
}
Actor other = singleSide
? null
: (pick == pickA ? pickB : pickA);
if (singleSide)
{
for (int i = 0; i < sticky.SideAIds.Count; i++)
{
Actor peer = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]);
if (peer != null && peer.isAlive() && peer != pick)
{
other = peer;
break;
}
}
}
scene.FollowUnit = pick;
scene.SubjectId = EventFeedUtil.SafeId(pick);
scene.RelatedUnit = other;
scene.RelatedId = other != null ? EventFeedUtil.SafeId(other) : 0;
try
{
scene.Position = pick.current_position;
}
catch
{
// keep
}
return true;
}
///
/// Hold presented tip tier across brief Skirmish↔Battle↔Mass flaps (up and down).
/// Peak still tracks max fighters for headcount / Mass band recovery.
///
public static void StabilizeScale(LiveSceneStickyState sticky, LiveEnsemble ensemble, float now)
{
if (sticky == null || ensemble == null)
{
return;
}
int n = Mathf.Max(1, ensemble.ParticipantCount);
if (n > sticky.PeakParticipants)
{
sticky.PeakParticipants = n;
}
EnsembleScale live = LiveEnsemble.ScaleForCount(n);
// Opposing camp wiped: drop Peak Mass hold immediately so tip tier matches mop-up.
bool wipedOpposing = sticky.HasOpposingSides
&& sticky.TotalCount > 0
&& (sticky.SideACount <= 0 || sticky.SideBCount <= 0);
if (wipedOpposing)
{
sticky.HasPresentedScale = true;
sticky.PresentedScale = live;
sticky.ScaleChangeSince = -999f;
sticky.ScaleDropSince = -999f;
ensemble.Scale = live;
if (ensemble.Frame == EnsembleFrame.NamedPair && live > EnsembleScale.Pair)
{
ensemble.Frame = EnsembleFrame.CountOnly;
}
return;
}
if (!sticky.HasPresentedScale)
{
sticky.HasPresentedScale = true;
sticky.PresentedScale = live;
sticky.ScaleChangeSince = -999f;
sticky.ScaleDropSince = -999f;
ensemble.Scale = live;
if (ensemble.Frame == EnsembleFrame.NamedPair && live > EnsembleScale.Pair)
{
ensemble.Frame = EnsembleFrame.CountOnly;
}
return;
}
if (live == sticky.PresentedScale)
{
sticky.ScaleChangeSince = -999f;
sticky.ScaleDropSince = -999f;
ensemble.Scale = sticky.PresentedScale;
return;
}
if (sticky.ScaleChangeSince < 0f)
{
sticky.ScaleChangeSince = now;
}
if (now - sticky.ScaleChangeSince < ScaleHoldSeconds)
{
ensemble.Scale = sticky.PresentedScale;
if (ensemble.Frame == EnsembleFrame.NamedPair && sticky.PresentedScale > EnsembleScale.Pair)
{
ensemble.Frame = EnsembleFrame.CountOnly;
}
return;
}
sticky.PresentedScale = live;
sticky.ScaleChangeSince = -999f;
sticky.ScaleDropSince = -999f;
ensemble.Scale = live;
if (ensemble.Frame == EnsembleFrame.NamedPair && live > EnsembleScale.Pair)
{
ensemble.Frame = EnsembleFrame.CountOnly;
}
}
///
/// Lock opposing camps once (when multi), enroll members, rewrite ensemble sides/counts.
///
public static void Refresh(
InterestCandidate scene,
LiveEnsemble ensemble,
Vector3 pos,
float radius)
{
if (scene?.Sticky == null || ensemble == null)
{
return;
}
LiveSceneStickyState sticky = scene.Sticky;
TryLockFromEnsemble(sticky, ensemble);
if (!sticky.HasOpposingSides)
{
return;
}
SeedFromEnsemble(sticky, ensemble);
bool warLike = sticky.Kind == EnsembleKind.WarFront || ensemble.Kind == EnsembleKind.WarFront;
bool plotLike = sticky.Kind == EnsembleKind.PlotCell || ensemble.Kind == EnsembleKind.PlotCell;
bool familyLike = sticky.Kind == EnsembleKind.FamilyPack || ensemble.Kind == EnsembleKind.FamilyPack;
bool outbreakLike = sticky.Kind == EnsembleKind.StatusOutbreak
|| ensemble.Kind == EnsembleKind.StatusOutbreak;
Actor bestA = null;
Actor bestB = null;
if (outbreakLike)
{
SeedOutbreakRoster(sticky, ensemble);
string statusId = scene.StatusId ?? "";
if (string.IsNullOrEmpty(statusId) && LiveEnsemble.IsStatusOutbreakSideKey(sticky.SideAKey))
{
statusId = sticky.SideAKey.Substring("status:".Length);
}
// Re-scan nearby carriers into the roster, then recount.
Vector2 scanPos = new Vector2(pos.x, pos.y);
var carriers = new System.Collections.Generic.List(8);
LiveEnsemble.CollectStatusCarriers(scanPos, Math.Max(radius, StatusOutbreakRadius), statusId, carriers);
for (int i = 0; i < carriers.Count; i++)
{
long id = EventFeedUtil.SafeId(carriers[i]);
if (id != 0)
{
AddUnique(sticky.SideAIds, id);
}
}
LiveEnsemble.TryApplyStatusOutbreakStickyCounts(sticky, statusId);
bestA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: false)
?? ensemble.Focus;
bestB = null;
for (int i = 0; i < sticky.SideAIds.Count; i++)
{
Actor peer = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]);
if (peer != null && peer.isAlive() && peer != bestA)
{
bestB = peer;
break;
}
}
}
else if (familyLike)
{
SeedFamilyRoster(sticky, ensemble);
LiveEnsemble.TryApplyFamilyStickyCounts(sticky);
bestA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: false)
?? ensemble.Focus;
bestB = null;
for (int i = 0; i < sticky.SideAIds.Count; i++)
{
Actor peer = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]);
if (peer != null && peer.isAlive() && peer != bestA)
{
bestB = peer;
break;
}
}
}
else if (plotLike)
{
SeedPlotRoster(sticky, ensemble);
LiveEnsemble.TryApplyPlotStickyCounts(sticky);
bestA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: false)
?? ensemble.Focus;
bestB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: false)
?? LiveEnsemble.PreferKingdomLeader(LiveEnsemble.FindKingdomByKey(sticky.SideBKey))
?? ensemble.Related;
}
else
{
float useRadius = warLike ? Math.Max(radius, WarTheaterRadius) : radius;
LiveEnsemble.RefreshStickyMemberRoster(
pos,
useRadius,
sticky.Frame,
sticky.SideAKey,
sticky.SideBKey,
sticky.SideAIds,
sticky.SideBIds,
out int countA,
out int countB,
out bestA,
out bestB,
requireCombat: !warLike);
sticky.SideACount = Math.Max(0, countA);
sticky.SideBCount = Math.Max(0, countB);
if (warLike)
{
// Counts stay kingdom-wide; focus picks prefer frontline fighters.
bestA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: true) ?? bestA;
bestB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: true) ?? bestB;
LiveEnsemble.TryApplyKingdomPopulationCounts(sticky);
}
}
int live = sticky.TotalCount;
scene.ParticipantCount = live;
if (live > sticky.PeakParticipants)
{
sticky.PeakParticipants = live;
}
ApplyToEnsemble(sticky, ensemble, bestA, bestB);
if (ensemble.Scale == EnsembleScale.Pair && sticky.Kind == EnsembleKind.Combat)
{
ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(3, sticky.PeakParticipants));
}
if (warLike)
{
ensemble.Kind = EnsembleKind.WarFront;
ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(3, live));
}
if (plotLike)
{
ensemble.Kind = EnsembleKind.PlotCell;
ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(3, live));
}
if (familyLike)
{
ensemble.Kind = EnsembleKind.FamilyPack;
ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(2, sticky.SideACount));
ensemble.SideB = null;
}
if (outbreakLike)
{
ensemble.Kind = EnsembleKind.StatusOutbreak;
ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(2, sticky.SideACount));
ensemble.SideB = null;
}
if (bestA != null
&& (ensemble.Focus == null
|| ((sticky.Kind == EnsembleKind.Combat || sticky.Kind == EnsembleKind.WarFront)
&& !LiveEnsemble.IsCombatParticipant(ensemble.Focus))))
{
ensemble.Focus = bestA;
}
if (bestB != null)
{
ensemble.Related = bestB;
}
// War / combat: if Focus is still a chore bystander, swap to a live fighter on either side.
if ((sticky.Kind == EnsembleKind.Combat || sticky.Kind == EnsembleKind.WarFront)
&& ensemble.Focus != null
&& !LiveEnsemble.IsCombatParticipant(ensemble.Focus))
{
Actor fighterA = LiveEnsemble.IsCombatParticipant(bestA) ? bestA : null;
Actor fighterB = LiveEnsemble.IsCombatParticipant(bestB) ? bestB : null;
Actor fighter = fighterA ?? fighterB;
if (fighter != null)
{
ensemble.Related = fighter == bestA ? bestB : bestA;
ensemble.Focus = fighter;
}
}
}
/// Lock sticky keys from a live opposing collective ensemble (once).
public static bool TryLockFromEnsemble(LiveSceneStickyState sticky, LiveEnsemble ensemble)
{
if (sticky == null || ensemble == null || sticky.HasOpposingSides)
{
return false;
}
if (!LiveEnsemble.HasOpposingCollectiveSides(ensemble))
{
return false;
}
int liveN = Math.Max(
ensemble.ParticipantCount,
(ensemble.SideA?.Count ?? 0) + (ensemble.SideB?.Count ?? 0));
// War / plot / family / outbreak lock even at low local fighter counts.
if (ensemble.Kind != EnsembleKind.WarFront
&& ensemble.Kind != EnsembleKind.PlotCell
&& ensemble.Kind != EnsembleKind.FamilyPack
&& ensemble.Kind != EnsembleKind.StatusOutbreak
&& liveN <= 2
&& ensemble.Scale <= EnsembleScale.Pair)
{
return false;
}
sticky.Kind = ensemble.Kind;
sticky.Frame = ensemble.Frame;
sticky.SideAKey = ensemble.SideA.Key ?? "";
sticky.SideADisplay = ensemble.SideA.Display ?? "";
sticky.SideAKingdom = ensemble.SideA.KingdomDisplay ?? "";
sticky.SideACount = Math.Max(0, ensemble.SideA.Count);
if (ensemble.Kind == EnsembleKind.FamilyPack
|| ensemble.Kind == EnsembleKind.StatusOutbreak
|| ensemble.SideB == null)
{
sticky.SideBKey = "";
sticky.SideBDisplay = "";
sticky.SideBKingdom = "";
sticky.SideBCount = 0;
}
else
{
sticky.SideBKey = ensemble.SideB.Key ?? "";
sticky.SideBDisplay = ensemble.SideB.Display ?? "";
sticky.SideBKingdom = ensemble.SideB.KingdomDisplay ?? "";
sticky.SideBCount = Math.Max(0, ensemble.SideB.Count);
}
return true;
}
/// Build orange reason from sticky state; never thin Fight prose.
public static string FormatReason(InterestCandidate scene, Actor focus, Actor related)
{
if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
{
return "";
}
LiveSceneStickyState sticky = scene.Sticky;
bool wipedOpposing = sticky.HasOpposingSides
&& sticky.TotalCount > 0
&& (sticky.SideACount <= 0 || sticky.SideBCount <= 0);
int scaleN = wipedOpposing
? Math.Max(1, sticky.TotalCount)
: Math.Max(3, sticky.PeakParticipants);
var ensemble = new LiveEnsemble
{
Kind = sticky.Kind,
Scale = LiveEnsemble.ScaleForCount(scaleN),
Focus = focus,
Related = related,
Frame = sticky.Frame,
SideA = new EnsembleSide
{
Key = sticky.SideAKey,
Display = sticky.SideADisplay,
KingdomDisplay = sticky.SideAKingdom,
Count = sticky.SideACount,
Best = focus
},
SideB = new EnsembleSide
{
Key = sticky.SideBKey,
Display = sticky.SideBDisplay,
KingdomDisplay = sticky.SideBKingdom,
Count = sticky.SideBCount,
Best = related
},
ParticipantCount = sticky.TotalCount
};
return EventReason.Ensemble(ensemble);
}
private static void ApplyToEnsemble(
LiveSceneStickyState sticky,
LiveEnsemble ensemble,
Actor bestA,
Actor bestB)
{
ensemble.Frame = sticky.Frame;
ensemble.SideA = new EnsembleSide
{
Key = sticky.SideAKey,
Display = string.IsNullOrEmpty(sticky.SideADisplay) ? sticky.SideAKey : sticky.SideADisplay,
KingdomDisplay = sticky.SideAKingdom,
Count = sticky.SideACount,
Best = bestA ?? ensemble.Focus
};
ensemble.SideB = new EnsembleSide
{
Key = sticky.SideBKey,
Display = string.IsNullOrEmpty(sticky.SideBDisplay) ? sticky.SideBKey : sticky.SideBDisplay,
KingdomDisplay = sticky.SideBKingdom,
Count = sticky.SideBCount,
Best = bestB ?? ensemble.Related
};
ensemble.ParticipantCount = sticky.TotalCount;
}
private static void SeedFromEnsemble(LiveSceneStickyState sticky, LiveEnsemble ensemble)
{
if (sticky == null || ensemble == null || !sticky.HasOpposingSides)
{
return;
}
bool plotLike = sticky.Kind == EnsembleKind.PlotCell || ensemble.Kind == EnsembleKind.PlotCell;
bool familyLike = sticky.Kind == EnsembleKind.FamilyPack || ensemble.Kind == EnsembleKind.FamilyPack;
bool outbreakLike = sticky.Kind == EnsembleKind.StatusOutbreak
|| ensemble.Kind == EnsembleKind.StatusOutbreak;
void Enroll(Actor actor, bool forcePlotter = false)
{
if (actor == null || !actor.isAlive())
{
return;
}
long id = EventFeedUtil.SafeId(actor);
if (id == 0)
{
return;
}
if (familyLike || outbreakLike)
{
AddUnique(sticky.SideAIds, id);
return;
}
if (plotLike || forcePlotter)
{
string kingdom = LiveEnsemble.KingdomKeyOf(actor);
if (!string.IsNullOrEmpty(kingdom)
&& kingdom.Equals(sticky.SideBKey, StringComparison.OrdinalIgnoreCase)
&& !forcePlotter)
{
AddUnique(sticky.SideBIds, id);
return;
}
// Default: living plot participants enroll on Side A.
AddUnique(sticky.SideAIds, id);
return;
}
string key = sticky.Frame == EnsembleFrame.KingdomVsKingdom
? LiveEnsemble.KingdomKeyOf(actor)
: LiveEnsemble.SpeciesKeyOf(actor);
if (string.IsNullOrEmpty(key))
{
return;
}
if (key.Equals(sticky.SideAKey, StringComparison.OrdinalIgnoreCase))
{
AddUnique(sticky.SideAIds, id);
}
else if (key.Equals(sticky.SideBKey, StringComparison.OrdinalIgnoreCase))
{
AddUnique(sticky.SideBIds, id);
}
}
Enroll(ensemble.Focus, forcePlotter: plotLike || familyLike || outbreakLike);
Enroll(ensemble.Related);
Enroll(ensemble.SideA?.Best, forcePlotter: plotLike || familyLike || outbreakLike);
Enroll(ensemble.SideB?.Best);
if (ensemble.ParticipantIds == null)
{
return;
}
for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
{
long id = ensemble.ParticipantIds[i];
if (id == 0)
{
continue;
}
Actor actor = LiveEnsemble.FindTrackedActor(id);
if (actor != null)
{
// Participant ids from TryBuildPlot are plotters first; related may also appear.
bool forceA = plotLike
&& (actor == ensemble.Focus
|| actor == ensemble.SideA?.Best
|| (ensemble.Related == null || actor != ensemble.Related));
Enroll(actor, forcePlotter: forceA && actor != ensemble.Related);
}
}
}
private static void SeedPlotRoster(LiveSceneStickyState sticky, LiveEnsemble ensemble)
{
if (sticky == null || ensemble == null)
{
return;
}
// Ensure Side A display stays collective.
if (string.IsNullOrEmpty(sticky.SideADisplay)
|| LiveEnsemble.IsPlotterSideKey(sticky.SideADisplay))
{
sticky.SideADisplay = "Plotters";
}
if (ensemble.SideA != null && ensemble.SideA.Count > sticky.SideACount)
{
sticky.SideACount = ensemble.SideA.Count;
}
if (ensemble.SideB != null && ensemble.SideB.Count > 0)
{
sticky.SideBCount = ensemble.SideB.Count;
}
}
private static void SeedFamilyRoster(LiveSceneStickyState sticky, LiveEnsemble ensemble)
{
if (sticky == null || ensemble == null)
{
return;
}
if (ensemble.SideA != null)
{
if (!string.IsNullOrEmpty(ensemble.SideA.Display)
&& !LiveEnsemble.IsFamilyPackSideKey(ensemble.SideA.Display))
{
sticky.SideADisplay = ensemble.SideA.Display;
}
if (ensemble.SideA.Count > sticky.SideACount)
{
sticky.SideACount = ensemble.SideA.Count;
}
}
sticky.SideBKey = "";
sticky.SideBDisplay = "";
sticky.SideBCount = 0;
}
private static void SeedOutbreakRoster(LiveSceneStickyState sticky, LiveEnsemble ensemble)
{
if (sticky == null || ensemble == null)
{
return;
}
if (ensemble.SideA != null)
{
if (!string.IsNullOrEmpty(ensemble.SideA.Display)
&& !LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Display))
{
sticky.SideADisplay = ensemble.SideA.Display;
}
if (ensemble.SideA.Count > sticky.SideACount)
{
sticky.SideACount = ensemble.SideA.Count;
}
}
sticky.SideBKey = "";
sticky.SideBDisplay = "";
sticky.SideBCount = 0;
}
private static void AddUnique(System.Collections.Generic.List ids, long id)
{
for (int i = 0; i < ids.Count; i++)
{
if (ids[i] == id)
{
return;
}
}
ids.Add(id);
}
}