Compare commits
3 commits
9a059c17dd
...
4b6c829bd0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b6c829bd0 | |||
| 798e3dce05 | |||
| c23b2b9c1e |
54 changed files with 16012 additions and 2975 deletions
|
|
@ -12,6 +12,7 @@ public static class ActorChroniclePatches
|
|||
[HarmonyPostfix]
|
||||
public static void PostfixNewKill(Actor __instance, Actor pDeadUnit)
|
||||
{
|
||||
LifeSagaMemory.RecordKill(__instance, pDeadUnit);
|
||||
Chronicle.NoteKill(__instance, pDeadUnit);
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +52,7 @@ public static class ActorChroniclePatches
|
|||
return;
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordDeath(__instance, __state, pType.ToString());
|
||||
Chronicle.NoteDeath(__instance, pType, __state);
|
||||
GraveMarkers.AddDeath(__instance);
|
||||
}
|
||||
|
|
@ -59,6 +61,7 @@ public static class ActorChroniclePatches
|
|||
[HarmonyPostfix]
|
||||
public static void PostfixLovers(Actor __instance, Actor pTarget)
|
||||
{
|
||||
LifeSagaMemory.RecordBondFormed(__instance, pTarget, "becomeLoversWith");
|
||||
Chronicle.NoteLovers(__instance, pTarget);
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +74,7 @@ public static class ActorChroniclePatches
|
|||
return;
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordFriendFormed(__instance, pActor);
|
||||
Chronicle.NoteBestFriends(__instance, pActor);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,92 @@ public static class ActorRelation
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Living parents reported by the running game's relation data.</summary>
|
||||
public static IEnumerable<Actor> EnumerateParents(Actor actor, int max = 2)
|
||||
{
|
||||
if (actor == null || max <= 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
object parents = ReadMember(actor, "getParents", "get_parents", "parents");
|
||||
if (parents == null)
|
||||
{
|
||||
parents = ReadMember(actor.data, "parents", "parent_ids", "parents_ids");
|
||||
}
|
||||
|
||||
int yielded = 0;
|
||||
var seen = new HashSet<long>();
|
||||
if (parents is IEnumerable enumerable)
|
||||
{
|
||||
foreach (object raw in enumerable)
|
||||
{
|
||||
Actor parent = ResolveActor(raw) ?? ResolveActorId(raw);
|
||||
if (!IsDistinctLivingRelation(actor, parent, seen))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return parent;
|
||||
yielded++;
|
||||
if (yielded >= max)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (yielded >= max)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
string[] singular =
|
||||
{
|
||||
"mother", "father", "parent", "mother_id", "father_id", "parent_id",
|
||||
"parent_1", "parent_2", "parent1", "parent2"
|
||||
};
|
||||
for (int i = 0; i < singular.Length && yielded < max; i++)
|
||||
{
|
||||
object raw = ReadMember(actor, singular[i])
|
||||
?? ReadMember(actor.data, singular[i]);
|
||||
Actor parent = ResolveActor(raw) ?? ResolveActorId(raw);
|
||||
if (!IsDistinctLivingRelation(actor, parent, seen))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return parent;
|
||||
yielded++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current living combat target, when the live attack target is an actor.</summary>
|
||||
public static Actor GetCombatOpponent(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!actor.has_attack_target || actor.attack_target == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Actor opponent = ResolveActor(actor.attack_target);
|
||||
return opponent != null && opponent != actor && opponent.isAlive()
|
||||
? opponent
|
||||
: null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a related unit for pack/family labels. Prefers lover, then family peer, then child.
|
||||
/// </summary>
|
||||
|
|
@ -315,6 +401,50 @@ public static class ActorRelation
|
|||
return null;
|
||||
}
|
||||
|
||||
private static Actor ResolveActorId(object raw)
|
||||
{
|
||||
if (raw is long longId)
|
||||
{
|
||||
return FindUnitById(longId);
|
||||
}
|
||||
|
||||
if (raw is int intId)
|
||||
{
|
||||
return FindUnitById(intId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (raw != null && long.TryParse(raw.ToString(), out long parsed))
|
||||
{
|
||||
return FindUnitById(parsed);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsDistinctLivingRelation(Actor subject, Actor related, HashSet<long> seen)
|
||||
{
|
||||
if (related == null || related == subject || !related.isAlive())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return seen.Add(related.getID());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Actor ReadActorMember(object target, params string[] names)
|
||||
{
|
||||
object raw = ReadMember(target, names);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -176,6 +176,7 @@ public static class Chronicle
|
|||
{
|
||||
ClearSession();
|
||||
ActivityLog.ClearSession();
|
||||
LifeSagaMemory.ClearSession();
|
||||
_boundWorld = world;
|
||||
LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session");
|
||||
}
|
||||
|
|
@ -695,6 +696,7 @@ public static class Chronicle
|
|||
_currentAgeId = "";
|
||||
_currentAgeName = "";
|
||||
ActivityLog.ClearSession();
|
||||
LifeSagaMemory.ClearSession();
|
||||
BumpRevision();
|
||||
// Keep _boundWorld so re-enable on same map does not loop-clear.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -800,13 +800,25 @@ public static class ChronicleHud
|
|||
}
|
||||
|
||||
_pausedIdleForLore = false;
|
||||
bool restoreSaga = LifeSagaViewController.TryRestoreAfterLore();
|
||||
if (!ModSettings.Enabled || SpectatorMode.Active)
|
||||
{
|
||||
if (restoreSaga)
|
||||
{
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SpectatorMode.SetActive(true, quiet: true);
|
||||
LogService.LogInfo("[IdleSpectator] Lore closed - resumed idle");
|
||||
if (restoreSaga)
|
||||
{
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
LogService.LogInfo("[IdleSpectator] Lore closed - resumed idle"
|
||||
+ (restoreSaga ? " (restored Saga)" : ""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -888,7 +900,7 @@ public static class ChronicleHud
|
|||
if (pauseIdle && SpectatorMode.Active)
|
||||
{
|
||||
_pausedIdleForLore = true;
|
||||
SpectatorMode.SetActive(false, quiet: true);
|
||||
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
|
||||
}
|
||||
else if (pauseIdle)
|
||||
{
|
||||
|
|
@ -1015,12 +1027,12 @@ public static class ChronicleHud
|
|||
_followFocus = followFocus;
|
||||
_detailPane = DetailPane.Life;
|
||||
|
||||
// Pause idle first: SetActive(false) calls ClearFollow(). Focusing before that
|
||||
// would instantly drop the camera off the browse subject.
|
||||
// Pause idle first: browsePause keeps StoryPlanner / registry for Lore close resume.
|
||||
// SetActive(false) still ClearFollow() so focus the browse subject after.
|
||||
if (pauseIdle && SpectatorMode.Active)
|
||||
{
|
||||
_pausedIdleForLore = true;
|
||||
SpectatorMode.SetActive(false, quiet: true);
|
||||
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
|
||||
}
|
||||
else if (pauseIdle)
|
||||
{
|
||||
|
|
@ -2283,7 +2295,7 @@ public static class ChronicleHud
|
|||
{
|
||||
if (SpectatorMode.Active)
|
||||
{
|
||||
SpectatorMode.SetActive(false, quiet: true);
|
||||
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
|
||||
WatchCaption.PinWhilePaused();
|
||||
WatchCaption.ShowStatusBanner(
|
||||
captured.Kind == ChronicleKind.World || captured.IsLegend
|
||||
|
|
|
|||
|
|
@ -98,6 +98,14 @@ public static class DossierAvatar
|
|||
}
|
||||
}
|
||||
|
||||
public static void SetHostVisible(bool visible)
|
||||
{
|
||||
if (_host != null)
|
||||
{
|
||||
_host.gameObject.SetActive(visible);
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetActive(bool active)
|
||||
{
|
||||
if (_host != null)
|
||||
|
|
@ -106,6 +114,109 @@ public static class DossierAvatar
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep the live portrait under saga/dossier chrome that must paint on top of it.
|
||||
/// </summary>
|
||||
public static void DrawBehind(Transform sibling)
|
||||
{
|
||||
if (_host == null || sibling == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert immediately under the sibling so the opaque stone frame cannot cover copy.
|
||||
int index = sibling.GetSiblingIndex();
|
||||
_host.SetSiblingIndex(index);
|
||||
if (_host.GetSiblingIndex() > sibling.GetSiblingIndex())
|
||||
{
|
||||
_host.SetSiblingIndex(sibling.GetSiblingIndex());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep the compact stone-style dossier frame - strip king/clan vanity overrides.
|
||||
/// </summary>
|
||||
public static void ForceCompactFrame()
|
||||
{
|
||||
if (_host == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_usingVanilla && _instance != null && _instance.gameObject.activeSelf)
|
||||
{
|
||||
try
|
||||
{
|
||||
_instance.show_banner_kingdom = false;
|
||||
_instance.show_banner_clan = false;
|
||||
if (_instance.kingdomBanner != null)
|
||||
{
|
||||
_instance.kingdomBanner.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
if (_instance.clanBanner != null)
|
||||
{
|
||||
_instance.clanBanner.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
if (_instance.avatarLoader != null)
|
||||
{
|
||||
_instance.avatarLoader.avatarSize = 0.55f;
|
||||
}
|
||||
|
||||
// Clear king/leader ornate frame overrides when the game exposes them.
|
||||
var prop = _instance.GetType().GetProperty("override_avatar_frames");
|
||||
if (prop != null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(_instance, false, null);
|
||||
}
|
||||
|
||||
var field = _instance.GetType().GetField("override_avatar_frames");
|
||||
if (field != null)
|
||||
{
|
||||
field.SetValue(_instance, false);
|
||||
}
|
||||
|
||||
StripNamedVanity(_instance.transform);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep showing whatever frame we have
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void StripNamedVanity(Transform root)
|
||||
{
|
||||
if (root == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < root.childCount; i++)
|
||||
{
|
||||
Transform child = root.GetChild(i);
|
||||
if (child == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string n = child.name ?? "";
|
||||
string lower = n.ToLowerInvariant();
|
||||
if (lower.Contains("banner")
|
||||
|| lower.Contains("jewel")
|
||||
|| lower.Contains("crown")
|
||||
|| lower.Contains("king_frame")
|
||||
|| lower.Contains("frame_king")
|
||||
|| lower.Contains("ornate"))
|
||||
{
|
||||
child.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
StripNamedVanity(child);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Show(Actor actor)
|
||||
{
|
||||
if (_host == null)
|
||||
|
|
@ -167,6 +278,7 @@ public static class DossierAvatar
|
|||
_shownActorId = id;
|
||||
_shownFormKey = formKey;
|
||||
DisableInteractions(_instance.gameObject);
|
||||
ForceCompactFrame();
|
||||
return;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
|
|
|
|||
|
|
@ -85,5 +85,25 @@ public static partial class EventCatalog
|
|||
IsFallback = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="disasterId"/> is an authored overlay or a live
|
||||
/// <see cref="AssetManager.disasters"/> asset (not tip-string heuristics).
|
||||
/// </summary>
|
||||
public static bool IsLiveOrAuthored(string disasterId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(disasterId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string id = disasterId.Trim();
|
||||
if (HasAuthored(id))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return ActivityAssetCatalog.TryGetDisasterAsset(id) != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,11 +149,18 @@ public static partial class EventCatalog
|
|||
|| s.Contains("equipment_rain")
|
||||
|| s.EndsWith("_brush", StringComparison.Ordinal)
|
||||
|| s.Contains("draw_")
|
||||
|| s.Contains("paint"))
|
||||
|| s.Contains("paint")
|
||||
|| s.StartsWith("boat_", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Creature drop powers that match live spectacle actors (evil_mage, necromancer, …).
|
||||
if (WorldActivityScanner.IsSpectacleAssetId(s))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return s.Contains("meteor")
|
||||
|| s.Contains("earthquake")
|
||||
|| s.Contains("tornado")
|
||||
|
|
@ -182,7 +189,10 @@ public static partial class EventCatalog
|
|||
|| s.Contains("ash")
|
||||
|| s.Contains("golden_brain")
|
||||
|| s.Contains("corrupted_brain")
|
||||
|| s.Contains("infection");
|
||||
|| s.Contains("infection")
|
||||
|| s.Contains("mage")
|
||||
|| s.Contains("necromancer")
|
||||
|| s.Contains("druid");
|
||||
}
|
||||
|
||||
private static void Ensure()
|
||||
|
|
|
|||
|
|
@ -17,15 +17,22 @@ public sealed class WorldLogEventEntry
|
|||
|
||||
public string MakeLabel(string special1, string special2)
|
||||
{
|
||||
string a = special1 ?? "";
|
||||
string b = special2 ?? "";
|
||||
string a = (special1 ?? "").Trim();
|
||||
string b = (special2 ?? "").Trim();
|
||||
string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate;
|
||||
return template
|
||||
.Replace("{id}", Id ?? "")
|
||||
string displayId = EventReason.HumanizeId(Id);
|
||||
if (string.IsNullOrEmpty(displayId))
|
||||
{
|
||||
displayId = (Id ?? "").Trim();
|
||||
}
|
||||
|
||||
string label = template
|
||||
.Replace("{id}", displayId)
|
||||
.Replace("{a}", a)
|
||||
.Replace("{b}", b)
|
||||
.Replace("{special1}", a)
|
||||
.Replace("{special2}", b);
|
||||
return CollapseSparseLabel(label, displayId);
|
||||
}
|
||||
|
||||
public string MakeLabel(WorldLogMessage message)
|
||||
|
|
@ -35,7 +42,79 @@ public sealed class WorldLogEventEntry
|
|||
return MakeLabel("", "");
|
||||
}
|
||||
|
||||
return MakeLabel(message.special1, message.special2);
|
||||
string a = message.special1 ?? "";
|
||||
string b = message.special2 ?? "";
|
||||
// Many disaster WorldLogs leave special1 empty - prefer the follow unit name,
|
||||
// else CollapseSparseLabel drops hanging "Tornado: " tails.
|
||||
if (string.IsNullOrWhiteSpace(a) && message.unit != null)
|
||||
{
|
||||
a = EventFeedUtil.SafeName(message.unit) ?? "";
|
||||
}
|
||||
|
||||
return MakeLabel(a, b);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop empty-slot leftovers ("Tornado: ", "Event: ") so tips never show a bare colon.
|
||||
/// </summary>
|
||||
public static string CollapseSparseLabel(string label, string displayIdFallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
{
|
||||
return string.IsNullOrEmpty(displayIdFallback) ? "Event" : displayIdFallback;
|
||||
}
|
||||
|
||||
string s = label.Trim();
|
||||
while (s.IndexOf(" ", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
s = s.Replace(" ", " ");
|
||||
}
|
||||
|
||||
// Trailing separator after an empty {a}/{b}.
|
||||
while (s.Length > 0)
|
||||
{
|
||||
char last = s[s.Length - 1];
|
||||
if (last == ':' || last == '-' || last == '·' || last == '|')
|
||||
{
|
||||
s = s.Substring(0, s.Length - 1).TrimEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Leading separator when {a} was first and empty.
|
||||
while (s.Length > 0)
|
||||
{
|
||||
char first = s[0];
|
||||
if (first == ':' || first == '-' || first == '·' || first == '|')
|
||||
{
|
||||
s = s.Substring(1).TrimStart();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
return string.IsNullOrEmpty(displayIdFallback) ? "Event" : displayIdFallback;
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>True when a rendered tip would look empty or end with a hanging colon.</summary>
|
||||
public static bool IsHangingLabel(string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string s = label.TrimEnd();
|
||||
return s.EndsWith(":", StringComparison.Ordinal)
|
||||
|| s.EndsWith(": ", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,9 +182,11 @@ public static partial class EventCatalog
|
|||
string id = string.IsNullOrEmpty(assetId) ? "unknown" : assetId.Trim();
|
||||
float strength = 40f;
|
||||
string category = "WorldLog";
|
||||
if (id.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
// Live disaster library / authored disaster overlays - not tip-string matching.
|
||||
if (Disaster.IsLiveOrAuthored(id))
|
||||
{
|
||||
strength = 95f;
|
||||
DisasterInterestEntry d = Disaster.GetOrFallback(id);
|
||||
strength = d.EventStrength > 0f ? d.EventStrength : 95f;
|
||||
category = "Disaster";
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +204,23 @@ public static partial class EventCatalog
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>WorldLog / disaster tips classified as Disaster via catalog or live library.</summary>
|
||||
public static bool IsDisasterCategory(string assetId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryGet(assetId, out WorldLogEventEntry entry)
|
||||
&& string.Equals(entry.Category, "Disaster", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Disaster.IsLiveOrAuthored(assetId);
|
||||
}
|
||||
|
||||
public static bool TryGetEventStrength(string assetId, out float eventStrength)
|
||||
{
|
||||
WorldLogEventEntry entry = GetOrFallback(assetId);
|
||||
|
|
|
|||
|
|
@ -114,6 +114,26 @@ public static partial class EventCatalog
|
|||
|
||||
public static string KeyFor(Actor follow, Actor related)
|
||||
{
|
||||
// Spectacle 1vN scraps share one scene so attack_target hops merge instead of
|
||||
// spawning combat:pair:owner:victimN keys that thrash Duel tips.
|
||||
if (follow != null && WorldActivityScanner.IsSpectaclePublic(follow))
|
||||
{
|
||||
long sid = EventFeedUtil.SafeId(follow);
|
||||
if (sid != 0)
|
||||
{
|
||||
return "combat:spectacle:" + sid;
|
||||
}
|
||||
}
|
||||
|
||||
if (related != null && WorldActivityScanner.IsSpectaclePublic(related))
|
||||
{
|
||||
long sid = EventFeedUtil.SafeId(related);
|
||||
if (sid != 0)
|
||||
{
|
||||
return "combat:spectacle:" + sid;
|
||||
}
|
||||
}
|
||||
|
||||
long a = EventFeedUtil.SafeId(follow);
|
||||
long b = EventFeedUtil.SafeId(related);
|
||||
return b != 0 ? PairKey(a, b) : Key(a);
|
||||
|
|
|
|||
|
|
@ -52,7 +52,67 @@ public static class EventReason
|
|||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(prevKey, nextKey, StringComparison.Ordinal);
|
||||
if (string.Equals(prevKey, nextKey, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Pack mop-up ↔ thin vs remnant: Mass - Igguorn ↔ Battle - Igguorn vs The Godo.
|
||||
return SameCombatTheaterCamps(prevKey, nextKey);
|
||||
}
|
||||
|
||||
private static bool SameCombatTheaterCamps(string prevKey, string nextKey)
|
||||
{
|
||||
if (TryPackSide(prevKey, out string pack) && TryVsSides(nextKey, out string a, out string b))
|
||||
{
|
||||
return CampEq(pack, a) || CampEq(pack, b);
|
||||
}
|
||||
|
||||
if (TryPackSide(nextKey, out pack) && TryVsSides(prevKey, out a, out b))
|
||||
{
|
||||
return CampEq(pack, a) || CampEq(pack, b);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryPackSide(string key, out string pack)
|
||||
{
|
||||
pack = "";
|
||||
if (string.IsNullOrEmpty(key) || !key.StartsWith("pack:", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
pack = key.Substring(5);
|
||||
return !string.IsNullOrEmpty(pack);
|
||||
}
|
||||
|
||||
private static bool TryVsSides(string key, out string a, out string b)
|
||||
{
|
||||
a = "";
|
||||
b = "";
|
||||
if (string.IsNullOrEmpty(key) || key.StartsWith("pack:", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int bar = key.IndexOf('|');
|
||||
if (bar <= 0 || bar >= key.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
a = key.Substring(0, bar);
|
||||
b = key.Substring(bar + 1);
|
||||
return !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b);
|
||||
}
|
||||
|
||||
private static bool CampEq(string a, string b)
|
||||
{
|
||||
return !string.IsNullOrEmpty(a)
|
||||
&& !string.IsNullOrEmpty(b)
|
||||
&& string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryCombatCampKey(string label, out string key)
|
||||
|
|
@ -71,7 +131,9 @@ public static class EventReason
|
|||
}
|
||||
|
||||
string tier = t.Substring(0, dash).Trim();
|
||||
if (!tier.Equals("Skirmish", StringComparison.OrdinalIgnoreCase)
|
||||
// Include Duel so A vs B ↔ B vs A is framing-only (no Watch / tip-order churn).
|
||||
if (!tier.Equals("Duel", StringComparison.OrdinalIgnoreCase)
|
||||
&& !tier.Equals("Skirmish", StringComparison.OrdinalIgnoreCase)
|
||||
&& !tier.Equals("Battle", StringComparison.OrdinalIgnoreCase)
|
||||
&& !tier.Equals("Mass", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1492,6 +1492,13 @@ public sealed class LiveEnsemble
|
|||
return false;
|
||||
}
|
||||
|
||||
// Sleeping / stunned / frozen / lying units are not in a live scrap even if an
|
||||
// attack_target pointer still lingers (soak + combat_focus cold after sleep).
|
||||
if (IsCombatIncapacitated(actor))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (actor.has_attack_target)
|
||||
|
|
@ -1509,6 +1516,49 @@ public sealed class LiveEnsemble
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when game state says the actor cannot meaningfully fight right now.
|
||||
/// Uses Actor APIs + live sleeping status - not an authored id deny list.
|
||||
/// </summary>
|
||||
public static bool IsCombatIncapacitated(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parked only: sleep / freeze / unconscious. Do not treat stun or isLying as out of
|
||||
// the scrap - those are normal combat FX and were collapsing Duels to "is fighting".
|
||||
try
|
||||
{
|
||||
var ids = actor.getStatusesIds();
|
||||
if (ids != null)
|
||||
{
|
||||
foreach (string id in ids)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string s = id.ToLowerInvariant();
|
||||
if (s.IndexOf("sleep", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("frozen", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("uncon", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sticky scoreboard roster: enroll matching side keys in radius, then count every
|
||||
/// tracked member that is still alive until the scene grace ends.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ public sealed class LiveSceneStickyState
|
|||
public bool HasPresentedScale;
|
||||
public EnsembleScale PresentedScale;
|
||||
public float ScaleChangeSince = -999f;
|
||||
/// <summary>
|
||||
/// After a camp hits 0, stay in mop-up until the thin side recovers to
|
||||
/// <see cref="StickyScoreboard.WipeRecoverMin"/> (stops 0↔1 Mass↔Battle thrash).
|
||||
/// </summary>
|
||||
public bool MopUpActive;
|
||||
public float MopUpSince = -999f;
|
||||
|
||||
public string SideAKey = "";
|
||||
public string SideADisplay = "";
|
||||
|
|
@ -54,6 +60,8 @@ public sealed class LiveSceneStickyState
|
|||
HasPresentedScale = false;
|
||||
PresentedScale = EnsembleScale.Pair;
|
||||
ScaleChangeSince = -999f;
|
||||
MopUpActive = false;
|
||||
MopUpSince = -999f;
|
||||
SideAKey = "";
|
||||
SideADisplay = "";
|
||||
SideAKingdom = "";
|
||||
|
|
@ -81,6 +89,8 @@ public sealed class LiveSceneStickyState
|
|||
other.HasPresentedScale = HasPresentedScale;
|
||||
other.PresentedScale = PresentedScale;
|
||||
other.ScaleChangeSince = ScaleChangeSince;
|
||||
other.MopUpActive = MopUpActive;
|
||||
other.MopUpSince = MopUpSince;
|
||||
other.SideAKey = SideAKey;
|
||||
other.SideADisplay = SideADisplay;
|
||||
other.SideAKingdom = SideAKingdom;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,21 @@ public static class MetaEventPatches
|
|||
public static void PostfixNewClan(Clan __result)
|
||||
{
|
||||
EmitNew("new_clan", "Politics", 70f, __result, "clan");
|
||||
Actor clanFounder = TryReadActor(__result);
|
||||
if (clanFounder != null)
|
||||
{
|
||||
string clanKey = "";
|
||||
try
|
||||
{
|
||||
clanKey = __result != null ? __result.getID().ToString() : "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
clanKey = "";
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordFounding(clanFounder, "clan", clanKey, "new_clan");
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(ArmyManager), nameof(ArmyManager.newArmy))]
|
||||
|
|
@ -77,6 +92,21 @@ public static class MetaEventPatches
|
|||
{
|
||||
// Outcome tip (decision intent like build_civ_city_here is Layer B).
|
||||
EmitNew("new_city", "Politics", 86f, __result, "city");
|
||||
Actor cityFounder = TryReadActor(__result);
|
||||
if (cityFounder != null)
|
||||
{
|
||||
string cityKey = "";
|
||||
try
|
||||
{
|
||||
cityKey = __result != null ? __result.getID().ToString() : "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
cityKey = "";
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordFounding(cityFounder, "city", cityKey, "new_city");
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(KingdomManager), nameof(KingdomManager.makeNewCivKingdom))]
|
||||
|
|
@ -101,6 +131,17 @@ public static class MetaEventPatches
|
|||
92f,
|
||||
anchor,
|
||||
EventReason.MetaNew(anchor, "kingdom"));
|
||||
string kingdomKey = "";
|
||||
try
|
||||
{
|
||||
kingdomKey = __result.getID().ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
kingdomKey = "";
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordFounding(anchor, "kingdom", kingdomKey, "new_kingdom");
|
||||
}
|
||||
|
||||
private static void EmitNew(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,20 @@ public static class PlotEventPatches
|
|||
}
|
||||
|
||||
PlotInterestFeed.EmitFromPlot(__result, "new");
|
||||
try
|
||||
{
|
||||
Actor author = __result.getAuthor();
|
||||
if (author != null && author.isAlive())
|
||||
{
|
||||
string plotKey = __result.getID().ToString();
|
||||
string assetId = ReadPlotAssetId(__result);
|
||||
LifeSagaMemory.RecordPlot(author, plotKey, assetId, finished: false, provenance: "newPlot");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(PlotManager), nameof(PlotManager.cancelPlot))]
|
||||
|
|
@ -40,6 +54,16 @@ public static class PlotEventPatches
|
|||
}
|
||||
|
||||
PlotInterestFeed.EmitFromPlot(pObject, "join");
|
||||
try
|
||||
{
|
||||
string plotKey = pObject.getID().ToString();
|
||||
string assetId = ReadPlotAssetId(pObject);
|
||||
LifeSagaMemory.RecordPlot(__instance, plotKey, assetId, finished: false, provenance: "setPlot");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.leavePlot))]
|
||||
|
|
@ -94,10 +118,38 @@ public static class PlotEventPatches
|
|||
}
|
||||
|
||||
PlotInterestFeed.Emit(assetId, pActor, "complete");
|
||||
try
|
||||
{
|
||||
string plotKey = __instance.getID().ToString();
|
||||
LifeSagaMemory.RecordPlot(pActor, plotKey, assetId, finished: true, provenance: "finishPlot");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
PlotInterestFeed.EmitFromPlot(__instance, "complete");
|
||||
try
|
||||
{
|
||||
Actor author = __instance.getAuthor();
|
||||
if (author != null && author.isAlive())
|
||||
{
|
||||
string plotKey = __instance.getID().ToString();
|
||||
LifeSagaMemory.RecordPlot(
|
||||
author,
|
||||
plotKey,
|
||||
ReadPlotAssetId(__instance),
|
||||
finished: true,
|
||||
provenance: "finishPlot");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadPlotAssetId(object plot)
|
||||
|
|
|
|||
|
|
@ -38,9 +38,29 @@ public static class RelationshipEventPatches
|
|||
return true;
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.setLover))]
|
||||
[HarmonyPrefix]
|
||||
public static void PrefixSetLover(Actor __instance, Actor pActor, out Actor __state)
|
||||
{
|
||||
__state = null;
|
||||
try
|
||||
{
|
||||
if (__instance != null && EventOutcome.IsLiving(__instance) && __instance.hasLover())
|
||||
{
|
||||
__state = __instance.lover;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
__state = null;
|
||||
}
|
||||
|
||||
_ = pActor;
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.setLover))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixSetLover(Actor __instance, Actor pActor)
|
||||
public static void PostfixSetLover(Actor __instance, Actor pActor, Actor __state)
|
||||
{
|
||||
if (!EventOutcome.IsLiving(__instance))
|
||||
{
|
||||
|
|
@ -50,10 +70,12 @@ public static class RelationshipEventPatches
|
|||
// setLover is already the confirmed state transition.
|
||||
if (pActor == null)
|
||||
{
|
||||
LifeSagaMemory.RecordBondBroken(__instance, __state, "setLover(null)");
|
||||
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordBondFormed(__instance, pActor, "setLover");
|
||||
RelationshipInterestFeed.Emit("set_lover", __instance, pActor);
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +125,7 @@ public static class RelationshipEventPatches
|
|||
continue;
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordBondBroken(unit, lover, "dead_lover_cleanup");
|
||||
RelationshipInterestFeed.Emit("clear_lover", unit, null);
|
||||
}
|
||||
catch
|
||||
|
|
@ -142,7 +165,11 @@ public static class RelationshipEventPatches
|
|||
increaseChildren
|
||||
&& EventOutcome.IsLiving(parent)
|
||||
&& EventOutcome.IsLiving(child),
|
||||
() => RelationshipInterestFeed.Emit("add_child", parent, child));
|
||||
() =>
|
||||
{
|
||||
LifeSagaMemory.RecordParentChild(parent, child);
|
||||
RelationshipInterestFeed.Emit("add_child", parent, child);
|
||||
});
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(FamilyManager), nameof(FamilyManager.newFamily))]
|
||||
|
|
@ -150,6 +177,21 @@ public static class RelationshipEventPatches
|
|||
public static void PostfixNewFamily(Family __result, Actor pActor)
|
||||
{
|
||||
Actor anchor = EventOutcome.IsLiving(pActor) ? pActor : null;
|
||||
if (anchor != null)
|
||||
{
|
||||
string familyKey = "";
|
||||
try
|
||||
{
|
||||
familyKey = __result != null ? __result.getID().ToString() : "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
familyKey = "";
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordFounding(anchor, "family", familyKey, "new_family");
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
|
||||
}
|
||||
|
||||
|
|
@ -405,6 +447,10 @@ public static class RelationshipEventPatches
|
|||
"Family.setAlpha",
|
||||
"become_alpha",
|
||||
pNew && EventOutcome.IsLiving(pActor),
|
||||
() => InterestFeeds.EmitAlpha(pActor, "family_set_alpha"));
|
||||
() =>
|
||||
{
|
||||
LifeSagaMemory.RecordRole(pActor, "become_alpha", "family_set_alpha");
|
||||
InterestFeeds.EmitAlpha(pActor, "family_set_alpha");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,40 @@ public static class WarEventPatches
|
|||
try
|
||||
{
|
||||
WarInterestFeed.EmitFromWarObject(__result, phase: "new");
|
||||
Actor founder = null;
|
||||
try
|
||||
{
|
||||
founder = __result?.main_attacker?.king;
|
||||
}
|
||||
catch
|
||||
{
|
||||
founder = null;
|
||||
}
|
||||
|
||||
string warKey = "";
|
||||
try
|
||||
{
|
||||
warKey = __result.getID().ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
warKey = "";
|
||||
}
|
||||
|
||||
if (founder != null && founder.isAlive() && !string.IsNullOrEmpty(warKey))
|
||||
{
|
||||
string kingdom = "";
|
||||
try
|
||||
{
|
||||
kingdom = founder.kingdom != null ? founder.kingdom.name : "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
kingdom = "";
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordWar(founder, warKey, kingdom, ended: false, provenance: "newWar");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -37,6 +71,38 @@ public static class WarEventPatches
|
|||
try
|
||||
{
|
||||
WarInterestFeed.EmitFromWarObject(__args[0], phase: "end");
|
||||
object war = __args[0];
|
||||
string warKey = "";
|
||||
try
|
||||
{
|
||||
if (war is War typed)
|
||||
{
|
||||
warKey = typed.getID().ToString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
warKey = "";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(warKey) && war is War endedWar)
|
||||
{
|
||||
Actor anchor = null;
|
||||
try
|
||||
{
|
||||
anchor = endedWar.main_attacker?.king
|
||||
?? endedWar.main_defender?.king;
|
||||
}
|
||||
catch
|
||||
{
|
||||
anchor = null;
|
||||
}
|
||||
|
||||
if (anchor != null && anchor.isAlive())
|
||||
{
|
||||
LifeSagaMemory.RecordWar(anchor, warKey, "", ended: true, provenance: "endWar");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ namespace IdleSpectator;
|
|||
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;
|
||||
|
|
@ -157,11 +159,15 @@ namespace IdleSpectator;
|
|||
sticky.PeakParticipants = n;
|
||||
}
|
||||
|
||||
UpdateMopUp(sticky, now);
|
||||
|
||||
EnsembleScale live = LiveEnsemble.ScaleForCount(n);
|
||||
// Opposing camp wiped: drop Peak Mass hold immediately so tip tier matches mop-up.
|
||||
// Opposing camp wiped / mop-up: drop Peak Mass hold so tip tier matches living pack.
|
||||
bool wipedOpposing = sticky.HasOpposingSides
|
||||
&& sticky.TotalCount > 0
|
||||
&& (sticky.SideACount <= 0 || sticky.SideBCount <= 0);
|
||||
&& (sticky.MopUpActive
|
||||
|| sticky.SideACount <= 0
|
||||
|| sticky.SideBCount <= 0);
|
||||
if (wipedOpposing)
|
||||
{
|
||||
sticky.HasPresentedScale = true;
|
||||
|
|
@ -465,6 +471,8 @@ namespace IdleSpectator;
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -477,11 +485,14 @@ namespace IdleSpectator;
|
|||
}
|
||||
|
||||
LiveSceneStickyState sticky = scene.Sticky;
|
||||
UpdateMopUp(sticky, Time.unscaledTime);
|
||||
GetPresentationCounts(sticky, out int countA, out int countB);
|
||||
bool wipedOpposing = sticky.HasOpposingSides
|
||||
&& sticky.TotalCount > 0
|
||||
&& (sticky.SideACount <= 0 || sticky.SideBCount <= 0);
|
||||
&& (countA <= 0 || countB <= 0);
|
||||
int liveN = Math.Max(0, countA) + Math.Max(0, countB);
|
||||
int scaleN = wipedOpposing
|
||||
? Math.Max(1, sticky.TotalCount)
|
||||
? Math.Max(1, liveN)
|
||||
: Math.Max(3, sticky.PeakParticipants);
|
||||
var ensemble = new LiveEnsemble
|
||||
{
|
||||
|
|
@ -495,7 +506,7 @@ namespace IdleSpectator;
|
|||
Key = sticky.SideAKey,
|
||||
Display = sticky.SideADisplay,
|
||||
KingdomDisplay = sticky.SideAKingdom,
|
||||
Count = sticky.SideACount,
|
||||
Count = countA,
|
||||
Best = focus
|
||||
},
|
||||
SideB = new EnsembleSide
|
||||
|
|
@ -503,14 +514,224 @@ namespace IdleSpectator;
|
|||
Key = sticky.SideBKey,
|
||||
Display = sticky.SideBDisplay,
|
||||
KingdomDisplay = sticky.SideBKingdom,
|
||||
Count = sticky.SideBCount,
|
||||
Count = countB,
|
||||
Best = related
|
||||
},
|
||||
ParticipantCount = sticky.TotalCount
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ public static class HappinessEventRouter
|
|||
if (entry.Life == HappinessLifePolicy.ProjectToLife
|
||||
&& entry.Overlap != HappinessOverlapPolicy.CanonicalMilestone)
|
||||
{
|
||||
NoteSagaFromHappiness(occ, subject, related, effectId);
|
||||
if (Chronicle.NoteHappinessLife(occ, subject, related))
|
||||
{
|
||||
occ.WroteLife = true;
|
||||
|
|
@ -305,6 +306,64 @@ public static class HappinessEventRouter
|
|||
return occ;
|
||||
}
|
||||
|
||||
private static void NoteSagaFromHappiness(
|
||||
HappinessOccurrence occ,
|
||||
Actor subject,
|
||||
Actor related,
|
||||
string effectId)
|
||||
{
|
||||
if (subject == null || string.IsNullOrEmpty(effectId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string id = effectId;
|
||||
if (id.IndexOf("become_king", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("became_king", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
LifeSagaMemory.RecordRole(subject, "become_king", "happiness:" + id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (id.IndexOf("become_leader", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("became_leader", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
LifeSagaMemory.RecordRole(subject, "become_leader", "happiness:" + id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (id.IndexOf("become_alpha", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
LifeSagaMemory.RecordRole(subject, "become_alpha", "happiness:" + id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (id.IndexOf("just_found_house", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("found_house", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
LifeSagaMemory.RecordHome(subject, gained: true, provenance: "happiness:" + id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (id.IndexOf("just_lost_house", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("lost_house", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
LifeSagaMemory.RecordHome(subject, gained: false, provenance: "happiness:" + id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (id.IndexOf("death_lover", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("grief", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
if (related != null)
|
||||
{
|
||||
LifeSagaMemory.RecordBondBroken(subject, related, "happiness:" + id);
|
||||
}
|
||||
}
|
||||
|
||||
_ = occ;
|
||||
}
|
||||
|
||||
private static HappinessOccurrence PublishAggregateMember(
|
||||
Actor subject,
|
||||
string effectId,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
|
@ -140,6 +141,97 @@ public static class HudIcons
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Readable saga-rail face: live inspect sprite when usable, else species icon.
|
||||
/// Skips egg forms and near-empty tiny crops that read as a single pixel.
|
||||
/// </summary>
|
||||
public static Sprite FromActorRailFace(Actor actor, string speciesFallbackId = null)
|
||||
{
|
||||
if (actor != null && actor.isAlive())
|
||||
{
|
||||
bool egg = false;
|
||||
try
|
||||
{
|
||||
egg = actor.isEgg();
|
||||
}
|
||||
catch
|
||||
{
|
||||
egg = false;
|
||||
}
|
||||
|
||||
if (!egg)
|
||||
{
|
||||
Sprite live = FromActorLive(actor);
|
||||
if (IsUsableRailFace(live))
|
||||
{
|
||||
return live;
|
||||
}
|
||||
|
||||
Sprite asset = FromActor(actor);
|
||||
if (IsUsableRailFace(asset) && !LooksLikeEggSprite(asset))
|
||||
{
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string species = speciesFallbackId;
|
||||
if (string.IsNullOrEmpty(species) && actor?.asset != null)
|
||||
{
|
||||
species = actor.asset.id;
|
||||
}
|
||||
|
||||
return FromSpeciesId(species);
|
||||
}
|
||||
|
||||
public static bool IsUsableRailFace(Sprite sprite)
|
||||
{
|
||||
if (sprite == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Reject 1-4px crops and blank atlas slices that look like dust on the rail.
|
||||
float w = sprite.rect.width;
|
||||
float h = sprite.rect.height;
|
||||
if (w < 8f || h < 8f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sprite.texture == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LooksLikeEggSprite(Sprite sprite)
|
||||
{
|
||||
if (sprite == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string n = sprite.name ?? "";
|
||||
return n.IndexOf("egg", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static Sprite ForChronicleKind(ChronicleKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
|
|
|
|||
|
|
@ -179,8 +179,9 @@ public sealed class InterestCandidate
|
|||
|
||||
/// <summary>
|
||||
/// Lock durable 1v1 owner/partner ids (no-op when owner missing).
|
||||
/// First living partner sticks across attack_target swaps; replace only when the
|
||||
/// locked partner is dead/missing (or <paramref name="replacePartner"/>).
|
||||
/// First living partner sticks across attack_target swaps. Replace only when the
|
||||
/// locked partner is confirmed dead and the owner is no longer fighting
|
||||
/// (or <paramref name="replacePartner"/>). Lookup misses never unlock the partner.
|
||||
/// </summary>
|
||||
public void StampCombatPair(Actor owner, Actor partner, bool replacePartner = false)
|
||||
{
|
||||
|
|
@ -212,12 +213,52 @@ public sealed class InterestCandidate
|
|||
return;
|
||||
}
|
||||
|
||||
// Locked partner still alive: keep them. Dead/missing: adopt the new foe once.
|
||||
Actor locked = LiveEnsemble.FindTrackedActor(PairPartnerId);
|
||||
if (locked == null || !locked.isAlive())
|
||||
// Prefer FindUnitById: FindAliveById null means dead OR miss - misses must not thrash.
|
||||
Actor locked = EventFeedUtil.FindUnitById(PairPartnerId);
|
||||
bool lockedAlive = false;
|
||||
bool lockedKnown = locked != null;
|
||||
try
|
||||
{
|
||||
PairPartnerId = pid;
|
||||
lockedAlive = locked != null && locked.isAlive();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Destroyed Unity object - treat as miss (keep lock).
|
||||
lockedKnown = false;
|
||||
lockedAlive = false;
|
||||
}
|
||||
|
||||
if (lockedAlive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lockedKnown)
|
||||
{
|
||||
// Unknown / unloaded / destroyed - keep the first partner id.
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirmed dead. While the owner is still swinging (spectacle 1vN / scrap mop),
|
||||
// do not chain-adopt the next attack_target into the durable lock.
|
||||
Actor ownerActor = null;
|
||||
try
|
||||
{
|
||||
ownerActor = owner != null && owner.isAlive()
|
||||
? owner
|
||||
: EventFeedUtil.FindAliveById(PairOwnerId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ownerActor = EventFeedUtil.FindAliveById(PairOwnerId);
|
||||
}
|
||||
|
||||
if (ownerActor != null && LiveEnsemble.IsCombatParticipant(ownerActor))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PairPartnerId = pid;
|
||||
}
|
||||
|
||||
public bool HasCombatPairLock => PairOwnerId != 0;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,12 @@ public static class InterestCompletion
|
|||
}
|
||||
|
||||
float age = now - sessionStartedAt;
|
||||
if (age >= candidate.MaxWatch)
|
||||
// Combat / war caps are owned by InterestDirector (MaxWatchFor + CombatFightIsHot).
|
||||
// An early MaxWatch gate here made live scraps look cold so max_cap re-picked
|
||||
// a new Duel partner every cap (spectacle 1vN thrash).
|
||||
if (age >= candidate.MaxWatch
|
||||
&& candidate.Completion != InterestCompletionKind.CombatActive
|
||||
&& candidate.Completion != InterestCompletionKind.WarFront)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -94,52 +99,34 @@ public static class InterestCompletion
|
|||
return false;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
Actor unit = c.FollowUnit;
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
return false;
|
||||
// Spectacle / pair owner may still be swinging after a brief Follow gap.
|
||||
unit = ResolveCombatHotAnchor(c);
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
bool hot = false;
|
||||
try
|
||||
// Single participant predicate: sleeping/stunned/frozen are cold even with a stale
|
||||
// attack_target pointer (combat_focus natural cold; Norron attack-gap truth).
|
||||
bool hot = unit != null && unit.isAlive() && LiveEnsemble.IsCombatParticipant(unit);
|
||||
if (!hot && unit != null && unit.isAlive() && RelatedStillFightingUs(c, unit))
|
||||
{
|
||||
if (unit.has_attack_target)
|
||||
{
|
||||
hot = true;
|
||||
}
|
||||
else if (unit.hasTask() && unit.ai?.task != null
|
||||
&& (unit.ai.task.in_combat || unit.ai.task.is_fireman))
|
||||
{
|
||||
hot = true;
|
||||
}
|
||||
else if (RelatedStillFightingUs(c, unit))
|
||||
{
|
||||
hot = true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
hot = true;
|
||||
}
|
||||
|
||||
if (!hot && c.AssetId == "live_battle")
|
||||
if (!hot)
|
||||
{
|
||||
// Chunk-local only - never CollectAllCombatFighters during sticky maintain.
|
||||
Vector3 pos = c.Position;
|
||||
try
|
||||
{
|
||||
if (c.FollowUnit != null && c.FollowUnit.isAlive())
|
||||
{
|
||||
pos = c.FollowUnit.current_position;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep Position
|
||||
}
|
||||
hot = PairPartnerStillFighting(c);
|
||||
}
|
||||
|
||||
hot = WorldActivityScanner.HasLiveCombatNear(pos, 18f);
|
||||
// Collective Mass/Battle: enrolled sticky fighters bridge attack_target gaps.
|
||||
// Never use unscoped HasLiveCombatNear - ambient scraps within 18 tiles were
|
||||
// pinning cold Duel pairs (Neen vs Yaaeore held by nearby Igguorn melees).
|
||||
bool pairOrDuel = IsPairOrDuelCombat(c);
|
||||
if (!hot && !pairOrDuel && c.HasStickyCombatSides)
|
||||
{
|
||||
hot = StickyRosterStillFighting(c);
|
||||
}
|
||||
|
||||
if (hot)
|
||||
|
|
@ -149,9 +136,129 @@ public static class InterestCompletion
|
|||
return true;
|
||||
}
|
||||
|
||||
// Hysteresis: WorldBox clears attack_target between swings.
|
||||
// Pair/Duel: bridge swing gaps. Survivor aftermath requires a fallen partner, so a
|
||||
// longer pair hold is safe - too short dropped mid-duel into false cold (Norron soak).
|
||||
const float pairHysteresisSeconds = 5f;
|
||||
const float combatHysteresisSeconds = 8f;
|
||||
return now - c.LastSeenAt < combatHysteresisSeconds;
|
||||
float hold = pairOrDuel ? pairHysteresisSeconds : combatHysteresisSeconds;
|
||||
return now - c.LastSeenAt < hold;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pair-locked / Duel tips stay hot only while the owned cast fights - not nearby strangers.
|
||||
/// </summary>
|
||||
private static bool IsPairOrDuelCombat(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c.HasCombatPairLock)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string tip = c.Label ?? "";
|
||||
return tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
||||
|| tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static bool PairPartnerStillFighting(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Actor partner = c.RelatedUnit;
|
||||
if ((partner == null || !partner.isAlive()) && c.PairPartnerId != 0)
|
||||
{
|
||||
partner = LiveEnsemble.FindTrackedActor(c.PairPartnerId);
|
||||
}
|
||||
|
||||
if (partner == null || !partner.isAlive())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return LiveEnsemble.IsCombatParticipant(partner);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool StickyRosterStillFighting(InterestCandidate c)
|
||||
{
|
||||
LiveSceneStickyState sticky = c?.Sticky;
|
||||
if (sticky == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (RosterIdsStillFighting(sticky.SideAIds)
|
||||
|| RosterIdsStillFighting(sticky.SideBIds))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool RosterIdsStillFighting(List<long> ids)
|
||||
{
|
||||
if (ids == null || ids.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
Actor unit = LiveEnsemble.FindTrackedActor(ids[i]);
|
||||
if (unit != null && unit.isAlive() && LiveEnsemble.IsCombatParticipant(unit))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Actor ResolveCombatHotAnchor(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (c.TheaterLeadId != 0)
|
||||
{
|
||||
Actor lead = LiveEnsemble.FindTrackedActor(c.TheaterLeadId);
|
||||
if (lead != null && lead.isAlive())
|
||||
{
|
||||
return lead;
|
||||
}
|
||||
}
|
||||
|
||||
if (c.PairOwnerId != 0)
|
||||
{
|
||||
Actor owner = LiveEnsemble.FindTrackedActor(c.PairOwnerId);
|
||||
if (owner != null && owner.isAlive())
|
||||
{
|
||||
return owner;
|
||||
}
|
||||
}
|
||||
|
||||
if (c.RelatedUnit != null && c.RelatedUnit.isAlive())
|
||||
{
|
||||
return c.RelatedUnit;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool WarFrontStillActive(InterestCandidate c)
|
||||
|
|
@ -181,6 +288,13 @@ public static class InterestCompletion
|
|||
sidesLive = TryResolveWarStillActive(c.CorrelationKey);
|
||||
}
|
||||
|
||||
// Sticky war tips stay hot while opposing camps remain locked, even when kingdom
|
||||
// asset lookup fails (harness synthetic keys / display-name aliases).
|
||||
if (!sidesLive && c.HasStickyCombatSides && c.CombatPeakParticipants > 0)
|
||||
{
|
||||
sidesLive = true;
|
||||
}
|
||||
|
||||
if (sidesLive)
|
||||
{
|
||||
c.LastSeenAt = now;
|
||||
|
|
@ -568,6 +682,11 @@ public static class InterestCompletion
|
|||
return false;
|
||||
}
|
||||
|
||||
if (LiveEnsemble.IsCombatIncapacitated(foe) || LiveEnsemble.IsCombatIncapacitated(unit))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!foe.has_attack_target || foe.attack_target == null || !foe.attack_target.isAlive())
|
||||
|
|
|
|||
1930
IdleSpectator/InterestDirector.StickyMaintain.cs
Normal file
1930
IdleSpectator/InterestDirector.StickyMaintain.cs
Normal file
File diff suppressed because it is too large
Load diff
659
IdleSpectator/InterestDirector.SwitchPolicy.cs
Normal file
659
IdleSpectator/InterestDirector.SwitchPolicy.cs
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public static partial class InterestDirector
|
||||
{
|
||||
private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch)
|
||||
{
|
||||
if (candidate == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same FixedDwell beat already watched on this subject - never cut back in.
|
||||
if (InterestVariety.IsBeatCooled(candidate))
|
||||
{
|
||||
InterestDropLog.Record("beat_cooldown", candidate.HappinessEffectId ?? candidate.AssetId ?? candidate.Key);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_current == null)
|
||||
{
|
||||
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
|
||||
// Same fighter, new attack_target: do not cut Duel A-vs-B → A-vs-C mid-scrap.
|
||||
if (IsCombatPartnerSwapNoise(_current, candidate))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"combat_partner_hold",
|
||||
$"cur={_current.Key} next={candidate.Key}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Crisis signal tip (war / disaster / outbreak) owns the camera against Mass/Battle scraps.
|
||||
// Must run before crisis cut-in: a leftover war crisis can MatchCrisis(Mass) while a
|
||||
// small WarFront tip does not. Drop reason keeps war_theater_hold alias for harness.
|
||||
if (StoryPlanner.IsCrisisCameraSignal(_current)
|
||||
&& candidate.Completion == InterestCompletionKind.CombatActive)
|
||||
{
|
||||
string holdReason = _current.Completion == InterestCompletionKind.WarFront
|
||||
? "war_theater_hold"
|
||||
: "crisis_theater_hold";
|
||||
InterestDropLog.Record(
|
||||
holdReason,
|
||||
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Crisis-owned tips (esp. forced epilogue_crisis) may cut without score-margin wars
|
||||
// against aftermath / ambient peers that outscore the closer on raw EventStrength.
|
||||
if (StoryPlanner.CrisisActive
|
||||
&& StoryPlanner.MatchesCrisis(candidate)
|
||||
&& !StoryPlanner.MatchesCrisis(_current)
|
||||
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Live fight: hold until cold (see who wins), unless something extremely urgent cuts in.
|
||||
// Same-arc theater refresh / absorb still allowed. WarFront on the same kingdom
|
||||
// theater may reclaim the chapter (Mass → War) without waiting for combat cold.
|
||||
if (CombatFightIsHot(_current, now)
|
||||
&& !IsSameStoryArc(_current, candidate)
|
||||
&& candidate.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
bool warTheaterReclaim = candidate.Completion == InterestCompletionKind.WarFront
|
||||
&& StickyScoreboard.SharesKingdomTheater(_current, candidate);
|
||||
if (warTheaterReclaim)
|
||||
{
|
||||
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
|
||||
}
|
||||
|
||||
if (IsCombatUrgentPeer(_current, candidate))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"combat_urgent_cut",
|
||||
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
|
||||
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
|
||||
}
|
||||
|
||||
InterestDropLog.Record(
|
||||
"combat_hold",
|
||||
$"cur={_current.Key} next={candidate.Key}");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool inGrace = InQuietGrace;
|
||||
if (inGrace)
|
||||
{
|
||||
// Story aftermath / same-arc continuation may cut freely during quiet grace.
|
||||
if (IsSameStoryArc(_current, candidate)
|
||||
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Quiet grace after a sticky hold: only margin-worthy events may cut in.
|
||||
// Prevents family-join / love status from stealing a fight between swings.
|
||||
if (_current != null
|
||||
&& (_current.Completion == InterestCompletionKind.CombatActive
|
||||
|| _current.Completion == InterestCompletionKind.StatusPhase
|
||||
|| _current.Completion == InterestCompletionKind.HappinessGrief
|
||||
|| IsStickyStoryScene(_current)))
|
||||
{
|
||||
float graceCur = _current.TotalScore;
|
||||
float graceNext = candidate.TotalScore;
|
||||
float onCurrentGrace = now - _currentStartedAt;
|
||||
ScoringWeights gw = InterestScoringConfig.W;
|
||||
float graceMargin = IsSoftStickyCluster(_current)
|
||||
? gw.cutInMargin
|
||||
: Mathf.Max(
|
||||
gw.cutInMargin,
|
||||
gw.stickyCutInMargin > 0f ? gw.stickyCutInMargin : 50f);
|
||||
if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate))
|
||||
{
|
||||
float valveMargin = gw.stickyVarietyValveMargin > 0f
|
||||
? gw.stickyVarietyValveMargin
|
||||
: 20f;
|
||||
graceMargin = Mathf.Min(graceMargin, valveMargin);
|
||||
if (graceNext >= graceCur - gw.rotateSlack
|
||||
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return graceNext >= graceCur + graceMargin
|
||||
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
|
||||
}
|
||||
|
||||
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
|
||||
}
|
||||
|
||||
if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool protectedScene = SessionProtected(now, onCurrent);
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float curScore = _current.TotalScore;
|
||||
float nextScore = candidate.TotalScore;
|
||||
bool nextFill = InterestScoring.IsFillScore(nextScore);
|
||||
bool curAmbient = IsAmbientShot(_current);
|
||||
bool curFill = curAmbient || InterestScoring.IsFillScore(curScore);
|
||||
bool sticky = IsStickyStoryScene(_current);
|
||||
float cutMargin = sticky
|
||||
? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f)
|
||||
: w.cutInMargin;
|
||||
bool varietyValve = VarietyValveOpen(_current, onCurrent)
|
||||
&& IsVarietyValveCandidate(candidate);
|
||||
if (varietyValve)
|
||||
{
|
||||
float valveMargin = w.stickyVarietyValveMargin > 0f ? w.stickyVarietyValveMargin : 20f;
|
||||
cutMargin = Mathf.Min(cutMargin, valveMargin);
|
||||
}
|
||||
|
||||
// Ambient fill: any real event may take the camera immediately (no min dwell).
|
||||
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Soft family pack: yield to discrete unit events after a short hold.
|
||||
if (IsSoftStickyCluster(_current)
|
||||
&& !IsSoftStickyCluster(candidate)
|
||||
&& !nextFill
|
||||
&& !IsAmbientShot(candidate)
|
||||
&& onCurrent >= SoftClusterYieldSeconds
|
||||
&& nextScore >= curScore - w.rotateSlack)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut.
|
||||
// Score-margin alone can never clear Mass (~150) with lover (~78); use class gate.
|
||||
if (varietyValve && !nextFill && !IsAmbientShot(candidate))
|
||||
{
|
||||
bool hotEnough = InterestScoring.IsHotScore(nextScore)
|
||||
|| nextScore >= curScore - w.rotateSlack;
|
||||
bool storyEnough = nextScore >= w.noticeScoreMin
|
||||
|| candidate.EventStrength >= w.noticeScoreMin;
|
||||
if (hotEnough || storyEnough)
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"variety_valve",
|
||||
$"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Same-arc refresh (combat/hatch/grief keys) may replace without full margin.
|
||||
if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// StoryPlanner hard-arc / crisis hold against unrelated peers.
|
||||
float storyHold = Mathf.Max(
|
||||
StoryPlanner.ArcHoldMargin(_current, candidate),
|
||||
StoryPlanner.CrisisHoldMargin(_current, candidate));
|
||||
if (storyHold > 0f)
|
||||
{
|
||||
cutMargin = Mathf.Max(cutMargin, storyHold);
|
||||
}
|
||||
|
||||
// Instant score-margin cut - no settle grace, no MinDwell block.
|
||||
// Soft clusters use the normal cut-in margin (not stickyCutInMargin).
|
||||
float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin;
|
||||
if (nextScore >= curScore + effectiveMargin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((sticky || storyHold > 0f) && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"below_margin",
|
||||
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"
|
||||
+ (varietyValve ? " valve" : "")
|
||||
+ (storyHold > 0f ? " story" : ""));
|
||||
}
|
||||
|
||||
// Fill never cuts a protected non-fill session without margin (already failed above).
|
||||
if (nextFill && !curFill && protectedScene)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds;
|
||||
if (nextFill && curFill && onCurrent < fillRotate)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Protected / sticky EventLed hold: only margin or same-arc (handled above).
|
||||
if ((protectedScene || sticky) && !curAmbient)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Peer rotate / stronger score after MinDwell when unprotected or on fill.
|
||||
if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return onCurrent >= MinDwellFor(_current)
|
||||
&& sinceSwitch >= _switchCooldown
|
||||
&& nextScore >= curScore - w.rotateSlack;
|
||||
}
|
||||
|
||||
/// <summary>Soft multi-actor holds that must yield to discrete unit events.</summary>
|
||||
private const float SoftClusterYieldSeconds = 4f;
|
||||
|
||||
private static bool IsSoftStickyCluster(InterestCandidate c) =>
|
||||
c != null && c.Completion == InterestCompletionKind.FamilyPack;
|
||||
|
||||
/// <summary>
|
||||
/// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world
|
||||
/// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers).
|
||||
/// </summary>
|
||||
private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next)
|
||||
{
|
||||
if (current == null || next == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f;
|
||||
if (next.TotalScore >= current.TotalScore + stickyMargin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// King / leadership deaths sit under the epic floor (king_killed=88) but must cut
|
||||
// live scraps - soak missed a king slaying while parked on a Duel thrash.
|
||||
if (IsLeadershipDeathPeer(next))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float epicMin = w.combatUrgentEventStrengthMin > 0f
|
||||
? w.combatUrgentEventStrengthMin
|
||||
: 95f;
|
||||
float urgentMargin = w.combatUrgentCutInMargin > 0f
|
||||
? w.combatUrgentCutInMargin
|
||||
: 20f;
|
||||
return next.EventStrength >= epicMin
|
||||
&& next.TotalScore >= current.TotalScore + urgentMargin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers.
|
||||
/// </summary>
|
||||
private static bool IsLeadershipDeathPeer(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string id = (c.AssetId ?? "").ToLowerInvariant();
|
||||
if (id.IndexOf("king_killed", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("king_dead", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("king_died", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("leader_killed", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("favorite_killed", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return id.IndexOf("kill", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("dead", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("died", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("slain", StringComparison.Ordinal) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
|
||||
/// Live CombatActive scraps never open the valve - hold until the fight goes cold.
|
||||
/// </summary>
|
||||
private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) =>
|
||||
!_varietyValveUsed
|
||||
&& VarietyValveTimeReady(scene, onCurrent)
|
||||
&& !CombatFightIsHot(scene, Time.unscaledTime);
|
||||
|
||||
private static bool IsStickyStoryScene(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// FamilyPack is a soft cluster - not hard sticky (see IsSoftStickyCluster).
|
||||
if (c.Completion == InterestCompletionKind.CombatActive
|
||||
|| c.Completion == InterestCompletionKind.WarFront
|
||||
|| c.Completion == InterestCompletionKind.PlotActive
|
||||
|| c.Completion == InterestCompletionKind.StatusPhase
|
||||
|| c.Completion == InterestCompletionKind.StatusOutbreak
|
||||
|| c.Completion == InterestCompletionKind.HappinessGrief)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hatch FixedDwell is a story beat - hold against weak peers.
|
||||
if (!string.IsNullOrEmpty(c.HappinessEffectId)
|
||||
&& (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(c.Key)
|
||||
&& (c.Key.StartsWith("combat:", StringComparison.Ordinal)
|
||||
|| c.Key.StartsWith("hatch:", StringComparison.Ordinal)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next)
|
||||
{
|
||||
if (current == null || next == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(current.Key)
|
||||
&& !string.IsNullOrEmpty(next.Key)
|
||||
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hatch: same subject beat may refresh without full margin.
|
||||
if (!string.IsNullOrEmpty(current.Key)
|
||||
&& !string.IsNullOrEmpty(next.Key)
|
||||
&& SameKeyPrefix(current.Key, next.Key, "hatch:"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Combat: only the same unordered pair (or escalate of that scrap) is same-arc.
|
||||
// Different combat:pair keys used to soft-cut and thrash Duel partners.
|
||||
if (current.Completion == InterestCompletionKind.CombatActive
|
||||
&& next.Completion == InterestCompletionKind.CombatActive
|
||||
&& IsSameCombatPairArc(current, next))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mass → War reclaim on the same kingdom pair (never War → Mass soft-cut).
|
||||
if (current.Completion == InterestCompletionKind.CombatActive
|
||||
&& next.Completion == InterestCompletionKind.WarFront
|
||||
&& StickyScoreboard.SharesKingdomTheater(current, next))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current.Completion == InterestCompletionKind.WarFront
|
||||
&& next.Completion == InterestCompletionKind.WarFront
|
||||
&& StickyScoreboard.SharesKingdomTheater(current, next))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current.Completion == InterestCompletionKind.HappinessGrief
|
||||
&& next.Completion == InterestCompletionKind.HappinessGrief
|
||||
&& current.SubjectId != 0
|
||||
&& current.SubjectId == next.SubjectId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// StoryPlanner aftermath / epilogue of the active climax.
|
||||
if (StoryPlanner.IsContinuationOf(current, next))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when next is the same 1v1 pair or a multi escalate of the current scrap.
|
||||
/// </summary>
|
||||
private static bool IsSameCombatPairArc(InterestCandidate current, InterestCandidate next)
|
||||
{
|
||||
if (current == null || next == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(current.Key)
|
||||
&& !string.IsNullOrEmpty(next.Key)
|
||||
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
CollectCombatActorIds(current, out long curA, out long curB);
|
||||
CollectCombatActorIds(next, out long nextA, out long nextB);
|
||||
bool samePair = curA != 0
|
||||
&& curB != 0
|
||||
&& nextA != 0
|
||||
&& nextB != 0
|
||||
&& ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA));
|
||||
if (samePair)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Duel → Mass/Battle escalate: shared fighter + larger theater.
|
||||
bool nextMulti = next.ParticipantCount >= 3
|
||||
|| string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||
|| HasStickyCollectiveMulti(next);
|
||||
if (!nextMulti)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return SharesCombatFighterId(current, nextA)
|
||||
|| SharesCombatFighterId(current, nextB)
|
||||
|| SharesCombatFighterId(next, curA)
|
||||
|| SharesCombatFighterId(next, curB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold.
|
||||
/// </summary>
|
||||
private static bool IsCombatPartnerSwapNoise(InterestCandidate current, InterestCandidate next)
|
||||
{
|
||||
if (current == null
|
||||
|| next == null
|
||||
|| current.Completion != InterestCompletionKind.CombatActive
|
||||
|| next.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Multi escalate / different theater is not partner-swap noise.
|
||||
bool nextMulti = next.ParticipantCount >= 3
|
||||
|| HasStickyCollectiveMulti(next)
|
||||
|| (string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||
&& next.ParticipantCount > 2);
|
||||
if (nextMulti && !IsThinPairCombat(next))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CollectCombatActorIds(current, out long curA, out long curB);
|
||||
CollectCombatActorIds(next, out long nextA, out long nextB);
|
||||
if (curA == 0 && curB == 0
|
||||
&& current.TheaterLeadId == 0
|
||||
&& current.PairOwnerId == 0
|
||||
&& current.SubjectId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mass/Battle held on a theater lead: peer Duel with a new attack_target is noise.
|
||||
if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current))
|
||||
{
|
||||
if (IsThinPairCombat(next) && SharesCombatOwnerOrLead(current, nextA, nextB))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsThinPairCombat(next))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same unordered pair with flipped Follow (Duel A vs B ↔ B vs A) is ownership noise.
|
||||
// Same-subject refreshes may still soft-cut; A↔B tip order must hold while both live.
|
||||
bool sameUnorderedPair = curA != 0
|
||||
&& curB != 0
|
||||
&& nextA != 0
|
||||
&& nextB != 0
|
||||
&& ((curA == nextA && curB == nextB)
|
||||
|| (curA == nextB && curB == nextA));
|
||||
if (sameUnorderedPair)
|
||||
{
|
||||
long curSub = current.SubjectId != 0
|
||||
? current.SubjectId
|
||||
: (current.PairOwnerId != 0
|
||||
? current.PairOwnerId
|
||||
: EventFeedUtil.SafeId(current.FollowUnit));
|
||||
long nextSub = next.SubjectId != 0
|
||||
? next.SubjectId
|
||||
: EventFeedUtil.SafeId(next.FollowUnit);
|
||||
if (curSub != 0 && nextSub != 0 && curSub != nextSub)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shared fighter + different partner = attack_target thrash.
|
||||
return SharesCombatOwnerOrLead(current, nextA, nextB)
|
||||
|| SharesCombatFighterId(current, nextA)
|
||||
|| SharesCombatFighterId(current, nextB);
|
||||
}
|
||||
|
||||
private static bool SharesCombatOwnerOrLead(InterestCandidate current, long nextA, long nextB)
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long lead = current.TheaterLeadId != 0
|
||||
? current.TheaterLeadId
|
||||
: (current.PairOwnerId != 0 ? current.PairOwnerId : current.SubjectId);
|
||||
if (lead == 0)
|
||||
{
|
||||
lead = EventFeedUtil.SafeId(current.FollowUnit);
|
||||
}
|
||||
|
||||
return lead != 0 && (lead == nextA || lead == nextB);
|
||||
}
|
||||
|
||||
private static bool IsThinPairCombat(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c.HasCombatPairLock || c.ParticipantCount <= 2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string tip = c.Label ?? "";
|
||||
if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
||||
|| tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string key = c.Key ?? "";
|
||||
return key.StartsWith("combat:pair:", StringComparison.Ordinal)
|
||||
|| (key.StartsWith("combat:", StringComparison.Ordinal)
|
||||
&& key.IndexOf(":pair:", StringComparison.Ordinal) < 0
|
||||
&& !string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void CollectCombatActorIds(InterestCandidate c, out long a, out long b)
|
||||
{
|
||||
a = 0;
|
||||
b = 0;
|
||||
if (c == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
a = c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId;
|
||||
b = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId;
|
||||
if (a == 0)
|
||||
{
|
||||
a = EventFeedUtil.SafeId(c.FollowUnit);
|
||||
}
|
||||
|
||||
if (b == 0)
|
||||
{
|
||||
b = EventFeedUtil.SafeId(c.RelatedUnit);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SharesCombatFighterId(InterestCandidate c, long id)
|
||||
{
|
||||
if (c == null || id == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CollectCombatActorIds(c, out long a, out long b);
|
||||
return id == a || id == b;
|
||||
}
|
||||
|
||||
private static bool SameKeyPrefix(string a, string b, string prefix)
|
||||
{
|
||||
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!a.StartsWith(prefix, StringComparison.Ordinal)
|
||||
|| !b.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(a, b, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -269,7 +269,9 @@ public static class InterestRegistry
|
|||
|
||||
bool match = string.IsNullOrEmpty(needle)
|
||||
|| (c.Key != null
|
||||
&& c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
&& c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
|| (c.AssetId != null
|
||||
&& c.AssetId.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
if (match)
|
||||
{
|
||||
c.ExpiresAt = now - 0.01f;
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ public static class InterestScoring
|
|||
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
|
||||
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
|
||||
float causalBonus = CausalHeat.ScoreBonus(c);
|
||||
float ledgerBonus = CharacterLedger.ScoreBonus(c);
|
||||
float sagaBonus = LifeSagaRoster.ScoreBonus(c);
|
||||
float ownershipBonus = StoryPlanner.OwnershipBoost(c);
|
||||
c.TotalScore = c.EventStrength + scaleBonus
|
||||
+ c.CharacterSignificance * charWeight
|
||||
|
|
@ -169,7 +169,7 @@ public static class InterestScoring
|
|||
- repeatPenalty
|
||||
+ frequencyAdjust
|
||||
+ causalBonus
|
||||
+ ledgerBonus
|
||||
+ sagaBonus
|
||||
+ ownershipBonus;
|
||||
string detail =
|
||||
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
|
||||
|
|
@ -190,9 +190,9 @@ public static class InterestScoring
|
|||
detail += $" causal=+{causalBonus:0.#}";
|
||||
}
|
||||
|
||||
if (ledgerBonus > 0.05f)
|
||||
if (sagaBonus > 0.05f)
|
||||
{
|
||||
detail += $" ledger=+{ledgerBonus:0.#}";
|
||||
detail += $" saga=+{sagaBonus:0.#}";
|
||||
}
|
||||
|
||||
if (ownershipBonus > 0.05f)
|
||||
|
|
|
|||
|
|
@ -36,12 +36,53 @@ public class StoryWeights
|
|||
public int ledgerCap = 16;
|
||||
public float ledgerWeight = 8f;
|
||||
public float historyDensityWindowSeconds = 600f;
|
||||
/// <summary>Multiply subject ledger heat after a family life chapter tip is shown.</summary>
|
||||
/// <summary>Obsolete ledger bleed (CharacterLedger removed); kept for json compat.</summary>
|
||||
public float ledgerBleedLifeChapter = 0.4f;
|
||||
/// <summary>Multiply other ledger entries after a family life chapter (village cool).</summary>
|
||||
/// <summary>Obsolete ledger bleed (CharacterLedger removed); kept for json compat.</summary>
|
||||
public float ledgerBleedVillage = 0.82f;
|
||||
/// <summary>Life-saga roster soft MC tip bonus.</summary>
|
||||
public float sagaMcWeight = 6f;
|
||||
/// <summary>Extra soft bonus when the saga slot is Prefer'd.</summary>
|
||||
public float sagaPreferWeight = 10f;
|
||||
/// <summary>Extra soft bonus when the unit is a WorldBox favorite.</summary>
|
||||
public float sagaGameFavoriteWeight = 8f;
|
||||
/// <summary>Chapter starvation window before dull non-favorite MCs lose rank.</summary>
|
||||
public float sagaDullSeconds = 300f;
|
||||
/// <summary>Extra FixedDwell beat cool for family chapters (seconds, stacks with base).</summary>
|
||||
public float familyLifeBeatCooldownSeconds = 90f;
|
||||
|
||||
// --- Crisis chapters (Phase 4) ---
|
||||
/// <summary>WarFront needs at least this many participants to open a crisis chapter.</summary>
|
||||
public int crisisWarParticipantMin = 8;
|
||||
/// <summary>Disaster / outbreak EventStrength floor to open a crisis chapter.</summary>
|
||||
public float crisisDisasterStrengthMin = 95f;
|
||||
/// <summary>Score boost for tips that belong to the live crisis.</summary>
|
||||
public float crisisOwnershipBoost = 22f;
|
||||
/// <summary>Extra cut-in margin while watching a crisis tip against unrelated peers.</summary>
|
||||
public float crisisHoldMargin = 40f;
|
||||
/// <summary>Keep the chapter after the last crisis signal for this many seconds.</summary>
|
||||
public float crisisLingerSeconds = 10f;
|
||||
/// <summary>
|
||||
/// Disaster / outbreak linger after the last WorldLog pulse (tornado tips are short FixedDwell).
|
||||
/// </summary>
|
||||
public float crisisDisasterLingerSeconds = 28f;
|
||||
/// <summary>Hard cap on crisis Active phase length (seconds).</summary>
|
||||
public float crisisMaxSeconds = 180f;
|
||||
/// <summary>Do not reopen a crisis for this many seconds after Done.</summary>
|
||||
public float crisisCooldownSeconds = 45f;
|
||||
/// <summary>
|
||||
/// Longer cool after disaster/outbreak closers so stacked storm tips cannot double-close.
|
||||
/// </summary>
|
||||
public float crisisDisasterCooldownSeconds = 120f;
|
||||
/// <summary>Forced closer strength when a crisis chapter ends.</summary>
|
||||
public float crisisEpilogueStrength = 52f;
|
||||
/// <summary>Forced closer max watch (seconds).</summary>
|
||||
public float crisisEpilogueMaxWatch = 16f;
|
||||
/// <summary>
|
||||
/// After a hard story/crisis closer, suppress soft-life crumbs and character-fill
|
||||
/// for this many seconds so AFK does not immediately crumb-surf.
|
||||
/// </summary>
|
||||
public float softFillQuietSeconds = 18f;
|
||||
}
|
||||
|
||||
/// <summary>Serializable weight block — field names must match scoring-model.json "weights".</summary>
|
||||
|
|
@ -152,6 +193,15 @@ public class ScoringWeights
|
|||
public float scannerHotBonus = 18f;
|
||||
public float scannerWarmBonus = 8f;
|
||||
public float scannerSpectacleBonus = 12f;
|
||||
/// <summary>
|
||||
/// Idle spectacles (evil mage pacing, dragon roosting) keep at least this action score
|
||||
/// so warm civic chores cannot own ambient forever.
|
||||
/// </summary>
|
||||
public float scannerSpectacleIdleFloor = 48f;
|
||||
/// <summary>
|
||||
/// Prefer a live spectacle over a non-spectacle ambient pick when within this score gap.
|
||||
/// </summary>
|
||||
public float scannerSpectaclePreferMargin = 28f;
|
||||
public float scannerActionKillsFactor = 0.2f;
|
||||
public float scannerActionKillsCap = 10f;
|
||||
public float scannerWarriorBonus = 6f;
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ public static class InterestVariety
|
|||
}
|
||||
|
||||
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
|
||||
CharacterLedger.CoolAfterSoftLifeBeat(chosen);
|
||||
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -727,7 +727,33 @@ public static class InterestVariety
|
|||
return Ranked[0];
|
||||
}
|
||||
|
||||
// Probabilistic among top-N only when scores are within epsilon.
|
||||
// Near-tie: Prefer'd MC, then any MC, else random among top-N.
|
||||
InterestCandidate bestSaga = null;
|
||||
int bestRank = -1;
|
||||
for (int i = 0; i < topN; i++)
|
||||
{
|
||||
if (Ranked[0].TotalScore - Ranked[i].TotalScore >= epsilon)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int rank = LifeSagaRoster.TipTouchesPrefer(Ranked[i])
|
||||
? 2
|
||||
: LifeSagaRoster.TipTouchesMc(Ranked[i])
|
||||
? 1
|
||||
: 0;
|
||||
if (rank > bestRank)
|
||||
{
|
||||
bestRank = rank;
|
||||
bestSaga = Ranked[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSaga != null && bestRank > 0)
|
||||
{
|
||||
return bestSaga;
|
||||
}
|
||||
|
||||
int pick = Rng.Next(topN);
|
||||
return Ranked[pick];
|
||||
}
|
||||
|
|
@ -1181,6 +1207,57 @@ public static class InterestVariety
|
|||
|| s.IndexOf("favorite", StringComparison.Ordinal) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-fill quiet crumbs: soft life chapters, short interrupt FX, and ambient Pack fill.
|
||||
/// Pack must not own the camera during quiet (and no longer breaks quiet).
|
||||
/// </summary>
|
||||
public static bool IsSoftFillQuietCrumb(InterestCandidate c) =>
|
||||
c != null
|
||||
&& (c.Completion == InterestCompletionKind.FamilyPack
|
||||
|| IsSoftLifeChapter(c)
|
||||
|| IsShortInterruptFxChapter(c));
|
||||
|
||||
/// <summary>
|
||||
/// Brief interrupt FX that should not own AFK during soft-fill quiet.
|
||||
/// Kept non-outbreak; real danger statuses stay hot.
|
||||
/// </summary>
|
||||
public static bool IsShortInterruptFxChapter(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string status = !string.IsNullOrEmpty(c.StatusId) ? c.StatusId : c.AssetId;
|
||||
if (string.IsNullOrEmpty(status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c.Completion != InterestCompletionKind.StatusPhase
|
||||
&& c.Completion != InterestCompletionKind.FixedDwell
|
||||
&& string.IsNullOrEmpty(c.StatusId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsShortInterruptFxId(status);
|
||||
}
|
||||
|
||||
public static bool IsShortInterruptFxId(string statusId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(statusId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string s = statusId.Trim().ToLowerInvariant();
|
||||
return s.IndexOf("stun", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("invincible", StringComparison.Ordinal) >= 0
|
||||
|| s == "surprised"
|
||||
|| s == "confused";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft personal status FX ids from the live status inventory shape:
|
||||
/// mood / life-cycle / brief FX. Danger and combat statuses stay hotter.
|
||||
|
|
@ -1192,11 +1269,16 @@ public static class InterestVariety
|
|||
return false;
|
||||
}
|
||||
|
||||
// Short interrupt FX count as soft crumbs for quiet + variety cooling.
|
||||
if (IsShortInterruptFxId(statusId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string s = statusId.Trim().ToLowerInvariant();
|
||||
// Danger / combat / possession stay camera-sticky for repeats.
|
||||
if (s.IndexOf("burn", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("poison", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("stun", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("drown", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("starv", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("possess", StringComparison.Ordinal) >= 0
|
||||
|
|
@ -1205,7 +1287,6 @@ public static class InterestVariety
|
|||
|| s.IndexOf("rage", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("frozen", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("shield", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("invincible", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("combat", StringComparison.Ordinal) >= 0
|
||||
|| s == "on_guard"
|
||||
|| s == "angry"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ public static class SpectatorMode
|
|||
|
||||
public static bool Active { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when idle was frozen for Lore/browse without wiping StoryPlanner / registry.
|
||||
/// Resume via <see cref="SetActive"/>(true) restores that session instead of a full clear enable.
|
||||
/// </summary>
|
||||
public static bool BrowsePaused { get; private set; }
|
||||
|
||||
private static bool _forceFileChecked;
|
||||
|
||||
public static void Toggle()
|
||||
|
|
@ -19,7 +25,11 @@ public static class SpectatorMode
|
|||
SetActive(!Active);
|
||||
}
|
||||
|
||||
public static void SetActive(bool active, bool quiet = false)
|
||||
/// <param name="browsePause">
|
||||
/// When disabling: freeze story/crisis/ledger/registry for Lore browse (do not Clear).
|
||||
/// Ignored when enabling - resume uses <see cref="BrowsePaused"/>.
|
||||
/// </param>
|
||||
public static void SetActive(bool active, bool quiet = false, bool browsePause = false)
|
||||
{
|
||||
if (active && !ModSettings.Enabled)
|
||||
{
|
||||
|
|
@ -36,22 +46,45 @@ public static class SpectatorMode
|
|||
if (Active)
|
||||
{
|
||||
WatchCaption.ClearPausePin();
|
||||
InterestDirector.OnSpectatorEnabled();
|
||||
bool fromBrowse = BrowsePaused;
|
||||
BrowsePaused = false;
|
||||
if (fromBrowse)
|
||||
{
|
||||
InterestDirector.OnSpectatorBrowseResumed();
|
||||
}
|
||||
else
|
||||
{
|
||||
InterestDirector.OnSpectatorEnabled();
|
||||
}
|
||||
|
||||
SpeciesDiscovery.OnSpectatorEnabled();
|
||||
Chronicle.OnSpectatorEnabled();
|
||||
ChronicleHud.OnIdleResumed();
|
||||
FocusRelationshipArrows.OnSpectatorEnabled();
|
||||
LogService.LogInfo("[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)");
|
||||
LogService.LogInfo(
|
||||
fromBrowse
|
||||
? "[IdleSpectator] Spectator resumed after Lore/browse pause"
|
||||
: "[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)");
|
||||
}
|
||||
else
|
||||
{
|
||||
InterestDirector.OnSpectatorDisabled();
|
||||
if (browsePause)
|
||||
{
|
||||
BrowsePaused = true;
|
||||
InterestDirector.OnSpectatorBrowsePaused();
|
||||
}
|
||||
else
|
||||
{
|
||||
BrowsePaused = false;
|
||||
InterestDirector.OnSpectatorDisabled();
|
||||
}
|
||||
|
||||
SpeciesDiscovery.OnSpectatorDisabled();
|
||||
FocusRelationshipArrows.OnSpectatorDisabled();
|
||||
CameraDirector.ClearFollow();
|
||||
if (quiet)
|
||||
{
|
||||
// Manual-input pause: leave dossier up so ShowStatusBanner can reuse it.
|
||||
// Manual-input / Lore pause: leave dossier up so ShowStatusBanner can reuse it.
|
||||
StateProbe.Reset();
|
||||
}
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,295 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Small cast of watched lives. Heat from favorites, focus, CausalHeat, and Chronicle density.
|
||||
/// </summary>
|
||||
public static class CharacterLedger
|
||||
{
|
||||
private struct LedgerEntry
|
||||
{
|
||||
public long UnitId;
|
||||
public float Heat;
|
||||
public float TouchedAt;
|
||||
}
|
||||
|
||||
private static readonly List<LedgerEntry> Entries = new List<LedgerEntry>(20);
|
||||
private static readonly List<int> ScratchIdx = new List<int>(8);
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
Entries.Clear();
|
||||
}
|
||||
|
||||
public static int Count => Entries.Count;
|
||||
|
||||
public static bool IsOnLedger(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
if (Entries[i].UnitId == unitId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static float Heat(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
if (Entries[i].UnitId == unitId)
|
||||
{
|
||||
return Entries[i].Heat;
|
||||
}
|
||||
}
|
||||
|
||||
return 0f;
|
||||
}
|
||||
|
||||
public static float ScoreBonus(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
StoryWeights s = InterestScoringConfig.Story;
|
||||
float h = Heat(c.SubjectId);
|
||||
if (h <= 0f && c.RelatedId != 0)
|
||||
{
|
||||
h = Heat(c.RelatedId) * 0.5f;
|
||||
}
|
||||
|
||||
return h * s.ledgerWeight;
|
||||
}
|
||||
|
||||
public static void Touch(long unitId, float heatAdd = 1f)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
if (Entries[i].UnitId != unitId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LedgerEntry e = Entries[i];
|
||||
e.Heat = Mathf.Min(8f, e.Heat + heatAdd);
|
||||
e.TouchedAt = now;
|
||||
Entries[i] = e;
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureCapacityForNew(unitId);
|
||||
Entries.Add(new LedgerEntry
|
||||
{
|
||||
UnitId = unitId,
|
||||
Heat = Mathf.Clamp(heatAdd, 0.5f, 8f),
|
||||
TouchedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
public static void NoteFeatured(InterestCandidate scene)
|
||||
{
|
||||
if (scene == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float add = 1f;
|
||||
if (scene.FollowUnit != null && Chronicle.IsFavoriteSubject(EventFeedUtil.SafeId(scene.FollowUnit)))
|
||||
{
|
||||
add += 1.5f;
|
||||
}
|
||||
|
||||
if (InterestScoring.IsNotable(scene.FollowUnit))
|
||||
{
|
||||
add += 0.75f;
|
||||
}
|
||||
|
||||
if (scene.SubjectId != 0)
|
||||
{
|
||||
Touch(scene.SubjectId, add);
|
||||
float density = Chronicle.RecentHistoryCount(
|
||||
scene.SubjectId,
|
||||
InterestScoringConfig.Story.historyDensityWindowSeconds);
|
||||
if (density > 0)
|
||||
{
|
||||
Touch(scene.SubjectId, Mathf.Min(2f, density * 0.15f));
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.RelatedId != 0)
|
||||
{
|
||||
Touch(scene.RelatedId, add * 0.6f);
|
||||
}
|
||||
|
||||
float causal = CausalHeat.Get(scene.SubjectId);
|
||||
if (causal > 0.5f)
|
||||
{
|
||||
Touch(scene.SubjectId, Mathf.Min(1.5f, causal));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft life chapters (parenthood, intimacy) should not keep ledger heat hot enough
|
||||
/// to monopolize the next few picks for the same village cast.
|
||||
/// </summary>
|
||||
public static void CoolAfterSoftLifeBeat(InterestCandidate scene)
|
||||
{
|
||||
if (scene == null || !InterestVariety.IsSoftLifeChapter(scene))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StoryWeights s = InterestScoringConfig.Story;
|
||||
float subjectMul = s.ledgerBleedLifeChapter > 0f ? s.ledgerBleedLifeChapter : 0.4f;
|
||||
float villageMul = s.ledgerBleedVillage > 0f ? s.ledgerBleedVillage : 0.82f;
|
||||
Bleed(scene.SubjectId, subjectMul);
|
||||
if (scene.RelatedId != 0)
|
||||
{
|
||||
Bleed(scene.RelatedId, Mathf.Lerp(subjectMul, 1f, 0.35f));
|
||||
}
|
||||
|
||||
// Mild cool across the rest of the ledger so harness-featured villagers fade.
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
long id = Entries[i].UnitId;
|
||||
if (id == 0 || id == scene.SubjectId || id == scene.RelatedId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Bleed(id, villageMul);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Bleed(long unitId, float multiplyHeat)
|
||||
{
|
||||
if (unitId == 0 || multiplyHeat >= 0.999f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
multiplyHeat = Mathf.Clamp(multiplyHeat, 0.05f, 1f);
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
if (Entries[i].UnitId != unitId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LedgerEntry e = Entries[i];
|
||||
e.Heat = Mathf.Max(0.15f, e.Heat * multiplyHeat);
|
||||
Entries[i] = e;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Enumerate(List<long> into)
|
||||
{
|
||||
if (into == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
into.Clear();
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
into.Add(Entries[i].UnitId);
|
||||
}
|
||||
}
|
||||
|
||||
public static Actor PreferLedgerUnit(IEnumerable<Actor> candidates)
|
||||
{
|
||||
if (candidates == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Actor best = null;
|
||||
float bestHeat = -1f;
|
||||
foreach (Actor a in candidates)
|
||||
{
|
||||
if (a == null || !a.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long id = EventFeedUtil.SafeId(a);
|
||||
float h = Heat(id);
|
||||
if (h > bestHeat)
|
||||
{
|
||||
bestHeat = h;
|
||||
best = a;
|
||||
}
|
||||
}
|
||||
|
||||
return bestHeat > 0f ? best : null;
|
||||
}
|
||||
|
||||
private static void EnsureCapacityForNew(long incomingId)
|
||||
{
|
||||
StoryWeights s = InterestScoringConfig.Story;
|
||||
int cap = s.ledgerCap > 0 ? s.ledgerCap : 16;
|
||||
if (Entries.Count < cap)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Never evict current arc cast.
|
||||
ScratchIdx.Clear();
|
||||
int worst = -1;
|
||||
float worstHeat = float.MaxValue;
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
long id = Entries[i].UnitId;
|
||||
if (StoryPlanner.Active != null && StoryPlanner.Active.ContainsCast(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (id == incomingId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float h = Entries[i].Heat;
|
||||
if (h < worstHeat)
|
||||
{
|
||||
worstHeat = h;
|
||||
worst = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (worst >= 0)
|
||||
{
|
||||
Entries.RemoveAt(worst);
|
||||
}
|
||||
else if (Entries.Count > 0)
|
||||
{
|
||||
// All protected - drop oldest non-incoming.
|
||||
Entries.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
IdleSpectator/Story/CrisisChapter.cs
Normal file
50
IdleSpectator/Story/CrisisChapter.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Parallel multi-minute chapter over war / disaster / outbreak (not a short StoryArc).</summary>
|
||||
public enum CrisisKind
|
||||
{
|
||||
None = 0,
|
||||
War = 1,
|
||||
Disaster = 2,
|
||||
Outbreak = 3
|
||||
}
|
||||
|
||||
public enum CrisisPhase
|
||||
{
|
||||
Active = 0,
|
||||
Epilogue = 1,
|
||||
Done = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long bias overlay: ownership boost, ambient suppress, forced closer.
|
||||
/// Lives beside short <see cref="StoryArc"/> so unrelated tips do not thrash chapter state.
|
||||
/// </summary>
|
||||
public sealed class CrisisChapter
|
||||
{
|
||||
public CrisisKind Kind = CrisisKind.None;
|
||||
public CrisisPhase Phase = CrisisPhase.Done;
|
||||
public string AnchorKey = "";
|
||||
public string Id = "";
|
||||
public float StartedAt;
|
||||
public float LastSignalAt;
|
||||
public float PhaseStartedAt;
|
||||
public long FollowId;
|
||||
public bool EpilogueInjected;
|
||||
/// <summary>Unordered kingdom theater pair for war chapters (matches local Mass scraps).</summary>
|
||||
public string TheaterKeyA = "";
|
||||
public string TheaterKeyB = "";
|
||||
|
||||
public bool IsLive => Phase == CrisisPhase.Active || Phase == CrisisPhase.Epilogue;
|
||||
|
||||
public void Advance(CrisisPhase next, float now)
|
||||
{
|
||||
if (Phase == next)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Phase = next;
|
||||
PhaseStartedAt = now;
|
||||
}
|
||||
}
|
||||
1332
IdleSpectator/Story/LifeSagaMemory.cs
Normal file
1332
IdleSpectator/Story/LifeSagaMemory.cs
Normal file
File diff suppressed because it is too large
Load diff
135
IdleSpectator/Story/LifeSagaOverview.cs
Normal file
135
IdleSpectator/Story/LifeSagaOverview.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility façade over <see cref="LifeSagaPresentation"/>.
|
||||
/// Prefer <see cref="LifeSagaPresentation.Build"/> for new UI.
|
||||
/// </summary>
|
||||
public static class LifeSagaOverview
|
||||
{
|
||||
public static LifeSagaSnapshot BuildSnapshot(LifeSagaSlot slot)
|
||||
{
|
||||
var snapshot = new LifeSagaSnapshot();
|
||||
if (slot == null || slot.UnitId == 0)
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
LifeSagaViewModel model = LifeSagaPresentation.Build(slot.UnitId);
|
||||
snapshot.UnitId = model.UnitId;
|
||||
snapshot.Header = model.Name;
|
||||
snapshot.Subtitle = model.Title;
|
||||
if (!string.IsNullOrEmpty(model.StakeLine))
|
||||
{
|
||||
snapshot.Rows.Add(new LifeSagaSnapshotRow
|
||||
{
|
||||
Kind = LifeSagaSnapshotRowKind.Headline,
|
||||
Text = model.StakeLine
|
||||
});
|
||||
}
|
||||
|
||||
if (model.Cast.Count > 0)
|
||||
{
|
||||
var parts = new List<string>(model.Cast.Count);
|
||||
for (int i = 0; i < model.Cast.Count; i++)
|
||||
{
|
||||
parts.Add(model.Cast[i].Relation + " " + model.Cast[i].Name);
|
||||
}
|
||||
|
||||
snapshot.Rows.Add(new LifeSagaSnapshotRow
|
||||
{
|
||||
Kind = LifeSagaSnapshotRowKind.Circle,
|
||||
Text = string.Join(" · ", parts)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.EvidenceLine))
|
||||
{
|
||||
snapshot.Rows.Add(new LifeSagaSnapshotRow
|
||||
{
|
||||
Kind = LifeSagaSnapshotRowKind.Current,
|
||||
Text = model.EvidenceTitle + ": " + model.EvidenceLine
|
||||
});
|
||||
}
|
||||
|
||||
for (int i = 0; i < model.Legacy.Count; i++)
|
||||
{
|
||||
snapshot.Rows.Add(new LifeSagaSnapshotRow
|
||||
{
|
||||
Kind = LifeSagaSnapshotRowKind.Chapter,
|
||||
ChapterKind = ToChapterKind(model.Legacy[i].Kind),
|
||||
Text = model.Legacy[i].Line
|
||||
});
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public static string Build(LifeSagaSlot slot)
|
||||
{
|
||||
return LifeSagaPresentation.BuildPlainText(slot);
|
||||
}
|
||||
|
||||
public static float StandoutTraitScore(Actor actor)
|
||||
{
|
||||
return UnitDossier.ReadStandoutTraitNames(actor, 1).Count > 0 ? 100f : 0f;
|
||||
}
|
||||
|
||||
private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case LifeSagaFactKind.Kill:
|
||||
case LifeSagaFactKind.RivalEarned:
|
||||
case LifeSagaFactKind.HardArcCombat:
|
||||
return LifeSagaChapterKind.Combat;
|
||||
case LifeSagaFactKind.WarJoin:
|
||||
case LifeSagaFactKind.WarEnd:
|
||||
case LifeSagaFactKind.HardArcWar:
|
||||
return LifeSagaChapterKind.War;
|
||||
case LifeSagaFactKind.PlotJoin:
|
||||
case LifeSagaFactKind.PlotEnd:
|
||||
case LifeSagaFactKind.HardArcPlot:
|
||||
return LifeSagaChapterKind.Plot;
|
||||
case LifeSagaFactKind.BondFormed:
|
||||
case LifeSagaFactKind.HardArcLove:
|
||||
return LifeSagaChapterKind.Love;
|
||||
case LifeSagaFactKind.BondBroken:
|
||||
case LifeSagaFactKind.Death:
|
||||
case LifeSagaFactKind.HardArcGrief:
|
||||
return LifeSagaChapterKind.Grief;
|
||||
default:
|
||||
return LifeSagaChapterKind.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum LifeSagaSnapshotRowKind
|
||||
{
|
||||
Headline,
|
||||
Current,
|
||||
Circle,
|
||||
Qualities,
|
||||
Chapter
|
||||
}
|
||||
|
||||
public sealed class LifeSagaSnapshotRow
|
||||
{
|
||||
public LifeSagaSnapshotRowKind Kind;
|
||||
public LifeSagaChapterKind ChapterKind;
|
||||
public string Text = "";
|
||||
}
|
||||
|
||||
public sealed class LifeSagaSnapshot
|
||||
{
|
||||
public long UnitId;
|
||||
public string Header = "";
|
||||
public string Subtitle = "";
|
||||
public readonly List<LifeSagaSnapshotRow> Rows = new List<LifeSagaSnapshotRow>();
|
||||
|
||||
public string ToPlainText()
|
||||
{
|
||||
return LifeSagaPresentation.BuildPlainText(UnitId);
|
||||
}
|
||||
}
|
||||
757
IdleSpectator/Story/LifeSagaPanel.cs
Normal file
757
IdleSpectator/Story/LifeSagaPanel.cs
Normal file
|
|
@ -0,0 +1,757 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Saga body matching Design A (cast + stakes): identity, stake, cast, evidence, legacy, Open Lore.
|
||||
/// Width is fixed to the tabbed chrome so right-anchored hover cannot slide the rail.
|
||||
/// Height sizes to content (capped) so lines wrap fully instead of ellipsis.
|
||||
/// </summary>
|
||||
public static class LifeSagaPanel
|
||||
{
|
||||
/// <summary>Matches WatchCaption tabbed chrome inner width (PanelWidthMax - pads).</summary>
|
||||
public const float BodyWidthFixed = 232f;
|
||||
/// <summary>Hard cap so Saga never eats the screen.</summary>
|
||||
public const float BodyHeightMax = 148f;
|
||||
|
||||
public const float BodyWidthMin = BodyWidthFixed;
|
||||
public const float BodyWidthMax = BodyWidthFixed;
|
||||
public const float BodyHeightMin = 56f;
|
||||
public const float BodyHeightFixed = BodyHeightMax;
|
||||
|
||||
private const float Pad = 3f;
|
||||
private const float AvatarSize = 32f;
|
||||
private const float ColGap = 6f;
|
||||
private const float CastFace = 16f;
|
||||
private const float CastSlotW = 54f;
|
||||
private const float LoreH = 11f;
|
||||
private const float SecLabelH = 10f;
|
||||
private const float LineH = 10f;
|
||||
private const int NameFont = 9;
|
||||
private const int TitleFont = 7;
|
||||
private const int StakeFont = 8;
|
||||
private const int BodyFont = 7;
|
||||
private const int MaxCast = 4;
|
||||
private const int MaxLegacy = 5;
|
||||
private const int BuildVersion = 7;
|
||||
|
||||
private static GameObject _root;
|
||||
private static RectTransform _rootRt;
|
||||
private static Text _nameText;
|
||||
private static Text _titleText;
|
||||
private static Text _stakeText;
|
||||
private static Text _castHead;
|
||||
private static Text _evidenceHead;
|
||||
private static Text _evidenceText;
|
||||
private static Text _legacyHead;
|
||||
private static Text _legacyText;
|
||||
private static Button _loreBtn;
|
||||
private static Text _loreBtnLabel;
|
||||
private static readonly CastSlot[] CastSlots = new CastSlot[MaxCast];
|
||||
private static long _boundId;
|
||||
private static string _speciesId = "";
|
||||
private static string _fingerprint = "";
|
||||
private static bool _ownsAvatar;
|
||||
private static int _builtVersion;
|
||||
private static float _laidOutHeight = BodyHeightMin;
|
||||
|
||||
private sealed class CastSlot
|
||||
{
|
||||
public GameObject Root;
|
||||
public Image FaceBg;
|
||||
public Image Face;
|
||||
public Text Name;
|
||||
public Text Relation;
|
||||
public long UnitId;
|
||||
}
|
||||
|
||||
public static bool Visible => _root != null && _root.activeSelf;
|
||||
public static float BodyHeight => _laidOutHeight;
|
||||
public static float BodyWidthPx => BodyWidthFixed;
|
||||
public static long BoundUnitId => _boundId;
|
||||
public static bool OwnsAvatar => _ownsAvatar && Visible;
|
||||
public static string LastLayoutDebug { get; private set; } = "";
|
||||
|
||||
public static void EnsureBuilt(Transform parent)
|
||||
{
|
||||
if (parent == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_root != null && _builtVersion == BuildVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_root != null)
|
||||
{
|
||||
Object.Destroy(_root);
|
||||
_root = null;
|
||||
_rootRt = null;
|
||||
_nameText = null;
|
||||
_titleText = null;
|
||||
_stakeText = null;
|
||||
_castHead = null;
|
||||
_evidenceHead = null;
|
||||
_evidenceText = null;
|
||||
_legacyHead = null;
|
||||
_legacyText = null;
|
||||
_loreBtn = null;
|
||||
_loreBtnLabel = null;
|
||||
}
|
||||
|
||||
_root = new GameObject("LifeSagaPanel", typeof(RectTransform));
|
||||
_root.transform.SetParent(parent, false);
|
||||
_rootRt = _root.GetComponent<RectTransform>();
|
||||
_rootRt.pivot = new Vector2(0f, 1f);
|
||||
_rootRt.anchorMin = new Vector2(0f, 1f);
|
||||
_rootRt.anchorMax = new Vector2(0f, 1f);
|
||||
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightMin);
|
||||
|
||||
_nameText = MakeLabel(_root.transform, "SagaName", NameFont, HudTheme.ValueOrange, FontStyle.Bold);
|
||||
_titleText = MakeLabel(_root.transform, "SagaTitle", TitleFont, HudTheme.Muted, FontStyle.Normal);
|
||||
_stakeText = MakeLabel(_root.transform, "StakeBody", StakeFont, HudTheme.ValueOrange, FontStyle.Bold);
|
||||
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
_castHead = MakeLabel(_root.transform, "CastHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
|
||||
_castHead.text = "Cast";
|
||||
_evidenceHead = MakeLabel(_root.transform, "EvidenceHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
|
||||
_evidenceHead.text = "Evidence";
|
||||
_evidenceText = MakeLabel(_root.transform, "EvidenceBody", BodyFont, HudTheme.Body, FontStyle.Normal);
|
||||
_evidenceText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_evidenceText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
_legacyHead = MakeLabel(_root.transform, "LegacyHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
|
||||
_legacyHead.text = "Legacy";
|
||||
_legacyText = MakeLabel(_root.transform, "LegacyBody", BodyFont, HudTheme.Body, FontStyle.Normal);
|
||||
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_legacyText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
|
||||
for (int i = 0; i < MaxCast; i++)
|
||||
{
|
||||
CastSlots[i] = BuildCast(i);
|
||||
}
|
||||
|
||||
GameObject loreGo = new GameObject("SagaLoreBtn", typeof(RectTransform), typeof(Image), typeof(Button));
|
||||
loreGo.transform.SetParent(_root.transform, false);
|
||||
Image loreBg = loreGo.GetComponent<Image>();
|
||||
loreBg.color = new Color(0f, 0f, 0f, 0f);
|
||||
loreBg.raycastTarget = true;
|
||||
_loreBtn = loreGo.GetComponent<Button>();
|
||||
_loreBtn.targetGraphic = loreBg;
|
||||
_loreBtn.onClick.AddListener(new UnityAction(OpenLore));
|
||||
_loreBtnLabel = MakeLabel(loreGo.transform, "Label", BodyFont, HudTheme.ValueOrange, FontStyle.Normal);
|
||||
_loreBtnLabel.text = "Open Lore";
|
||||
_loreBtnLabel.alignment = TextAnchor.MiddleLeft;
|
||||
RectTransform loreRt = loreGo.GetComponent<RectTransform>();
|
||||
loreRt.pivot = new Vector2(0f, 1f);
|
||||
loreRt.anchorMin = new Vector2(0f, 1f);
|
||||
loreRt.anchorMax = new Vector2(0f, 1f);
|
||||
loreRt.sizeDelta = new Vector2(72f, LoreH);
|
||||
|
||||
_builtVersion = BuildVersion;
|
||||
_root.SetActive(false);
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
_boundId = 0;
|
||||
_speciesId = "";
|
||||
_fingerprint = "";
|
||||
_ownsAvatar = false;
|
||||
_laidOutHeight = BodyHeightMin;
|
||||
if (_root != null)
|
||||
{
|
||||
_root.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetVisible(bool visible)
|
||||
{
|
||||
if (_root == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_root.SetActive(visible);
|
||||
if (!visible)
|
||||
{
|
||||
_ownsAvatar = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Bind(LifeSagaViewModel model, bool loreInteractive)
|
||||
{
|
||||
if (_root == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (model == null || model.UnitId == 0)
|
||||
{
|
||||
BindEmptyPickState();
|
||||
return;
|
||||
}
|
||||
|
||||
string fp = Fingerprint(model, loreInteractive);
|
||||
bool same = string.Equals(fp, _fingerprint, System.StringComparison.Ordinal) && _boundId == model.UnitId;
|
||||
_fingerprint = fp;
|
||||
_boundId = model.UnitId;
|
||||
_speciesId = model.SpeciesId ?? "";
|
||||
|
||||
if (!same)
|
||||
{
|
||||
_nameText.text = StripStar(model.Name);
|
||||
_titleText.text = model.Title ?? "";
|
||||
_stakeText.text = model.StakeLine ?? "";
|
||||
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
{
|
||||
if (i < model.Cast.Count && model.Cast[i] != null)
|
||||
{
|
||||
BindCast(CastSlots[i], model.Cast[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
CastSlots[i].Root.SetActive(false);
|
||||
CastSlots[i].UnitId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_evidenceText.text = FormatEvidence(model);
|
||||
|
||||
var legacyParts = new List<string>(MaxLegacy);
|
||||
var seen = new HashSet<string>();
|
||||
string stakeKey = (_stakeText.text ?? "").Trim().ToLowerInvariant();
|
||||
for (int i = 0; i < model.Legacy.Count && legacyParts.Count < MaxLegacy; i++)
|
||||
{
|
||||
if (model.Legacy[i] == null || string.IsNullOrEmpty(model.Legacy[i].Line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string line = model.Legacy[i].Line.Trim();
|
||||
string key = line.ToLowerInvariant();
|
||||
if (!string.IsNullOrEmpty(stakeKey)
|
||||
&& string.Equals(key, stakeKey, System.StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
legacyParts.Add(line);
|
||||
}
|
||||
|
||||
_legacyText.text = legacyParts.Count > 0 ? string.Join(" · ", legacyParts) : "";
|
||||
}
|
||||
|
||||
if (_loreBtn != null)
|
||||
{
|
||||
_loreBtn.gameObject.SetActive(loreInteractive);
|
||||
_loreBtn.interactable = loreInteractive;
|
||||
}
|
||||
|
||||
RefreshAvatar(model);
|
||||
Layout();
|
||||
_root.SetActive(true);
|
||||
}
|
||||
|
||||
public static float Place(float y, float padX)
|
||||
{
|
||||
if (!Visible || _rootRt == null)
|
||||
{
|
||||
return y;
|
||||
}
|
||||
|
||||
_rootRt.anchoredPosition = new Vector2(padX, -y);
|
||||
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight);
|
||||
if (_ownsAvatar)
|
||||
{
|
||||
DossierAvatar.Place(padX + Pad, y + Pad, AvatarSize);
|
||||
DossierAvatar.DrawBehind(_root.transform);
|
||||
DossierAvatar.ForceCompactFrame();
|
||||
}
|
||||
|
||||
return y + _laidOutHeight + 2f;
|
||||
}
|
||||
|
||||
private static string FormatEvidence(LifeSagaViewModel model)
|
||||
{
|
||||
if (model == null || string.IsNullOrEmpty(model.EvidenceLine))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(96);
|
||||
if (!string.IsNullOrEmpty(model.EvidenceTitle))
|
||||
{
|
||||
sb.Append(model.EvidenceTitle).Append(" · ");
|
||||
}
|
||||
|
||||
sb.Append(model.EvidenceLine);
|
||||
if (!string.IsNullOrEmpty(model.SecondaryEvidenceLine))
|
||||
{
|
||||
sb.Append(" · ");
|
||||
if (!string.IsNullOrEmpty(model.SecondaryEvidenceTitle))
|
||||
{
|
||||
sb.Append(model.SecondaryEvidenceTitle).Append(" · ");
|
||||
}
|
||||
|
||||
sb.Append(model.SecondaryEvidenceLine);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void RefreshAvatar(LifeSagaViewModel model)
|
||||
{
|
||||
if (model == null || model.UnitId == 0)
|
||||
{
|
||||
_ownsAvatar = false;
|
||||
return;
|
||||
}
|
||||
|
||||
DossierAvatar.EnsureHost(_root.transform.parent, AvatarSize);
|
||||
DossierAvatar.SetHostVisible(true);
|
||||
Actor live = EventFeedUtil.FindAliveById(model.UnitId);
|
||||
if (live != null && live.isAlive())
|
||||
{
|
||||
DossierAvatar.Show(live);
|
||||
DossierAvatar.ForceCompactFrame();
|
||||
}
|
||||
else
|
||||
{
|
||||
DossierAvatar.ShowSpecies(model.SpeciesId);
|
||||
}
|
||||
|
||||
_ownsAvatar = true;
|
||||
}
|
||||
|
||||
private static void Layout()
|
||||
{
|
||||
float colX = Pad + AvatarSize + ColGap;
|
||||
float textW = BodyWidthFixed - colX - Pad;
|
||||
float y = Pad;
|
||||
|
||||
PlaceText(_nameText, colX, y, textW, NameFont + 2f);
|
||||
y += NameFont + 2f;
|
||||
if (!string.IsNullOrEmpty(_titleText.text))
|
||||
{
|
||||
PlaceText(_titleText, colX, y, textW, LineH);
|
||||
y += LineH;
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide(_titleText);
|
||||
}
|
||||
|
||||
y += 2f;
|
||||
bool hasStake = !string.IsNullOrEmpty(_stakeText.text);
|
||||
if (hasStake)
|
||||
{
|
||||
float stakeH = Mathf.Clamp(
|
||||
EstimateWrapHeight(_stakeText.text, textW, StakeFont),
|
||||
LineH,
|
||||
LineH * 3f);
|
||||
PlaceText(_stakeText, colX, y, textW, stakeH);
|
||||
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
y += stakeH + 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide(_stakeText);
|
||||
}
|
||||
|
||||
int castN = 0;
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
{
|
||||
if (CastSlots[i].Root.activeSelf)
|
||||
{
|
||||
castN++;
|
||||
}
|
||||
}
|
||||
|
||||
if (castN > 0)
|
||||
{
|
||||
PlaceText(_castHead, colX, y, textW, SecLabelH);
|
||||
y += SecLabelH;
|
||||
float castX = colX;
|
||||
float castRowH = CastFace + 18f;
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
{
|
||||
if (!CastSlots[i].Root.activeSelf)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
RectTransform rt = CastSlots[i].Root.GetComponent<RectTransform>();
|
||||
rt.anchoredPosition = new Vector2(castX, -y);
|
||||
rt.sizeDelta = new Vector2(CastSlotW, castRowH);
|
||||
CastSlots[i].Root.transform.SetAsLastSibling();
|
||||
castX += CastSlotW + 2f;
|
||||
}
|
||||
|
||||
y += castRowH + 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide(_castHead);
|
||||
}
|
||||
|
||||
bool hasEvidence = !string.IsNullOrEmpty(_evidenceText.text);
|
||||
bool hasLegacy = !string.IsNullOrEmpty(_legacyText.text);
|
||||
bool loreActive = _loreBtn != null && _loreBtn.gameObject.activeSelf;
|
||||
float loreReserve = loreActive ? LoreH + 2f : 0f;
|
||||
float yMax = BodyHeightMax - Pad - loreReserve;
|
||||
|
||||
if (hasEvidence && y + SecLabelH + LineH <= yMax)
|
||||
{
|
||||
_evidenceHead.text = "Evidence";
|
||||
PlaceText(_evidenceHead, colX, y, textW, SecLabelH);
|
||||
y += SecLabelH;
|
||||
float evidenceH = Mathf.Clamp(
|
||||
EstimateWrapHeight(_evidenceText.text, textW, BodyFont),
|
||||
LineH,
|
||||
LineH * 3f);
|
||||
evidenceH = Mathf.Min(evidenceH, Mathf.Max(LineH, yMax - y - (hasLegacy ? SecLabelH + LineH : 0f)));
|
||||
PlaceText(_evidenceText, colX, y, textW, evidenceH);
|
||||
_evidenceText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_evidenceText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
y += evidenceH + 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide(_evidenceHead);
|
||||
Hide(_evidenceText);
|
||||
}
|
||||
|
||||
if (hasLegacy && y + SecLabelH + LineH <= yMax)
|
||||
{
|
||||
PlaceText(_legacyHead, colX, y, textW, SecLabelH);
|
||||
y += SecLabelH;
|
||||
float legacyH = Mathf.Clamp(
|
||||
EstimateWrapHeight(_legacyText.text, textW, BodyFont),
|
||||
LineH,
|
||||
LineH * 3f);
|
||||
legacyH = Mathf.Min(legacyH, Mathf.Max(LineH, yMax - y));
|
||||
PlaceText(_legacyText, colX, y, textW, legacyH);
|
||||
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_legacyText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
y += legacyH + 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide(_legacyHead);
|
||||
Hide(_legacyText);
|
||||
}
|
||||
|
||||
if (loreActive)
|
||||
{
|
||||
y = Mathf.Max(y, AvatarSize + Pad + 2f);
|
||||
RectTransform loreRt = _loreBtn.GetComponent<RectTransform>();
|
||||
loreRt.anchoredPosition = new Vector2(colX, -y);
|
||||
loreRt.sizeDelta = new Vector2(72f, LoreH);
|
||||
_loreBtn.transform.SetAsLastSibling();
|
||||
if (_loreBtnLabel != null)
|
||||
{
|
||||
RectTransform labelRt = _loreBtnLabel.rectTransform;
|
||||
labelRt.anchoredPosition = Vector2.zero;
|
||||
labelRt.sizeDelta = new Vector2(72f, LoreH);
|
||||
}
|
||||
|
||||
y += LoreH;
|
||||
}
|
||||
|
||||
y = Mathf.Max(y + Pad, AvatarSize + Pad * 2f);
|
||||
_laidOutHeight = Mathf.Clamp(y, BodyHeightMin, BodyHeightMax);
|
||||
|
||||
if (_rootRt != null)
|
||||
{
|
||||
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight);
|
||||
}
|
||||
|
||||
LastLayoutDebug =
|
||||
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} name='{_nameText?.text}' stake='{Short(_stakeText?.text)}' cast={castN} evidence={(hasEvidence ? 1 : 0)} legacy={(hasLegacy ? 1 : 0)}";
|
||||
}
|
||||
|
||||
private static float EstimateWrapHeight(string text, float width, float fontSize)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return LineH;
|
||||
}
|
||||
|
||||
float charsPerLine = Mathf.Max(8f, width / Mathf.Max(3.8f, fontSize * 0.55f));
|
||||
int lines = Mathf.Max(1, Mathf.CeilToInt(text.Length / charsPerLine));
|
||||
return lines * (fontSize + 2f);
|
||||
}
|
||||
|
||||
private static CastSlot BuildCast(int index)
|
||||
{
|
||||
GameObject root = new GameObject("Cast" + index, typeof(RectTransform));
|
||||
root.transform.SetParent(_root.transform, false);
|
||||
RectTransform rt = root.GetComponent<RectTransform>();
|
||||
rt.pivot = new Vector2(0f, 1f);
|
||||
rt.anchorMin = new Vector2(0f, 1f);
|
||||
rt.anchorMax = new Vector2(0f, 1f);
|
||||
|
||||
GameObject bgGo = new GameObject("FaceBg", typeof(RectTransform), typeof(Image));
|
||||
bgGo.transform.SetParent(root.transform, false);
|
||||
RectTransform bgRt = bgGo.GetComponent<RectTransform>();
|
||||
bgRt.pivot = new Vector2(0f, 1f);
|
||||
bgRt.anchorMin = new Vector2(0f, 1f);
|
||||
bgRt.anchorMax = new Vector2(0f, 1f);
|
||||
bgRt.anchoredPosition = Vector2.zero;
|
||||
bgRt.sizeDelta = new Vector2(CastFace, CastFace);
|
||||
Image bg = bgGo.GetComponent<Image>();
|
||||
bg.color = HudTheme.InsetFill;
|
||||
bg.raycastTarget = false;
|
||||
|
||||
GameObject faceGo = new GameObject("Face", typeof(RectTransform), typeof(Image));
|
||||
faceGo.transform.SetParent(root.transform, false);
|
||||
RectTransform faceRt = faceGo.GetComponent<RectTransform>();
|
||||
faceRt.pivot = new Vector2(0.5f, 0.5f);
|
||||
faceRt.anchorMin = new Vector2(0f, 1f);
|
||||
faceRt.anchorMax = new Vector2(0f, 1f);
|
||||
faceRt.anchoredPosition = new Vector2(CastFace * 0.5f, -CastFace * 0.5f);
|
||||
faceRt.sizeDelta = new Vector2(CastFace - 2f, CastFace - 2f);
|
||||
Image face = faceGo.GetComponent<Image>();
|
||||
face.color = Color.white;
|
||||
face.preserveAspect = true;
|
||||
face.raycastTarget = false;
|
||||
|
||||
Text name = MakeLabel(root.transform, "Name", BodyFont, HudTheme.Body, FontStyle.Bold);
|
||||
name.alignment = TextAnchor.UpperLeft;
|
||||
RectTransform nameRt = name.rectTransform;
|
||||
nameRt.anchoredPosition = new Vector2(CastFace + 2f, 0f);
|
||||
nameRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
|
||||
|
||||
Text relation = MakeLabel(root.transform, "Relation", BodyFont, HudTheme.Muted, FontStyle.Normal);
|
||||
relation.alignment = TextAnchor.UpperLeft;
|
||||
RectTransform relRt = relation.rectTransform;
|
||||
relRt.anchoredPosition = new Vector2(CastFace + 2f, -9f);
|
||||
relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
|
||||
|
||||
root.SetActive(false);
|
||||
return new CastSlot
|
||||
{
|
||||
Root = root,
|
||||
FaceBg = bg,
|
||||
Face = face,
|
||||
Name = name,
|
||||
Relation = relation
|
||||
};
|
||||
}
|
||||
|
||||
private static void BindCast(CastSlot slot, LifeSagaCastMember member)
|
||||
{
|
||||
slot.UnitId = member.UnitId;
|
||||
slot.Name.text = FirstNonEmpty(member.Name, "Someone");
|
||||
slot.Relation.text = FirstNonEmpty(member.Relation, "Linked");
|
||||
|
||||
Actor live = member.Alive ? EventFeedUtil.FindAliveById(member.UnitId) : null;
|
||||
Sprite sprite = HudIcons.FromActorRailFace(live, member.SpeciesId)
|
||||
?? HudIcons.FromSpeciesId(member.SpeciesId)
|
||||
?? HudIcons.FromUiIcon("iconCitizen");
|
||||
slot.Face.sprite = sprite;
|
||||
slot.Face.enabled = sprite != null;
|
||||
slot.Face.color = sprite != null ? Color.white : new Color(0.35f, 0.36f, 0.4f, 1f);
|
||||
slot.Root.SetActive(true);
|
||||
}
|
||||
|
||||
private static void BindEmptyPickState()
|
||||
{
|
||||
_boundId = 0;
|
||||
_speciesId = "";
|
||||
_fingerprint = "empty-pick";
|
||||
_ownsAvatar = false;
|
||||
if (_nameText != null)
|
||||
{
|
||||
_nameText.text = "Pick a life";
|
||||
}
|
||||
|
||||
if (_titleText != null)
|
||||
{
|
||||
_titleText.text = "from the rail";
|
||||
}
|
||||
|
||||
if (_stakeText != null)
|
||||
{
|
||||
_stakeText.text = "Hover or click a glyph to read their saga.";
|
||||
}
|
||||
|
||||
if (_evidenceText != null)
|
||||
{
|
||||
_evidenceText.text = "";
|
||||
}
|
||||
|
||||
if (_legacyText != null)
|
||||
{
|
||||
_legacyText.text = "";
|
||||
}
|
||||
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
{
|
||||
if (CastSlots[i]?.Root != null)
|
||||
{
|
||||
CastSlots[i].Root.SetActive(false);
|
||||
CastSlots[i].UnitId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (_loreBtn != null)
|
||||
{
|
||||
_loreBtn.gameObject.SetActive(false);
|
||||
_loreBtn.interactable = false;
|
||||
}
|
||||
|
||||
DossierAvatar.SetHostVisible(false);
|
||||
Layout();
|
||||
if (_root != null)
|
||||
{
|
||||
_root.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Harness: invoke Open Lore for the bound saga life.</summary>
|
||||
public static void HarnessOpenLore()
|
||||
{
|
||||
OpenLore();
|
||||
}
|
||||
|
||||
private static void OpenLore()
|
||||
{
|
||||
if (_boundId == 0 || LifeSagaViewController.IsHoverPreview)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaViewController.StashLoreReturn(_boundId);
|
||||
ChronicleHud.OpenUnitHistory(_boundId, pauseIdle: true, followFocus: false);
|
||||
}
|
||||
|
||||
private static Text MakeLabel(Transform parent, string name, int size, Color color, FontStyle style)
|
||||
{
|
||||
int fitSize = size < 7 ? 7 : size;
|
||||
Text text = HudCanvas.MakeText(parent, name, "", fitSize);
|
||||
text.color = color;
|
||||
text.fontStyle = style;
|
||||
text.fontSize = fitSize;
|
||||
text.resizeTextForBestFit = false;
|
||||
text.alignment = TextAnchor.UpperLeft;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
text.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
text.raycastTarget = false;
|
||||
RectTransform rt = text.rectTransform;
|
||||
rt.pivot = new Vector2(0f, 1f);
|
||||
rt.anchorMin = new Vector2(0f, 1f);
|
||||
rt.anchorMax = new Vector2(0f, 1f);
|
||||
return text;
|
||||
}
|
||||
|
||||
private static void PlaceText(Text text, float x, float y, float w, float h)
|
||||
{
|
||||
if (text == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
text.resizeTextForBestFit = false;
|
||||
text.enabled = true;
|
||||
Color c = text.color;
|
||||
if (c.a < 0.99f)
|
||||
{
|
||||
c.a = 1f;
|
||||
text.color = c;
|
||||
}
|
||||
|
||||
RectTransform rt = text.rectTransform;
|
||||
rt.anchoredPosition = new Vector2(x, -y);
|
||||
rt.sizeDelta = new Vector2(w, h);
|
||||
text.gameObject.SetActive(!string.IsNullOrEmpty(text.text));
|
||||
}
|
||||
|
||||
private static void Hide(Text text)
|
||||
{
|
||||
if (text != null)
|
||||
{
|
||||
text.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Fingerprint(LifeSagaViewModel model, bool loreInteractive)
|
||||
{
|
||||
var sb = new StringBuilder(192);
|
||||
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.Title);
|
||||
sb.Append('|').Append(model.StakeLine).Append('|').Append(model.EvidenceLine);
|
||||
sb.Append('|').Append(model.SecondaryEvidenceLine);
|
||||
sb.Append('|').Append(loreInteractive ? '1' : '0');
|
||||
for (int i = 0; i < model.Cast.Count; i++)
|
||||
{
|
||||
var c = model.Cast[i];
|
||||
if (c == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append('|').Append(c.UnitId).Append(':').Append(c.Relation).Append(':').Append(c.Name);
|
||||
}
|
||||
|
||||
for (int i = 0; i < model.Legacy.Count; i++)
|
||||
{
|
||||
if (model.Legacy[i] != null)
|
||||
{
|
||||
sb.Append('#').Append(model.Legacy[i].Line);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string StripStar(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return name.StartsWith("★ ") ? name.Substring(2) : name;
|
||||
}
|
||||
|
||||
private static string FirstNonEmpty(params string[] parts)
|
||||
{
|
||||
if (parts == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(parts[i]))
|
||||
{
|
||||
return parts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string Short(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return s.Length <= 28 ? s : s.Substring(0, 27) + "…";
|
||||
}
|
||||
}
|
||||
871
IdleSpectator/Story/LifeSagaPresentation.cs
Normal file
871
IdleSpectator/Story/LifeSagaPresentation.cs
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Adaptive saga presentation model. Consumes live actor state + LifeSagaMemory.
|
||||
/// No task/status/trait/Chronicle dumps.
|
||||
/// </summary>
|
||||
public static class LifeSagaPresentation
|
||||
{
|
||||
private static bool _heirProbeDone;
|
||||
private static bool _heirApiAvailable;
|
||||
private static MethodInfo _heirMethod;
|
||||
private static object _heirTargetSample;
|
||||
|
||||
public static LifeSagaViewModel Build(long unitId)
|
||||
{
|
||||
var model = new LifeSagaViewModel { UnitId = unitId };
|
||||
if (unitId == 0)
|
||||
{
|
||||
return model;
|
||||
}
|
||||
|
||||
LifeSagaSlot slot = LifeSagaRoster.Get(unitId);
|
||||
Actor actor = EventFeedUtil.FindAliveById(unitId);
|
||||
LifeSagaLifeMemory memory = LifeSagaMemory.Get(unitId);
|
||||
|
||||
string name = FirstNonEmpty(
|
||||
slot?.DisplayName,
|
||||
memory?.Identity.Name,
|
||||
SafeName(actor),
|
||||
"Someone");
|
||||
model.Name = name;
|
||||
if (slot != null && (slot.Prefer || slot.GameFavorite))
|
||||
{
|
||||
model.Name = "★ " + name;
|
||||
}
|
||||
|
||||
model.Title = BuildTitle(slot, actor, memory);
|
||||
model.SpeciesId = FirstNonEmpty(
|
||||
slot?.SpeciesId,
|
||||
memory?.Identity.SpeciesId,
|
||||
actor != null && actor.asset != null ? actor.asset.id : "");
|
||||
ResolveLenses(slot, actor, memory, out LifeSagaLens primary, out LifeSagaLens secondary);
|
||||
model.PrimaryLens = primary;
|
||||
model.SecondaryLens = secondary;
|
||||
|
||||
LifeSagaFact stake = LifeSagaMemory.StrongestUnresolved(unitId);
|
||||
model.StakeLine = stake != null
|
||||
? LifeSagaMemory.FormatFactLine(stake)
|
||||
: BuildFallbackStake(slot, actor, memory);
|
||||
model.StakeProvenance = stake != null ? "observed" : (string.IsNullOrEmpty(model.StakeLine) ? "" : "is");
|
||||
|
||||
BuildCast(model, slot, actor, memory, primary);
|
||||
BuildEvidence(model, primary, secondary, slot, actor, memory);
|
||||
BuildLegacy(model, unitId, stake);
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>Compatibility plain text for harness diagnostics.</summary>
|
||||
public static string BuildPlainText(long unitId)
|
||||
{
|
||||
return Build(unitId).ToPlainText();
|
||||
}
|
||||
|
||||
public static string BuildPlainText(LifeSagaSlot slot)
|
||||
{
|
||||
return Build(slot?.UnitId ?? 0).ToPlainText();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live probe: only report an heir when a game-authored succession API is discoverable.
|
||||
/// </summary>
|
||||
public static bool TryGetLikelySuccessor(Actor actor, out Actor heir, out string evidence)
|
||||
{
|
||||
heir = null;
|
||||
evidence = "";
|
||||
EnsureHeirProbe();
|
||||
if (!_heirApiAvailable || actor == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
object clan = actor.GetType().GetProperty("clan")?.GetValue(actor, null)
|
||||
?? actor.GetType().GetField("clan")?.GetValue(actor);
|
||||
if (clan == null || _heirMethod == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
object result = null;
|
||||
if (_heirMethod.GetParameters().Length == 0)
|
||||
{
|
||||
result = _heirMethod.Invoke(clan, null);
|
||||
}
|
||||
else if (_heirMethod.GetParameters().Length == 1)
|
||||
{
|
||||
result = _heirMethod.Invoke(clan, new object[] { actor });
|
||||
}
|
||||
|
||||
heir = result as Actor;
|
||||
if (heir != null && heir.isAlive() && heir != actor)
|
||||
{
|
||||
evidence = "clan_succession";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// omit rather than guess
|
||||
}
|
||||
|
||||
heir = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool HeirApiAvailable
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureHeirProbe();
|
||||
return _heirApiAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureHeirProbe()
|
||||
{
|
||||
if (_heirProbeDone)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_heirProbeDone = true;
|
||||
try
|
||||
{
|
||||
Type clanType = typeof(Actor).Assembly.GetType("Clan")
|
||||
?? Type.GetType("Clan, Assembly-CSharp");
|
||||
if (clanType == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] names =
|
||||
{
|
||||
"findNextHeir", "getNextHeir", "getHeir", "findHeir", "calculateHeir", "getSuccessor"
|
||||
};
|
||||
for (int i = 0; i < names.Length; i++)
|
||||
{
|
||||
MethodInfo m = clanType.GetMethod(
|
||||
names[i],
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
if (m != null && (typeof(Actor).IsAssignableFrom(m.ReturnType) || m.ReturnType == typeof(object)))
|
||||
{
|
||||
_heirMethod = m;
|
||||
_heirApiAvailable = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Culture succession helpers.
|
||||
Type cultureType = typeof(Actor).Assembly.GetType("Culture");
|
||||
if (cultureType != null)
|
||||
{
|
||||
MethodInfo m = cultureType.GetMethod(
|
||||
"findNextHeir",
|
||||
BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
if (m != null)
|
||||
{
|
||||
_heirMethod = m;
|
||||
_heirApiAvailable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_heirApiAvailable = false;
|
||||
}
|
||||
|
||||
_ = _heirTargetSample;
|
||||
}
|
||||
|
||||
private static void ResolveLenses(
|
||||
LifeSagaSlot slot,
|
||||
Actor actor,
|
||||
LifeSagaLifeMemory memory,
|
||||
out LifeSagaLens primary,
|
||||
out LifeSagaLens secondary)
|
||||
{
|
||||
var scores = new Dictionary<LifeSagaLens, float>();
|
||||
void Add(LifeSagaLens lens, float amount)
|
||||
{
|
||||
if (lens == LifeSagaLens.None || amount <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scores.TryGetValue(lens, out float cur))
|
||||
{
|
||||
cur = 0f;
|
||||
}
|
||||
|
||||
scores[lens] = cur + amount;
|
||||
}
|
||||
|
||||
if (slot != null)
|
||||
{
|
||||
if (slot.IsKing || slot.IsClanChief || slot.IsLeader)
|
||||
{
|
||||
Add(LifeSagaLens.CrownClan, 40f);
|
||||
}
|
||||
|
||||
if (slot.IsAlpha)
|
||||
{
|
||||
Add(LifeSagaLens.PackAlpha, 38f);
|
||||
}
|
||||
|
||||
if (slot.IsPlotAuthor)
|
||||
{
|
||||
Add(LifeSagaLens.Plotter, 36f);
|
||||
}
|
||||
|
||||
if (slot.IsFounder)
|
||||
{
|
||||
Add(LifeSagaLens.FounderWanderer, 30f);
|
||||
}
|
||||
|
||||
if (slot.IsSingleton)
|
||||
{
|
||||
Add(LifeSagaLens.LastOfKind, 34f);
|
||||
}
|
||||
|
||||
if (slot.Kills > 0 || slot.IsArmyCaptain)
|
||||
{
|
||||
Add(LifeSagaLens.WarriorKiller, Mathf.Min(35f, 12f + slot.Kills * 0.4f));
|
||||
}
|
||||
|
||||
switch (slot.AdmissionReason)
|
||||
{
|
||||
case LifeSagaAdmissionReason.King:
|
||||
case LifeSagaAdmissionReason.ClanChief:
|
||||
case LifeSagaAdmissionReason.CityLeader:
|
||||
Add(LifeSagaLens.CrownClan, 8f);
|
||||
break;
|
||||
case LifeSagaAdmissionReason.Alpha:
|
||||
Add(LifeSagaLens.PackAlpha, 8f);
|
||||
break;
|
||||
case LifeSagaAdmissionReason.PlotAuthor:
|
||||
Add(LifeSagaLens.Plotter, 8f);
|
||||
break;
|
||||
case LifeSagaAdmissionReason.Founder:
|
||||
Add(LifeSagaLens.FounderWanderer, 8f);
|
||||
break;
|
||||
case LifeSagaAdmissionReason.Singleton:
|
||||
Add(LifeSagaLens.LastOfKind, 8f);
|
||||
break;
|
||||
case LifeSagaAdmissionReason.NotableKills:
|
||||
Add(LifeSagaLens.WarriorKiller, 8f);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (memory != null)
|
||||
{
|
||||
for (int i = 0; i < memory.Facts.Count; i++)
|
||||
{
|
||||
LifeSagaFact f = memory.Facts[i];
|
||||
if (f == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (f.Kind)
|
||||
{
|
||||
case LifeSagaFactKind.BondFormed:
|
||||
case LifeSagaFactKind.BondBroken:
|
||||
case LifeSagaFactKind.HardArcLove:
|
||||
case LifeSagaFactKind.HardArcGrief:
|
||||
Add(LifeSagaLens.LoveGrief, 12f);
|
||||
break;
|
||||
case LifeSagaFactKind.Kill:
|
||||
case LifeSagaFactKind.RivalEarned:
|
||||
case LifeSagaFactKind.HardArcCombat:
|
||||
Add(LifeSagaLens.WarriorKiller, 14f);
|
||||
break;
|
||||
case LifeSagaFactKind.WarJoin:
|
||||
case LifeSagaFactKind.HardArcWar:
|
||||
Add(LifeSagaLens.CrownClan, 10f);
|
||||
Add(LifeSagaLens.WarriorKiller, 8f);
|
||||
break;
|
||||
case LifeSagaFactKind.PlotJoin:
|
||||
case LifeSagaFactKind.HardArcPlot:
|
||||
Add(LifeSagaLens.Plotter, 16f);
|
||||
break;
|
||||
case LifeSagaFactKind.Founding:
|
||||
case LifeSagaFactKind.HomeGained:
|
||||
case LifeSagaFactKind.HomeLost:
|
||||
Add(LifeSagaLens.FounderWanderer, 12f);
|
||||
break;
|
||||
case LifeSagaFactKind.RoleChange:
|
||||
if ((f.Note ?? "").IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
Add(LifeSagaLens.PackAlpha, 14f);
|
||||
}
|
||||
else
|
||||
{
|
||||
Add(LifeSagaLens.CrownClan, 12f);
|
||||
}
|
||||
|
||||
break;
|
||||
case LifeSagaFactKind.ParentChild:
|
||||
Add(LifeSagaLens.PackAlpha, 6f);
|
||||
Add(LifeSagaLens.LoveGrief, 4f);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (actor != null && LifeSagaRoster.IsPackAlpha(actor))
|
||||
{
|
||||
Add(LifeSagaLens.PackAlpha, 10f);
|
||||
}
|
||||
|
||||
primary = LifeSagaLens.FounderWanderer;
|
||||
secondary = LifeSagaLens.None;
|
||||
float best = -1f;
|
||||
float second = -1f;
|
||||
foreach (KeyValuePair<LifeSagaLens, float> kv in scores)
|
||||
{
|
||||
if (kv.Value > best)
|
||||
{
|
||||
second = best;
|
||||
secondary = primary;
|
||||
best = kv.Value;
|
||||
primary = kv.Key;
|
||||
}
|
||||
else if (kv.Value > second)
|
||||
{
|
||||
second = kv.Value;
|
||||
secondary = kv.Key;
|
||||
}
|
||||
}
|
||||
|
||||
if (secondary == primary || second < best * 0.45f)
|
||||
{
|
||||
secondary = LifeSagaLens.None;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildTitle(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
|
||||
{
|
||||
if (slot != null)
|
||||
{
|
||||
if (slot.IsKing)
|
||||
{
|
||||
string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
|
||||
return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom;
|
||||
}
|
||||
|
||||
if (slot.IsClanChief) return "Clan chief";
|
||||
if (slot.IsLeader)
|
||||
{
|
||||
string city = FirstNonEmpty(slot.CityKey, CityName(actor));
|
||||
return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city;
|
||||
}
|
||||
|
||||
if (slot.IsAlpha) return "Pack alpha";
|
||||
if (slot.IsArmyCaptain) return "Army captain";
|
||||
if (slot.IsFounder) return "Founder";
|
||||
if (slot.IsPlotAuthor) return "Plotter";
|
||||
if (slot.IsSingleton) return "Last of their kind";
|
||||
}
|
||||
|
||||
if (memory != null)
|
||||
{
|
||||
for (int i = 0; i < memory.Facts.Count; i++)
|
||||
{
|
||||
if (memory.Facts[i]?.Kind == LifeSagaFactKind.Founding)
|
||||
{
|
||||
return "Founder";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "A life in motion";
|
||||
}
|
||||
|
||||
private static string BuildFallbackStake(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
|
||||
{
|
||||
if (LifeSagaMemory.TryGetEarnedRival(slot?.UnitId ?? 0, out LifeSagaFact rival))
|
||||
{
|
||||
return LifeSagaMemory.FormatFactLine(rival);
|
||||
}
|
||||
|
||||
if (slot != null)
|
||||
{
|
||||
switch (slot.AdmissionReason)
|
||||
{
|
||||
case LifeSagaAdmissionReason.King:
|
||||
return "Carries a kingdom's fate";
|
||||
case LifeSagaAdmissionReason.Alpha:
|
||||
return "Holds the pack together";
|
||||
case LifeSagaAdmissionReason.PlotAuthor:
|
||||
return "A scheme is still unfolding";
|
||||
case LifeSagaAdmissionReason.Singleton:
|
||||
return "Their lineage hangs by a thread";
|
||||
case LifeSagaAdmissionReason.Founder:
|
||||
return "What they built still needs defending";
|
||||
}
|
||||
}
|
||||
|
||||
if (actor != null && TryGetLikelySuccessor(actor, out Actor heir, out _))
|
||||
{
|
||||
return "Succession points toward " + SafeName(heir);
|
||||
}
|
||||
|
||||
return memory != null && memory.Facts.Count > 0
|
||||
? LifeSagaMemory.FormatFactLine(memory.Facts[0])
|
||||
: "";
|
||||
}
|
||||
|
||||
private static void BuildCast(
|
||||
LifeSagaViewModel model,
|
||||
LifeSagaSlot slot,
|
||||
Actor actor,
|
||||
LifeSagaLifeMemory memory,
|
||||
LifeSagaLens primary)
|
||||
{
|
||||
var seen = new HashSet<long>();
|
||||
long selfId = model.UnitId;
|
||||
seen.Add(selfId);
|
||||
|
||||
void AddLive(Actor other, string label, string truth)
|
||||
{
|
||||
if (other == null || model.Cast.Count >= 4)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long id = EventFeedUtil.SafeId(other);
|
||||
if (id == 0 || !seen.Add(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
model.Cast.Add(new LifeSagaCastMember
|
||||
{
|
||||
UnitId = id,
|
||||
Name = SafeName(other),
|
||||
SpeciesId = other.asset != null ? other.asset.id : "",
|
||||
Relation = label,
|
||||
Truth = truth,
|
||||
Alive = true
|
||||
});
|
||||
}
|
||||
|
||||
void AddObserved(LifeSagaIdentity identity, string label, string truth)
|
||||
{
|
||||
if (identity.Id == 0 || model.Cast.Count >= 4 || !seen.Add(identity.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool alive = EventFeedUtil.FindAliveById(identity.Id) != null;
|
||||
model.Cast.Add(new LifeSagaCastMember
|
||||
{
|
||||
UnitId = identity.Id,
|
||||
Name = FirstNonEmpty(identity.Name, "Someone"),
|
||||
SpeciesId = identity.SpeciesId ?? "",
|
||||
Relation = label,
|
||||
Truth = truth,
|
||||
Alive = alive
|
||||
});
|
||||
}
|
||||
|
||||
if (actor != null)
|
||||
{
|
||||
AddLive(ActorRelation.GetLover(actor), "Lover", "is");
|
||||
AddLive(ActorRelation.GetBestFriend(actor), "Best friend", "is");
|
||||
foreach (Actor parent in ActorRelation.EnumerateParents(actor, 2))
|
||||
{
|
||||
AddLive(parent, "Parent", "is");
|
||||
}
|
||||
|
||||
foreach (Actor child in ActorRelation.EnumerateChildren(actor, 2))
|
||||
{
|
||||
AddLive(child, "Child", "is");
|
||||
}
|
||||
|
||||
if (TryGetLikelySuccessor(actor, out Actor heir, out _))
|
||||
{
|
||||
AddLive(heir, "Likely successor", "likely");
|
||||
}
|
||||
}
|
||||
|
||||
if (LifeSagaMemory.TryGetEarnedRival(model.UnitId, out LifeSagaFact rivalFact))
|
||||
{
|
||||
Actor liveRival = EventFeedUtil.FindAliveById(rivalFact.OtherId);
|
||||
if (liveRival != null)
|
||||
{
|
||||
AddLive(liveRival, "Earned rival", "observed");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddObserved(rivalFact.Other, "Earned rival", "observed");
|
||||
}
|
||||
}
|
||||
|
||||
// Former bonds from memory when live cast still has room.
|
||||
if (memory != null && model.Cast.Count < 4)
|
||||
{
|
||||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++)
|
||||
{
|
||||
LifeSagaFact f = memory.Facts[i];
|
||||
if (f == null || f.OtherId == 0 || seen.Contains(f.OtherId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (f.Kind == LifeSagaFactKind.BondBroken)
|
||||
{
|
||||
AddObserved(f.Other, "Lost lover", "observed");
|
||||
}
|
||||
else if (f.Kind == LifeSagaFactKind.ParentChild && EventFeedUtil.FindAliveById(f.OtherId) == null)
|
||||
{
|
||||
AddObserved(f.Other, "Child", "observed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pack / kin peers fill remaining slots for pack-shaped and family-shaped lenses.
|
||||
if (actor != null
|
||||
&& model.Cast.Count < 4
|
||||
&& (primary == LifeSagaLens.PackAlpha || primary == LifeSagaLens.LoveGrief))
|
||||
{
|
||||
string peerLabel = primary == LifeSagaLens.PackAlpha ? "Packmate" : "Kin";
|
||||
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: 8))
|
||||
{
|
||||
if (peer == null || peer == actor)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddLive(peer, peerLabel, "is");
|
||||
if (model.Cast.Count >= 4)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = slot;
|
||||
}
|
||||
|
||||
private static void BuildEvidence(
|
||||
LifeSagaViewModel model,
|
||||
LifeSagaLens primary,
|
||||
LifeSagaLens secondary,
|
||||
LifeSagaSlot slot,
|
||||
Actor actor,
|
||||
LifeSagaLifeMemory memory)
|
||||
{
|
||||
model.EvidenceTitle = EvidenceTitle(primary);
|
||||
model.EvidenceLine = EvidenceLine(primary, slot, actor, memory);
|
||||
if (secondary != LifeSagaLens.None)
|
||||
{
|
||||
model.SecondaryEvidenceTitle = EvidenceTitle(secondary);
|
||||
model.SecondaryEvidenceLine = EvidenceLine(secondary, slot, actor, memory);
|
||||
}
|
||||
}
|
||||
|
||||
private static string EvidenceTitle(LifeSagaLens lens)
|
||||
{
|
||||
switch (lens)
|
||||
{
|
||||
case LifeSagaLens.CrownClan: return "Realm";
|
||||
case LifeSagaLens.PackAlpha: return "Pack";
|
||||
case LifeSagaLens.WarriorKiller: return "Combat";
|
||||
case LifeSagaLens.Plotter: return "Intrigue";
|
||||
case LifeSagaLens.FounderWanderer: return "Journey";
|
||||
case LifeSagaLens.LoveGrief: return "Bond";
|
||||
case LifeSagaLens.LastOfKind: return "Survival";
|
||||
default: return "Evidence";
|
||||
}
|
||||
}
|
||||
|
||||
private static string EvidenceLine(
|
||||
LifeSagaLens lens,
|
||||
LifeSagaSlot slot,
|
||||
Actor actor,
|
||||
LifeSagaLifeMemory memory)
|
||||
{
|
||||
switch (lens)
|
||||
{
|
||||
case LifeSagaLens.CrownClan:
|
||||
{
|
||||
string realm = FirstNonEmpty(slot?.KingdomKey, KingdomName(actor));
|
||||
int wars = CountFacts(memory, LifeSagaFactKind.WarJoin);
|
||||
if (!string.IsNullOrEmpty(realm) && wars > 0)
|
||||
{
|
||||
return realm + " · " + wars + " war" + (wars == 1 ? "" : "s") + " observed";
|
||||
}
|
||||
|
||||
return FirstNonEmpty(realm, "Holding power");
|
||||
}
|
||||
case LifeSagaLens.PackAlpha:
|
||||
{
|
||||
int children = CountFacts(memory, LifeSagaFactKind.ParentChild);
|
||||
return children > 0
|
||||
? "Pack line with " + children + " recorded offspring"
|
||||
: "Leads the family pack";
|
||||
}
|
||||
case LifeSagaLens.WarriorKiller:
|
||||
{
|
||||
int kills = slot?.Kills ?? 0;
|
||||
int encounters = CountFacts(memory, LifeSagaFactKind.CombatEncounter)
|
||||
+ CountFacts(memory, LifeSagaFactKind.Kill);
|
||||
if (kills > 0)
|
||||
{
|
||||
return kills + " kills · " + encounters + " remembered clashes";
|
||||
}
|
||||
|
||||
return encounters > 0 ? encounters + " remembered clashes" : "A fighter's reputation";
|
||||
}
|
||||
case LifeSagaLens.Plotter:
|
||||
{
|
||||
int plots = CountFacts(memory, LifeSagaFactKind.PlotJoin);
|
||||
return plots > 0 ? plots + " plot thread" + (plots == 1 ? "" : "s") : "Scheming still";
|
||||
}
|
||||
case LifeSagaLens.FounderWanderer:
|
||||
{
|
||||
int founded = CountFacts(memory, LifeSagaFactKind.Founding);
|
||||
return founded > 0 ? "Founded " + founded + " lasting place" + (founded == 1 ? "" : "s")
|
||||
: FirstNonEmpty(CityName(actor), "Building a place in the world");
|
||||
}
|
||||
case LifeSagaLens.LoveGrief:
|
||||
{
|
||||
int bonds = CountFacts(memory, LifeSagaFactKind.BondFormed);
|
||||
int losses = CountFacts(memory, LifeSagaFactKind.BondBroken);
|
||||
if (losses > 0)
|
||||
{
|
||||
return "Carrying " + losses + " observed loss" + (losses == 1 ? "" : "es");
|
||||
}
|
||||
|
||||
return bonds > 0 ? "Bound " + bonds + " time" + (bonds == 1 ? "" : "s") : "A life shaped by attachment";
|
||||
}
|
||||
case LifeSagaLens.LastOfKind:
|
||||
return "The last living thread of their kind";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildLegacy(LifeSagaViewModel model, long unitId, LifeSagaFact stake)
|
||||
{
|
||||
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 5);
|
||||
var seenLines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < facts.Count; i++)
|
||||
{
|
||||
LifeSagaFact f = facts[i];
|
||||
if (f == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stake already owns the strongest unresolved beat - do not repeat it in Legacy.
|
||||
if (stake != null
|
||||
&& ((!string.IsNullOrEmpty(stake.Key)
|
||||
&& string.Equals(stake.Key, f.Key, StringComparison.Ordinal))
|
||||
|| (stake.Kind == f.Kind && stake.Other.Id != 0 && stake.Other.Id == f.Other.Id)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string line = LifeSagaMemory.FormatFactLine(f);
|
||||
if (string.IsNullOrEmpty(line)
|
||||
|| string.Equals(line, model.StakeLine, StringComparison.OrdinalIgnoreCase)
|
||||
|| !seenLines.Add(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
model.Legacy.Add(new LifeSagaLegacyBeat
|
||||
{
|
||||
Kind = f.Kind,
|
||||
Line = line,
|
||||
Truth = f.Resolved ? "observed" : "observed",
|
||||
At = f.At,
|
||||
DateLabel = f.DateLabel ?? ""
|
||||
});
|
||||
|
||||
if (model.Legacy.Count >= 4)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int CountFacts(LifeSagaLifeMemory memory, LifeSagaFactKind kind)
|
||||
{
|
||||
if (memory == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int n = 0;
|
||||
for (int i = 0; i < memory.Facts.Count; i++)
|
||||
{
|
||||
if (memory.Facts[i] != null && memory.Facts[i].Kind == kind)
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
private static string KingdomName(Actor actor)
|
||||
{
|
||||
try { return actor?.kingdom?.name ?? ""; }
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
private static string CityName(Actor actor)
|
||||
{
|
||||
try { return actor?.city?.name ?? ""; }
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
private static string SafeName(Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
string name = actor?.getName();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return actor?.asset?.id ?? "";
|
||||
}
|
||||
|
||||
private static string FirstNonEmpty(params string[] values)
|
||||
{
|
||||
if (values == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(values[i]))
|
||||
{
|
||||
return values[i];
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public enum LifeSagaLens
|
||||
{
|
||||
None,
|
||||
CrownClan,
|
||||
PackAlpha,
|
||||
WarriorKiller,
|
||||
Plotter,
|
||||
FounderWanderer,
|
||||
LoveGrief,
|
||||
LastOfKind
|
||||
}
|
||||
|
||||
public sealed class LifeSagaCastMember
|
||||
{
|
||||
public long UnitId;
|
||||
public string Name = "";
|
||||
public string SpeciesId = "";
|
||||
public string Relation = "";
|
||||
public string Truth = "is";
|
||||
public bool Alive;
|
||||
}
|
||||
|
||||
public sealed class LifeSagaLegacyBeat
|
||||
{
|
||||
public LifeSagaFactKind Kind;
|
||||
public string Line = "";
|
||||
public string Truth = "observed";
|
||||
public float At;
|
||||
public string DateLabel = "";
|
||||
}
|
||||
|
||||
public sealed class LifeSagaViewModel
|
||||
{
|
||||
public long UnitId;
|
||||
public string Name = "";
|
||||
public string Title = "";
|
||||
public string SpeciesId = "";
|
||||
public string StakeLine = "";
|
||||
public string StakeProvenance = "";
|
||||
public LifeSagaLens PrimaryLens;
|
||||
public LifeSagaLens SecondaryLens;
|
||||
public string EvidenceTitle = "";
|
||||
public string EvidenceLine = "";
|
||||
public string SecondaryEvidenceTitle = "";
|
||||
public string SecondaryEvidenceLine = "";
|
||||
public readonly List<LifeSagaCastMember> Cast = new List<LifeSagaCastMember>(4);
|
||||
public readonly List<LifeSagaLegacyBeat> Legacy = new List<LifeSagaLegacyBeat>(5);
|
||||
|
||||
public string ToPlainText()
|
||||
{
|
||||
var sb = new StringBuilder(320);
|
||||
if (!string.IsNullOrEmpty(Name))
|
||||
{
|
||||
sb.Append(Name);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
if (sb.Length > 0) sb.Append('\n');
|
||||
sb.Append(Title);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(StakeLine))
|
||||
{
|
||||
if (sb.Length > 0) sb.Append('\n');
|
||||
sb.Append("At stake ").Append(StakeLine);
|
||||
}
|
||||
|
||||
if (Cast.Count > 0)
|
||||
{
|
||||
if (sb.Length > 0) sb.Append('\n');
|
||||
sb.Append("Cast ");
|
||||
for (int i = 0; i < Cast.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(" · ");
|
||||
sb.Append(Cast[i].Relation).Append(' ').Append(Cast[i].Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(EvidenceLine))
|
||||
{
|
||||
if (sb.Length > 0) sb.Append('\n');
|
||||
sb.Append(EvidenceTitle).Append(" ").Append(EvidenceLine);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(SecondaryEvidenceLine))
|
||||
{
|
||||
if (sb.Length > 0) sb.Append('\n');
|
||||
sb.Append(SecondaryEvidenceTitle).Append(" ").Append(SecondaryEvidenceLine);
|
||||
}
|
||||
|
||||
for (int i = 0; i < Legacy.Count; i++)
|
||||
{
|
||||
if (sb.Length > 0) sb.Append('\n');
|
||||
sb.Append("Legacy ").Append(Legacy[i].Line);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
605
IdleSpectator/Story/LifeSagaRail.cs
Normal file
605
IdleSpectator/Story/LifeSagaRail.cs
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
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 under the tabs.
|
||||
/// Click toggles Prefer (and pins Saga subject while on Saga). Hover previews.
|
||||
/// </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 = 0.28f;
|
||||
|
||||
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(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;
|
||||
|
||||
/// <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: 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.</summary>
|
||||
public static bool IsPointerOverRail()
|
||||
{
|
||||
if (_row == null || !_row.activeInHierarchy || _rowRt == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (RectTransformUtility.RectangleContainsScreenPoint(_rowRt, Input.mousePosition, null))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public static int LastShownCount { 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;
|
||||
LastPreferPipVisible = false;
|
||||
LastHoverRowCount = 0;
|
||||
LastHoverHeight = 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;
|
||||
LifeSagaRoster.Tick(now);
|
||||
SlotScratch.Clear();
|
||||
LifeSagaRoster.CopySlots(SlotScratch);
|
||||
string fp = Fingerprint(SlotScratch);
|
||||
bool structureChanged = !string.Equals(fp, _fingerprint, StringComparison.Ordinal)
|
||||
|| LifeSagaRoster.Dirty;
|
||||
bool facesDue = now >= _nextFaceRefreshAt;
|
||||
if (!structureChanged && !facesDue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (structureChanged)
|
||||
{
|
||||
LifeSagaRoster.Dirty = false;
|
||||
_fingerprint = fp;
|
||||
LastShownCount = 0;
|
||||
LastTipSample = "";
|
||||
LastActiveHighlight = false;
|
||||
LastPreferPipVisible = false;
|
||||
long watchId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
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 prefer = saga.Prefer || saga.GameFavorite;
|
||||
slot.UnitId = saga.UnitId;
|
||||
ApplyFace(slot, saga);
|
||||
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 (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);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < Slots.Length && i < SlotScratch.Count; i++)
|
||||
{
|
||||
if (Slots[i] == null || !Slots[i].Root.activeSelf || SlotScratch[i] == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplyFace(Slots[i], SlotScratch[i]);
|
||||
}
|
||||
}
|
||||
|
||||
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 under the tabs (stable top chrome).
|
||||
/// 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 >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaViewController.BeginHoverPreview(Slots[index].UnitId);
|
||||
LastHoverHeight = LifeSagaPanel.BodyHeight;
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (slot == null || saga == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor actor = saga.Resolve();
|
||||
Sprite sprite = HudIcons.FromActorRailFace(actor, saga.SpeciesId);
|
||||
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 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);
|
||||
}
|
||||
|
||||
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);
|
||||
if (LifeSagaViewController.SelectedTab == LifeSagaViewController.Tab.Saga
|
||||
|| (LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
|
||||
&& !LifeSagaViewController.IsHoverPreview))
|
||||
{
|
||||
LifeSagaViewController.SetPersistentSaga(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 string Fingerprint(List<LifeSagaSlot> board)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
long watchId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
sb.Append(watchId).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();
|
||||
}
|
||||
}
|
||||
17
IdleSpectator/Story/LifeSagaRailHover.cs
Normal file
17
IdleSpectator/Story/LifeSagaRailHover.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Pointer hover bridge for <see cref="LifeSagaRail"/> slots.</summary>
|
||||
public sealed class LifeSagaRailHover : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
|
||||
{
|
||||
public int Index;
|
||||
|
||||
public void OnPointerEnter(PointerEventData eventData) => LifeSagaRail.ShowHover(Index);
|
||||
|
||||
public void OnPointerExit(PointerEventData eventData) => LifeSagaRail.HideHover();
|
||||
|
||||
// Relayout may briefly deactivate slots; do not cancel sticky hover here.
|
||||
// PointerExit + ViewController grace own the leave path; Clear() owns teardown.
|
||||
}
|
||||
1908
IdleSpectator/Story/LifeSagaRoster.cs
Normal file
1908
IdleSpectator/Story/LifeSagaRoster.cs
Normal file
File diff suppressed because it is too large
Load diff
317
IdleSpectator/Story/LifeSagaViewController.cs
Normal file
317
IdleSpectator/Story/LifeSagaViewController.cs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Owns Dossier/Saga tab selection, hover preview, and camera read-pause lease.
|
||||
/// </summary>
|
||||
public static class LifeSagaViewController
|
||||
{
|
||||
public const string ReadPauseOwner = "saga_hover";
|
||||
public const float HoverExitGraceSeconds = 0.35f;
|
||||
|
||||
public enum Tab
|
||||
{
|
||||
Dossier = 0,
|
||||
Saga = 1
|
||||
}
|
||||
|
||||
private static Tab _selectedTab = Tab.Dossier;
|
||||
private static long _persistentSagaId;
|
||||
private static bool _persistentPinned;
|
||||
private static long _hoverPreviewId;
|
||||
private static Tab _tabBeforeHover = Tab.Dossier;
|
||||
private static float _hoverExitAt = -1f;
|
||||
private static bool _pauseHeld;
|
||||
private static bool _loreReturnToSaga;
|
||||
private static long _loreReturnSagaId;
|
||||
|
||||
public static Tab SelectedTab => _selectedTab;
|
||||
public static long PersistentSagaId => _persistentSagaId;
|
||||
public static long HoverPreviewId => _hoverPreviewId;
|
||||
public static bool IsHoverPreview => _hoverPreviewId != 0;
|
||||
|
||||
public static Tab EffectiveTab =>
|
||||
_hoverPreviewId != 0 ? Tab.Saga : _selectedTab;
|
||||
|
||||
public static long EffectiveSagaId
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hoverPreviewId != 0)
|
||||
{
|
||||
return _hoverPreviewId;
|
||||
}
|
||||
|
||||
return ResolvePersistentSagaId();
|
||||
}
|
||||
}
|
||||
|
||||
public static void SelectTab(Tab tab)
|
||||
{
|
||||
// Explicit tab click wins over hover preview restore.
|
||||
if (_hoverPreviewId != 0 || _pauseHeld)
|
||||
{
|
||||
CancelPreviewAndPause();
|
||||
_tabBeforeHover = tab;
|
||||
}
|
||||
|
||||
_selectedTab = tab;
|
||||
if (tab == Tab.Saga)
|
||||
{
|
||||
long watch = WatchedMcId();
|
||||
if (watch != 0)
|
||||
{
|
||||
_persistentSagaId = watch;
|
||||
_persistentPinned = true;
|
||||
}
|
||||
else if (!IsValidMc(_persistentSagaId) || !_persistentPinned)
|
||||
{
|
||||
_persistentSagaId = 0;
|
||||
_persistentPinned = false;
|
||||
}
|
||||
}
|
||||
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
public static void SetPersistentSaga(long unitId)
|
||||
{
|
||||
if (unitId == 0 || !LifeSagaRoster.IsMc(unitId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_persistentSagaId = unitId;
|
||||
_persistentPinned = true;
|
||||
}
|
||||
|
||||
/// <summary>Stash Saga return target before opening Lore from the Saga panel.</summary>
|
||||
public static void StashLoreReturn(long unitId)
|
||||
{
|
||||
_loreReturnToSaga = true;
|
||||
_loreReturnSagaId = unitId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After Lore closes, restore Saga tab + subject when a stash is present.
|
||||
/// Returns true when a restore was applied.
|
||||
/// </summary>
|
||||
public static bool TryRestoreAfterLore()
|
||||
{
|
||||
if (!_loreReturnToSaga)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long id = _loreReturnSagaId;
|
||||
_loreReturnToSaga = false;
|
||||
_loreReturnSagaId = 0;
|
||||
|
||||
CancelPreviewAndPause();
|
||||
_selectedTab = Tab.Saga;
|
||||
_tabBeforeHover = Tab.Saga;
|
||||
if (IsValidMc(id))
|
||||
{
|
||||
_persistentSagaId = id;
|
||||
_persistentPinned = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
long watch = WatchedMcId();
|
||||
if (watch != 0)
|
||||
{
|
||||
_persistentSagaId = watch;
|
||||
_persistentPinned = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_persistentSagaId = 0;
|
||||
_persistentPinned = false;
|
||||
}
|
||||
}
|
||||
|
||||
WatchCaption.RequestRelayout();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void BeginHoverPreview(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hoverExitAt = -1f;
|
||||
if (_hoverPreviewId == 0)
|
||||
{
|
||||
_tabBeforeHover = _selectedTab;
|
||||
}
|
||||
|
||||
_hoverPreviewId = unitId;
|
||||
if (!_pauseHeld)
|
||||
{
|
||||
InterestDirector.AcquireReadPause(ReadPauseOwner);
|
||||
_pauseHeld = true;
|
||||
}
|
||||
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
public static void EndHoverPreview()
|
||||
{
|
||||
if (_hoverPreviewId == 0 && !_pauseHeld)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
|
||||
}
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
if (_hoverPreviewId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep preview alive while the pointer stays on the dossier/rail chrome.
|
||||
if (WatchCaption.IsPointerOverPanel() || LifeSagaRail.IsPointerOverRail())
|
||||
{
|
||||
_hoverExitAt = -1f;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pointer left chrome after a panel dwell cleared the timer - re-arm grace.
|
||||
if (_hoverExitAt < 0f)
|
||||
{
|
||||
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Time.unscaledTime < _hoverExitAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FinishHoverExit();
|
||||
}
|
||||
|
||||
public static void CancelPreviewAndPause()
|
||||
{
|
||||
_hoverExitAt = -1f;
|
||||
_hoverPreviewId = 0;
|
||||
if (_pauseHeld)
|
||||
{
|
||||
InterestDirector.ReleaseReadPause(ReadPauseOwner);
|
||||
_pauseHeld = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
CancelPreviewAndPause();
|
||||
_selectedTab = Tab.Dossier;
|
||||
_persistentSagaId = 0;
|
||||
_persistentPinned = false;
|
||||
_tabBeforeHover = Tab.Dossier;
|
||||
_loreReturnToSaga = false;
|
||||
_loreReturnSagaId = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harness: simulate Tick clearing the exit timer while the pointer dwells on the panel.
|
||||
/// </summary>
|
||||
public static void HarnessSimulatePanelDwell()
|
||||
{
|
||||
if (_hoverPreviewId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hoverExitAt = -1f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harness: run one hover Tick as if the pointer is off rail and panel chrome.
|
||||
/// </summary>
|
||||
public static void HarnessTickExitAwayFromChrome()
|
||||
{
|
||||
if (_hoverPreviewId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hoverExitAt < 0f)
|
||||
{
|
||||
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Time.unscaledTime < _hoverExitAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FinishHoverExit();
|
||||
}
|
||||
|
||||
public static LifeSagaViewModel BuildEffectivePresentation()
|
||||
{
|
||||
long id = EffectiveSagaId;
|
||||
return id != 0 ? LifeSagaPresentation.Build(id) : new LifeSagaViewModel();
|
||||
}
|
||||
|
||||
private static void FinishHoverExit()
|
||||
{
|
||||
_hoverExitAt = -1f;
|
||||
_hoverPreviewId = 0;
|
||||
_selectedTab = _tabBeforeHover;
|
||||
if (_pauseHeld)
|
||||
{
|
||||
InterestDirector.ReleaseReadPause(ReadPauseOwner);
|
||||
_pauseHeld = false;
|
||||
}
|
||||
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watched MC wins when on roster. Otherwise only an explicitly pinned alive MC.
|
||||
/// Never falls back to first-roster / stale last-active filler.
|
||||
/// </summary>
|
||||
private static long ResolvePersistentSagaId()
|
||||
{
|
||||
long watch = WatchedMcId();
|
||||
if (watch != 0)
|
||||
{
|
||||
_persistentSagaId = watch;
|
||||
_persistentPinned = true;
|
||||
return watch;
|
||||
}
|
||||
|
||||
if (_persistentPinned && IsValidMc(_persistentSagaId))
|
||||
{
|
||||
return _persistentSagaId;
|
||||
}
|
||||
|
||||
_persistentSagaId = 0;
|
||||
_persistentPinned = false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool IsValidMc(long unitId)
|
||||
{
|
||||
return unitId != 0
|
||||
&& LifeSagaRoster.IsMc(unitId)
|
||||
&& EventFeedUtil.FindAliveById(unitId) != null;
|
||||
}
|
||||
|
||||
private static long WatchedMcId()
|
||||
{
|
||||
long watchId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
return watchId != 0 && LifeSagaRoster.IsMc(watchId) ? watchId : 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -32,8 +32,15 @@ public sealed class StoryArc
|
|||
public StoryPhase Phase = StoryPhase.None;
|
||||
public string AnchorKey = "";
|
||||
public string ClimaxKey = "";
|
||||
/// <summary>
|
||||
/// Short cast/theater identity for the dossier rail (e.g. "Evil Mage vs Lona").
|
||||
/// Not Kind/Phase taxonomy - who the story is about.
|
||||
/// </summary>
|
||||
public string Headline = "";
|
||||
public float StartedAt;
|
||||
public float PhaseStartedAt;
|
||||
/// <summary>When parked off-camera (0 = currently watching / never parked).</summary>
|
||||
public float ParkedAt;
|
||||
/// <summary>True when this arc uses hard hold margins (not soft anonymous duels).</summary>
|
||||
public bool HardHold;
|
||||
public readonly List<long> CastIds = new List<long>(4);
|
||||
|
|
@ -42,6 +49,8 @@ public sealed class StoryArc
|
|||
public bool IsActive =>
|
||||
Phase != StoryPhase.None && Phase != StoryPhase.Done;
|
||||
|
||||
public bool IsParked => ParkedAt > 0f && IsActive;
|
||||
|
||||
public bool ContainsCast(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@ public static class StoryReason
|
|||
public const string AftermathPlotFallout = "aftermath_plot_fallout";
|
||||
public const string AftermathLoveLinger = "aftermath_love_linger";
|
||||
public const string EpilogueRelated = "epilogue_related";
|
||||
public const string EpilogueCrisis = "epilogue_crisis";
|
||||
|
||||
public static bool IsStoryAsset(string assetId)
|
||||
{
|
||||
|
|
@ -21,6 +22,11 @@ public static class StoryReason
|
|||
|| assetId.StartsWith("epilogue_", System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsCrisisEpilogue(string assetId)
|
||||
{
|
||||
return string.Equals(assetId, EpilogueCrisis, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string AftermathLabel(string assetId, Actor follow, Actor related)
|
||||
{
|
||||
string name = EventFeedUtil.SafeName(follow);
|
||||
|
|
@ -49,10 +55,12 @@ public static class StoryReason
|
|||
return string.IsNullOrEmpty(other)
|
||||
? name + " carries on after the story"
|
||||
: name + " seeks " + other + " after the story";
|
||||
case EpilogueCrisis:
|
||||
return name + " surveys the aftermath of the crisis";
|
||||
case AftermathSurvivor:
|
||||
default:
|
||||
// Only name a dead theater partner. Living lover/friend fallbacks must never
|
||||
// print as the fallen (soak: "stands over Clemond" after a duel vs someone else).
|
||||
// Only name a dead theater partner. Never claim "the fallen" while the partner lives
|
||||
// (soak: Norron vs Nerari attack gaps minted false survivor tips).
|
||||
if (related != null)
|
||||
{
|
||||
bool dead = false;
|
||||
|
|
@ -69,6 +77,11 @@ public static class StoryReason
|
|||
{
|
||||
return name + " stands over " + other;
|
||||
}
|
||||
|
||||
if (!dead)
|
||||
{
|
||||
return name + " catches their breath";
|
||||
}
|
||||
}
|
||||
|
||||
return name + " stands over the fallen";
|
||||
|
|
|
|||
|
|
@ -229,6 +229,68 @@ public sealed class UnitDossier
|
|||
FillTopStatuses(dossier, actor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Highest-value live status using the same scalable ranking as dossier chips.
|
||||
/// </summary>
|
||||
public static string ReadTopStatus(Actor actor)
|
||||
{
|
||||
var scratch = new UnitDossier();
|
||||
FillTopStatuses(scratch, actor);
|
||||
return scratch.TopStatuses.Count > 0
|
||||
? scratch.TopStatuses[0]?.Name ?? ""
|
||||
: "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interesting, non-species-default traits using the dossier's live trait ranking.
|
||||
/// </summary>
|
||||
public static List<string> ReadStandoutTraitNames(Actor actor, int max, int minScore = 80)
|
||||
{
|
||||
var names = new List<string>(Math.Max(0, max));
|
||||
if (actor == null || max <= 0)
|
||||
{
|
||||
return names;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ranked = new List<ActorTrait>();
|
||||
if (!actor.hasTraits())
|
||||
{
|
||||
return names;
|
||||
}
|
||||
|
||||
foreach (ActorTrait trait in actor.getTraits())
|
||||
{
|
||||
if (trait == null
|
||||
|| string.IsNullOrEmpty(trait.id)
|
||||
|| IsDefaultTraitForActor(trait, actor)
|
||||
|| TraitInterestScore(trait, actor) < minScore)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ranked.Add(trait);
|
||||
}
|
||||
|
||||
ranked.Sort((a, b) => TraitInterestScore(b, actor).CompareTo(TraitInterestScore(a, actor)));
|
||||
for (int i = 0; i < ranked.Count && names.Count < max; i++)
|
||||
{
|
||||
string name = TraitDisplayName(ranked[i]);
|
||||
if (!string.IsNullOrEmpty(name) && !names.Contains(name))
|
||||
{
|
||||
names.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Exotic actors may not expose a normal trait collection.
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
public bool ContainsIgnoreCase(string needle)
|
||||
{
|
||||
if (string.IsNullOrEmpty(needle))
|
||||
|
|
@ -1290,8 +1352,18 @@ public sealed class UnitDossier
|
|||
private static string BuildDetailLine(UnitDossier d)
|
||||
{
|
||||
// Compact text fallback for logs/harness: task + optional kingdom.
|
||||
// Story/crisis FixedDwell reasons own the orange line - do not append combat
|
||||
// task crumbs that read like a stale "Fighting · Kingdom" spine.
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (!string.IsNullOrEmpty(d.TaskText))
|
||||
InterestCandidate watch = InterestDirector.CurrentCandidate;
|
||||
bool suppressCombatTask = StoryPlanner.IsStoryOwnedTip(watch)
|
||||
|| (!string.IsNullOrEmpty(d.ReasonLine)
|
||||
&& (d.ReasonLine.IndexOf("surveys the aftermath",
|
||||
System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| d.ReasonLine.IndexOf("stands over",
|
||||
System.StringComparison.OrdinalIgnoreCase) >= 0));
|
||||
if (!string.IsNullOrEmpty(d.TaskText)
|
||||
&& !(suppressCombatTask && IsCombatishTask(d.TaskText)))
|
||||
{
|
||||
sb.Append(d.TaskText);
|
||||
}
|
||||
|
|
@ -1322,6 +1394,21 @@ public sealed class UnitDossier
|
|||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool IsCombatishTask(string task)
|
||||
{
|
||||
if (string.IsNullOrEmpty(task))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string t = task.Trim();
|
||||
return t.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Attack", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Battle", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Hunt", System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static void FillSex(UnitDossier d, Actor actor)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,13 +11,17 @@ namespace IdleSpectator;
|
|||
/// </summary>
|
||||
public static class WorldActivityScanner
|
||||
{
|
||||
private static readonly HashSet<string> SpectacleAssets = new HashSet<string>
|
||||
/// <summary>Seed ids - live actor_library discovery extends this each refresh.</summary>
|
||||
private static readonly string[] SpectacleAssetSeed =
|
||||
{
|
||||
"dragon",
|
||||
"greg",
|
||||
"demon",
|
||||
"evil_mage",
|
||||
"white_mage",
|
||||
"necromancer",
|
||||
"plague_doctor",
|
||||
"druid",
|
||||
"ufo",
|
||||
"cold_one",
|
||||
"walker",
|
||||
|
|
@ -31,9 +35,31 @@ public static class WorldActivityScanner
|
|||
"skeleton",
|
||||
"zombie",
|
||||
"ghost",
|
||||
"mush"
|
||||
"mush",
|
||||
"crabzilla"
|
||||
};
|
||||
|
||||
private static readonly string[] SpectacleIdTokens =
|
||||
{
|
||||
"mage",
|
||||
"necromancer",
|
||||
"druid",
|
||||
"plague_doctor",
|
||||
"dragon",
|
||||
"demon",
|
||||
"cold_one",
|
||||
"tumor",
|
||||
"bioblob",
|
||||
"assimilator",
|
||||
"crabzilla",
|
||||
"fire_elemental",
|
||||
"god_finger"
|
||||
};
|
||||
|
||||
private static HashSet<string> _spectacleAssetIds;
|
||||
private static float _spectacleIdsCachedAt = -999f;
|
||||
private const float SpectacleIdsCacheSeconds = 30f;
|
||||
|
||||
private static readonly System.Random Rng = new System.Random();
|
||||
private static float _lastScanAt = -999f;
|
||||
private static InterestEvent _cachedBest;
|
||||
|
|
@ -105,11 +131,8 @@ public static class WorldActivityScanner
|
|||
continue;
|
||||
}
|
||||
|
||||
if (battle.getDeathsTotal() > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deaths alone are not live combat - finished battle tiles near a cold unit
|
||||
// used to pin CombatStillActive forever.
|
||||
if (LiveEnsemble.TryBuildCombat(bpos, 14f, out LiveEnsemble near)
|
||||
&& near != null
|
||||
&& near.ParticipantCount >= 1)
|
||||
|
|
@ -312,10 +335,21 @@ public static class WorldActivityScanner
|
|||
private static void ScanNow()
|
||||
{
|
||||
_cachedBattle = BuildHottestBattle();
|
||||
// Busy war maps: skip O(units) character scoring when a real fight already won.
|
||||
// Busy war maps: skip full O(units) scoring, but still consider live spectacles
|
||||
// (evil mage / dragon) so mass civ scraps cannot erase them forever.
|
||||
if (_cachedBattle != null && _cachedBattle.ParticipantCount >= 3)
|
||||
{
|
||||
_cachedBest = _cachedBattle;
|
||||
InterestEvent spectacle = BuildBestSpectacleUnit();
|
||||
if (spectacle != null
|
||||
&& (_cachedBattle.FollowUnit == null || !IsSpectacle(_cachedBattle.FollowUnit))
|
||||
&& _cachedBattle.NotableParticipantCount <= 0)
|
||||
{
|
||||
// Anonymous scrap vs a live spectacle - ambient follows the spectacle.
|
||||
_cachedBest = spectacle;
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedBest = PreferRicherAction(_cachedBattle, spectacle) ?? _cachedBattle;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -877,6 +911,43 @@ public static class WorldActivityScanner
|
|||
return null;
|
||||
}
|
||||
|
||||
// Prefer spectacles over civ ambient. Idle/warm chores never beat a live mage/dragon;
|
||||
// Hot combatants still need the prefer margin (spectacles should not erase every scrap).
|
||||
if (!IsSpectacle(best))
|
||||
{
|
||||
Actor spectacle = null;
|
||||
float spectacleScore = float.MinValue;
|
||||
for (int i = 0; i < alive.Count; i++)
|
||||
{
|
||||
Actor actor = alive[i];
|
||||
if (!IsSpectacle(actor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float unitScore = ScoreActor(actor, speciesCounts);
|
||||
if (unitScore > spectacleScore)
|
||||
{
|
||||
spectacleScore = unitScore;
|
||||
spectacle = actor;
|
||||
}
|
||||
}
|
||||
|
||||
if (spectacle != null)
|
||||
{
|
||||
bool bestHotFight = best.has_attack_target
|
||||
&& ActivityInterestTable.Classify(best) == ActivityBand.Hot;
|
||||
float preferMargin = InterestScoringConfig.W.scannerSpectaclePreferMargin > 0f
|
||||
? InterestScoringConfig.W.scannerSpectaclePreferMargin
|
||||
: 28f;
|
||||
if (!bestHotFight || spectacleScore + preferMargin >= bestScore)
|
||||
{
|
||||
best = spectacle;
|
||||
bestScore = spectacleScore;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActivityBand band = ActivityInterestTable.Classify(best);
|
||||
SplitActorScore(best, speciesCounts, out float actionScore, out float charScore);
|
||||
int fighters = 0;
|
||||
|
|
@ -1021,12 +1092,15 @@ public static class WorldActivityScanner
|
|||
SplitActorScore(actor, speciesCounts, out float action, out float character);
|
||||
// Action-primary total used for ranking; character is a light tie-break.
|
||||
float score = action + character * InterestScoringConfig.W.scannerRankCharWeight;
|
||||
// Character Ledger bias for ambient fill - prefer watched lives over strangers.
|
||||
// Life-saga bias for ambient fill - Prefer/MC must beat anonymous villagers post-quiet.
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
float ledger = CharacterLedger.Heat(id);
|
||||
if (ledger > 0f)
|
||||
if (LifeSagaRoster.IsPrefer(id))
|
||||
{
|
||||
score += ledger * Mathf.Max(1f, InterestScoringConfig.Story.ledgerWeight * 0.35f);
|
||||
score += Mathf.Max(8f, InterestScoringConfig.Story.sagaPreferWeight * 0.75f);
|
||||
}
|
||||
else if (LifeSagaRoster.IsMc(id))
|
||||
{
|
||||
score += Mathf.Max(5f, InterestScoringConfig.Story.sagaMcWeight * 0.7f);
|
||||
}
|
||||
|
||||
float causal = CausalHeat.Get(id);
|
||||
|
|
@ -1073,7 +1147,8 @@ public static class WorldActivityScanner
|
|||
actionScore += w.scannerWarmBonus;
|
||||
}
|
||||
|
||||
if (IsSpectacle(actor))
|
||||
bool spectacle = IsSpectacle(actor);
|
||||
if (spectacle)
|
||||
{
|
||||
actionScore += w.scannerSpectacleBonus;
|
||||
}
|
||||
|
|
@ -1111,17 +1186,32 @@ public static class WorldActivityScanner
|
|||
characterScore += w.scannerSingletonBonus;
|
||||
}
|
||||
|
||||
// Policy overlay (evil_mage / dragon / …) - same source as InterestScoring meta.
|
||||
float speciesBonus = EventCatalogConfig.SpeciesBonus(speciesId);
|
||||
if (speciesBonus > 0f)
|
||||
{
|
||||
characterScore += speciesBonus;
|
||||
}
|
||||
|
||||
float renownDiv = w.scannerCharRenownDivisor > 0f ? w.scannerCharRenownDivisor : 120f;
|
||||
characterScore += Mathf.Min(w.scannerCharRenownCap, actor.renown / renownDiv);
|
||||
characterScore += Mathf.Min(w.scannerCharKillsCap, kills * w.scannerCharKillsFactor);
|
||||
|
||||
// Cold kings barely score - do not let identity monopolize the camera.
|
||||
if (band == ActivityBand.Cold && !actor.has_attack_target && !IsSpectacle(actor))
|
||||
// Spectacles keep full action (idle floor applied below).
|
||||
if (band == ActivityBand.Cold && !actor.has_attack_target && !spectacle)
|
||||
{
|
||||
actionScore *= w.scannerColdActionMul;
|
||||
characterScore *= w.scannerColdCharMul;
|
||||
}
|
||||
|
||||
// Idle evil mages / dragons still beat warm chores for ambient fill.
|
||||
if (spectacle && band == ActivityBand.Cold && !actor.has_attack_target)
|
||||
{
|
||||
float floor = w.scannerSpectacleIdleFloor > 0f ? w.scannerSpectacleIdleFloor : 42f;
|
||||
actionScore = Mathf.Max(actionScore, floor);
|
||||
}
|
||||
|
||||
actionScore += (actor.getID() % 7) * 0.01f;
|
||||
}
|
||||
|
||||
|
|
@ -1163,16 +1253,88 @@ public static class WorldActivityScanner
|
|||
return false;
|
||||
}
|
||||
|
||||
string id = actor.asset.id;
|
||||
if (SpectacleAssets.Contains(id))
|
||||
return IsSpectacleAssetId(actor.asset.id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live spectacle actor / power id class (mages, dragons, demons, …).
|
||||
/// Seed + token heuristics + live <c>actor_library</c> discovery.
|
||||
/// </summary>
|
||||
public static bool IsSpectacleAssetId(string assetId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string id = assetId.Trim().ToLowerInvariant();
|
||||
if (id.StartsWith("boat_", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
HashSet<string> live = EnsureSpectacleAssetIds();
|
||||
if (live.Contains(id))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Loose match for variant ids.
|
||||
foreach (string key in SpectacleAssets)
|
||||
return MatchesSpectacleToken(id);
|
||||
}
|
||||
|
||||
private static HashSet<string> EnsureSpectacleAssetIds()
|
||||
{
|
||||
float now = Time.unscaledTime;
|
||||
if (_spectacleAssetIds != null && now - _spectacleIdsCachedAt < SpectacleIdsCacheSeconds)
|
||||
{
|
||||
if (id.Contains(key))
|
||||
return _spectacleAssetIds;
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < SpectacleAssetSeed.Length; i++)
|
||||
{
|
||||
set.Add(SpectacleAssetSeed[i]);
|
||||
}
|
||||
|
||||
List<string> liveActors = ActivityAssetCatalog.EnumerateLiveActorAssetIds();
|
||||
for (int i = 0; i < liveActors.Count; i++)
|
||||
{
|
||||
string id = liveActors[i];
|
||||
if (string.IsNullOrEmpty(id) || id.StartsWith("boat_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (MatchesSpectacleToken(id) || set.Contains(id))
|
||||
{
|
||||
set.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
_spectacleAssetIds = set;
|
||||
_spectacleIdsCachedAt = now;
|
||||
return _spectacleAssetIds;
|
||||
}
|
||||
|
||||
private static bool MatchesSpectacleToken(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string s = id.ToLowerInvariant();
|
||||
for (int i = 0; i < SpectacleIdTokens.Length; i++)
|
||||
{
|
||||
if (s.IndexOf(SpectacleIdTokens[i], StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < SpectacleAssetSeed.Length; i++)
|
||||
{
|
||||
if (s.IndexOf(SpectacleAssetSeed[i], StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1181,6 +1343,53 @@ public static class WorldActivityScanner
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Best living spectacle unit for ambient ranking (mages, dragons, …).</summary>
|
||||
private static InterestEvent BuildBestSpectacleUnit()
|
||||
{
|
||||
Actor best = null;
|
||||
float bestScore = float.MinValue;
|
||||
Dictionary<string, int> counts = CountSpeciesPopulations();
|
||||
foreach (Actor actor in EnumerateAliveUnits())
|
||||
{
|
||||
if (actor == null || !actor.isAlive() || !IsSpectacle(actor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (actor.asset != null && actor.asset.is_boat)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float score = ScoreActor(actor, counts);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = actor;
|
||||
}
|
||||
}
|
||||
|
||||
if (best == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
SplitActorScore(best, counts, out float actionScore, out float charScore);
|
||||
float rankChar = InterestScoringConfig.W.scannerRankCharWeight;
|
||||
return new InterestEvent
|
||||
{
|
||||
Score = actionScore + charScore * rankChar,
|
||||
Position = best.current_position,
|
||||
FollowUnit = best,
|
||||
Label = "",
|
||||
CreatedAt = Time.unscaledTime,
|
||||
AssetId = best.asset != null ? best.asset.id : "spectacle",
|
||||
ParticipantCount = 0,
|
||||
NotableParticipantCount = 0,
|
||||
CharacterSignificance = charScore
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsSingletonSpecies(Actor actor, Dictionary<string, int> speciesCounts)
|
||||
{
|
||||
if (actor?.asset == null)
|
||||
|
|
|
|||
|
|
@ -75,7 +75,11 @@ public static class WorldLogEventHarness
|
|||
}
|
||||
|
||||
string labelSample = entry.MakeLabel("Alpha", "Beta");
|
||||
if (string.IsNullOrEmpty(labelSample))
|
||||
string emptySlotSample = entry.MakeLabel("", "");
|
||||
bool hanging = WorldLogEventEntry.IsHangingLabel(labelSample)
|
||||
|| WorldLogEventEntry.IsHangingLabel(emptySlotSample)
|
||||
|| string.IsNullOrWhiteSpace(emptySlotSample);
|
||||
if (hanging)
|
||||
{
|
||||
emptyLabel++;
|
||||
}
|
||||
|
|
@ -85,7 +89,7 @@ public static class WorldLogEventHarness
|
|||
{
|
||||
notes = "missing_authored";
|
||||
}
|
||||
else if (string.IsNullOrEmpty(labelSample))
|
||||
else if (hanging)
|
||||
{
|
||||
notes = "empty_label";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,26 @@
|
|||
"id": "demon",
|
||||
"bonus": 12
|
||||
},
|
||||
{
|
||||
"id": "evil_mage",
|
||||
"bonus": 14
|
||||
},
|
||||
{
|
||||
"id": "necromancer",
|
||||
"bonus": 14
|
||||
},
|
||||
{
|
||||
"id": "white_mage",
|
||||
"bonus": 12
|
||||
},
|
||||
{
|
||||
"id": "plague_doctor",
|
||||
"bonus": 12
|
||||
},
|
||||
{
|
||||
"id": "druid",
|
||||
"bonus": 10
|
||||
},
|
||||
{
|
||||
"id": "angle",
|
||||
"bonus": 12
|
||||
|
|
@ -2630,6 +2650,13 @@
|
|||
"category": "Story",
|
||||
"camera": true,
|
||||
"label": "{a} carries on after the story"
|
||||
},
|
||||
{
|
||||
"id": "epilogue_crisis",
|
||||
"strength": 52,
|
||||
"category": "Story",
|
||||
"camera": true,
|
||||
"label": "{a} surveys the aftermath of the crisis"
|
||||
}
|
||||
],
|
||||
"traits": [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.28.54",
|
||||
"version": "0.29.48",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@
|
|||
"scannerHotBonus": 18,
|
||||
"scannerWarmBonus": 8,
|
||||
"scannerSpectacleBonus": 12,
|
||||
"scannerSpectacleIdleFloor": 48,
|
||||
"scannerSpectaclePreferMargin": 28,
|
||||
"scannerActionKillsFactor": 0.2,
|
||||
"scannerActionKillsCap": 10,
|
||||
"scannerWarriorBonus": 6,
|
||||
|
|
@ -129,7 +131,23 @@
|
|||
"historyDensityWindowSeconds": 600,
|
||||
"ledgerBleedLifeChapter": 0.4,
|
||||
"ledgerBleedVillage": 0.82,
|
||||
"familyLifeBeatCooldownSeconds": 90
|
||||
"sagaMcWeight": 6,
|
||||
"sagaPreferWeight": 10,
|
||||
"sagaGameFavoriteWeight": 8,
|
||||
"sagaDullSeconds": 300,
|
||||
"familyLifeBeatCooldownSeconds": 90,
|
||||
"crisisWarParticipantMin": 8,
|
||||
"crisisDisasterStrengthMin": 95,
|
||||
"crisisOwnershipBoost": 22,
|
||||
"crisisHoldMargin": 40,
|
||||
"crisisLingerSeconds": 10,
|
||||
"crisisDisasterLingerSeconds": 28,
|
||||
"crisisMaxSeconds": 180,
|
||||
"crisisCooldownSeconds": 45,
|
||||
"crisisDisasterCooldownSeconds": 120,
|
||||
"crisisEpilogueStrength": 52,
|
||||
"crisisEpilogueMaxWatch": 16,
|
||||
"softFillQuietSeconds": 18
|
||||
},
|
||||
|
||||
"sources": [
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ Also stay B: diet/sleep/random/check ticks, `warrior_train_with_dummy`, boat tra
|
|||
|
||||
Spell Signal: spectacle summons/curses/fire/teleport/meteor; FX silence/shield/cure/grass/vegetation/skeleton stay B.
|
||||
|
||||
Power Signal: disasters plus spectacle creature drops (`evil_mage`, `white_mage`, `necromancer`,
|
||||
`druid`, `plague_doctor`, dragons/demons, …) via live spectacle asset discovery - not B ambient.
|
||||
|
||||
## Patch-only / no catalog rows
|
||||
|
||||
| Surface | Live / notes | Wire status | Priority |
|
||||
|
|
|
|||
105
docs/life-saga.md
Normal file
105
docs/life-saga.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# Life-saga roster
|
||||
|
||||
Dynamic top-10 cast of interesting lives (MCs). Owns the dossier rail and soft camera bias.
|
||||
Chronicle stays a history book and never drives the camera.
|
||||
Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/StoryPlanner.cs) as chapter beats.
|
||||
Durable observed facts live in [`LifeSagaMemory`](../IdleSpectator/Story/LifeSagaMemory.cs) and survive roster churn.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
|---------|--------|
|
||||
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
|
||||
| Durable observed facts | `LifeSagaMemory` |
|
||||
| Adaptive presentation | `LifeSagaPresentation` |
|
||||
| Dossier / Saga tabs + hover preview | `LifeSagaViewController` |
|
||||
| Saga body UI | `LifeSagaPanel` |
|
||||
| Rail icons / Prefer click | `LifeSagaRail` |
|
||||
| Camera read-pause lease | `InterestDirector` |
|
||||
| Short climax → aftermath → epilogue | `StoryPlanner` (thinned) |
|
||||
| Spine under tip | `StoryPlanner.FormatSpineLabel` |
|
||||
| Full history | Chronicle / Lore |
|
||||
|
||||
## Admission
|
||||
|
||||
Live discovery (no tip-string allowlists):
|
||||
|
||||
- Game favorite, king, city leader, kills/renown floors (`InterestScoring.IsNotable`)
|
||||
- Pack alpha, clan chief, army captain, active plot author, founder, species singleton
|
||||
|
||||
Standing is recomputed from live roles each refill.
|
||||
Heat comes from chapter stamps / featured tips and decays with idle time.
|
||||
Each slot keeps its original admission reason even when its live role later changes.
|
||||
Hard-arc admissions also retain the arc kind and chapter context that brought the life onto the roster.
|
||||
|
||||
## Dynamic top-10
|
||||
|
||||
- Cap stays 10; order follows Effective = Standing + Heat + pin bonuses.
|
||||
- Prefer and WorldBox favorites are hard pins and fully count toward Cap.
|
||||
- Admission-role units always compete on the periodic scan.
|
||||
- Non-admission "nobodies" challenge Cap only via hard-arc chapter stamps (Combat / War / Plot / Love / Grief).
|
||||
- Soft `NoteFeatured` heats existing MCs only.
|
||||
- Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash.
|
||||
|
||||
## Camera (soft)
|
||||
|
||||
- No rail camera lock. Prefer is a toggle soft-favorite for idle scoring.
|
||||
- Score ladder: crisis ownership > active short-arc ownership > Prefer soft > MC soft > CausalHeat.
|
||||
- Near-ties prefer Prefer'd MCs, then any MC.
|
||||
- Ambient fill prefers roster MCs over random villagers.
|
||||
- Glyph hover acquires a camera read-pause lease: feeds/story/roster continue, but selection and focus repair are deferred until release.
|
||||
|
||||
## Rail UX
|
||||
|
||||
- Up to 10 slots with live inspect-UI sprites (refreshed ~3x/sec for animation frames).
|
||||
- Egg forms and tiny/blank atlas crops fall back to the species icon (letter last).
|
||||
- Active chrome = camera follow only; Prefer uses a star pip / distinct border.
|
||||
- Click toggles saga Prefer (does not mutate WorldBox unit favorite).
|
||||
- While on the **Saga** tab, click also pins that life as the persistent Saga subject.
|
||||
- Tabs sit above the cast rail; the rail is a **horizontal strip under the tabs** so Dossier↔Saga body swaps cannot move the glyphs.
|
||||
- **Dossier** shows the watched unit dossier; **Saga** shows the adaptive saga panel for the watched MC when on the roster.
|
||||
- If the watched unit is not an MC and nothing is pinned, Saga shows an empty *Pick a life from the rail* state (never a stale first-roster filler).
|
||||
- Hovering a glyph temporarily previews that life in the Saga body and pauses camera switching.
|
||||
- Leaving the rail (or panel after a dwell) re-arms a short grace, then restores the prior tab and releases the pause lease.
|
||||
- Clicking a tab during hover cancels the preview immediately and applies that tab.
|
||||
- Tabbed chrome locks a constant outer width so Dossier↔Saga hover cannot slide glyphs.
|
||||
- Saga body matches Design A: identity, stake (accent wrap), Cast (face + name + relation), Evidence (lens module), Legacy, Open Lore.
|
||||
- Open Lore from Saga stashes return-to-Saga; Lore close restores the Saga tab and subject when still an MC.
|
||||
- Body height sizes to content up to a cap; lines wrap fully (no ellipsis truncation).
|
||||
- Glyph hover keeps the preview while the pointer stays on the rail or dossier panel.
|
||||
- Dossier story spine (`Kind · Phase`) is hidden when the orange reason already names the same kind and phase is Climax.
|
||||
- Never show live task/status/trait rows or call a live attack target a rival.
|
||||
- Earned rivals require memory evidence (repeated encounters, kin kills, war/plot opposition).
|
||||
- Likely succession is omitted unless a game-authored heir API probe succeeds.
|
||||
- Pack / kin lenses fill remaining Cast slots with live family peers (`Packmate` / `Kin`) after lover/friend/parents/children/heir/rival.
|
||||
|
||||
## Soft-fill quiet (AFK hygiene)
|
||||
|
||||
After hard story/crisis closers, soft-fill quiet suppresses soft-life crumbs and short interrupt FX (`stunned` / `invincible`).
|
||||
`FamilyPack` does not break quiet (ambient cluster fill).
|
||||
Combat / war / plot / outbreak / earthquake / story beats / epic strength still cut in.
|
||||
Quiet can refresh briefly when a soft crumb ends (bounded).
|
||||
|
||||
## Session
|
||||
|
||||
- `StoryPlanner.Clear` does not wipe the roster or saga memory.
|
||||
- Lore browse / brief idle-off keeps the roster and memory.
|
||||
- World replacement, harness batch end, and `interest_saga_clear` wipe roster + memory.
|
||||
|
||||
## Harness
|
||||
|
||||
- `saga_roster_diversity`
|
||||
- `saga_camera_prefers_mc`
|
||||
- `saga_prefer_soft_bias`
|
||||
- `saga_overview_has_mc`
|
||||
- `saga_adaptive_dossier`
|
||||
- `saga_memory_survives`
|
||||
- `saga_hover_read_pause`
|
||||
- `saga_replace_on_death`
|
||||
- `saga_admit_roles`
|
||||
- `saga_replace_weaker`
|
||||
- `saga_rail_active_highlight`
|
||||
- `saga_rail_prefer_click`
|
||||
- `saga_soft_fill_no_pack`
|
||||
|
||||
See also: [story-planner.md](story-planner.md).
|
||||
|
|
@ -5,7 +5,36 @@ IdleSpectator commits to short multi-beat stories instead of channel-surfing ran
|
|||
Feeds, sticky ensembles, presentability, and `InterestDirector` stay in place.
|
||||
`StoryPlanner` sits above the director and owns *which story* the next 30–90s belongs to.
|
||||
|
||||
See also: [scoring-model.md](scoring-model.md), [event-reason.md](event-reason.md).
|
||||
## Short-arc board (not the dossier rail)
|
||||
|
||||
Hard storylines (notable duel / mass / war / plot / love / grief) still form a small
|
||||
watching + parked board (cap 4) for short-chapter resume.
|
||||
Cutting away **parks** a hard arc; soft anonymous scraps still end.
|
||||
There is **no** rail Commit / hard pin anymore.
|
||||
|
||||
- Lore browse uses `browsePause` - freezes idle without `StoryPlanner.Clear()`.
|
||||
- `StoryPlanner.Clear` does **not** wipe the life-saga roster.
|
||||
- Combat spine Kind comes from sticky/live ensemble scale + `StoryArcKind`, not tip Label prefixes.
|
||||
|
||||
Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to 10 MCs).
|
||||
Click toggles Prefer (soft favorite).
|
||||
Dossier/Saga tabs and hover preview are owned by `LifeSagaViewController` + `LifeSagaPanel`.
|
||||
Durable observed facts live in `LifeSagaMemory` (independent of ChronicleEnabled).
|
||||
Hover temporarily pauses camera switching without disabling Idle Spectator.
|
||||
See [life-saga.md](life-saga.md).
|
||||
|
||||
Crisis camera hold: WarFront always blocks Mass/Battle cut-ins (`war_theater_hold`).
|
||||
Disaster / outbreak signals use the same class rule while their chapter is live
|
||||
(`crisis_theater_hold`), classified via live `AssetManager.disasters` + catalog categories
|
||||
(not tip-string `IndexOf`).
|
||||
|
||||
`InterestDirector` is a partial class: session orchestrator + `StickyMaintain` + `SwitchPolicy`.
|
||||
|
||||
Harness: `story_lore_pause_keeps`, `story_board_park_resume` (short-arc park/resume via
|
||||
`story_resume_parked`), `story_crisis_war`, `story_crisis_disaster`, `war_front_sticky`,
|
||||
plus `saga_*` scenarios.
|
||||
|
||||
See also: [life-saga.md](life-saga.md), [scoring-model.md](scoring-model.md), [event-reason.md](event-reason.md).
|
||||
|
||||
## Pipeline
|
||||
|
||||
|
|
@ -106,23 +135,61 @@ Harness: `story_spine` / `story_hold_margin` / `story_family_variety` / `story_i
|
|||
/ `combat_wiped_side` / `reason_life_principal`
|
||||
(`story_arc_combat`, `story_spine_scoped`, `story_aftermath_combat`, `story_family_variety`).
|
||||
|
||||
## Crisis chapters (Phase 4)
|
||||
|
||||
Parallel overlay beside short `StoryArc` - war / disaster / outbreak thresholds open a
|
||||
multi-minute chapter without thrashing when unrelated tips cut in.
|
||||
|
||||
| Signal | Enter when |
|
||||
|--------|------------|
|
||||
| WarFront | **live** participants ≥ `crisisWarParticipantMin` (default 8), both sides present (peak lock alone does not enter) |
|
||||
| EarthquakeActive / Disaster category | live or EventStrength ≥ `crisisDisasterStrengthMin` (95) |
|
||||
| StatusOutbreak | EventStrength ≥ disaster floor |
|
||||
|
||||
While live:
|
||||
|
||||
- `crisisOwnershipBoost` + `crisisHoldMargin` on matching tips
|
||||
- Character-fill ambient is suppressed (`SuppressAmbientFill`)
|
||||
- Epic peers (≥ disaster floor) may still cut
|
||||
|
||||
On exit (signal linger / max duration / harness `interest_crisis_end`): inject
|
||||
`epilogue_crisis` (`{a} surveys the aftermath of the crisis`), then cooldown.
|
||||
|
||||
Disaster / outbreak chapters use a shared family anchor (`disaster` / `outbreak`), a longer
|
||||
linger after the last WorldLog pulse, and `crisisDisasterCooldownSeconds` (default 120)
|
||||
armed when the closer injects so stacked tornado tips cannot double-close.
|
||||
|
||||
War chapters stamp a kingdom theater pair. Local Mass/Battle on that pair keeps the chapter
|
||||
warm and cannot steal the War tip (`war_theater_hold`). Crisis closers clear short-arc spine
|
||||
and suppress combat task detail crumbs under the orange reason.
|
||||
|
||||
Short-arc related epilogue is skipped when a crisis closer owns the exit.
|
||||
When a short-arc war linger injects while a war crisis is live, the crisis quiet-closes
|
||||
(no second `epilogue_crisis`). Harness batch end and `interest_story_purge_leftovers`
|
||||
drop leftover crisis closers so free AFK does not inherit a fake ending.
|
||||
After hard story/crisis ends, `softFillQuietSeconds` suppresses soft-life crumbs,
|
||||
short interrupt FX (`stunned` / `invincible`), and character-fill ambient briefly.
|
||||
`FamilyPack` does not break that quiet window (see [life-saga.md](life-saga.md)).
|
||||
|
||||
## Selection discipline
|
||||
|
||||
- `InterestVariety.Pick` takes `#1` when score gap ≥ `story.nearTieEpsilon` (default 8).
|
||||
- Near-ties still roll among top-3.
|
||||
- Causal heat + Character Ledger heat add into `RecalcTotal`.
|
||||
- Causal heat + life-saga Prefer/MC soft bias add into `RecalcTotal`.
|
||||
|
||||
## Knobs
|
||||
|
||||
`scoring-model.json` → `story` block (loaded by `InterestScoringConfig.Story`).
|
||||
|
||||
Catalog policy ids live under `event-catalog.json` → `story`
|
||||
(`aftermath_survivor`, `aftermath_mourner`, `aftermath_war_linger`, …).
|
||||
(`aftermath_survivor`, `aftermath_mourner`, `aftermath_war_linger`, `epilogue_crisis`, …).
|
||||
|
||||
## Harness
|
||||
|
||||
| Scenario | Proves |
|
||||
|----------|--------|
|
||||
| `story_crisis_war` | WarFront → crisis active → `epilogue_crisis` |
|
||||
| `story_crisis_disaster` | EarthquakeActive → crisis → `epilogue_crisis`; cool blocks reopen |
|
||||
| `story_aftermath_combat` | duel cold → aftermath tip |
|
||||
| `story_aftermath_partner_truth` | aftermath never names living lover as fallen |
|
||||
| `story_aftermath_cast_noise` | hatch noise pending still gets aftermath |
|
||||
|
|
@ -140,13 +207,18 @@ Catalog policy ids live under `event-catalog.json` → `story`
|
|||
| `story_resume_cooled` | cooled soft tip does not resume after cut-in |
|
||||
| `story_worldlog_meta_cool` | king_dead cools; peer wins next pick |
|
||||
| `combat_wiped_side` | wiped camp never prints vs (0) |
|
||||
| `story_aftermath_war` | war sticky cold → aftermath |
|
||||
| `story_aftermath_war` | war sticky cold → aftermath; no dual crisis closer |
|
||||
| `story_crisis_hygiene` | auto-opened war crisis + short-arc linger; purge leaves no epilogue leak |
|
||||
| `story_near_tie` | large gap always picks #1 |
|
||||
| `story_arc_combat` | climax → aftermath ownership |
|
||||
| `story_arc_preempt_resume` | epic cut then fresh scrap aftermath |
|
||||
| `story_ledger_revisit` | featured unit wins near-tie |
|
||||
|
||||
```bash
|
||||
./scripts/harness-run.sh --repeat 3 story_crisis_war
|
||||
./scripts/harness-run.sh --repeat 3 story_crisis_disaster
|
||||
./scripts/harness-run.sh --repeat 3 story_aftermath_war
|
||||
./scripts/harness-run.sh --repeat 3 war_front_sticky
|
||||
./scripts/harness-run.sh --repeat 3 story_aftermath_combat
|
||||
./scripts/harness-run.sh --repeat 3 story_aftermath_partner_truth
|
||||
./scripts/harness-run.sh --repeat 3 story_aftermath_cast_noise
|
||||
|
|
@ -168,8 +240,10 @@ Catalog policy ids live under `event-catalog.json` → `story`
|
|||
|
||||
## Files
|
||||
|
||||
- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject, spine labels
|
||||
- `IdleSpectator/Story/StoryPlanner.cs` - short-arc ownership, cold-hook, inject, spine, crisis
|
||||
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + chapter stamps
|
||||
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - saga hover + rail
|
||||
- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state
|
||||
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
|
||||
- `IdleSpectator/Story/CharacterLedger.cs` - watched lives (cap 16)
|
||||
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
|
||||
- `IdleSpectator/WatchCaption.cs` - dossier `Kind · Phase` spine row
|
||||
|
|
|
|||
Loading…
Reference in a new issue