feat(saga): ship adaptive dossier tabs with durable memory
- Add Dossier/Saga tabs, Design A panel, and hover preview with read-pause - Persist observed saga facts across roster churn; pin subject without stale fallback - Restore Saga after Open Lore; hide redundant Kind · Climax spine under matching tips - Densify dossier header/chips and keep the cast rail as stable top chrome - Extend harness coverage for hover restore, empty pick, Evidence, and Lore return
This commit is contained in:
parent
798e3dce05
commit
4b6c829bd0
30 changed files with 7495 additions and 904 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);
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ public static class AgentHarness
|
|||
private static string _rememberedTip = "";
|
||||
private static long _rememberedRelatedId;
|
||||
private static string LastScreenshotPath = "";
|
||||
private static Vector2 _sagaPanelSizeAnchor;
|
||||
private static readonly string[] PreferredSpawnAssets =
|
||||
{
|
||||
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
|
||||
|
|
@ -2866,7 +2867,11 @@ public static class AgentHarness
|
|||
|
||||
case "interest_saga_clear":
|
||||
LifeSagaRoster.Clear();
|
||||
LifeSagaMemory.Clear();
|
||||
LifeSagaViewController.Clear();
|
||||
LifeSagaRail.Clear();
|
||||
LifeSagaPanel.Clear();
|
||||
_sagaPanelSizeAnchor = Vector2.zero;
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: "saga_cleared");
|
||||
break;
|
||||
|
|
@ -2938,7 +2943,6 @@ public static class AgentHarness
|
|||
bool ok = LifeSagaRoster.HarnessForceAdmit(focus);
|
||||
if (ok)
|
||||
{
|
||||
LifeSagaRoster.StampChapter(id, "Harness chapter", Time.unscaledTime);
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
|
|
@ -2950,6 +2954,303 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "saga_stamp_chapter_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
string line = (cmd.value ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
line = (cmd.label ?? "").Trim();
|
||||
}
|
||||
|
||||
LifeSagaChapterKind kind = LifeSagaChapterKind.Unknown;
|
||||
string kindRaw = (cmd.asset ?? "").Trim();
|
||||
if (!string.IsNullOrEmpty(kindRaw)
|
||||
&& System.Enum.TryParse(kindRaw, ignoreCase: true, out LifeSagaChapterKind parsed))
|
||||
{
|
||||
kind = parsed;
|
||||
}
|
||||
|
||||
bool ok = id != 0
|
||||
&& LifeSagaRoster.IsMc(id)
|
||||
&& !string.IsNullOrEmpty(line);
|
||||
if (ok)
|
||||
{
|
||||
LifeSagaRoster.StampChapter(id, kind, line);
|
||||
LifeSagaRoster.Dirty = true;
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(
|
||||
cmd,
|
||||
ok,
|
||||
detail:
|
||||
$"stamp id={id} kind={kind} line={line} chapters={LifeSagaRoster.Get(id)?.Chapters.Count ?? 0}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_weaken_nonprefer":
|
||||
{
|
||||
// Age out non-Prefer slots so a hotter admit can displace them.
|
||||
float now = Time.unscaledTime;
|
||||
var board = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
||||
LifeSagaRoster.CopySlots(board);
|
||||
int n = 0;
|
||||
for (int i = 0; i < board.Count; i++)
|
||||
{
|
||||
LifeSagaSlot s = board[i];
|
||||
if (s == null || s.Prefer || s.GameFavorite)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LifeSagaRoster.HarnessSetScores(s.UnitId, standing: 1f, heat: 0f);
|
||||
s.AdmittedAt = now - 900f;
|
||||
s.TouchedAt = now - 900f;
|
||||
s.LastChapterAt = 0f;
|
||||
n++;
|
||||
}
|
||||
|
||||
LifeSagaRoster.Dirty = true;
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"weakened={n} roster={LifeSagaRoster.Count}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_consider_hard_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
bool ok = id != 0
|
||||
&& LifeSagaRoster.ConsiderAdmit(id, heat: 6f, hardArcAdmit: true);
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok,
|
||||
detail:
|
||||
$"hardAdmit id={id} on={LifeSagaRoster.IsMc(id)} roster={LifeSagaRoster.Count}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_rail_refresh":
|
||||
{
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
LifeSagaRail.Refresh();
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail:
|
||||
$"railShown={LifeSagaRail.LastShownCount} activeLit={LifeSagaRail.LastActiveHighlight} preferPip={LifeSagaRail.LastPreferPipVisible}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_show_hover_first":
|
||||
{
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
LifeSagaRail.Refresh();
|
||||
LifeSagaRail.ShowHover(0);
|
||||
WatchCaption.RequestRelayout();
|
||||
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
|
||||
bool ok = LifeSagaViewController.IsHoverPreview
|
||||
&& model.UnitId != 0
|
||||
&& LifeSagaRail.LastHoverHeight > 0f;
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(
|
||||
cmd,
|
||||
ok,
|
||||
detail:
|
||||
$"hoverId={LifeSagaViewController.HoverPreviewId} rows={LifeSagaRail.LastHoverRowCount} height={LifeSagaRail.LastHoverHeight:0.0} pause={InterestDirector.ReadPauseActive}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_hide_hover":
|
||||
{
|
||||
LifeSagaRail.HideHover();
|
||||
WatchCaption.RequestRelayout();
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"pause={InterestDirector.ReadPauseActive}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_hover_panel_dwell":
|
||||
{
|
||||
LifeSagaViewController.HarnessSimulatePanelDwell();
|
||||
bool ok = LifeSagaViewController.IsHoverPreview;
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"hoverId={LifeSagaViewController.HoverPreviewId}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_hover_tick_away":
|
||||
{
|
||||
LifeSagaViewController.HarnessTickExitAwayFromChrome();
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail:
|
||||
$"hoverPreview={LifeSagaViewController.IsHoverPreview} pause={InterestDirector.ReadPauseActive}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_click_first":
|
||||
{
|
||||
LifeSagaRail.Refresh();
|
||||
long before = LifeSagaViewController.PersistentSagaId;
|
||||
LifeSagaRail.HarnessClick(0);
|
||||
WatchCaption.RequestRelayout();
|
||||
long after = LifeSagaViewController.EffectiveSagaId;
|
||||
bool ok = after != 0;
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"before={before} after={after} prefer={LifeSagaRail.LastPreferPipVisible}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_open_lore":
|
||||
{
|
||||
LifeSagaViewController.SelectTab(LifeSagaViewController.Tab.Saga);
|
||||
WatchCaption.RequestRelayout();
|
||||
LifeSagaPanel.HarnessOpenLore();
|
||||
bool ok = Chronicle.HudVisible;
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"lore={ok} bound={LifeSagaPanel.BoundUnitId}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_select_tab":
|
||||
{
|
||||
string raw = (cmd.value ?? "dossier").Trim().ToLowerInvariant();
|
||||
LifeSagaViewController.Tab tab = raw.StartsWith("s")
|
||||
? LifeSagaViewController.Tab.Saga
|
||||
: LifeSagaViewController.Tab.Dossier;
|
||||
LifeSagaViewController.SelectTab(tab);
|
||||
WatchCaption.RequestRelayout();
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"tab={LifeSagaViewController.SelectedTab}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_memory_record_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
if (focus == null || !focus.isAlive())
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no_focus");
|
||||
break;
|
||||
}
|
||||
|
||||
string kindRaw = (cmd.asset ?? "Kill").Trim();
|
||||
LifeSagaFactKind kind = LifeSagaFactKind.Kill;
|
||||
if (kindRaw.IndexOf("bond", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
kind = LifeSagaFactKind.BondFormed;
|
||||
}
|
||||
else if (kindRaw.IndexOf("war", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
kind = LifeSagaFactKind.WarJoin;
|
||||
}
|
||||
else if (kindRaw.IndexOf("plot", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
kind = LifeSagaFactKind.PlotJoin;
|
||||
}
|
||||
else if (kindRaw.IndexOf("found", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
kind = LifeSagaFactKind.Founding;
|
||||
}
|
||||
else if (kindRaw.IndexOf("role", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| kindRaw.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
kind = LifeSagaFactKind.RoleChange;
|
||||
}
|
||||
|
||||
LifeSagaFact fact = LifeSagaMemory.Record(
|
||||
kind,
|
||||
focus,
|
||||
null,
|
||||
correlationKey: "harness:" + EventFeedUtil.SafeId(focus) + ":" + kind,
|
||||
strength: 80f,
|
||||
provenance: "harness",
|
||||
note: cmd.value ?? "");
|
||||
bool ok = fact != null;
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: $"kind={kind} facts={LifeSagaMemory.FactsFor(EventFeedUtil.SafeId(focus)).Count}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_set_scores_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
float standing = ParseFloat(cmd.value, 0.1f);
|
||||
float heat = ParseFloat(cmd.label, 0f);
|
||||
bool ok = id != 0 && LifeSagaRoster.HarnessSetScores(id, standing, heat);
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: $"id={id} standing={standing} heat={heat}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "soft_fill_quiet_arm":
|
||||
{
|
||||
StoryPlanner.NoteHardStoryQuiet();
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"softQuiet={StoryPlanner.SoftFillQuietActive}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_world_scan":
|
||||
{
|
||||
// Force a live admission scan even while harness Busy suppresses Refill discovery.
|
||||
LifeSagaRoster.HarnessWorldScan();
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"roster={LifeSagaRoster.Count}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "interest_variety_note":
|
||||
{
|
||||
// Stamp the current (or pending needle in value) as the last shown variety arc.
|
||||
|
|
@ -3506,6 +3807,7 @@ public static class AgentHarness
|
|||
// Free AFK after harness must not inherit a leftover crisis closer tip.
|
||||
StoryPlanner.PurgeCloserLeftovers();
|
||||
LifeSagaRoster.Clear();
|
||||
LifeSagaMemory.Clear();
|
||||
string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL";
|
||||
WriteLastResult(status, $"ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail}");
|
||||
LogService.LogInfo(
|
||||
|
|
@ -3569,14 +3871,27 @@ public static class AgentHarness
|
|||
}
|
||||
|
||||
string assetId = string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset;
|
||||
Actor unit = ResolveUnit(assetId);
|
||||
if (unit == null || !unit.isAlive())
|
||||
string mode = (cmd.value ?? "").Trim().ToLowerInvariant();
|
||||
bool wantOther = mode.IndexOf("other", StringComparison.Ordinal) >= 0;
|
||||
bool preferOffRoster = mode.IndexOf("off_roster", StringComparison.Ordinal) >= 0
|
||||
|| mode.IndexOf("other", StringComparison.Ordinal) >= 0;
|
||||
Actor exclude = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
|
||||
Actor unit = null;
|
||||
if (wantOther)
|
||||
{
|
||||
// Ignore stale harness spawn handle; scan the live world.
|
||||
_lastSpawned = null;
|
||||
unit = string.IsNullOrEmpty(assetId)
|
||||
? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f)
|
||||
: WorldActivityScanner.FindAliveByAssetId(assetId, CameraPos(), 5000f);
|
||||
unit = FindOtherAliveUnitPreferOffRoster(exclude, assetId, preferOffRoster);
|
||||
}
|
||||
else
|
||||
{
|
||||
unit = ResolveUnit(assetId);
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
// Ignore stale harness spawn handle; scan the live world.
|
||||
_lastSpawned = null;
|
||||
unit = string.IsNullOrEmpty(assetId)
|
||||
? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f)
|
||||
: WorldActivityScanner.FindAliveByAssetId(assetId, CameraPos(), 5000f);
|
||||
}
|
||||
}
|
||||
|
||||
if (unit == null || !unit.isAlive())
|
||||
|
|
@ -3595,7 +3910,7 @@ public static class AgentHarness
|
|||
_lastSpawnedAssetId = unit.asset != null ? unit.asset.id : "";
|
||||
CameraDirector.FocusUnit(unit);
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"picked={SafeName(unit)} asset={_lastSpawnedAssetId}");
|
||||
Emit(cmd, ok: true, detail: $"picked={SafeName(unit)} asset={_lastSpawnedAssetId} other={wantOther}");
|
||||
}
|
||||
|
||||
private static void DoSpawn(HarnessCommand cmd)
|
||||
|
|
@ -4614,6 +4929,29 @@ public static class AgentHarness
|
|||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("stunned", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("invincible", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
bool inv = expect.IndexOf("invincible", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
c.StatusId = inv ? "invincible" : "stunned";
|
||||
c.AssetId = c.StatusId;
|
||||
c.Source = "status";
|
||||
c.Completion = InterestCompletionKind.StatusPhase;
|
||||
c.Category = "Status";
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("family_pack", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("pack_soft", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.AssetId = "family_pack";
|
||||
c.Source = "family";
|
||||
c.Category = "Family";
|
||||
c.Completion = InterestCompletionKind.FamilyPack;
|
||||
c.Label = string.IsNullOrEmpty(c.Label) ? "Pack - Harness (3) · gathering" : c.Label;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("just_slept", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("rest_life", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("ends a rest", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
|
|
@ -4664,6 +5002,48 @@ public static class AgentHarness
|
|||
}
|
||||
}
|
||||
|
||||
private static Actor FindOtherAliveUnitPreferOffRoster(
|
||||
Actor exclude,
|
||||
string preferAsset,
|
||||
bool preferOffRoster)
|
||||
{
|
||||
Actor bestOff = null;
|
||||
Actor bestAny = null;
|
||||
if (World.world?.units == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (Actor actor in World.world.units)
|
||||
{
|
||||
if (actor == null || !actor.isAlive() || actor == exclude)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(preferAsset)
|
||||
&& (actor.asset == null
|
||||
|| !string.Equals(actor.asset.id, preferAsset, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
if (preferOffRoster && id != 0 && !LifeSagaRoster.IsMc(id))
|
||||
{
|
||||
bestOff = actor;
|
||||
break;
|
||||
}
|
||||
|
||||
if (bestAny == null)
|
||||
{
|
||||
bestAny = actor;
|
||||
}
|
||||
}
|
||||
|
||||
return bestOff ?? bestAny ?? FindOtherAliveUnit(exclude, preferAsset);
|
||||
}
|
||||
|
||||
private static Actor FindOtherAliveUnit(Actor exclude, string preferAsset)
|
||||
{
|
||||
if (World.world?.units == null)
|
||||
|
|
@ -7991,13 +8371,9 @@ public static class AgentHarness
|
|||
case "story_spine":
|
||||
{
|
||||
string want = (cmd.value ?? "").Trim();
|
||||
// HUD display only - do not fall back to FormatSpineLabel (Climax may be suppressed).
|
||||
string have = WatchCaption.LastStorySpine ?? "";
|
||||
string formatted = StoryPlanner.FormatSpineLabel() ?? "";
|
||||
// Prefer live dossier text; fall back to planner format if HUD not painted yet.
|
||||
if (string.IsNullOrEmpty(have))
|
||||
{
|
||||
have = formatted;
|
||||
}
|
||||
|
||||
bool wantEmpty = string.IsNullOrEmpty(want)
|
||||
|| string.Equals(want, "empty", StringComparison.OrdinalIgnoreCase)
|
||||
|
|
@ -8027,10 +8403,7 @@ public static class AgentHarness
|
|||
pass = have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
detail =
|
||||
$"storySpine='{have}' want='{want}' formatted='{formatted}' "
|
||||
+ $"belongs={StoryPlanner.BelongsToActiveStoryBeat(InterestDirector.CurrentCandidate)} "
|
||||
+ $"phase={StoryPlanner.Active?.Phase.ToString() ?? "None"}";
|
||||
detail = $"spine='{have}' want='{want}' formatted='{formatted}'";
|
||||
break;
|
||||
}
|
||||
case "story_hold_margin":
|
||||
|
|
@ -8115,8 +8488,231 @@ public static class AgentHarness
|
|||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
pass = id != 0 && LifeSagaRoster.IsPrefer(id);
|
||||
detail = $"prefer focus={SafeName(focus)} id={id} on={pass}";
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = id != 0 && LifeSagaRoster.IsPrefer(id);
|
||||
pass = have == want;
|
||||
detail = $"prefer focus={SafeName(focus)} id={id} have={have} want={want}";
|
||||
break;
|
||||
}
|
||||
case "saga_rail_active_highlight":
|
||||
{
|
||||
LifeSagaRail.Refresh();
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
pass = LifeSagaRail.LastActiveHighlight == want;
|
||||
detail =
|
||||
$"activeLit={LifeSagaRail.LastActiveHighlight} want={want} preferPip={LifeSagaRail.LastPreferPipVisible}";
|
||||
break;
|
||||
}
|
||||
case "saga_rail_prefer_pip":
|
||||
{
|
||||
LifeSagaRail.Refresh();
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
pass = LifeSagaRail.LastPreferPipVisible == want;
|
||||
detail =
|
||||
$"preferPip={LifeSagaRail.LastPreferPipVisible} want={want} activeLit={LifeSagaRail.LastActiveHighlight}";
|
||||
break;
|
||||
}
|
||||
case "saga_snapshot_layout":
|
||||
{
|
||||
int rows = LifeSagaRail.LastHoverRowCount;
|
||||
float height = LifeSagaRail.LastHoverHeight;
|
||||
pass = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
|
||||
&& height >= 100f
|
||||
&& height <= 200f;
|
||||
detail = $"snapshot rows={rows} height={height:0.0} tab={LifeSagaViewController.EffectiveTab} compact={pass}";
|
||||
break;
|
||||
}
|
||||
case "saga_tab_selected":
|
||||
{
|
||||
string want = (cmd.value ?? "dossier").Trim().ToLowerInvariant();
|
||||
string have = LifeSagaViewController.SelectedTab == LifeSagaViewController.Tab.Saga
|
||||
? "saga"
|
||||
: "dossier";
|
||||
pass = have == want || (want.StartsWith("s") && have == "saga")
|
||||
|| (want.StartsWith("d") && have == "dossier");
|
||||
detail = $"selected={have} want={want}";
|
||||
break;
|
||||
}
|
||||
case "saga_tab_effective":
|
||||
{
|
||||
string want = (cmd.value ?? "dossier").Trim().ToLowerInvariant();
|
||||
string have = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
|
||||
? "saga"
|
||||
: "dossier";
|
||||
pass = have == want || (want.StartsWith("s") && have == "saga")
|
||||
|| (want.StartsWith("d") && have == "dossier");
|
||||
detail = $"effective={have} want={want} hover={LifeSagaViewController.HoverPreviewId}";
|
||||
break;
|
||||
}
|
||||
case "saga_hover_preview":
|
||||
{
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = LifeSagaViewController.IsHoverPreview;
|
||||
pass = have == want;
|
||||
detail = $"hoverPreview={have} want={want} id={LifeSagaViewController.HoverPreviewId}";
|
||||
break;
|
||||
}
|
||||
case "saga_read_pause":
|
||||
{
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = InterestDirector.ReadPauseActive;
|
||||
pass = have == want;
|
||||
detail = $"readPause={have} want={want}";
|
||||
break;
|
||||
}
|
||||
case "saga_panel_bound":
|
||||
{
|
||||
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
|
||||
pass = model != null && model.UnitId != 0 && !string.IsNullOrEmpty(model.Name);
|
||||
detail = $"boundId={model?.UnitId ?? 0} name='{model?.Name}' lens={model?.PrimaryLens}";
|
||||
break;
|
||||
}
|
||||
case "saga_panel_evidence":
|
||||
{
|
||||
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = model != null && !string.IsNullOrEmpty(model.EvidenceLine);
|
||||
pass = have == want;
|
||||
detail =
|
||||
$"evidence='{model?.EvidenceTitle}:{model?.EvidenceLine}' want={want} lens={model?.PrimaryLens}";
|
||||
break;
|
||||
}
|
||||
case "saga_panel_empty":
|
||||
{
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
|
||||
&& LifeSagaPanel.Visible
|
||||
&& LifeSagaPanel.BoundUnitId == 0;
|
||||
pass = have == want;
|
||||
detail =
|
||||
$"empty={have} want={want} bound={LifeSagaPanel.BoundUnitId} effective={LifeSagaViewController.EffectiveSagaId}";
|
||||
break;
|
||||
}
|
||||
case "chronicle_visible":
|
||||
{
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = Chronicle.HudVisible;
|
||||
pass = have == want;
|
||||
detail = $"loreVisible={have} want={want}";
|
||||
break;
|
||||
}
|
||||
case "saga_panel_fixed_size":
|
||||
{
|
||||
// Locked tabbed chrome width + content-height Saga body (capped).
|
||||
Vector2 size = WatchCaption.LastPanelSize;
|
||||
float bodyH = LifeSagaPanel.BodyHeight;
|
||||
float bodyW = LifeSagaPanel.BodyWidthPx;
|
||||
bool onSaga = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga;
|
||||
bool widthOk = Mathf.Abs(bodyW - LifeSagaPanel.BodyWidthFixed) < 0.5f;
|
||||
bool heightOk = bodyH >= LifeSagaPanel.BodyHeightMin - 0.5f
|
||||
&& bodyH <= LifeSagaPanel.BodyHeightMax + 0.5f;
|
||||
bool compact = widthOk && heightOk;
|
||||
if (onSaga)
|
||||
{
|
||||
compact = compact
|
||||
&& LifeSagaPanel.Visible
|
||||
&& size.y <= LifeSagaPanel.BodyHeightMax + 100f
|
||||
&& size.x <= 260f
|
||||
&& size.x >= bodyW - 0.5f;
|
||||
}
|
||||
else
|
||||
{
|
||||
compact = size.x <= 260f && size.y <= 280f && size.y >= 40f;
|
||||
}
|
||||
|
||||
pass = compact;
|
||||
detail =
|
||||
$"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} maxH={LifeSagaPanel.BodyHeightMax} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
|
||||
break;
|
||||
}
|
||||
case "saga_memory_has_focus":
|
||||
{
|
||||
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = LifeSagaMemory.HasFacts(id);
|
||||
pass = have == want;
|
||||
detail = $"id={id} hasFacts={have} want={want} lives={LifeSagaMemory.LifeCount}";
|
||||
break;
|
||||
}
|
||||
case "saga_memory_fact_focus":
|
||||
{
|
||||
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
string want = (cmd.value ?? "").Trim();
|
||||
pass = false;
|
||||
var facts = LifeSagaMemory.FactsFor(id);
|
||||
for (int i = 0; i < facts.Count; i++)
|
||||
{
|
||||
if (facts[i] != null
|
||||
&& facts[i].Kind.ToString().IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
pass = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
detail = $"id={id} wantKind={want} facts={facts.Count} pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "saga_memory_life_count":
|
||||
{
|
||||
int want = ParseInt(cmd.value, 1);
|
||||
int have = LifeSagaMemory.LifeCount;
|
||||
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
||||
pass = cmp == "min" ? have >= want : have == want;
|
||||
detail = $"lives={have} want={want} cmp={cmp}";
|
||||
break;
|
||||
}
|
||||
case "saga_admission_persisted":
|
||||
{
|
||||
var board = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
||||
LifeSagaRoster.CopySlots(board);
|
||||
LifeSagaSlot slot = board.Count > 0 ? board[0] : null;
|
||||
pass = slot != null && slot.AdmissionReason != LifeSagaAdmissionReason.None;
|
||||
detail =
|
||||
$"admission={slot?.AdmissionReason.ToString() ?? "missing"} id={slot?.UnitId ?? 0}";
|
||||
break;
|
||||
}
|
||||
case "saga_role_tip":
|
||||
{
|
||||
string have = SampleRailTip();
|
||||
string any = (cmd.value ?? "").Trim();
|
||||
pass = false;
|
||||
if (!string.IsNullOrEmpty(have) && !string.IsNullOrEmpty(any))
|
||||
{
|
||||
string[] parts = any.Split('|');
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
string needle = (parts[i] ?? "").Trim();
|
||||
if (needle.Length > 0
|
||||
&& have.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
pass = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detail = $"roleTip='{have}' any='{any}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "saga_prefer_count":
|
||||
{
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
int want = ParseInt(cmd.value, 1);
|
||||
var board = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
||||
LifeSagaRoster.CopySlots(board);
|
||||
int have = 0;
|
||||
for (int i = 0; i < board.Count; i++)
|
||||
{
|
||||
if (board[i] != null && board[i].Prefer)
|
||||
{
|
||||
have++;
|
||||
}
|
||||
}
|
||||
|
||||
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
||||
pass = cmp == "min" ? have >= want : have == want;
|
||||
detail = $"preferCount={have} want={want} cmp={cmp} roster={board.Count}";
|
||||
break;
|
||||
}
|
||||
case "tip_matches_unit":
|
||||
|
|
@ -12177,7 +12773,7 @@ public static class AgentHarness
|
|||
LifeSagaRoster.CopySlots(slots);
|
||||
if (slots.Count > 0 && slots[0] != null)
|
||||
{
|
||||
return LifeSagaOverview.Build(slots[0]) ?? "";
|
||||
return LifeSagaPresentation.BuildPlainText(slots[0]) ?? "";
|
||||
}
|
||||
|
||||
return "";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -157,6 +157,23 @@ internal static class HarnessScenarios
|
|||
return SagaPreferSoftBias();
|
||||
case "saga_overview_has_mc":
|
||||
return SagaOverviewHasMc();
|
||||
case "saga_snapshot_popover":
|
||||
case "saga_adaptive_dossier":
|
||||
return SagaAdaptiveDossier();
|
||||
case "saga_memory_survives":
|
||||
return SagaMemorySurvives();
|
||||
case "saga_hover_read_pause":
|
||||
return SagaHoverReadPause();
|
||||
case "saga_admit_roles":
|
||||
return SagaAdmitRoles();
|
||||
case "saga_replace_weaker":
|
||||
return SagaReplaceWeaker();
|
||||
case "saga_rail_active_highlight":
|
||||
return SagaRailActiveHighlight();
|
||||
case "saga_rail_prefer_click":
|
||||
return SagaRailPreferClick();
|
||||
case "saga_soft_fill_no_pack":
|
||||
return SagaSoftFillNoPack();
|
||||
case "story_spine_scoped":
|
||||
return StorySpineScoped();
|
||||
case "story_family_variety":
|
||||
|
|
@ -347,8 +364,16 @@ internal static class HarnessScenarios
|
|||
Nested("reg_saga_camera", "saga_camera_prefers_mc"),
|
||||
Nested("reg_saga_prefer", "saga_prefer_soft_bias"),
|
||||
Nested("reg_saga_overview", "saga_overview_has_mc"),
|
||||
Nested("reg_saga_snapshot", "saga_adaptive_dossier"),
|
||||
Nested("reg_saga_memory", "saga_memory_survives"),
|
||||
Nested("reg_saga_hover_pause", "saga_hover_read_pause"),
|
||||
Nested("reg_saga_diversity", "saga_roster_diversity"),
|
||||
Nested("reg_saga_death", "saga_replace_on_death"),
|
||||
Nested("reg_saga_admit", "saga_admit_roles"),
|
||||
Nested("reg_saga_replace", "saga_replace_weaker"),
|
||||
Nested("reg_saga_rail_active", "saga_rail_active_highlight"),
|
||||
Nested("reg_saga_rail_prefer", "saga_rail_prefer_click"),
|
||||
Nested("reg_saga_soft_fill", "saga_soft_fill_no_pack"),
|
||||
Nested("reg_discovery", "discovery"),
|
||||
Nested("reg_worldlog", "world_log"),
|
||||
Nested("reg_presentability", "camera_presentability"),
|
||||
|
|
@ -2616,7 +2641,8 @@ internal static class HarnessScenarios
|
|||
Step("ss53", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("ss54", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("ss55", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("ss56", "assert", expect: "story_spine", value: "Duel · Climax"),
|
||||
// Tip already leads with Duel - hide redundant Kind · Climax spine row.
|
||||
Step("ss56", "assert", expect: "story_spine", value: "empty"),
|
||||
// In-place escalate must retarget Kind Mass without a director SwitchTo.
|
||||
Step("ss57", "combat_ensemble_escalate", value: "6"),
|
||||
Step("ss57b", "combat_wire_attack_sides", asset: "human", value: "wolf"),
|
||||
|
|
@ -2624,16 +2650,15 @@ internal static class HarnessScenarios
|
|||
Step("ss57d", "wait", value: "0.35"),
|
||||
Step("ss57e", "combat_maintain_focus"),
|
||||
Step("ss58", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||
// Spine Kind follows tip Label (Battle/Mass/Skirmish), not a collapsed Mass bucket.
|
||||
Step("ss59", "assert", expect: "story_spine",
|
||||
value: "Battle · Climax|Mass · Climax|Skirmish · Climax"),
|
||||
// Climax spine suppressed when tip already names the kind.
|
||||
Step("ss59", "assert", expect: "story_spine", value: "empty"),
|
||||
// Shrink back to a named duel - spine Kind must follow the Label, not scale-hold.
|
||||
Step("ss59b", "combat_isolate_pair", value: "20"),
|
||||
Step("ss59c", "combat_maintain_focus"),
|
||||
Step("ss59d", "wait", value: "0.35"),
|
||||
Step("ss59e", "combat_maintain_focus"),
|
||||
Step("ss59f", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("ss59g", "assert", expect: "story_spine", value: "Duel · Climax"),
|
||||
Step("ss59g", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("ss60", "interest_force_session", asset: "human",
|
||||
label: "{a} appears in the world", tier: "Action", expect: "spawn_leave_duel",
|
||||
value: "lead=event;evt=95"),
|
||||
|
|
@ -2760,7 +2785,7 @@ internal static class HarnessScenarios
|
|||
Step("sac22", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("sac23", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("sac24", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("sac24b", "assert", expect: "story_spine", value: "Duel · Climax"),
|
||||
Step("sac24b", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("sac25", "interest_expire_pending", value: ""),
|
||||
// Survivor aftermath requires a fallen theater partner (both-alive must not mint stands-over).
|
||||
Step("sac25b", "combat_kill_related"),
|
||||
|
|
@ -2938,7 +2963,10 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>Saga overview hover has MC name and no planner jargon / trait descs.</summary>
|
||||
/// <summary>
|
||||
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
|
||||
/// no planner jargon / trait descs / Chronicle dumps.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> SagaOverviewHasMc()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
|
|
@ -2954,16 +2982,186 @@ internal static class HarnessScenarios
|
|||
Step("som8", "focus", asset: "human"),
|
||||
Step("som9", "interest_saga_clear"),
|
||||
Step("som10", "saga_force_admit_focus"),
|
||||
Step("som11", "director_run", wait: 0.4f),
|
||||
Step("som12", "assert", expect: "saga_roster_count", value: "1", label: "min"),
|
||||
Step("som13", "assert", expect: "saga_overview_tip_not",
|
||||
value: "Climax|Aftermath|Epilogue|parked|watching|Known for:|Appearance brings comfort"),
|
||||
value: "Climax|Aftermath|Epilogue|parked|watching|Appearance brings comfort|Standing|Known for|Harness chapter|Fighting "),
|
||||
Step("som13b", "assert", expect: "saga_overview_tip",
|
||||
value: "At stake|Cast|Legacy|King|Leader|Alpha|Dragon|Human|life"),
|
||||
Step("som14", "assert", expect: "story_rail_count", value: "1", label: "min"),
|
||||
Step("som15", "assert", expect: "saga_admission_persisted"),
|
||||
Step("som15b", "saga_rail_refresh"),
|
||||
Step("som15c", "assert", expect: "saga_admission_persisted"),
|
||||
Step("som16", "saga_show_hover_first"),
|
||||
Step("som17", "assert", expect: "saga_tab_effective", value: "saga"),
|
||||
Step("som17b", "assert", expect: "saga_snapshot_layout"),
|
||||
Step("som20", "saga_stamp_chapter_focus", asset: "Combat",
|
||||
value: "Survived a bloody duel against a rival"),
|
||||
Step("som21", "saga_rail_refresh"),
|
||||
Step("som22", "saga_show_hover_first"),
|
||||
Step("som23", "assert", expect: "saga_overview_tip", value: "Survived a bloody duel"),
|
||||
Step("som24", "assert", expect: "saga_overview_tip_not",
|
||||
value: "Climax|Aftermath|Epilogue|Appearance brings comfort|Standing age|Fighting "),
|
||||
Step("som25", "assert", expect: "saga_snapshot_layout"),
|
||||
Step("som26", "screenshot", value: "saga-adaptive-hover"),
|
||||
Step("som90", "fast_timing", value: "false"),
|
||||
Step("som99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Dossier/Saga tabs, hover preview restore, Prefer-only click, fixed body.</summary>
|
||||
private static List<HarnessCommand> SagaAdaptiveDossier()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sad0", "dismiss_windows"),
|
||||
Step("sad1", "wait_world"),
|
||||
Step("sad2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sad3", "fast_timing", value: "true"),
|
||||
Step("sad4", "spawn", asset: "human", count: 2),
|
||||
Step("sad5", "spectator", value: "off"),
|
||||
Step("sad6", "spectator", value: "on"),
|
||||
Step("sad7", "interest_saga_clear"),
|
||||
Step("sad8", "pick_unit", asset: "human"),
|
||||
Step("sad9", "focus", asset: "human"),
|
||||
Step("sad10", "saga_force_admit_focus"),
|
||||
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
|
||||
Step("sad12", "saga_select_tab", value: "saga"),
|
||||
Step("sad13", "assert", expect: "saga_tab_effective", value: "saga"),
|
||||
Step("sad14", "assert", expect: "saga_panel_bound"),
|
||||
Step("sad14b", "assert", expect: "saga_panel_fixed_size"),
|
||||
Step("sad15", "screenshot", value: "saga-tab-selected"),
|
||||
Step("sad15b", "saga_stamp_chapter_focus", asset: "Love", value: "Bound with a lasting partner"),
|
||||
Step("sad15c", "saga_memory_record_focus", asset: "Founding", value: "kingdom"),
|
||||
Step("sad15d", "saga_select_tab", value: "saga"),
|
||||
Step("sad15e", "assert", expect: "saga_panel_fixed_size"),
|
||||
Step("sad15f", "screenshot", value: "saga-tab-rich"),
|
||||
Step("sad16", "saga_select_tab", value: "dossier"),
|
||||
Step("sad16b", "assert", expect: "saga_panel_fixed_size"),
|
||||
Step("sad16c", "screenshot", value: "saga-dossier-paired"),
|
||||
Step("sad17", "saga_show_hover_first"),
|
||||
Step("sad17b", "assert", expect: "saga_panel_fixed_size"),
|
||||
Step("sad18", "assert", expect: "saga_tab_selected", value: "dossier"),
|
||||
Step("sad19", "assert", expect: "saga_tab_effective", value: "saga"),
|
||||
Step("sad20", "assert", expect: "saga_hover_preview", value: "true"),
|
||||
Step("sad21", "assert", expect: "saga_read_pause", value: "true"),
|
||||
// Panel dwell clears the exit timer; leaving chrome must re-arm and restore Dossier.
|
||||
Step("sad21b", "saga_hover_panel_dwell"),
|
||||
Step("sad21c", "saga_hover_tick_away"),
|
||||
Step("sad21d", "wait", wait: 0.1f),
|
||||
Step("sad21e", "saga_hover_tick_away"),
|
||||
Step("sad21f", "wait", wait: 0.4f),
|
||||
Step("sad21g", "saga_hover_tick_away"),
|
||||
Step("sad21h", "assert", expect: "saga_hover_preview", value: "false"),
|
||||
Step("sad21i", "assert", expect: "saga_tab_effective", value: "dossier"),
|
||||
Step("sad21j", "assert", expect: "saga_read_pause", value: "false"),
|
||||
Step("sad21k", "saga_show_hover_first"),
|
||||
Step("sad21l", "assert", expect: "saga_hover_preview", value: "true"),
|
||||
// Tab click during preview cancels preview and applies the selected tab.
|
||||
Step("sad21m", "saga_select_tab", value: "dossier"),
|
||||
Step("sad21n", "assert", expect: "saga_hover_preview", value: "false"),
|
||||
Step("sad21o", "assert", expect: "saga_tab_effective", value: "dossier"),
|
||||
Step("sad22", "saga_show_hover_first"),
|
||||
Step("sad22b", "saga_hide_hover"),
|
||||
Step("sad23", "wait", wait: 0.4f),
|
||||
Step("sad23b", "saga_hover_tick_away"),
|
||||
Step("sad24", "assert", expect: "saga_hover_preview", value: "false"),
|
||||
Step("sad25", "assert", expect: "saga_tab_effective", value: "dossier"),
|
||||
Step("sad26", "assert", expect: "saga_read_pause", value: "false"),
|
||||
Step("sad27", "assert", expect: "saga_overview_tip_not", value: "Fighting |Why |Now |Notable "),
|
||||
Step("sad28", "saga_select_tab", value: "saga"),
|
||||
Step("sad29", "assert", expect: "saga_panel_evidence", value: "true"),
|
||||
// Non-MC watch with no pin → empty pick state; rail click pins subject.
|
||||
Step("sad30", "interest_saga_clear"),
|
||||
Step("sad31", "spawn", asset: "human", count: 2),
|
||||
Step("sad32", "pick_unit", asset: "human"),
|
||||
Step("sad33", "focus", asset: "human"),
|
||||
Step("sad34", "saga_force_admit_focus"),
|
||||
Step("sad35", "spawn", asset: "sheep", count: 1),
|
||||
Step("sad36", "pick_unit", asset: "sheep"),
|
||||
Step("sad37", "focus", asset: "sheep"),
|
||||
Step("sad38", "saga_select_tab", value: "saga"),
|
||||
Step("sad39", "assert", expect: "saga_panel_empty", value: "true"),
|
||||
Step("sad40", "saga_click_first"),
|
||||
Step("sad41", "assert", expect: "saga_panel_empty", value: "false"),
|
||||
Step("sad42", "assert", expect: "saga_panel_bound"),
|
||||
Step("sad43", "screenshot", value: "saga-rail-top"),
|
||||
// Open Lore from Saga and restore Saga on close.
|
||||
Step("sad44", "saga_open_lore"),
|
||||
Step("sad45", "assert", expect: "chronicle_visible", value: "true"),
|
||||
Step("sad46", "lore_close"),
|
||||
Step("sad47", "assert", expect: "saga_tab_effective", value: "saga"),
|
||||
Step("sad48", "assert", expect: "saga_panel_bound"),
|
||||
Step("sad90", "fast_timing", value: "false"),
|
||||
Step("sad99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Memory survives roster eviction and captures without Chronicle.</summary>
|
||||
private static List<HarnessCommand> SagaMemorySurvives()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sms0", "dismiss_windows"),
|
||||
Step("sms1", "wait_world"),
|
||||
Step("sms2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sms3", "set_setting", expect: "chronicle_enabled", value: "false"),
|
||||
Step("sms4", "fast_timing", value: "true"),
|
||||
Step("sms5", "spawn", asset: "human", count: 2),
|
||||
Step("sms6", "spectator", value: "off"),
|
||||
Step("sms7", "spectator", value: "on"),
|
||||
Step("sms8", "interest_saga_clear"),
|
||||
Step("sms9", "pick_unit", asset: "human"),
|
||||
Step("sms10", "focus", asset: "human"),
|
||||
Step("sms11", "saga_force_admit_focus"),
|
||||
Step("sms12", "saga_memory_record_focus", asset: "Kill", value: "Named foe"),
|
||||
Step("sms13", "assert", expect: "saga_memory_has_focus", value: "true"),
|
||||
Step("sms14", "assert", expect: "saga_memory_fact_focus", value: "Kill"),
|
||||
Step("sms15", "saga_set_scores_focus", value: "0.1", label: "0"),
|
||||
Step("sms16", "pick_unit", asset: "human", label: "other"),
|
||||
Step("sms17", "focus", asset: "human"),
|
||||
Step("sms18", "saga_force_admit_focus"),
|
||||
Step("sms19", "saga_world_scan"),
|
||||
Step("sms20", "assert", expect: "saga_memory_life_count", value: "1", label: "min"),
|
||||
Step("sms21", "set_setting", expect: "chronicle_enabled", value: "true"),
|
||||
Step("sms90", "fast_timing", value: "false"),
|
||||
Step("sms99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Hover acquires read-pause; camera owner stays fixed until release.</summary>
|
||||
private static List<HarnessCommand> SagaHoverReadPause()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("shp0", "dismiss_windows"),
|
||||
Step("shp1", "wait_world"),
|
||||
Step("shp2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("shp3", "fast_timing", value: "true"),
|
||||
Step("shp4", "spawn", asset: "human", count: 2),
|
||||
Step("shp5", "spectator", value: "off"),
|
||||
Step("shp6", "spectator", value: "on"),
|
||||
Step("shp7", "interest_saga_clear"),
|
||||
Step("shp8", "pick_unit", asset: "human"),
|
||||
Step("shp9", "focus", asset: "human"),
|
||||
Step("shp10", "saga_force_admit_focus"),
|
||||
Step("shp11", "interest_force_session", asset: "human", label: "PauseHold",
|
||||
tier: "Action", value: "lead=event;evt=70;force=true;ttl=30"),
|
||||
Step("shp12", "assert", expect: "tip_contains", value: "PauseHold"),
|
||||
Step("shp13", "saga_show_hover_first"),
|
||||
Step("shp14", "assert", expect: "saga_read_pause", value: "true"),
|
||||
Step("shp15", "interest_inject", asset: "human", label: "ShouldNotCut", tier: "Action",
|
||||
expect: "shp_cut", value: "lead=event;evt=95;force=false;ttl=30;pick=other"),
|
||||
Step("shp16", "director_run", wait: 1.0f),
|
||||
Step("shp17", "assert", expect: "tip_contains", value: "PauseHold"),
|
||||
Step("shp18", "assert", expect: "tip_not_contains", value: "ShouldNotCut"),
|
||||
Step("shp19", "saga_hide_hover"),
|
||||
Step("shp20", "wait", wait: 0.35f),
|
||||
Step("shp21", "assert", expect: "saga_read_pause", value: "false"),
|
||||
Step("shp90", "fast_timing", value: "false"),
|
||||
Step("shp99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Multi-species notables fill distinct roster slots.</summary>
|
||||
private static List<HarnessCommand> SagaRosterDiversity()
|
||||
{
|
||||
|
|
@ -3026,6 +3224,204 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended roles: singleton dragon overview + hard-arc nobody admit path.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> SagaAdmitRoles()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sar0", "dismiss_windows"),
|
||||
Step("sar1", "wait_world"),
|
||||
Step("sar2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sar3", "fast_timing", value: "true"),
|
||||
Step("sar4", "spawn", asset: "dragon", count: 1),
|
||||
Step("sar5", "spawn", asset: "human", count: 1),
|
||||
Step("sar6", "spectator", value: "off"),
|
||||
Step("sar7", "spectator", value: "on"),
|
||||
Step("sar8", "interest_saga_clear"),
|
||||
Step("sar9", "pick_unit", asset: "dragon"),
|
||||
Step("sar10", "focus", asset: "dragon"),
|
||||
// Force admit so role flags refresh from live singleton/pop helpers.
|
||||
Step("sar11", "saga_force_admit_focus"),
|
||||
Step("sar12", "assert", expect: "saga_has_focus"),
|
||||
Step("sar13", "saga_rail_refresh"),
|
||||
Step("sar14", "assert", expect: "saga_role_tip", value: "Lone|Last of their kind|Dragon"),
|
||||
// Nobody human may enter only via hard-arc ConsiderAdmit.
|
||||
Step("sar20", "pick_unit", asset: "human"),
|
||||
Step("sar21", "focus", asset: "human"),
|
||||
Step("sar22", "saga_consider_hard_focus"),
|
||||
Step("sar23", "assert", expect: "saga_has_focus"),
|
||||
Step("sar24", "assert", expect: "saga_roster_count", value: "2", label: "min"),
|
||||
Step("sar90", "fast_timing", value: "false"),
|
||||
Step("sar99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Full Cap: Prefer pin survives; hotter hard-arc admit displaces weakest non-pin.</summary>
|
||||
private static List<HarnessCommand> SagaReplaceWeaker()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("srw0", "dismiss_windows"),
|
||||
Step("srw1", "wait_world"),
|
||||
Step("srw2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("srw3", "fast_timing", value: "true"),
|
||||
Step("srw4", "spawn", asset: "human", count: 11),
|
||||
Step("srw5", "spectator", value: "off"),
|
||||
Step("srw6", "spectator", value: "on"),
|
||||
Step("srw7", "interest_saga_clear"),
|
||||
// Fill Cap with force-admits.
|
||||
Step("srw10", "pick_unit", asset: "human"),
|
||||
Step("srw11", "focus", asset: "human"),
|
||||
Step("srw12", "saga_force_admit_focus"),
|
||||
Step("srw13", "saga_prefer_focus"),
|
||||
Step("srw14", "assert", expect: "saga_prefer_focus_on"),
|
||||
Step("srw20", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw21", "focus", asset: "human"),
|
||||
Step("srw22", "saga_force_admit_focus"),
|
||||
Step("srw23", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw24", "focus", asset: "human"),
|
||||
Step("srw25", "saga_force_admit_focus"),
|
||||
Step("srw26", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw27", "focus", asset: "human"),
|
||||
Step("srw28", "saga_force_admit_focus"),
|
||||
Step("srw29", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw30", "focus", asset: "human"),
|
||||
Step("srw31", "saga_force_admit_focus"),
|
||||
Step("srw32", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw33", "focus", asset: "human"),
|
||||
Step("srw34", "saga_force_admit_focus"),
|
||||
Step("srw35", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw36", "focus", asset: "human"),
|
||||
Step("srw37", "saga_force_admit_focus"),
|
||||
Step("srw38", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw39", "focus", asset: "human"),
|
||||
Step("srw40", "saga_force_admit_focus"),
|
||||
Step("srw41", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw42", "focus", asset: "human"),
|
||||
Step("srw43", "saga_force_admit_focus"),
|
||||
Step("srw44", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw45", "focus", asset: "human"),
|
||||
Step("srw46", "saga_force_admit_focus"),
|
||||
Step("srw50", "assert", expect: "saga_roster_count", value: "10"),
|
||||
Step("srw51", "saga_weaken_nonprefer"),
|
||||
// 11th human challenges Cap via hard-arc admit path.
|
||||
Step("srw60", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw61", "focus", asset: "human"),
|
||||
Step("srw62", "saga_consider_hard_focus"),
|
||||
Step("srw63", "assert", expect: "saga_has_focus"),
|
||||
Step("srw64", "assert", expect: "saga_roster_count", value: "10"),
|
||||
// Prefer pin still present; challenger focus itself is not Prefer.
|
||||
Step("srw70", "assert", expect: "saga_prefer_focus_on", value: "false"),
|
||||
Step("srw71", "assert", expect: "saga_prefer_count", value: "1", label: "min"),
|
||||
Step("srw90", "fast_timing", value: "false"),
|
||||
Step("srw99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Active rail chrome tracks camera follow, not Prefer alone.</summary>
|
||||
private static List<HarnessCommand> SagaRailActiveHighlight()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sra0", "dismiss_windows"),
|
||||
Step("sra1", "wait_world"),
|
||||
Step("sra2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sra3", "fast_timing", value: "true"),
|
||||
Step("sra4", "spawn", asset: "human", count: 2),
|
||||
Step("sra5", "spectator", value: "off"),
|
||||
Step("sra6", "spectator", value: "on"),
|
||||
Step("sra7", "interest_saga_clear"),
|
||||
Step("sra8", "pick_unit", asset: "human"),
|
||||
Step("sra9", "focus", asset: "human"),
|
||||
Step("sra10", "saga_force_admit_focus"),
|
||||
Step("sra11", "saga_prefer_focus"),
|
||||
Step("sra12", "saga_rail_refresh"),
|
||||
Step("sra13", "assert", expect: "saga_rail_active_highlight", value: "true"),
|
||||
Step("sra14", "assert", expect: "saga_rail_prefer_pip", value: "true"),
|
||||
Step("sra20", "pick_unit", asset: "human", value: "other"),
|
||||
Step("sra21", "focus", asset: "human"),
|
||||
Step("sra22", "saga_force_admit_focus"),
|
||||
Step("sra23", "saga_rail_refresh"),
|
||||
// Follow moved off Prefer'd MC - Active highlight should track the new follow.
|
||||
Step("sra24", "assert", expect: "saga_rail_active_highlight", value: "true"),
|
||||
Step("sra25", "assert", expect: "saga_rail_prefer_pip", value: "true"),
|
||||
Step("sra90", "fast_timing", value: "false"),
|
||||
Step("sra99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Prefer toggle flips on then off with immediate pip state.</summary>
|
||||
private static List<HarnessCommand> SagaRailPreferClick()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("srp0", "dismiss_windows"),
|
||||
Step("srp1", "wait_world"),
|
||||
Step("srp2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("srp3", "fast_timing", value: "true"),
|
||||
Step("srp4", "spawn", asset: "human", count: 1),
|
||||
Step("srp5", "spectator", value: "off"),
|
||||
Step("srp6", "spectator", value: "on"),
|
||||
Step("srp7", "interest_saga_clear"),
|
||||
Step("srp8", "pick_unit", asset: "human"),
|
||||
Step("srp9", "focus", asset: "human"),
|
||||
Step("srp10", "saga_force_admit_focus"),
|
||||
Step("srp11", "saga_prefer_focus"),
|
||||
Step("srp12", "saga_rail_refresh"),
|
||||
Step("srp13", "assert", expect: "saga_prefer_focus_on"),
|
||||
Step("srp14", "assert", expect: "saga_rail_prefer_pip", value: "true"),
|
||||
Step("srp20", "saga_prefer_focus"),
|
||||
Step("srp21", "saga_rail_refresh"),
|
||||
Step("srp22", "assert", expect: "saga_prefer_focus_on", value: "false"),
|
||||
Step("srp23", "assert", expect: "saga_rail_prefer_pip", value: "false"),
|
||||
Step("srp90", "fast_timing", value: "false"),
|
||||
Step("srp99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// During soft-fill quiet, Pack and stun/invincible crumbs do not win; combat still can.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> SagaSoftFillNoPack()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("ssf0", "dismiss_windows"),
|
||||
Step("ssf1", "wait_world"),
|
||||
Step("ssf2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("ssf3", "fast_timing", value: "true"),
|
||||
Step("ssf4", "spawn", asset: "human", count: 2),
|
||||
Step("ssf5", "spectator", value: "off"),
|
||||
Step("ssf6", "spectator", value: "on"),
|
||||
Step("ssf7", "pick_unit", asset: "human"),
|
||||
Step("ssf8", "focus", asset: "human"),
|
||||
Step("ssf9", "interest_end_session"),
|
||||
Step("ssf10", "soft_fill_quiet_arm"),
|
||||
Step("ssf11", "assert", expect: "soft_fill_quiet", value: "true"),
|
||||
Step("ssf20", "interest_expire_pending", value: ""),
|
||||
Step("ssf21", "interest_inject", asset: "human", label: "Pack - QuietBlock (3) · gathering",
|
||||
tier: "Action", expect: "pack_soft",
|
||||
value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("ssf22", "interest_inject", asset: "human", label: "QuietStun",
|
||||
tier: "Action", expect: "stunned",
|
||||
value: "lead=event;evt=68;char=5;force=false;ttl=60;pick=other"),
|
||||
Step("ssf23", "interest_inject", asset: "human", label: "QuietInvincible",
|
||||
tier: "Action", expect: "invincible",
|
||||
value: "lead=event;evt=66;char=5;force=false;ttl=60"),
|
||||
Step("ssf24", "director_run", wait: 1.2f),
|
||||
Step("ssf25", "assert", expect: "tip_not_contains", value: "Pack -"),
|
||||
Step("ssf26", "assert", expect: "tip_not_contains", value: "QuietStun"),
|
||||
Step("ssf27", "assert", expect: "tip_not_contains", value: "QuietInvincible"),
|
||||
Step("ssf30", "interest_combat_session", asset: "human", expect: "ssf_combat"),
|
||||
Step("ssf31", "director_run", wait: 0.8f),
|
||||
Step("ssf32", "assert", expect: "tip_matches_any", value: "Duel -|fighting|Skirmish -"),
|
||||
Step("ssf90", "fast_timing", value: "false"),
|
||||
Step("ssf99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sticky WarFront tips stay kingdom-framed across count dips and follow loss.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -460,9 +460,51 @@ public static partial class InterestDirector
|
|||
private static float _browseFrozenLastSwitchAt = -999f;
|
||||
private static float _browseFrozenLastInterestingAt = -999f;
|
||||
|
||||
/// <summary>Camera read-pause lease owners (hover preview). Feeds/story continue; switching deferred.</summary>
|
||||
private static readonly HashSet<string> ReadPauseOwners = new HashSet<string>();
|
||||
private static bool _readPauseNeedsReconcile;
|
||||
|
||||
public static bool ReadPauseActive => ReadPauseOwners.Count > 0;
|
||||
|
||||
public static bool AcquireReadPause(string owner)
|
||||
{
|
||||
if (string.IsNullOrEmpty(owner))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool added = ReadPauseOwners.Add(owner);
|
||||
return added || ReadPauseOwners.Contains(owner);
|
||||
}
|
||||
|
||||
public static void ReleaseReadPause(string owner)
|
||||
{
|
||||
if (string.IsNullOrEmpty(owner))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ReadPauseOwners.Remove(owner) && ReadPauseOwners.Count == 0)
|
||||
{
|
||||
_readPauseNeedsReconcile = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearReadPause()
|
||||
{
|
||||
if (ReadPauseOwners.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ReadPauseOwners.Clear();
|
||||
_readPauseNeedsReconcile = true;
|
||||
}
|
||||
|
||||
public static void OnSpectatorEnabled()
|
||||
{
|
||||
ClearBrowseFreeze();
|
||||
ClearReadPause();
|
||||
InterestRegistry.Clear();
|
||||
InterestVariety.Clear();
|
||||
InterestScoring.ClearCache();
|
||||
|
|
@ -488,6 +530,7 @@ public static partial class InterestDirector
|
|||
public static void OnSpectatorDisabled()
|
||||
{
|
||||
ClearBrowseFreeze();
|
||||
ClearReadPause();
|
||||
_current = null;
|
||||
_interrupted = null;
|
||||
InterestRegistry.Clear();
|
||||
|
|
@ -598,6 +641,20 @@ public static partial class InterestDirector
|
|||
|
||||
StoryPlanner.Tick(now);
|
||||
LifeSagaRoster.Tick(now);
|
||||
LifeSagaMemory.EnsureWorldBound();
|
||||
|
||||
if (ReadPauseActive)
|
||||
{
|
||||
// Keep feeds/story/roster alive; freeze every camera-mutating path.
|
||||
return;
|
||||
}
|
||||
|
||||
if (_readPauseNeedsReconcile)
|
||||
{
|
||||
_readPauseNeedsReconcile = false;
|
||||
MaintainCameraFocus(now);
|
||||
}
|
||||
|
||||
UpdateSessionLiveness(now, onCurrent);
|
||||
MaintainCombatFocus(now, force: false);
|
||||
MaintainWarFront(now, force: false);
|
||||
|
|
@ -1829,6 +1886,12 @@ public static partial class InterestDirector
|
|||
LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label);
|
||||
}
|
||||
|
||||
// Soft crumbs ending during/after hard quiet must not reopen the floodgates.
|
||||
if (ended != null && InterestVariety.IsSoftFillQuietCrumb(ended))
|
||||
{
|
||||
StoryPlanner.NoteSoftCrumbQuietRefresh(now);
|
||||
}
|
||||
|
||||
StoryPlanner.OnEndCurrent(ended, now);
|
||||
|
||||
// Resume interrupted Epic-preempted scene if still valid.
|
||||
|
|
@ -1925,10 +1988,10 @@ public static partial class InterestDirector
|
|||
continue;
|
||||
}
|
||||
|
||||
// After hard story/crisis closers, skip soft-life crumbs for a beat so AFK
|
||||
// does not immediately hatch/reproduce/sleep surf. Leave them pending.
|
||||
// After hard story/crisis closers, skip soft-life / short interrupt crumbs
|
||||
// so AFK does not immediately hatch/reproduce/sleep/stun surf. Leave them pending.
|
||||
if (softQuiet
|
||||
&& InterestVariety.IsSoftLifeChapter(c)
|
||||
&& InterestVariety.IsSoftFillQuietCrumb(c)
|
||||
&& !BreaksSoftFillQuiet(c))
|
||||
{
|
||||
InterestDropLog.Record("soft_fill_quiet", c.AssetId ?? c.HappinessEffectId ?? c.Key);
|
||||
|
|
@ -1973,12 +2036,12 @@ public static partial class InterestDirector
|
|||
return false;
|
||||
}
|
||||
|
||||
// FamilyPack is ambient cluster fill - must not break soft-fill quiet.
|
||||
if (c.Completion == InterestCompletionKind.CombatActive
|
||||
|| c.Completion == InterestCompletionKind.WarFront
|
||||
|| c.Completion == InterestCompletionKind.PlotActive
|
||||
|| c.Completion == InterestCompletionKind.StatusOutbreak
|
||||
|| c.Completion == InterestCompletionKind.EarthquakeActive
|
||||
|| c.Completion == InterestCompletionKind.FamilyPack)
|
||||
|| c.Completion == InterestCompletionKind.EarthquakeActive)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1207,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.
|
||||
|
|
@ -1218,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
|
||||
|
|
@ -1231,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"
|
||||
|
|
|
|||
1332
IdleSpectator/Story/LifeSagaMemory.cs
Normal file
1332
IdleSpectator/Story/LifeSagaMemory.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,504 +1,135 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-line saga hover card: Title / Why / Circle / Chapters.
|
||||
/// No trait descs, no Chronicle dump, no planner jargon.
|
||||
/// Compatibility façade over <see cref="LifeSagaPresentation"/>.
|
||||
/// Prefer <see cref="LifeSagaPresentation.Build"/> for new UI.
|
||||
/// </summary>
|
||||
public static class LifeSagaOverview
|
||||
{
|
||||
private const int EpithetMinScore = 80;
|
||||
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)
|
||||
{
|
||||
if (slot == null || slot.UnitId == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
Actor actor = slot.Resolve();
|
||||
var sb = new StringBuilder(256);
|
||||
string title = BuildTitle(slot, actor);
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
sb.Append(title);
|
||||
}
|
||||
|
||||
string why = BuildWhy(slot, actor);
|
||||
AppendLine(sb, why);
|
||||
|
||||
string circle = BuildCircle(actor);
|
||||
AppendLine(sb, circle);
|
||||
|
||||
string chapters = BuildChapters(slot);
|
||||
AppendLine(sb, chapters);
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static void AppendLine(StringBuilder sb, string line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
sb.Append(line);
|
||||
}
|
||||
|
||||
private static string BuildTitle(LifeSagaSlot slot, Actor actor)
|
||||
{
|
||||
string name = !string.IsNullOrEmpty(slot.DisplayName)
|
||||
? slot.DisplayName
|
||||
: "Someone";
|
||||
string epithet = TryEpithetName(actor);
|
||||
string role = BuildRole(slot, actor);
|
||||
bool starred = slot.Prefer || slot.GameFavorite;
|
||||
|
||||
var sb = new StringBuilder(64);
|
||||
if (starred)
|
||||
{
|
||||
sb.Append("★ ");
|
||||
}
|
||||
|
||||
sb.Append(name);
|
||||
if (!string.IsNullOrEmpty(epithet))
|
||||
{
|
||||
sb.Append(" the ").Append(epithet);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(role))
|
||||
{
|
||||
sb.Append(" · ").Append(role);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildRole(LifeSagaSlot slot, Actor actor)
|
||||
{
|
||||
if (slot.IsKing)
|
||||
{
|
||||
string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
|
||||
return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom;
|
||||
}
|
||||
|
||||
if (slot.IsAlpha)
|
||||
{
|
||||
return "Alpha of the pack";
|
||||
}
|
||||
|
||||
if (slot.IsLeader)
|
||||
{
|
||||
string city = FirstNonEmpty(slot.CityKey, CityName(actor));
|
||||
return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city;
|
||||
}
|
||||
|
||||
string species = SpeciesLabel(slot, actor);
|
||||
string kingdomFallback = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
|
||||
if (!string.IsNullOrEmpty(species) && !string.IsNullOrEmpty(kingdomFallback))
|
||||
{
|
||||
return species + " of " + kingdomFallback;
|
||||
}
|
||||
|
||||
return FirstNonEmpty(species, kingdomFallback);
|
||||
}
|
||||
|
||||
private static string BuildWhy(LifeSagaSlot slot, Actor actor)
|
||||
{
|
||||
// Title already carries role / favorite star - Why adds standing/event only.
|
||||
var parts = new List<string>(3);
|
||||
if (slot.Kills >= 10)
|
||||
{
|
||||
parts.Add(slot.Kills + " kills");
|
||||
}
|
||||
|
||||
if (slot.Renown >= 80f)
|
||||
{
|
||||
parts.Add("renown " + Mathf.RoundToInt(slot.Renown));
|
||||
}
|
||||
|
||||
if (IsAtWar(actor))
|
||||
{
|
||||
string side = FirstNonEmpty(slot.KingdomKey, KingdomName(actor), SpeciesLabel(slot, actor));
|
||||
if (!string.IsNullOrEmpty(side))
|
||||
{
|
||||
parts.Add("leading " + side + " in war");
|
||||
}
|
||||
else
|
||||
{
|
||||
parts.Add("at war");
|
||||
}
|
||||
}
|
||||
else if (slot.IsKing && parts.Count == 0)
|
||||
{
|
||||
// Newly crowned with no kill/war signal - omit rather than reprint King.
|
||||
}
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private static string BuildCircle(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var parts = new List<string>(3);
|
||||
Actor lover = ActorRelation.GetLover(actor);
|
||||
if (lover != null)
|
||||
{
|
||||
parts.Add("Lover " + SafeName(lover));
|
||||
}
|
||||
|
||||
Actor friend = ActorRelation.GetBestFriend(actor);
|
||||
if (friend != null && friend != lover)
|
||||
{
|
||||
parts.Add("Friend " + SafeName(friend));
|
||||
}
|
||||
|
||||
foreach (Actor child in ActorRelation.EnumerateChildren(actor, max: 1))
|
||||
{
|
||||
if (child != null && child != lover && child != friend)
|
||||
{
|
||||
parts.Add("Child " + SafeName(child));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private static string BuildChapters(LifeSagaSlot slot)
|
||||
{
|
||||
if (slot.Chapters == null || slot.Chapters.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(128);
|
||||
int n = Mathf.Min(LifeSagaRoster.MaxChapters, slot.Chapters.Count);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
string line = slot.Chapters[i];
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
sb.Append("· ").Append(line);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
return LifeSagaPresentation.BuildPlainText(slot);
|
||||
}
|
||||
|
||||
public static float StandoutTraitScore(Actor actor)
|
||||
{
|
||||
ActorTrait top = PickTopTrait(actor, out int score);
|
||||
if (top == null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return Mathf.Max(0, score);
|
||||
return UnitDossier.ReadStandoutTraitNames(actor, 1).Count > 0 ? 100f : 0f;
|
||||
}
|
||||
|
||||
private static string TryEpithetName(Actor actor)
|
||||
private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind)
|
||||
{
|
||||
ActorTrait top = PickTopTrait(actor, out int score);
|
||||
if (top == null || score < EpithetMinScore)
|
||||
switch (kind)
|
||||
{
|
||||
return "";
|
||||
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;
|
||||
}
|
||||
|
||||
return TraitDisplayName(top);
|
||||
}
|
||||
|
||||
private static ActorTrait PickTopTrait(Actor actor, out int bestScore)
|
||||
{
|
||||
bestScore = int.MinValue;
|
||||
if (actor == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!actor.hasTraits())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ActorTrait best = null;
|
||||
foreach (ActorTrait trait in actor.getTraits())
|
||||
{
|
||||
if (trait == null || string.IsNullOrEmpty(trait.id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsDefaultTraitForActor(trait, actor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int score = TraitInterestScore(trait, actor);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = trait;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int TraitInterestScore(ActorTrait trait, Actor actor)
|
||||
{
|
||||
if (trait == null)
|
||||
{
|
||||
return int.MinValue;
|
||||
}
|
||||
|
||||
int score = 0;
|
||||
try
|
||||
{
|
||||
score += (int)trait.rarity * 100;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (trait.type == TraitType.Negative)
|
||||
{
|
||||
score += 25;
|
||||
}
|
||||
else if (trait.type == TraitType.Positive)
|
||||
{
|
||||
score += 15;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!IsDefaultTraitForActor(trait, actor))
|
||||
{
|
||||
score += 50;
|
||||
}
|
||||
else
|
||||
{
|
||||
score -= 40;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (trait.rate_birth > 0 && trait.rate_birth <= 5)
|
||||
{
|
||||
score += 20;
|
||||
}
|
||||
else if (trait.rate_birth >= 40)
|
||||
{
|
||||
score -= 15;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
string id = trait.id ?? "";
|
||||
if (id.IndexOf("miracle", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("immortal", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("zombie", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("blessed", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("cursed", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("evil", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
score += 35;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool IsDefaultTraitForActor(ActorTrait trait, Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (trait == null || actor == null || actor.asset == null
|
||||
|| trait.default_for_actor_assets == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return trait.default_for_actor_assets.Contains(actor.asset);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string TraitDisplayName(ActorTrait trait)
|
||||
{
|
||||
try
|
||||
{
|
||||
string locale = trait.getLocaleID();
|
||||
if (!string.IsNullOrEmpty(locale))
|
||||
{
|
||||
string localized = LocalizedTextManager.getText(locale);
|
||||
if (!string.IsNullOrEmpty(localized) && localized != locale)
|
||||
{
|
||||
return localized;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
|
||||
string id = trait.id ?? "";
|
||||
if (id.Length == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return char.ToUpperInvariant(id[0]) + id.Substring(1).Replace('_', ' ');
|
||||
}
|
||||
|
||||
private static bool IsAtWar(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Kingdom k = actor.kingdom;
|
||||
if (k == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return k.hasEnemies();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SpeciesLabel(LifeSagaSlot slot, Actor actor)
|
||||
{
|
||||
string id = FirstNonEmpty(slot?.SpeciesId, actor?.asset != null ? actor.asset.id : "");
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (id.Length == 1)
|
||||
{
|
||||
return id.ToUpperInvariant();
|
||||
}
|
||||
|
||||
return char.ToUpperInvariant(id[0]) + id.Substring(1);
|
||||
}
|
||||
|
||||
private static string KingdomName(Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
return actor?.kingdom != null ? (actor.kingdom.name ?? "") : "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string CityName(Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
return actor?.city != null ? (actor.city.name ?? "") : "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeName(Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
string n = actor.getName();
|
||||
if (!string.IsNullOrEmpty(n))
|
||||
{
|
||||
return n;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return actor?.asset != null ? actor.asset.id : "Someone";
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -8,33 +8,48 @@ using UnityEngine.UI;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Dossier rail of life-saga MCs. Click toggles Prefer (soft favorite). Hover shows overview card.
|
||||
/// 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;
|
||||
private const float SlotSize = 14f;
|
||||
public const float SlotSize = 18f;
|
||||
private const float Gap = 2f;
|
||||
private const float HoverWidth = 300f;
|
||||
private const float HoverLineHeight = 12f;
|
||||
private const float HoverMaxHeight = 84f;
|
||||
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 Text _hoverText;
|
||||
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 = "";
|
||||
|
|
@ -44,6 +59,43 @@ public static class LifeSagaRail
|
|||
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)
|
||||
|
|
@ -65,14 +117,6 @@ public static class LifeSagaRail
|
|||
Slots[i] = BuildSlot(_row.transform, i);
|
||||
}
|
||||
|
||||
_hoverText = HudCanvas.MakeText(_row.transform, "Hover", "", 7);
|
||||
_hoverText.color = HudTheme.Muted;
|
||||
_hoverText.alignment = TextAnchor.UpperLeft;
|
||||
_hoverText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_hoverText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
_hoverText.raycastTarget = false;
|
||||
_hoverText.gameObject.SetActive(false);
|
||||
|
||||
_row.SetActive(false);
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +125,11 @@ public static class LifeSagaRail
|
|||
_fingerprint = "";
|
||||
LastShownCount = 0;
|
||||
LastTipSample = "";
|
||||
HideHover();
|
||||
LastActiveHighlight = false;
|
||||
LastPreferPipVisible = false;
|
||||
LastHoverRowCount = 0;
|
||||
LastHoverHeight = 0f;
|
||||
LifeSagaViewController.CancelPreviewAndPause();
|
||||
if (_row != null)
|
||||
{
|
||||
_row.SetActive(false);
|
||||
|
|
@ -117,60 +165,118 @@ public static class LifeSagaRail
|
|||
return;
|
||||
}
|
||||
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
float now = Time.unscaledTime;
|
||||
LifeSagaRoster.Tick(now);
|
||||
SlotScratch.Clear();
|
||||
LifeSagaRoster.CopySlots(SlotScratch);
|
||||
string fp = Fingerprint(SlotScratch);
|
||||
if (string.Equals(fp, _fingerprint, StringComparison.Ordinal)
|
||||
&& !LifeSagaRoster.Dirty)
|
||||
bool structureChanged = !string.Equals(fp, _fingerprint, StringComparison.Ordinal)
|
||||
|| LifeSagaRoster.Dirty;
|
||||
bool facesDue = now >= _nextFaceRefreshAt;
|
||||
if (!structureChanged && !facesDue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaRoster.Dirty = false;
|
||||
_fingerprint = fp;
|
||||
LastShownCount = 0;
|
||||
LastTipSample = "";
|
||||
long watchId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
float x = 0f;
|
||||
for (int i = 0; i < Slots.Length; i++)
|
||||
if (structureChanged)
|
||||
{
|
||||
Slot slot = Slots[i];
|
||||
if (i >= SlotScratch.Count || SlotScratch[i] == null || SlotScratch[i].UnitId == 0)
|
||||
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.Root.SetActive(false);
|
||||
slot.UnitId = 0;
|
||||
slot.Tip = "";
|
||||
continue;
|
||||
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++;
|
||||
}
|
||||
|
||||
LifeSagaSlot saga = SlotScratch[i];
|
||||
bool watching = watchId != 0 && watchId == saga.UnitId;
|
||||
bool lit = watching || saga.Prefer || saga.GameFavorite;
|
||||
slot.UnitId = saga.UnitId;
|
||||
slot.Label.text = GlyphFor(saga);
|
||||
slot.Bg.color = lit
|
||||
? HudTheme.ValueOrange
|
||||
: new Color(HudTheme.Muted.r, HudTheme.Muted.g, HudTheme.Muted.b, 0.55f);
|
||||
slot.Label.color = lit ? Color.black : HudTheme.Body;
|
||||
slot.Tip = LifeSagaOverview.Build(saga);
|
||||
if (LastShownCount == 0)
|
||||
_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++)
|
||||
{
|
||||
LastTipSample = slot.Tip ?? "";
|
||||
}
|
||||
if (Slots[i] == null || !Slots[i].Root.activeSelf || SlotScratch[i] == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
slot.Rt.anchoredPosition = new Vector2(x, 0f);
|
||||
slot.Root.SetActive(true);
|
||||
x += SlotSize + Gap;
|
||||
LastShownCount++;
|
||||
ApplyFace(Slots[i], SlotScratch[i]);
|
||||
}
|
||||
}
|
||||
|
||||
_rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize);
|
||||
_row.SetActive(LastShownCount > 0);
|
||||
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 rail under the spine; returns next y.</summary>
|
||||
/// <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)
|
||||
|
|
@ -179,76 +285,35 @@ public static class LifeSagaRail
|
|||
}
|
||||
|
||||
_rowRt.anchoredPosition = new Vector2(padX, -y);
|
||||
float next = y + SlotSize + 2f;
|
||||
if (_hoverText != null && _hoverText.gameObject.activeSelf)
|
||||
{
|
||||
RectTransform ht = _hoverText.GetComponent<RectTransform>();
|
||||
ht.pivot = new Vector2(0f, 1f);
|
||||
ht.anchorMin = new Vector2(0f, 1f);
|
||||
ht.anchorMax = new Vector2(0f, 1f);
|
||||
ht.anchoredPosition = new Vector2(0f, -(SlotSize + 1f));
|
||||
float hoverH = EstimateHoverHeight(_hoverText.text);
|
||||
ht.sizeDelta = new Vector2(HoverWidth, hoverH);
|
||||
next += hoverH + 1f;
|
||||
}
|
||||
|
||||
return next;
|
||||
return y + SlotSize + 2f;
|
||||
}
|
||||
|
||||
internal static void ShowHover(int index)
|
||||
{
|
||||
if (_hoverText == null || index < 0 || index >= Slots.Length)
|
||||
if (index < 0 || index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string tip = Slots[index].Tip;
|
||||
if (string.IsNullOrEmpty(tip) && Slots[index].UnitId != 0)
|
||||
{
|
||||
LifeSagaSlot s = LifeSagaRoster.Get(Slots[index].UnitId);
|
||||
tip = LifeSagaOverview.Build(s);
|
||||
Slots[index].Tip = tip;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(tip))
|
||||
{
|
||||
HideHover();
|
||||
return;
|
||||
}
|
||||
|
||||
_hoverText.text = tip;
|
||||
_hoverText.gameObject.SetActive(true);
|
||||
LifeSagaViewController.BeginHoverPreview(Slots[index].UnitId);
|
||||
LastHoverHeight = LifeSagaPanel.BodyHeight;
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
internal static void HideHover()
|
||||
{
|
||||
if (_hoverText != null)
|
||||
{
|
||||
_hoverText.gameObject.SetActive(false);
|
||||
_hoverText.text = "";
|
||||
}
|
||||
|
||||
LifeSagaViewController.EndHoverPreview();
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
private static float EstimateHoverHeight(string text)
|
||||
public static long UnitIdAt(int index)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
if (index < 0 || index >= Slots.Length || Slots[index] == null)
|
||||
{
|
||||
return HoverLineHeight;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int lines = 1;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
|
||||
return Mathf.Min(HoverMaxHeight, lines * HoverLineHeight + 2f);
|
||||
return Slots[index].UnitId;
|
||||
}
|
||||
|
||||
private static Slot BuildSlot(Transform parent, int index)
|
||||
|
|
@ -265,16 +330,66 @@ public static class LifeSagaRail
|
|||
bg.color = HudTheme.ToolFill;
|
||||
bg.raycastTarget = true;
|
||||
|
||||
Text label = HudCanvas.MakeText(go.transform, "Glyph", "", 9);
|
||||
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);
|
||||
|
|
@ -292,12 +407,120 @@ public static class LifeSagaRail
|
|||
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)
|
||||
|
|
@ -317,38 +540,32 @@ public static class LifeSagaRail
|
|||
}
|
||||
|
||||
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 GlyphFor(LifeSagaSlot saga)
|
||||
private static string GlyphFallback(LifeSagaSlot saga)
|
||||
{
|
||||
if (saga == null)
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
if (saga.IsKing)
|
||||
{
|
||||
return "K";
|
||||
}
|
||||
|
||||
if (saga.IsAlpha)
|
||||
{
|
||||
return "A";
|
||||
}
|
||||
|
||||
if (saga.IsLeader)
|
||||
{
|
||||
return "L";
|
||||
}
|
||||
|
||||
if (saga.Prefer || saga.GameFavorite)
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
|
@ -376,7 +593,10 @@ public static class LifeSagaRail
|
|||
.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(';');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,4 +11,7 @@ public sealed class LifeSagaRailHover : MonoBehaviour, IPointerEnterHandler, IPo
|
|||
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.
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -388,6 +388,25 @@ public static class StoryPlanner
|
|||
_softFillQuietUntil = Mathf.Max(_softFillQuietUntil, now + quiet);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded quiet refresh after a soft crumb ends so AFK does not immediately re-surf.
|
||||
/// Caps total quiet at 1.5x the base window from now.
|
||||
/// </summary>
|
||||
public static void NoteSoftCrumbQuietRefresh(float now = -1f)
|
||||
{
|
||||
if (now < 0f)
|
||||
{
|
||||
now = Time.unscaledTime;
|
||||
}
|
||||
|
||||
StoryWeights s = InterestScoringConfig.Story;
|
||||
float quiet = s.softFillQuietSeconds > 0f ? s.softFillQuietSeconds : 18f;
|
||||
float refresh = quiet * 0.35f;
|
||||
float capUntil = now + quiet * 1.5f;
|
||||
float next = Mathf.Max(_softFillQuietUntil, now + refresh);
|
||||
_softFillQuietUntil = Mathf.Min(next, capUntil);
|
||||
}
|
||||
|
||||
public static void Tick(float now)
|
||||
{
|
||||
TickCrisis(now);
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1092,15 +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;
|
||||
// Life-saga bias for ambient fill - prefer roster MCs (Prefer first) over strangers.
|
||||
// Life-saga bias for ambient fill - Prefer/MC must beat anonymous villagers post-quiet.
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
if (LifeSagaRoster.IsPrefer(id))
|
||||
{
|
||||
score += Mathf.Max(4f, InterestScoringConfig.Story.sagaPreferWeight * 0.45f);
|
||||
score += Mathf.Max(8f, InterestScoringConfig.Story.sagaPreferWeight * 0.75f);
|
||||
}
|
||||
else if (LifeSagaRoster.IsMc(id))
|
||||
{
|
||||
score += Mathf.Max(2f, InterestScoringConfig.Story.sagaMcWeight * 0.45f);
|
||||
score += Mathf.Max(5f, InterestScoringConfig.Story.sagaMcWeight * 0.7f);
|
||||
}
|
||||
|
||||
float causal = CausalHeat.Get(id);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.29.7",
|
||||
"version": "0.29.48",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,90 @@
|
|||
# Life-saga roster
|
||||
|
||||
Long-lived cast of interesting lives (MCs). Owns the dossier rail and soft camera bias.
|
||||
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` |
|
||||
| Hover card | `LifeSagaOverview` |
|
||||
| Rail glyphs / Prefer click | `LifeSagaRail` |
|
||||
| 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 glyphs (role/species letter).
|
||||
- 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).
|
||||
- Hover: Title (name + optional epithet + role) / Why / Circle / Chapters.
|
||||
- Chapters are stamped saga beats only - not Chronicle peeks.
|
||||
- Trait names may appear once as a title epithet; trait descs never appear on the hover.
|
||||
- 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.
|
||||
- Lore browse / brief idle-off keeps the roster.
|
||||
- Harness batch end and `interest_saga_clear` wipe the roster.
|
||||
- `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
|
||||
|
||||
|
|
@ -42,6 +92,14 @@ Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/Story
|
|||
- `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).
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ There is **no** rail Commit / hard pin anymore.
|
|||
- 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). Hover is a multi-line saga overview.
|
||||
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`).
|
||||
|
|
@ -164,14 +167,15 @@ 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 and
|
||||
character-fill ambient briefly.
|
||||
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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue