- Limit saga context to relevant beats and suppress repeated facts - Stabilize combat labels and commit telemetry after focus changes - Preserve relationship participants and normalize event prose - Compact narrative sidecars and extend presentation coverage
1043 lines
34 KiB
C#
1043 lines
34 KiB
C#
using System;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static class StickyScoreboard
|
|
{
|
|
public const float ScaleHoldSeconds = 2.5f;
|
|
/// <summary>Thin camp must reach this alive count to leave mop-up pack framing.</summary>
|
|
public const int WipeRecoverMin = 2;
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Promote another living roster member onto the candidate when follow is lost.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hold presented tip tier across brief Skirmish↔Battle↔Mass flaps (up and down).
|
|
/// Peak still tracks max fighters for headcount / Mass band recovery.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
UpdateMopUp(sticky, now);
|
|
|
|
EnsembleScale live = LiveEnsemble.ScaleForCount(n);
|
|
// Opposing camp wiped / mop-up: drop Peak Mass hold so tip tier matches living pack.
|
|
bool wipedOpposing = sticky.HasOpposingSides
|
|
&& sticky.TotalCount > 0
|
|
&& (sticky.MopUpActive
|
|
|| 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hold structural Duel/Skirmish/Battle/Mass wording across transient probe changes.
|
|
/// Headcount and same-theater reframes remain live without becoming new story beats.
|
|
/// </summary>
|
|
public static string StabilizeCombatReason(
|
|
LiveSceneStickyState sticky,
|
|
string current,
|
|
string proposed,
|
|
float now,
|
|
bool allowImmediate)
|
|
{
|
|
if (sticky == null || string.IsNullOrEmpty(proposed))
|
|
{
|
|
return string.IsNullOrEmpty(proposed) ? (current ?? "") : proposed;
|
|
}
|
|
|
|
string prior = !string.IsNullOrEmpty(sticky.PresentedReason)
|
|
? sticky.PresentedReason
|
|
: (current ?? "");
|
|
if (string.IsNullOrEmpty(prior)
|
|
|| !IsCombatReason(prior)
|
|
|| !IsCombatReason(proposed)
|
|
|| allowImmediate)
|
|
{
|
|
sticky.PresentedReason = proposed;
|
|
sticky.PendingReason = "";
|
|
sticky.ReasonChangeSince = -999f;
|
|
return proposed;
|
|
}
|
|
|
|
if (string.Equals(prior, proposed, StringComparison.Ordinal))
|
|
{
|
|
sticky.PendingReason = "";
|
|
sticky.ReasonChangeSince = -999f;
|
|
return proposed;
|
|
}
|
|
|
|
if (EventReason.IsHeadcountOnlyChange(prior, proposed)
|
|
|| EventReason.IsCombatFramingOnlyChange(prior, proposed))
|
|
{
|
|
sticky.PresentedReason = proposed;
|
|
sticky.PendingReason = "";
|
|
sticky.ReasonChangeSince = -999f;
|
|
return proposed;
|
|
}
|
|
|
|
if (!string.Equals(sticky.PendingReason, proposed, StringComparison.Ordinal))
|
|
{
|
|
sticky.PendingReason = proposed;
|
|
sticky.ReasonChangeSince = now;
|
|
return prior;
|
|
}
|
|
|
|
if (now - sticky.ReasonChangeSince < ScaleHoldSeconds)
|
|
{
|
|
return prior;
|
|
}
|
|
|
|
sticky.PresentedReason = proposed;
|
|
sticky.PendingReason = "";
|
|
sticky.ReasonChangeSince = -999f;
|
|
return proposed;
|
|
}
|
|
|
|
private static bool IsCombatReason(string label)
|
|
{
|
|
string value = (label ?? "").Trim();
|
|
return value.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
|
|| value.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|
|
|| value.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|
|
|| value.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|
|
|| value.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
}
|
|
|
|
public static bool HarnessProbeReasonStability(out string detail)
|
|
{
|
|
var sticky = new LiveSceneStickyState();
|
|
const string duel = "Duel - Aro vs Bex";
|
|
const string battle = "Battle - Humans (4) vs Orcs (3)";
|
|
const string battleTick = "Battle - Humans (3) vs Orcs (4)";
|
|
string first = StabilizeCombatReason(sticky, duel, battle, 10f, false);
|
|
string held = StabilizeCombatReason(sticky, first, battle, 11f, false);
|
|
string committed = StabilizeCombatReason(sticky, held, battle, 13f, false);
|
|
string headcount = StabilizeCombatReason(sticky, committed, battleTick, 13.1f, false);
|
|
|
|
sticky.Clear();
|
|
string immediate = StabilizeCombatReason(sticky, duel, battle, 20f, true);
|
|
bool ok = first == duel
|
|
&& held == duel
|
|
&& committed == battle
|
|
&& headcount == battleTick
|
|
&& immediate == battle;
|
|
detail = "first='" + first + "' held='" + held
|
|
+ "' committed='" + committed + "' headcount='" + headcount
|
|
+ "' immediate='" + immediate + "' pass=" + ok;
|
|
return ok;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lock opposing camps once (when multi), enroll members, rewrite ensemble sides/counts.
|
|
/// </summary>
|
|
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<Actor>(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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Lock sticky keys from a live opposing collective ensemble (once).</summary>
|
|
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);
|
|
}
|
|
|
|
// Lock-time scale is a peak floor - live refresh may dip without erasing the theater.
|
|
sticky.PeakParticipants = Math.Max(sticky.PeakParticipants, liveN);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Build orange reason from sticky state; never thin Fight prose.</summary>
|
|
public static string FormatReason(InterestCandidate scene, Actor focus, Actor related)
|
|
{
|
|
if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
LiveSceneStickyState sticky = scene.Sticky;
|
|
UpdateMopUp(sticky, Time.unscaledTime);
|
|
GetPresentationCounts(sticky, out int countA, out int countB);
|
|
bool wipedOpposing = sticky.HasOpposingSides
|
|
&& sticky.TotalCount > 0
|
|
&& (countA <= 0 || countB <= 0);
|
|
int liveN = Math.Max(0, countA) + Math.Max(0, countB);
|
|
int scaleN = wipedOpposing
|
|
? Math.Max(1, liveN)
|
|
: 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 = countA,
|
|
Best = focus
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = sticky.SideBKey,
|
|
Display = sticky.SideBDisplay,
|
|
KingdomDisplay = sticky.SideBKingdom,
|
|
Count = countB,
|
|
Best = related
|
|
},
|
|
ParticipantCount = liveN
|
|
};
|
|
return EventReason.Ensemble(ensemble);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Presentation counts for combat tips: mop-up forces a thin recovering camp to 0
|
|
/// until it reaches <see cref="WipeRecoverMin"/>.
|
|
/// </summary>
|
|
public static void GetPresentationCounts(LiveSceneStickyState sticky, out int countA, out int countB)
|
|
{
|
|
countA = 0;
|
|
countB = 0;
|
|
if (sticky == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
countA = Math.Max(0, sticky.SideACount);
|
|
countB = Math.Max(0, sticky.SideBCount);
|
|
if (!sticky.MopUpActive)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (countA > 0 && countA < WipeRecoverMin)
|
|
{
|
|
countA = 0;
|
|
}
|
|
|
|
if (countB > 0 && countB < WipeRecoverMin)
|
|
{
|
|
countB = 0;
|
|
}
|
|
}
|
|
|
|
private static void UpdateMopUp(LiveSceneStickyState sticky, float now)
|
|
{
|
|
if (sticky == null || !sticky.HasOpposingSides)
|
|
{
|
|
return;
|
|
}
|
|
|
|
int a = Math.Max(0, sticky.SideACount);
|
|
int b = Math.Max(0, sticky.SideBCount);
|
|
if (a <= 0 || b <= 0)
|
|
{
|
|
if (!sticky.MopUpActive)
|
|
{
|
|
sticky.MopUpActive = true;
|
|
sticky.MopUpSince = now;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (!sticky.MopUpActive)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Leave mop-up only when both camps are meaningfully back.
|
|
if (a >= WipeRecoverMin && b >= WipeRecoverMin)
|
|
{
|
|
sticky.MopUpActive = false;
|
|
sticky.MopUpSince = -999f;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when two sticky scenes share the same unordered kingdom theater
|
|
/// (WarFront ↔ local Mass on Igguorn vs The Godo).
|
|
/// Matches asset ids, display names, and KingdomDisplay aliases.
|
|
/// </summary>
|
|
public static bool SharesKingdomTheater(InterestCandidate a, InterestCandidate b)
|
|
{
|
|
if (a?.Sticky == null || b?.Sticky == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!TryKingdomAliasPair(a.Sticky, out string[] a1, out string[] a2)
|
|
|| !TryKingdomAliasPair(b.Sticky, out string[] b1, out string[] b2))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return (AliasesOverlap(a1, b1) && AliasesOverlap(a2, b2))
|
|
|| (AliasesOverlap(a1, b2) && AliasesOverlap(a2, b1));
|
|
}
|
|
|
|
/// <summary>True when sticky kingdom pair matches a stored crisis theater.</summary>
|
|
public static bool SharesKingdomTheater(InterestCandidate c, string theaterA, string theaterB)
|
|
{
|
|
if (c?.Sticky == null
|
|
|| string.IsNullOrEmpty(theaterA)
|
|
|| string.IsNullOrEmpty(theaterB)
|
|
|| !TryKingdomAliasPair(c.Sticky, out string[] a1, out string[] a2))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string[] tA = KingdomAliases(theaterA);
|
|
string[] tB = KingdomAliases(theaterB);
|
|
return (AliasesOverlap(a1, tA) && AliasesOverlap(a2, tB))
|
|
|| (AliasesOverlap(a1, tB) && AliasesOverlap(a2, tA));
|
|
}
|
|
|
|
public static bool TryKingdomPair(LiveSceneStickyState sticky, out string a, out string b)
|
|
{
|
|
a = "";
|
|
b = "";
|
|
if (!TryKingdomAliasPair(sticky, out string[] aAliases, out string[] bAliases))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
a = aAliases.Length > 0 ? aAliases[0] : "";
|
|
b = bAliases.Length > 0 ? bAliases[0] : "";
|
|
return !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b) && !KingdomEq(a, b);
|
|
}
|
|
|
|
private static bool TryKingdomAliasPair(
|
|
LiveSceneStickyState sticky,
|
|
out string[] sideA,
|
|
out string[] sideB)
|
|
{
|
|
sideA = null;
|
|
sideB = null;
|
|
if (sticky == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (sticky.Frame == EnsembleFrame.KingdomVsKingdom)
|
|
{
|
|
sideA = KingdomAliases(sticky.SideAKey, sticky.SideAKingdom, sticky.SideADisplay);
|
|
sideB = KingdomAliases(sticky.SideBKey, sticky.SideBKingdom, sticky.SideBDisplay);
|
|
}
|
|
else
|
|
{
|
|
sideA = KingdomAliases(sticky.SideAKingdom, sticky.SideAKey, sticky.SideADisplay);
|
|
sideB = KingdomAliases(sticky.SideBKingdom, sticky.SideBKey, sticky.SideBDisplay);
|
|
}
|
|
|
|
return sideA.Length > 0 && sideB.Length > 0 && !AliasesOverlap(sideA, sideB);
|
|
}
|
|
|
|
private static string[] KingdomAliases(params string[] raw)
|
|
{
|
|
var list = new System.Collections.Generic.List<string>(6);
|
|
for (int i = 0; i < raw.Length; i++)
|
|
{
|
|
AddKingdomAlias(list, raw[i]);
|
|
if (!string.IsNullOrEmpty(raw[i]))
|
|
{
|
|
AddKingdomAlias(list, LiveEnsemble.KingdomDisplay(raw[i]));
|
|
}
|
|
}
|
|
|
|
return list.ToArray();
|
|
}
|
|
|
|
private static void AddKingdomAlias(System.Collections.Generic.List<string> list, string key)
|
|
{
|
|
string n = NormKingdom(key);
|
|
if (string.IsNullOrEmpty(n))
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < list.Count; i++)
|
|
{
|
|
if (string.Equals(list[i], n, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
list.Add(n);
|
|
}
|
|
|
|
private static bool AliasesOverlap(string[] a, string[] b)
|
|
{
|
|
if (a == null || b == null || a.Length == 0 || b.Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (int i = 0; i < a.Length; i++)
|
|
{
|
|
for (int j = 0; j < b.Length; j++)
|
|
{
|
|
if (KingdomEq(a[i], b[j]))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string NormKingdom(string key)
|
|
{
|
|
return string.IsNullOrEmpty(key) ? "" : key.Trim().ToLowerInvariant();
|
|
}
|
|
|
|
private static bool KingdomEq(string a, string b)
|
|
{
|
|
return !string.IsNullOrEmpty(a)
|
|
&& !string.IsNullOrEmpty(b)
|
|
&& string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
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<long> ids, long id)
|
|
{
|
|
for (int i = 0; i < ids.Count; i++)
|
|
{
|
|
if (ids[i] == id)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
ids.Add(id);
|
|
}
|
|
}
|