worldbox-observer-mod/IdleSpectator/Story/LifeSagaRail.cs
DazedAnon 529d18dfa4 feat(saga): enhance rail species frame handling and live capture retries
- Introduce new saga rail commands for species frame auditing and sweeping
- Implement retry logic for capturing inactive live frames in the rail
- Refactor face resolution logic to prioritize live frames over species icons
- Update harness scenarios to validate new rail functionality and performance
- Enhance documentation to clarify live capture behavior and fallback handling
2026-07-24 15:17:03 -05:00

883 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Dossier rail of life-saga MCs as a horizontal strip at the top of caption chrome.
/// Click toggles Prefer only. Hover previews Cast/Legacy depth under the caption.
/// </summary>
public static class LifeSagaRail
{
private const int MaxSlots = LifeSagaRoster.Cap;
public const float SlotSize = 18f;
private const float Gap = 2f;
private const float BadgeSize = 7f;
private const float FaceRefreshSeconds = 1.0f;
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(MaxSlots);
private static readonly List<long> InvolvedScratch = new List<long>(8);
private static readonly Dictionary<long, Sprite> LastFaces = new Dictionary<long, Sprite>(MaxSlots);
private static readonly Slot[] Slots = new Slot[MaxSlots];
private static GameObject _row;
private static RectTransform _rowRt;
private static string _fingerprint = "";
private static float _nextFaceRefreshAt;
private static int _retryCursor;
/// <summary>Harness: tip text of the first visible rail slot after last Refresh.</summary>
public static string LastTipSample { get; private set; } = "";
/// <summary>Harness: true when the watched MC slot used Active (not Prefer) chrome.</summary>
public static bool LastActiveHighlight { get; private set; }
/// <summary>Harness: count of Involved (non-follow) MC glyphs lit on last Refresh.</summary>
public static int LastInvolvedHighlightCount { get; private set; }
/// <summary>Harness: Prefer pip visible on first Prefer'd slot.</summary>
public static bool LastPreferPipVisible { get; private set; }
/// <summary>Harness: structured presentation row count for open preview (compat).</summary>
public static int LastHoverRowCount { get; private set; }
/// <summary>Harness: fixed saga body height while previewing.</summary>
public static float LastHoverHeight { get; private set; }
private sealed class Slot
{
public GameObject Root;
public RectTransform Rt;
public Image Bg;
public Image Face;
public Text Label;
public Image Badge;
public Image PreferPip;
public Button Button;
public long UnitId;
public string Tip = "";
public LifeSagaRailHover Hover;
}
public static bool Visible =>
_row != null && _row.activeSelf;
/// <summary>Natural rail row width after last Refresh (glyph strip only).</summary>
public static float RowWidthPx =>
_rowRt != null && _row != null && _row.activeSelf ? _rowRt.sizeDelta.x : 0f;
/// <summary>Natural rail row height after last Refresh.</summary>
public static float RowHeightPx =>
Visible ? SlotSize : 0f;
/// <summary>True when the pointer is over any visible rail glyph (not empty rail chrome).</summary>
public static bool IsPointerOverGlyph()
{
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i]?.Rt == null || !Slots[i].Root.activeInHierarchy)
{
continue;
}
if (RectTransformUtility.RectangleContainsScreenPoint(Slots[i].Rt, Input.mousePosition, null))
{
return true;
}
}
return false;
}
/// <summary>True when the pointer is over the rail row or any visible glyph.</summary>
public static bool IsPointerOverRail()
{
if (_row == null || !_row.activeInHierarchy || _rowRt == null)
{
return false;
}
if (RectTransformUtility.RectangleContainsScreenPoint(_rowRt, Input.mousePosition, null))
{
return true;
}
return IsPointerOverGlyph();
}
public static int LastShownCount { get; private set; }
/// <summary>Harness: unit-keyed last-face snapshots retained for the current roster.</summary>
public static int CachedFaceCount => LastFaces.Count;
/// <summary>Harness/telemetry: bounded inactive live-capture attempts.</summary>
public static int LiveCaptureRetryCount { get; private set; }
public static float LastLiveCaptureMs { get; private set; }
public static float MaxLiveCaptureMs { get; private set; }
public static void EnsureBuilt(Transform parent)
{
if (_row != null || parent == null)
{
return;
}
_row = new GameObject("LifeSagaRail", typeof(RectTransform));
_row.transform.SetParent(parent, false);
_rowRt = _row.GetComponent<RectTransform>();
_rowRt.pivot = new Vector2(0f, 1f);
_rowRt.anchorMin = new Vector2(0f, 1f);
_rowRt.anchorMax = new Vector2(0f, 1f);
for (int i = 0; i < MaxSlots; i++)
{
Slots[i] = BuildSlot(_row.transform, i);
}
_row.SetActive(false);
}
public static void Clear()
{
_fingerprint = "";
LastShownCount = 0;
LastTipSample = "";
LastActiveHighlight = false;
LastInvolvedHighlightCount = 0;
LastPreferPipVisible = false;
LastHoverRowCount = 0;
LastHoverHeight = 0f;
_retryCursor = 0;
LiveCaptureRetryCount = 0;
LastLiveCaptureMs = 0f;
MaxLiveCaptureMs = 0f;
LifeSagaViewController.CancelPreviewAndPause();
if (_row != null)
{
_row.SetActive(false);
}
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i]?.Root != null)
{
Slots[i].Root.SetActive(false);
Slots[i].UnitId = 0;
Slots[i].Tip = "";
}
}
}
public static void Refresh()
{
if (_row == null)
{
return;
}
if (!SpectatorMode.Active && !SpectatorMode.BrowsePaused)
{
Clear();
return;
}
if (!ModSettings.ShowDossierCaption || WatchCaption.HasStatusBannerActive())
{
Clear();
return;
}
float now = Time.unscaledTime;
// Director already Ticks the roster each frame - do not Refill twice.
SlotScratch.Clear();
LifeSagaRoster.CopySlots(SlotScratch);
long watchId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
InvolvedScratch.Clear();
InterestCandidate tip = InterestDirector.CurrentCandidate;
LifeSagaRoster.CollectTouchedMcIds(tip, InvolvedScratch);
string fp = Fingerprint(SlotScratch, watchId, InvolvedScratch);
bool structureChanged = !string.Equals(fp, _fingerprint, StringComparison.Ordinal)
|| LifeSagaRoster.Dirty;
bool facesDue = now >= _nextFaceRefreshAt;
if (!structureChanged && !facesDue)
{
return;
}
if (structureChanged)
{
PruneFaceCache();
LifeSagaRoster.Dirty = false;
_fingerprint = fp;
LastShownCount = 0;
LastTipSample = "";
LastActiveHighlight = false;
LastInvolvedHighlightCount = 0;
LastPreferPipVisible = false;
float x = 0f;
for (int i = 0; i < Slots.Length; i++)
{
Slot slot = Slots[i];
if (i >= SlotScratch.Count || SlotScratch[i] == null || SlotScratch[i].UnitId == 0)
{
slot.Root.SetActive(false);
slot.UnitId = 0;
slot.Tip = "";
continue;
}
LifeSagaSlot saga = SlotScratch[i];
bool watching = watchId != 0 && watchId == saga.UnitId;
bool involved = !watching && IsInvolved(saga.UnitId);
bool prefer = saga.Prefer || saga.GameFavorite;
slot.UnitId = saga.UnitId;
ApplyFace(slot, saga, refreshLive: watching);
ApplyBadge(slot, saga);
if (slot.PreferPip != null)
{
slot.PreferPip.gameObject.SetActive(prefer);
if (prefer)
{
LastPreferPipVisible = true;
}
}
if (watching)
{
slot.Bg.color = HudTheme.ValueOrange;
LastActiveHighlight = true;
}
else if (involved)
{
// Softer gold - second saga lit while another MC is followed.
slot.Bg.color = new Color(0.92f, 0.72f, 0.28f, 0.92f);
LastInvolvedHighlightCount++;
}
else if (prefer)
{
slot.Bg.color = new Color(0.45f, 0.38f, 0.22f, 0.95f);
}
else
{
slot.Bg.color = new Color(HudTheme.Muted.r, HudTheme.Muted.g, HudTheme.Muted.b, 0.45f);
}
slot.Tip = LifeSagaPresentation.BuildPlainText(saga);
if (LastShownCount == 0)
{
LastTipSample = slot.Tip ?? "";
}
slot.Rt.anchoredPosition = new Vector2(x, 0f);
slot.Root.SetActive(true);
x += SlotSize + Gap;
LastShownCount++;
}
_rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize);
_row.SetActive(LastShownCount > 0);
RetryOneMissingLiveFace(watchId);
}
else
{
// Animate only the Active (watched) glyph; others keep static faces.
for (int i = 0; i < Slots.Length && i < SlotScratch.Count; i++)
{
if (Slots[i] == null || !Slots[i].Root.activeSelf || SlotScratch[i] == null)
{
continue;
}
if (watchId == 0 || SlotScratch[i].UnitId != watchId)
{
continue;
}
ApplyFace(Slots[i], SlotScratch[i], refreshLive: true);
}
// A unit may enter the roster while its render frame is unavailable (egg,
// atlas not ready, transient tiny crop). Retry at most one inactive missing
// capture per one-second rail cadence; round-robin prevents an egg from
// starving later slots. Species fallbacks are display-only and never cached.
RetryOneMissingLiveFace(watchId);
}
if (LifeSagaViewController.IsHoverPreview)
{
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
LastHoverRowCount = model.Cast.Count + model.Legacy.Count
+ (string.IsNullOrEmpty(model.StakeLine) ? 0 : 1);
LastHoverHeight = LifeSagaPanel.BodyHeight;
}
else
{
LastHoverRowCount = 0;
LastHoverHeight = 0f;
}
_nextFaceRefreshAt = now + FaceRefreshSeconds;
}
/// <summary>
/// Place the horizontal rail at the top of caption chrome (stable under hover).
/// Returns the next y below the rail.
/// </summary>
public static float Place(float y, float padX)
{
if (!Visible || _rowRt == null)
{
return y;
}
_rowRt.anchoredPosition = new Vector2(padX, -y);
return y + SlotSize + 2f;
}
internal static void ShowHover(int index)
{
if (index < 0)
{
index = 0;
}
if (index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
{
// Rail glyphs can be empty after a Clear while roster still has MCs
// (fingerprint early-out). Force a structural rebuild once.
_fingerprint = "";
Refresh();
}
if (index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
{
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i] != null && Slots[i].UnitId != 0)
{
index = i;
break;
}
}
}
if (index < 0 || index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
{
return;
}
LifeSagaViewController.BeginHoverPreview(Slots[index].UnitId);
LastHoverHeight = Mathf.Max(LifeSagaPanel.BodyHeight, BodyHeightMinFallback);
WatchCaption.RequestRelayout();
}
private const float BodyHeightMinFallback = 1f;
internal static void HideHover()
{
LifeSagaViewController.EndHoverPreview();
WatchCaption.RequestRelayout();
}
public static long UnitIdAt(int index)
{
if (index < 0 || index >= Slots.Length || Slots[index] == null)
{
return 0;
}
return Slots[index].UnitId;
}
private static Slot BuildSlot(Transform parent, int index)
{
GameObject go = new GameObject("SagaRail" + index, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
RectTransform rt = go.GetComponent<RectTransform>();
rt.pivot = new Vector2(0f, 1f);
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.sizeDelta = new Vector2(SlotSize, SlotSize);
Image bg = go.GetComponent<Image>();
bg.color = HudTheme.ToolFill;
bg.raycastTarget = true;
GameObject faceGo = new GameObject("Face", typeof(RectTransform), typeof(Image));
faceGo.transform.SetParent(go.transform, false);
RectTransform faceRt = faceGo.GetComponent<RectTransform>();
faceRt.anchorMin = Vector2.zero;
faceRt.anchorMax = Vector2.one;
faceRt.offsetMin = new Vector2(1f, 1f);
faceRt.offsetMax = new Vector2(-1f, -1f);
Image face = faceGo.GetComponent<Image>();
face.color = Color.white;
face.preserveAspect = true;
face.raycastTarget = false;
Text label = HudCanvas.MakeText(go.transform, "Glyph", "", 8);
label.alignment = TextAnchor.MiddleCenter;
label.resizeTextForBestFit = false;
label.raycastTarget = false;
RectTransform labelRt = label.GetComponent<RectTransform>();
labelRt.anchorMin = Vector2.zero;
labelRt.anchorMax = Vector2.one;
labelRt.offsetMin = Vector2.zero;
labelRt.offsetMax = Vector2.zero;
label.gameObject.SetActive(false);
GameObject badgeGo = new GameObject("Badge", typeof(RectTransform), typeof(Image));
badgeGo.transform.SetParent(go.transform, false);
RectTransform badgeRt = badgeGo.GetComponent<RectTransform>();
badgeRt.pivot = new Vector2(1f, 1f);
badgeRt.anchorMin = new Vector2(1f, 1f);
badgeRt.anchorMax = new Vector2(1f, 1f);
badgeRt.anchoredPosition = new Vector2(1f, 1f);
badgeRt.sizeDelta = new Vector2(BadgeSize, BadgeSize);
Image badge = badgeGo.GetComponent<Image>();
badge.color = Color.white;
badge.preserveAspect = true;
badge.raycastTarget = false;
badgeGo.SetActive(false);
GameObject pipGo = new GameObject("PreferPip", typeof(RectTransform), typeof(Image));
pipGo.transform.SetParent(go.transform, false);
RectTransform pipRt = pipGo.GetComponent<RectTransform>();
pipRt.pivot = new Vector2(0f, 0f);
pipRt.anchorMin = new Vector2(0f, 0f);
pipRt.anchorMax = new Vector2(0f, 0f);
pipRt.anchoredPosition = new Vector2(1f, 1f);
pipRt.sizeDelta = new Vector2(5f, 5f);
Image pip = pipGo.GetComponent<Image>();
pip.color = new Color(1f, 0.85f, 0.25f, 1f);
pip.raycastTarget = false;
Sprite star = HudIcons.FromUiIcon("iconFavorite")
?? HudIcons.FromUiIcon("iconStar")
?? HudIcons.FromUiIcon("iconImportant");
if (star != null)
{
pip.sprite = star;
}
pipGo.SetActive(false);
Button button = go.GetComponent<Button>();
button.targetGraphic = bg;
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
button.colors = colors;
int captured = index;
button.onClick.AddListener(new UnityAction(() => OnClick(captured)));
LifeSagaRailHover hover = go.AddComponent<LifeSagaRailHover>();
hover.Index = index;
go.SetActive(false);
return new Slot
{
Root = go,
Rt = rt,
Bg = bg,
Face = face,
Label = label,
Badge = badge,
PreferPip = pip,
Button = button,
Hover = hover
};
}
private static void ApplyFace(Slot slot, LifeSagaSlot saga, bool refreshLive)
{
if (slot == null || saga == null)
{
return;
}
Sprite sprite = ResolveFace(saga, refreshLive);
if (HudIcons.IsUsableRailFace(sprite) && slot.Face != null)
{
slot.Face.sprite = sprite;
slot.Face.color = Color.white;
slot.Face.preserveAspect = true;
slot.Face.gameObject.SetActive(true);
RectTransform faceRt = slot.Face.rectTransform;
faceRt.anchorMin = new Vector2(0.5f, 0.5f);
faceRt.anchorMax = new Vector2(0.5f, 0.5f);
faceRt.pivot = new Vector2(0.5f, 0.5f);
faceRt.anchoredPosition = Vector2.zero;
float w = Mathf.Max(1f, sprite.rect.width);
float h = Mathf.Max(1f, sprite.rect.height);
float max = SlotSize - 2f;
float scale = max / Mathf.Max(w, h);
faceRt.sizeDelta = new Vector2(w * scale, h * scale);
if (slot.Label != null)
{
slot.Label.gameObject.SetActive(false);
}
return;
}
if (slot.Face != null)
{
slot.Face.sprite = null;
slot.Face.gameObject.SetActive(false);
}
if (slot.Label != null)
{
slot.Label.text = GlyphFallback(saga);
slot.Label.color = HudTheme.Body;
slot.Label.gameObject.SetActive(true);
}
}
private static Sprite ResolveFace(LifeSagaSlot saga, bool refreshLive)
{
if (saga == null || saga.UnitId == 0)
{
return null;
}
LastFaces.TryGetValue(saga.UnitId, out Sprite sprite);
// Capture every character once, then refresh only the watched glyph. A failed
// refresh never discards the last good phenotype/kingdom-tinted frame.
if (refreshLive)
{
long started = System.Diagnostics.Stopwatch.GetTimestamp();
Sprite live = HudIcons.FromActorLiveRailFace(saga.Resolve());
long elapsed = System.Diagnostics.Stopwatch.GetTimestamp() - started;
LastLiveCaptureMs = (float)(
elapsed * 1000.0 / System.Diagnostics.Stopwatch.Frequency);
MaxLiveCaptureMs = Mathf.Max(MaxLiveCaptureMs, LastLiveCaptureMs);
if (HudIcons.IsUsableRailFace(live))
{
sprite = live;
LastFaces[saga.UnitId] = live;
}
}
// Display fallback only. Do not let it masquerade as a retained live frame.
return HudIcons.IsUsableRailFace(sprite)
? sprite
: HudIcons.FromSpeciesId(saga.SpeciesId);
}
private static void RetryOneMissingLiveFace(long watchId)
{
int count = Mathf.Min(Slots.Length, SlotScratch.Count);
if (count <= 0)
{
return;
}
_retryCursor = Mathf.Clamp(_retryCursor, 0, count - 1);
for (int offset = 0; offset < count; offset++)
{
int index = (_retryCursor + offset) % count;
LifeSagaSlot saga = SlotScratch[index];
Slot slot = Slots[index];
if (saga == null
|| slot == null
|| !slot.Root.activeSelf
|| saga.UnitId == 0
|| saga.UnitId == watchId
|| LastFaces.ContainsKey(saga.UnitId))
{
continue;
}
Actor actor = saga.Resolve();
if (actor == null || !actor.isAlive())
{
continue;
}
ApplyFace(slot, saga, refreshLive: true);
LiveCaptureRetryCount++;
_retryCursor = (index + 1) % count;
return;
}
}
public static bool HarnessProbeFallbackCacheIsolation(out string detail)
{
const long syntheticId = -9100001;
int before = LastFaces.Count;
LastFaces.Remove(syntheticId);
var synthetic = new LifeSagaSlot
{
UnitId = syntheticId,
SpeciesId = "human"
};
Sprite fallback = ResolveFace(synthetic, refreshLive: true);
bool fallbackShown = HudIcons.IsUsableRailFace(fallback);
bool cachedFallback = LastFaces.ContainsKey(syntheticId);
// A retained frame must survive a later failed/dead refresh.
Sprite retainedSeed = HudIcons.FromSpeciesId("human");
bool retained = false;
if (HudIcons.IsUsableRailFace(retainedSeed))
{
LastFaces[syntheticId] = retainedSeed;
retained = ResolveFace(synthetic, refreshLive: true) == retainedSeed;
}
LastFaces.Remove(syntheticId);
bool countRestored = LastFaces.Count == before;
bool pass = fallbackShown && !cachedFallback && retained && countRestored;
detail =
$"fallbackShown={fallbackShown} cachedFallback={cachedFallback} "
+ $"retained={retained} count={before}/{LastFaces.Count} pass={pass}";
return pass;
}
public static bool HarnessProbeFocusUsesDistinctLiveFrame(out string detail)
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
LastFaces.TryGetValue(id, out Sprite cached);
Sprite species = focus != null ? HudIcons.FromActor(focus) : null;
bool cachedUsable = HudIcons.IsUsableRailFace(cached);
bool distinct = cachedUsable
&& species != null
&& !HudIcons.SameSpriteSource(cached, species);
bool generated = HudIcons.MatchesGeneratedActorFrame(focus, cached);
detail =
$"id={id} asset={focus?.asset?.id} cached={DescribeSprite(cached)} "
+ $"species={DescribeSprite(species)} distinct={distinct} generated={generated} "
+ $"captureMs={LastLiveCaptureMs:0.00}/{MaxLiveCaptureMs:0.00} "
+ HudIcons.DescribeActorRailSources(focus);
return distinct && generated;
}
private static string DescribeSprite(Sprite sprite)
{
if (sprite == null)
{
return "none";
}
try
{
return (sprite.name ?? "?")
+ ":" + sprite.rect.width.ToString("0")
+ "x" + sprite.rect.height.ToString("0");
}
catch
{
return "invalid";
}
}
private static void PruneFaceCache()
{
var keep = new HashSet<long>();
for (int i = 0; i < SlotScratch.Count; i++)
{
if (SlotScratch[i] != null && SlotScratch[i].UnitId != 0)
{
keep.Add(SlotScratch[i].UnitId);
}
}
var remove = new List<long>();
foreach (long id in LastFaces.Keys)
{
if (!keep.Contains(id))
{
remove.Add(id);
}
}
for (int i = 0; i < remove.Count; i++)
{
LastFaces.Remove(remove[i]);
}
}
private static void ApplyBadge(Slot slot, LifeSagaSlot saga)
{
if (slot?.Badge == null || saga == null)
{
return;
}
string icon = null;
if (saga.IsKing)
{
icon = "iconKings";
}
else if (saga.IsClanChief)
{
icon = "iconClan";
}
else if (saga.IsLeader)
{
icon = "iconLeaders";
}
else if (saga.IsAlpha)
{
icon = "iconFamilies";
}
else if (saga.IsArmyCaptain)
{
icon = "iconArmy";
}
else if (saga.IsFounder)
{
icon = "iconCity";
}
else if (saga.IsPlotAuthor)
{
icon = "iconPlot";
}
Sprite sprite = !string.IsNullOrEmpty(icon) ? HudIcons.FromUiIcon(icon) : null;
if (sprite == null && saga.IsKing)
{
sprite = HudIcons.FromUiIcon("iconKing");
}
if (sprite == null)
{
slot.Badge.gameObject.SetActive(false);
return;
}
slot.Badge.sprite = sprite;
slot.Badge.gameObject.SetActive(true);
}
internal static void HarnessClick(int index)
{
OnClick(index);
}
/// <summary>Harness: click the visible slot for an exact unit instead of assuming slot 0.</summary>
internal static bool HarnessClickUnit(long unitId)
{
if (unitId == 0)
{
return false;
}
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i] != null && Slots[i].UnitId == unitId)
{
OnClick(i);
return true;
}
}
return false;
}
private static void OnClick(int index)
{
if (index < 0 || index >= Slots.Length)
{
return;
}
long id = Slots[index].UnitId;
if (id == 0)
{
return;
}
if (!SpectatorMode.Active && SpectatorMode.BrowsePaused)
{
SpectatorMode.SetActive(true, quiet: true);
}
LifeSagaRoster.TogglePrefer(id);
_fingerprint = "";
LifeSagaRoster.Dirty = true;
Refresh();
WatchCaption.RequestRelayout();
}
private static string GlyphFallback(LifeSagaSlot saga)
{
if (saga == null)
{
return "?";
}
if (saga.IsKing) return "K";
if (saga.IsClanChief) return "C";
if (saga.IsAlpha) return "A";
if (saga.IsArmyCaptain) return "P";
if (saga.IsLeader) return "L";
if (saga.Prefer || saga.GameFavorite) return "★";
string sp = saga.SpeciesId ?? "";
if (sp.Length > 0)
{
return char.ToUpperInvariant(sp[0]).ToString();
}
return "?";
}
private static bool IsInvolved(long unitId)
{
if (unitId == 0)
{
return false;
}
for (int i = 0; i < InvolvedScratch.Count; i++)
{
if (InvolvedScratch[i] == unitId)
{
return true;
}
}
return false;
}
private static string Fingerprint(List<LifeSagaSlot> board, long watchId, List<long> involved)
{
var sb = new StringBuilder();
sb.Append(watchId).Append('|');
if (involved != null)
{
for (int i = 0; i < involved.Count; i++)
{
sb.Append('i').Append(involved[i]).Append(',');
}
}
sb.Append('|');
for (int i = 0; i < board.Count; i++)
{
LifeSagaSlot s = board[i];
if (s == null)
{
continue;
}
sb.Append(s.UnitId).Append(':')
.Append(s.Prefer ? 'P' : '-')
.Append(s.GameFavorite ? 'F' : '-')
.Append(s.IsKing ? 'K' : '-')
.Append(s.IsClanChief ? 'C' : '-')
.Append(s.IsArmyCaptain ? 'A' : '-')
.Append(s.Chapters.Count).Append(':')
.Append(Mathf.RoundToInt(LifeSagaRoster.EffectiveInterest(s))).Append(':')
.Append(s.DisplayName ?? "").Append(';');
}
return sb.ToString();
}
}