From 4b6c829bd0fe9e4c3ee46b3930e3a630b51db651 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 22 Jul 2026 03:27:46 -0500 Subject: [PATCH] feat(saga): ship adaptive dossier tabs with durable memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- IdleSpectator/ActorChroniclePatches.cs | 4 + IdleSpectator/ActorRelation.cs | 130 ++ IdleSpectator/AgentHarness.cs | 638 +++++++- IdleSpectator/Chronicle.cs | 2 + IdleSpectator/ChronicleHud.cs | 14 +- IdleSpectator/DossierAvatar.cs | 112 ++ .../Events/Patches/MetaEventPatches.cs | 41 + .../Events/Patches/PlotEventPatches.cs | 52 + .../Patches/RelationshipEventPatches.cs | 52 +- .../Events/Patches/WarEventPatches.cs | 66 + IdleSpectator/HappinessEventRouter.cs | 59 + IdleSpectator/HarnessScenarios.cs | 414 ++++- IdleSpectator/HudIcons.cs | 92 ++ IdleSpectator/InterestDirector.cs | 73 +- IdleSpectator/InterestVariety.cs | 59 +- IdleSpectator/Story/LifeSagaMemory.cs | 1332 +++++++++++++++++ IdleSpectator/Story/LifeSagaOverview.cs | 595 ++------ IdleSpectator/Story/LifeSagaPanel.cs | 757 ++++++++++ IdleSpectator/Story/LifeSagaPresentation.cs | 871 +++++++++++ IdleSpectator/Story/LifeSagaRail.cs | 468 ++++-- IdleSpectator/Story/LifeSagaRailHover.cs | 3 + IdleSpectator/Story/LifeSagaRoster.cs | 1299 ++++++++++++++-- IdleSpectator/Story/LifeSagaViewController.cs | 317 ++++ IdleSpectator/Story/StoryPlanner.cs | 19 + IdleSpectator/UnitDossier.cs | 62 + IdleSpectator/WatchCaption.cs | 770 ++++++++-- IdleSpectator/WorldActivityScanner.cs | 6 +- IdleSpectator/mod.json | 2 +- docs/life-saga.md | 78 +- docs/story-planner.md | 12 +- 30 files changed, 7495 insertions(+), 904 deletions(-) create mode 100644 IdleSpectator/Story/LifeSagaMemory.cs create mode 100644 IdleSpectator/Story/LifeSagaPanel.cs create mode 100644 IdleSpectator/Story/LifeSagaPresentation.cs create mode 100644 IdleSpectator/Story/LifeSagaViewController.cs diff --git a/IdleSpectator/ActorChroniclePatches.cs b/IdleSpectator/ActorChroniclePatches.cs index 060b091..553b150 100644 --- a/IdleSpectator/ActorChroniclePatches.cs +++ b/IdleSpectator/ActorChroniclePatches.cs @@ -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); } } diff --git a/IdleSpectator/ActorRelation.cs b/IdleSpectator/ActorRelation.cs index 4301293..22ff821 100644 --- a/IdleSpectator/ActorRelation.cs +++ b/IdleSpectator/ActorRelation.cs @@ -191,6 +191,92 @@ public static class ActorRelation } } + /// Living parents reported by the running game's relation data. + public static IEnumerable 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(); + 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++; + } + } + + /// Current living combat target, when the live attack target is an actor. + 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; + } + } + /// /// Resolve a related unit for pack/family labels. Prefers lover, then family peer, then child. /// @@ -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 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); diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 85e5509..79bacd9 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -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(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(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(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 ""; diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index a229b68..2397b8f 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -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. } diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index a7a25c7..0e233df 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -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)" : "")); } /// diff --git a/IdleSpectator/DossierAvatar.cs b/IdleSpectator/DossierAvatar.cs index 4cfffee..80f1d22 100644 --- a/IdleSpectator/DossierAvatar.cs +++ b/IdleSpectator/DossierAvatar.cs @@ -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 } } + /// + /// Keep the live portrait under saga/dossier chrome that must paint on top of it. + /// + 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()); + } + } + + /// + /// Keep the compact stone-style dossier frame - strip king/clan vanity overrides. + /// + 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) diff --git a/IdleSpectator/Events/Patches/MetaEventPatches.cs b/IdleSpectator/Events/Patches/MetaEventPatches.cs index 183b885..18e30ea 100644 --- a/IdleSpectator/Events/Patches/MetaEventPatches.cs +++ b/IdleSpectator/Events/Patches/MetaEventPatches.cs @@ -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( diff --git a/IdleSpectator/Events/Patches/PlotEventPatches.cs b/IdleSpectator/Events/Patches/PlotEventPatches.cs index 5e02199..a36b94a 100644 --- a/IdleSpectator/Events/Patches/PlotEventPatches.cs +++ b/IdleSpectator/Events/Patches/PlotEventPatches.cs @@ -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) diff --git a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs index 92767bd..eb5169d 100644 --- a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs +++ b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs @@ -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"); + }); } } diff --git a/IdleSpectator/Events/Patches/WarEventPatches.cs b/IdleSpectator/Events/Patches/WarEventPatches.cs index 2acb2ca..81eec00 100644 --- a/IdleSpectator/Events/Patches/WarEventPatches.cs +++ b/IdleSpectator/Events/Patches/WarEventPatches.cs @@ -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 { diff --git a/IdleSpectator/HappinessEventRouter.cs b/IdleSpectator/HappinessEventRouter.cs index 6c85264..465ee20 100644 --- a/IdleSpectator/HappinessEventRouter.cs +++ b/IdleSpectator/HappinessEventRouter.cs @@ -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, diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 69f8d81..33176a9 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -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 }; } - /// Saga overview hover has MC name and no planner jargon / trait descs. + /// + /// Compact saga snapshot: admission headline, collapsible layout, structured chapters, + /// no planner jargon / trait descs / Chronicle dumps. + /// private static List SagaOverviewHasMc() { return new List @@ -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"), }; } + /// Dossier/Saga tabs, hover preview restore, Prefer-only click, fixed body. + private static List SagaAdaptiveDossier() + { + return new List + { + 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"), + }; + } + + /// Memory survives roster eviction and captures without Chronicle. + private static List SagaMemorySurvives() + { + return new List + { + 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"), + }; + } + + /// Hover acquires read-pause; camera owner stays fixed until release. + private static List SagaHoverReadPause() + { + return new List + { + 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"), + }; + } + /// Multi-species notables fill distinct roster slots. private static List SagaRosterDiversity() { @@ -3026,6 +3224,204 @@ internal static class HarnessScenarios }; } + /// + /// Extended roles: singleton dragon overview + hard-arc nobody admit path. + /// + private static List SagaAdmitRoles() + { + return new List + { + 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"), + }; + } + + /// Full Cap: Prefer pin survives; hotter hard-arc admit displaces weakest non-pin. + private static List SagaReplaceWeaker() + { + return new List + { + 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"), + }; + } + + /// Active rail chrome tracks camera follow, not Prefer alone. + private static List SagaRailActiveHighlight() + { + return new List + { + 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"), + }; + } + + /// Prefer toggle flips on then off with immediate pip state. + private static List SagaRailPreferClick() + { + return new List + { + 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"), + }; + } + + /// + /// During soft-fill quiet, Pack and stun/invincible crumbs do not win; combat still can. + /// + private static List SagaSoftFillNoPack() + { + return new List + { + 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"), + }; + } + /// /// Sticky WarFront tips stay kingdom-framed across count dips and follow loss. /// diff --git a/IdleSpectator/HudIcons.cs b/IdleSpectator/HudIcons.cs index da34dbd..d71bf73 100644 --- a/IdleSpectator/HudIcons.cs +++ b/IdleSpectator/HudIcons.cs @@ -1,3 +1,4 @@ +using System; using UnityEngine; namespace IdleSpectator; @@ -140,6 +141,97 @@ public static class HudIcons } } + /// + /// 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. + /// + 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) diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index d00b8a7..1537ada 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -460,9 +460,51 @@ public static partial class InterestDirector private static float _browseFrozenLastSwitchAt = -999f; private static float _browseFrozenLastInterestingAt = -999f; + /// Camera read-pause lease owners (hover preview). Feeds/story continue; switching deferred. + private static readonly HashSet ReadPauseOwners = new HashSet(); + 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; } diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index 0fcea53..060294e 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -1207,6 +1207,57 @@ public static class InterestVariety || s.IndexOf("favorite", StringComparison.Ordinal) >= 0; } + /// + /// 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). + /// + public static bool IsSoftFillQuietCrumb(InterestCandidate c) => + c != null + && (c.Completion == InterestCompletionKind.FamilyPack + || IsSoftLifeChapter(c) + || IsShortInterruptFxChapter(c)); + + /// + /// Brief interrupt FX that should not own AFK during soft-fill quiet. + /// Kept non-outbreak; real danger statuses stay hot. + /// + 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"; + } + /// /// 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" diff --git a/IdleSpectator/Story/LifeSagaMemory.cs b/IdleSpectator/Story/LifeSagaMemory.cs new file mode 100644 index 0000000..aa623ec --- /dev/null +++ b/IdleSpectator/Story/LifeSagaMemory.cs @@ -0,0 +1,1332 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// World-session structured saga facts keyed by unit id. +/// Survives roster eviction; cleared only on world replacement / harness clear / teardown. +/// Independent of ChronicleEnabled and camera feed gates. +/// +public static class LifeSagaMemory +{ + public const int MaxFactsPerLife = 48; + public const int MaxLives = 120; + public const int RivalEncounterThreshold = 2; + + private static readonly Dictionary Lives = + new Dictionary(64); + private static readonly Dictionary PairEncounters = + new Dictionary(64); + private static object _boundWorld; + private static int _worldEpoch; + + public static int WorldEpoch => _worldEpoch; + public static int LifeCount => Lives.Count; + + public static void EnsureWorldBound() + { + object world = null; + try + { + world = World.world; + } + catch + { + world = null; + } + + if (ReferenceEquals(world, _boundWorld)) + { + return; + } + + ClearSession(); + _boundWorld = world; + _worldEpoch++; + } + + public static void ClearSession() + { + Lives.Clear(); + PairEncounters.Clear(); + _boundWorld = null; + } + + public static void Clear() => ClearSession(); + + public static LifeSagaLifeMemory Get(long unitId) + { + if (unitId == 0) + { + return null; + } + + EnsureWorldBound(); + return Lives.TryGetValue(unitId, out LifeSagaLifeMemory life) ? life : null; + } + + public static LifeSagaLifeMemory GetOrCreate(long unitId) + { + if (unitId == 0) + { + return null; + } + + EnsureWorldBound(); + if (Lives.TryGetValue(unitId, out LifeSagaLifeMemory existing)) + { + return existing; + } + + PruneIfNeeded(); + var life = new LifeSagaLifeMemory { UnitId = unitId }; + Lives[unitId] = life; + return life; + } + + public static bool HasFacts(long unitId) + { + LifeSagaLifeMemory life = Get(unitId); + return life != null && life.Facts.Count > 0; + } + + public static IReadOnlyList FactsFor(long unitId) + { + LifeSagaLifeMemory life = Get(unitId); + return life != null ? life.Facts : Array.Empty(); + } + + public static LifeSagaFact Record( + LifeSagaFactKind kind, + Actor subject, + Actor other = null, + string correlationKey = null, + float strength = 50f, + string provenance = "", + string kingdomKey = "", + string cityKey = "", + string clanKey = "", + string familyKey = "", + string warKey = "", + string plotKey = "", + bool resolved = false, + string note = "") + { + if (subject == null) + { + return null; + } + + long subjectId = EventFeedUtil.SafeId(subject); + if (subjectId == 0) + { + return null; + } + + LifeSagaIdentity subjectSnap = Snapshot(subject); + LifeSagaIdentity otherSnap = Snapshot(other); + return RecordIds( + kind, + subjectId, + subjectSnap, + otherSnap.Id, + otherSnap, + correlationKey, + strength, + provenance, + kingdomKey, + cityKey, + clanKey, + familyKey, + warKey, + plotKey, + resolved, + note); + } + + public static LifeSagaFact RecordIds( + LifeSagaFactKind kind, + long subjectId, + LifeSagaIdentity subject, + long otherId, + LifeSagaIdentity other, + string correlationKey = null, + float strength = 50f, + string provenance = "", + string kingdomKey = "", + string cityKey = "", + string clanKey = "", + string familyKey = "", + string warKey = "", + string plotKey = "", + bool resolved = false, + string note = "") + { + if (subjectId == 0) + { + return null; + } + + EnsureWorldBound(); + LifeSagaLifeMemory life = GetOrCreate(subjectId); + if (life == null) + { + return null; + } + + if (subject.Id != 0) + { + life.Identity = subject; + } + + string key = string.IsNullOrEmpty(correlationKey) + ? DefaultKey(kind, subjectId, otherId, warKey, plotKey) + : correlationKey; + + LifeSagaFact existing = FindByKey(life, key); + float now = Time.unscaledTime; + StampWorldDate(out double worldTime, out string dateLabel); + if (existing != null) + { + existing.Strength = Mathf.Max(existing.Strength, strength); + existing.Resolved = resolved; + existing.At = now; + existing.WorldTime = worldTime; + existing.DateLabel = dateLabel; + if (!string.IsNullOrEmpty(note)) + { + existing.Note = note; + } + + if (other.Id != 0 && existing.Other.Id == 0) + { + existing.Other = other; + } + + life.TouchedAt = now; + return existing; + } + + var fact = new LifeSagaFact + { + Key = key, + Kind = kind, + SubjectId = subjectId, + OtherId = otherId, + Subject = subject.Id != 0 ? subject : SnapshotId(subjectId), + Other = other.Id != 0 ? other : (otherId != 0 ? SnapshotId(otherId) : default), + At = now, + WorldTime = worldTime, + DateLabel = dateLabel, + Strength = strength, + Provenance = provenance ?? "", + KingdomKey = kingdomKey ?? "", + CityKey = cityKey ?? "", + ClanKey = clanKey ?? "", + FamilyKey = familyKey ?? "", + WarKey = warKey ?? "", + PlotKey = plotKey ?? "", + Resolved = resolved, + Note = note ?? "" + }; + life.Facts.Insert(0, fact); + life.TouchedAt = now; + while (life.Facts.Count > MaxFactsPerLife) + { + life.Facts.RemoveAt(life.Facts.Count - 1); + } + + // Mirror onto the other life for shared events (bonds, kills, kin). + if (otherId != 0 && otherId != subjectId + && (kind == LifeSagaFactKind.BondFormed + || kind == LifeSagaFactKind.BondBroken + || kind == LifeSagaFactKind.FriendFormed + || kind == LifeSagaFactKind.ParentChild + || kind == LifeSagaFactKind.Kill + || kind == LifeSagaFactKind.Death + || kind == LifeSagaFactKind.CombatEncounter + || kind == LifeSagaFactKind.RivalEarned + || kind == LifeSagaFactKind.WarJoin + || kind == LifeSagaFactKind.PlotJoin)) + { + MirrorToOther(fact, otherId); + } + + return fact; + } + + public static void RecordBondFormed(Actor a, Actor b, string provenance = "set_lover") + { + if (a == null || b == null) + { + return; + } + + Record( + LifeSagaFactKind.BondFormed, + a, + b, + correlationKey: "bond:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)) + ":formed:" + + Time.unscaledTime.ToString("0.###"), + strength: 70f, + provenance: provenance); + } + + public static void RecordBondBroken(Actor survivor, Actor former, string provenance = "clear_lover") + { + if (survivor == null) + { + return; + } + + long survivorId = EventFeedUtil.SafeId(survivor); + LifeSagaIdentity formerSnap = Snapshot(former); + if (formerSnap.Id == 0) + { + // Fall back to last known lover fact on this life. + LifeSagaLifeMemory life = Get(survivorId); + if (life != null) + { + for (int i = 0; i < life.Facts.Count; i++) + { + LifeSagaFact f = life.Facts[i]; + if (f != null && f.Kind == LifeSagaFactKind.BondFormed && f.OtherId != 0) + { + formerSnap = f.Other; + break; + } + } + } + } + + if (formerSnap.Id == 0) + { + return; + } + + RecordIds( + LifeSagaFactKind.BondBroken, + survivorId, + Snapshot(survivor), + formerSnap.Id, + formerSnap, + correlationKey: "bond:" + PairKey(survivorId, formerSnap.Id) + ":broken:" + + Time.unscaledTime.ToString("0.###"), + strength: 75f, + provenance: provenance, + resolved: true); + } + + public static void RecordParentChild(Actor parent, Actor child, string provenance = "add_child") + { + if (parent == null || child == null) + { + return; + } + + long parentId = EventFeedUtil.SafeId(parent); + long childId = EventFeedUtil.SafeId(child); + Record( + LifeSagaFactKind.ParentChild, + parent, + child, + correlationKey: "parent:" + parentId + ":" + childId, + strength: 65f, + provenance: provenance); + } + + public static void RecordFriendFormed(Actor a, Actor b, string provenance = "set_best_friend") + { + if (a == null || b == null) + { + return; + } + + Record( + LifeSagaFactKind.FriendFormed, + a, + b, + correlationKey: "friend:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)), + strength: 55f, + provenance: provenance); + } + + public static void RecordKill(Actor killer, Actor victim, string provenance = "newKillAction") + { + if (killer == null || victim == null) + { + return; + } + + long killerId = EventFeedUtil.SafeId(killer); + long victimId = EventFeedUtil.SafeId(victim); + Record( + LifeSagaFactKind.Kill, + killer, + victim, + correlationKey: "kill:" + killerId + ":" + victimId + ":" + + Time.unscaledTime.ToString("0.###"), + strength: 80f, + provenance: provenance); + + NoteCombatEncounter(killer, victim, provenance); + TryMarkKinKillRival(killer, victim); + } + + public static void RecordDeath(Actor victim, Actor killer, string attackType, string provenance = "die") + { + if (victim == null) + { + return; + } + + long victimId = EventFeedUtil.SafeId(victim); + Record( + LifeSagaFactKind.Death, + victim, + killer, + correlationKey: "death:" + victimId, + strength: 90f, + provenance: provenance, + resolved: true, + note: attackType ?? ""); + + if (killer != null) + { + NoteCombatEncounter(killer, victim, provenance); + } + } + + public static void NoteCombatEncounter(Actor a, Actor b, string provenance = "combat") + { + if (a == null || b == null) + { + return; + } + + long idA = EventFeedUtil.SafeId(a); + long idB = EventFeedUtil.SafeId(b); + if (idA == 0 || idB == 0 || idA == idB) + { + return; + } + + string pair = PairKey(idA, idB); + if (!PairEncounters.TryGetValue(pair, out int count)) + { + count = 0; + } + + count++; + PairEncounters[pair] = count; + + Record( + LifeSagaFactKind.CombatEncounter, + a, + b, + correlationKey: "encounter:" + pair, + strength: 40f + Mathf.Min(20f, count * 5f), + provenance: provenance, + note: "x" + count); + + if (count >= RivalEncounterThreshold) + { + EarnRival(a, b, "repeated_encounters:" + count, provenance); + } + } + + public static void EarnRival(Actor subject, Actor rival, string evidence, string provenance) + { + if (subject == null || rival == null) + { + return; + } + + long subjectId = EventFeedUtil.SafeId(subject); + long rivalId = EventFeedUtil.SafeId(rival); + Record( + LifeSagaFactKind.RivalEarned, + subject, + rival, + correlationKey: "rival:" + PairKey(subjectId, rivalId), + strength: 85f, + provenance: provenance, + note: evidence ?? ""); + } + + public static void RecordWar( + Actor subject, + string warKey, + string kingdomKey, + bool ended, + string provenance = "war") + { + if (subject == null || string.IsNullOrEmpty(warKey)) + { + return; + } + + Record( + ended ? LifeSagaFactKind.WarEnd : LifeSagaFactKind.WarJoin, + subject, + null, + correlationKey: "war:" + warKey + ":" + EventFeedUtil.SafeId(subject), + strength: ended ? 70f : 78f, + provenance: provenance, + kingdomKey: kingdomKey, + warKey: warKey, + resolved: ended); + } + + public static void RecordPlot( + Actor subject, + string plotKey, + string plotAsset, + bool finished, + string provenance = "plot") + { + if (subject == null || string.IsNullOrEmpty(plotKey)) + { + return; + } + + Record( + finished ? LifeSagaFactKind.PlotEnd : LifeSagaFactKind.PlotJoin, + subject, + null, + correlationKey: "plot:" + plotKey + ":" + EventFeedUtil.SafeId(subject), + strength: finished ? 72f : 76f, + provenance: provenance, + plotKey: plotKey, + note: plotAsset ?? "", + resolved: finished); + } + + public static void RecordFounding(Actor subject, string metaKind, string metaKey, string provenance) + { + if (subject == null) + { + return; + } + + Record( + LifeSagaFactKind.Founding, + subject, + null, + correlationKey: "found:" + (metaKind ?? "meta") + ":" + (metaKey ?? EventFeedUtil.SafeId(subject).ToString()), + strength: 74f, + provenance: provenance, + kingdomKey: metaKind == "kingdom" ? metaKey : "", + cityKey: metaKind == "city" ? metaKey : "", + familyKey: metaKind == "family" ? metaKey : "", + clanKey: metaKind == "clan" ? metaKey : "", + note: metaKind ?? ""); + } + + public static void RecordRole(Actor subject, string roleId, string provenance = "happiness") + { + if (subject == null || string.IsNullOrEmpty(roleId)) + { + return; + } + + Record( + LifeSagaFactKind.RoleChange, + subject, + null, + correlationKey: "role:" + EventFeedUtil.SafeId(subject) + ":" + roleId, + strength: 68f, + provenance: provenance, + note: roleId); + } + + public static void RecordHome(Actor subject, bool gained, string provenance = "happiness") + { + if (subject == null) + { + return; + } + + Record( + gained ? LifeSagaFactKind.HomeGained : LifeSagaFactKind.HomeLost, + subject, + null, + correlationKey: "home:" + EventFeedUtil.SafeId(subject) + ":" + (gained ? "gain" : "loss") + ":" + + Time.unscaledTime.ToString("0.###"), + strength: 50f, + provenance: provenance); + } + + public static void RecordHardArc( + long unitId, + StoryArcKind arcKind, + string note, + long relatedId = 0, + string provenance = "story_planner") + { + if (unitId == 0) + { + return; + } + + LifeSagaIdentity subject = SnapshotId(unitId); + LifeSagaIdentity other = relatedId != 0 ? SnapshotId(relatedId) : default; + LifeSagaFactKind kind = HardArcKind(arcKind); + RecordIds( + kind, + unitId, + subject, + relatedId, + other, + correlationKey: "arc:" + unitId + ":" + arcKind + ":" + (note ?? "").GetHashCode(), + strength: 60f, + provenance: provenance, + note: note ?? ""); + } + + public static LifeSagaIdentity Snapshot(Actor actor) + { + if (actor == null) + { + return default; + } + + long id = EventFeedUtil.SafeId(actor); + if (id == 0) + { + return default; + } + + string name = ""; + string species = ""; + try + { + name = actor.getName() ?? ""; + } + catch + { + name = ""; + } + + try + { + species = actor.asset != null ? actor.asset.id : ""; + } + catch + { + species = ""; + } + + return new LifeSagaIdentity + { + Id = id, + Name = name, + SpeciesId = species + }; + } + + public static LifeSagaIdentity SnapshotId(long unitId) + { + if (unitId == 0) + { + return default; + } + + Actor live = EventFeedUtil.FindAliveById(unitId); + if (live != null) + { + return Snapshot(live); + } + + LifeSagaLifeMemory life = Get(unitId); + if (life != null && life.Identity.Id != 0) + { + return life.Identity; + } + + return new LifeSagaIdentity { Id = unitId }; + } + + public static Actor ResolveAlive(long unitId) => EventFeedUtil.FindAliveById(unitId); + + public static bool TryGetEarnedRival(long unitId, out LifeSagaFact rivalFact) + { + rivalFact = null; + LifeSagaLifeMemory life = Get(unitId); + if (life == null) + { + return false; + } + + for (int i = 0; i < life.Facts.Count; i++) + { + LifeSagaFact f = life.Facts[i]; + if (f != null && f.Kind == LifeSagaFactKind.RivalEarned && f.OtherId != 0) + { + rivalFact = f; + return true; + } + } + + return false; + } + + public static LifeSagaFact StrongestUnresolved(long unitId) + { + LifeSagaLifeMemory life = Get(unitId); + if (life == null) + { + return null; + } + + LifeSagaFact best = null; + for (int i = 0; i < life.Facts.Count; i++) + { + LifeSagaFact f = life.Facts[i]; + if (f == null || f.Resolved) + { + continue; + } + + if (!IsStakeWorthy(f.Kind)) + { + continue; + } + + if (best == null || f.Strength > best.Strength + || (Mathf.Approximately(f.Strength, best.Strength) && f.At > best.At)) + { + best = f; + } + } + + return best; + } + + public static List SelectLegacy(long unitId, int max = 5) + { + var result = new List(max); + LifeSagaLifeMemory life = Get(unitId); + if (life == null || max <= 0) + { + return result; + } + + var usedBuckets = new HashSet(); + // Prefer diversity across turning-point buckets. + for (int pass = 0; pass < 2 && result.Count < max; pass++) + { + for (int i = 0; i < life.Facts.Count && result.Count < max; i++) + { + LifeSagaFact f = life.Facts[i]; + if (f == null || !IsLegacyWorthy(f.Kind)) + { + continue; + } + + int bucket = LegacyBucket(f.Kind); + if (pass == 0 && usedBuckets.Contains(bucket)) + { + continue; + } + + if (ContainsFact(result, f)) + { + continue; + } + + result.Add(f); + usedBuckets.Add(bucket); + } + } + + return result; + } + + public static List ChaptersFor(long unitId, int max = 3) + { + var chapters = new List(max); + List legacy = SelectLegacy(unitId, max); + for (int i = 0; i < legacy.Count; i++) + { + LifeSagaFact f = legacy[i]; + chapters.Add(new LifeSagaChapter + { + Kind = ToChapterKind(f.Kind), + Line = FormatFactLine(f), + At = f.At + }); + } + + return chapters; + } + + public static string FormatFactLine(LifeSagaFact fact) + { + if (fact == null) + { + return ""; + } + + string other = !string.IsNullOrEmpty(fact.Other.Name) ? fact.Other.Name : ""; + switch (fact.Kind) + { + case LifeSagaFactKind.BondFormed: + return string.IsNullOrEmpty(other) ? "Found love" : "Bound with " + other; + case LifeSagaFactKind.BondBroken: + return string.IsNullOrEmpty(other) ? "Lost a lover" : "Lost " + other; + case LifeSagaFactKind.FriendFormed: + return string.IsNullOrEmpty(other) ? "Made a true friend" : "Befriended " + other; + case LifeSagaFactKind.ParentChild: + return string.IsNullOrEmpty(other) ? "A child was born" : "Child " + other; + case LifeSagaFactKind.Kill: + return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other; + case LifeSagaFactKind.Death: + return string.IsNullOrEmpty(other) ? "Died" : "Slain by " + other; + case LifeSagaFactKind.RivalEarned: + return string.IsNullOrEmpty(other) ? "Earned a rival" : "Rivalry with " + other; + case LifeSagaFactKind.WarJoin: + return "Entered a war"; + case LifeSagaFactKind.WarEnd: + return "War ended"; + case LifeSagaFactKind.PlotJoin: + return "Joined a plot"; + case LifeSagaFactKind.PlotEnd: + return "Plot resolved"; + case LifeSagaFactKind.Founding: + return !string.IsNullOrEmpty(fact.Note) ? "Founded " + fact.Note : "Founded a new home"; + case LifeSagaFactKind.RoleChange: + return RoleNote(fact.Note); + case LifeSagaFactKind.HomeGained: + return "Found a home"; + case LifeSagaFactKind.HomeLost: + return "Lost a home"; + case LifeSagaFactKind.HardArcLove: + return FirstNonEmpty(fact.Note, "A relationship changed them"); + case LifeSagaFactKind.HardArcGrief: + return FirstNonEmpty(fact.Note, "Carrying a loss"); + case LifeSagaFactKind.HardArcCombat: + return FirstNonEmpty(fact.Note, "A fight marked them"); + case LifeSagaFactKind.HardArcWar: + return FirstNonEmpty(fact.Note, "War closed in"); + case LifeSagaFactKind.HardArcPlot: + return FirstNonEmpty(fact.Note, "Entangled in a plot"); + case LifeSagaFactKind.CombatEncounter: + return string.IsNullOrEmpty(other) ? "Fought again" : "Clashed with " + other; + default: + return FirstNonEmpty(fact.Note, "A turning point"); + } + } + + private static void TryMarkKinKillRival(Actor killer, Actor victim) + { + if (killer == null || victim == null) + { + return; + } + + long killerId = EventFeedUtil.SafeId(killer); + // If victim is close kin of someone on roster/memory, or killer killed kin of a remembered bond. + LifeSagaLifeMemory killerLife = Get(killerId); + if (killerLife == null) + { + return; + } + + long victimId = EventFeedUtil.SafeId(victim); + for (int i = 0; i < killerLife.Facts.Count; i++) + { + LifeSagaFact f = killerLife.Facts[i]; + if (f == null || f.OtherId == 0) + { + continue; + } + + if (f.Kind == LifeSagaFactKind.BondFormed + || f.Kind == LifeSagaFactKind.FriendFormed + || f.Kind == LifeSagaFactKind.ParentChild) + { + // Victim killed someone close to killer? Already handled as Kill. + // Killer slew kin of a bonded other: check victim parents/children against bonded id. + if (IsCloseKin(victim, f.OtherId)) + { + Actor rival = EventFeedUtil.FindAliveById(f.OtherId); + if (rival != null) + { + EarnRival(killer, rival, "killed_kin", "kin_kill"); + EarnRival(rival, killer, "kin_slain", "kin_kill"); + } + } + } + } + + _ = victimId; + } + + private static bool IsCloseKin(Actor actor, long relativeId) + { + if (actor == null || relativeId == 0) + { + return false; + } + + try + { + foreach (Actor p in ActorRelation.EnumerateParents(actor, 2)) + { + if (EventFeedUtil.SafeId(p) == relativeId) + { + return true; + } + } + + foreach (Actor c in ActorRelation.EnumerateChildren(actor, 8)) + { + if (EventFeedUtil.SafeId(c) == relativeId) + { + return true; + } + } + + Actor lover = ActorRelation.GetLover(actor); + if (lover != null && EventFeedUtil.SafeId(lover) == relativeId) + { + return true; + } + } + catch + { + // ignore + } + + return false; + } + + private static void MirrorToOther(LifeSagaFact fact, long otherId) + { + if (fact == null || otherId == 0) + { + return; + } + + LifeSagaLifeMemory life = GetOrCreate(otherId); + if (life == null) + { + return; + } + + string mirrorKey = fact.Key + ":mirror"; + if (FindByKey(life, mirrorKey) != null) + { + return; + } + + LifeSagaFactKind mirrorKind = fact.Kind; + if (fact.Kind == LifeSagaFactKind.Kill) + { + mirrorKind = LifeSagaFactKind.Death; + } + else if (fact.Kind == LifeSagaFactKind.Death) + { + mirrorKind = LifeSagaFactKind.Kill; + } + + var mirror = new LifeSagaFact + { + Key = mirrorKey, + Kind = mirrorKind, + SubjectId = otherId, + OtherId = fact.SubjectId, + Subject = fact.Other.Id != 0 ? fact.Other : SnapshotId(otherId), + Other = fact.Subject, + At = fact.At, + WorldTime = fact.WorldTime, + DateLabel = fact.DateLabel, + Strength = fact.Strength, + Provenance = fact.Provenance, + KingdomKey = fact.KingdomKey, + CityKey = fact.CityKey, + ClanKey = fact.ClanKey, + FamilyKey = fact.FamilyKey, + WarKey = fact.WarKey, + PlotKey = fact.PlotKey, + Resolved = fact.Resolved, + Note = fact.Note + }; + life.Facts.Insert(0, mirror); + life.TouchedAt = fact.At; + while (life.Facts.Count > MaxFactsPerLife) + { + life.Facts.RemoveAt(life.Facts.Count - 1); + } + } + + private static LifeSagaFact FindByKey(LifeSagaLifeMemory life, string key) + { + if (life == null || string.IsNullOrEmpty(key)) + { + return null; + } + + for (int i = 0; i < life.Facts.Count; i++) + { + if (life.Facts[i] != null + && string.Equals(life.Facts[i].Key, key, StringComparison.Ordinal)) + { + return life.Facts[i]; + } + } + + return null; + } + + private static void PruneIfNeeded() + { + if (Lives.Count < MaxLives) + { + return; + } + + var ranked = new List(Lives.Count); + foreach (KeyValuePair kv in Lives) + { + ranked.Add(kv.Value); + } + + ranked.Sort((a, b) => + { + int ap = ProtectScore(a); + int bp = ProtectScore(b); + int cmp = ap.CompareTo(bp); + if (cmp != 0) + { + return cmp; + } + + return a.TouchedAt.CompareTo(b.TouchedAt); + }); + + int drop = Mathf.Max(1, Lives.Count - MaxLives + 8); + for (int i = 0; i < ranked.Count && drop > 0; i++) + { + LifeSagaLifeMemory life = ranked[i]; + if (ProtectScore(life) >= 2) + { + continue; + } + + Lives.Remove(life.UnitId); + drop--; + } + } + + private static int ProtectScore(LifeSagaLifeMemory life) + { + if (life == null) + { + return 0; + } + + if (LifeSagaRoster.IsMc(life.UnitId)) + { + return 3; + } + + LifeSagaSlot slot = LifeSagaRoster.Get(life.UnitId); + if (slot != null && (slot.Prefer || slot.GameFavorite)) + { + return 3; + } + + for (int i = 0; i < life.Facts.Count; i++) + { + if (life.Facts[i] != null && !life.Facts[i].Resolved && IsStakeWorthy(life.Facts[i].Kind)) + { + return 2; + } + } + + return 1; + } + + private static string DefaultKey( + LifeSagaFactKind kind, + long subjectId, + long otherId, + string warKey, + string plotKey) + { + return kind + ":" + subjectId + ":" + otherId + ":" + (warKey ?? "") + ":" + (plotKey ?? ""); + } + + private static string PairKey(long a, long b) + { + return a <= b ? a + ":" + b : b + ":" + a; + } + + private static bool IsStakeWorthy(LifeSagaFactKind kind) + { + switch (kind) + { + case LifeSagaFactKind.WarJoin: + case LifeSagaFactKind.PlotJoin: + case LifeSagaFactKind.RivalEarned: + case LifeSagaFactKind.BondBroken: + case LifeSagaFactKind.HardArcWar: + case LifeSagaFactKind.HardArcPlot: + case LifeSagaFactKind.HardArcGrief: + case LifeSagaFactKind.RoleChange: + case LifeSagaFactKind.Founding: + return true; + default: + return false; + } + } + + private static bool IsLegacyWorthy(LifeSagaFactKind kind) + { + switch (kind) + { + case LifeSagaFactKind.CombatEncounter: + return false; + default: + return kind != LifeSagaFactKind.Unknown; + } + } + + private static int LegacyBucket(LifeSagaFactKind kind) + { + switch (kind) + { + case LifeSagaFactKind.Founding: + case LifeSagaFactKind.HomeGained: + return 0; + case LifeSagaFactKind.BondFormed: + case LifeSagaFactKind.FriendFormed: + case LifeSagaFactKind.ParentChild: + case LifeSagaFactKind.HardArcLove: + return 1; + case LifeSagaFactKind.RoleChange: + return 2; + case LifeSagaFactKind.Kill: + case LifeSagaFactKind.CombatEncounter: + case LifeSagaFactKind.RivalEarned: + case LifeSagaFactKind.HardArcCombat: + case LifeSagaFactKind.WarJoin: + case LifeSagaFactKind.WarEnd: + case LifeSagaFactKind.HardArcWar: + case LifeSagaFactKind.PlotJoin: + case LifeSagaFactKind.PlotEnd: + case LifeSagaFactKind.HardArcPlot: + return 3; + case LifeSagaFactKind.BondBroken: + case LifeSagaFactKind.HomeLost: + case LifeSagaFactKind.Death: + case LifeSagaFactKind.HardArcGrief: + return 4; + default: + return 5; + } + } + + private static bool ContainsFact(List list, LifeSagaFact fact) + { + for (int i = 0; i < list.Count; i++) + { + if (list[i] != null && list[i].Key == fact.Key) + { + return true; + } + } + + return false; + } + + private static LifeSagaFactKind HardArcKind(StoryArcKind arcKind) + { + switch (arcKind) + { + case StoryArcKind.Love: + return LifeSagaFactKind.HardArcLove; + case StoryArcKind.Grief: + return LifeSagaFactKind.HardArcGrief; + case StoryArcKind.CombatDuel: + case StoryArcKind.CombatMass: + return LifeSagaFactKind.HardArcCombat; + case StoryArcKind.WarFront: + return LifeSagaFactKind.HardArcWar; + case StoryArcKind.Plot: + return LifeSagaFactKind.HardArcPlot; + default: + return LifeSagaFactKind.Unknown; + } + } + + private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind) + { + switch (kind) + { + case LifeSagaFactKind.Kill: + case LifeSagaFactKind.CombatEncounter: + 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; + } + } + + private static string RoleNote(string roleId) + { + if (string.IsNullOrEmpty(roleId)) + { + return "Rose in standing"; + } + + if (roleId.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Became king"; + } + + if (roleId.IndexOf("leader", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Became a leader"; + } + + if (roleId.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Became alpha"; + } + + return "Role: " + roleId; + } + + 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 ""; + } + + private static void StampWorldDate(out double worldTime, out string dateLabel) + { + worldTime = 0; + dateLabel = ""; + try + { + if (World.world == null) + { + return; + } + + worldTime = World.world.getCurWorldTime(); + int[] raw = Date.getRawDate(worldTime); + if (raw != null && raw.Length >= 3) + { + string monthName = Date.formatMonth(raw[1]); + if (string.IsNullOrEmpty(monthName)) + { + monthName = "m" + raw[1]; + } + + dateLabel = monthName + " " + raw[2]; + } + } + catch + { + // keep empty + } + } +} + +public enum LifeSagaFactKind +{ + Unknown, + BondFormed, + BondBroken, + FriendFormed, + ParentChild, + Kill, + Death, + CombatEncounter, + RivalEarned, + WarJoin, + WarEnd, + PlotJoin, + PlotEnd, + Founding, + RoleChange, + HomeGained, + HomeLost, + HardArcLove, + HardArcGrief, + HardArcCombat, + HardArcWar, + HardArcPlot +} + +public struct LifeSagaIdentity +{ + public long Id; + public string Name; + public string SpeciesId; +} + +public sealed class LifeSagaFact +{ + public string Key = ""; + public LifeSagaFactKind Kind; + public long SubjectId; + public long OtherId; + public LifeSagaIdentity Subject; + public LifeSagaIdentity Other; + public float At; + public double WorldTime; + public string DateLabel = ""; + public float Strength; + public string Provenance = ""; + public string KingdomKey = ""; + public string CityKey = ""; + public string ClanKey = ""; + public string FamilyKey = ""; + public string WarKey = ""; + public string PlotKey = ""; + public bool Resolved; + public string Note = ""; +} + +public sealed class LifeSagaLifeMemory +{ + public long UnitId; + public LifeSagaIdentity Identity; + public float TouchedAt; + public readonly List Facts = new List(16); +} diff --git a/IdleSpectator/Story/LifeSagaOverview.cs b/IdleSpectator/Story/LifeSagaOverview.cs index 09aff77..1d65ef5 100644 --- a/IdleSpectator/Story/LifeSagaOverview.cs +++ b/IdleSpectator/Story/LifeSagaOverview.cs @@ -1,504 +1,135 @@ -using System; using System.Collections.Generic; -using System.Text; -using UnityEngine; namespace IdleSpectator; /// -/// Multi-line saga hover card: Title / Why / Circle / Chapters. -/// No trait descs, no Chronicle dump, no planner jargon. +/// Compatibility façade over . +/// Prefer for new UI. /// 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(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(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(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 Rows = new List(); + + public string ToPlainText() + { + return LifeSagaPresentation.BuildPlainText(UnitId); } } diff --git a/IdleSpectator/Story/LifeSagaPanel.cs b/IdleSpectator/Story/LifeSagaPanel.cs new file mode 100644 index 0000000..cae53f9 --- /dev/null +++ b/IdleSpectator/Story/LifeSagaPanel.cs @@ -0,0 +1,757 @@ +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; + +namespace IdleSpectator; + +/// +/// 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. +/// +public static class LifeSagaPanel +{ + /// Matches WatchCaption tabbed chrome inner width (PanelWidthMax - pads). + public const float BodyWidthFixed = 232f; + /// Hard cap so Saga never eats the screen. + 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(); + _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(); + loreBg.color = new Color(0f, 0f, 0f, 0f); + loreBg.raycastTarget = true; + _loreBtn = loreGo.GetComponent public static class WatchCaption { - private const float PanelWidthMax = 420f; - private const float SpeciesSize = 16f; - private const float SexSize = 14f; - private const float LiveMax = 44f; - private const float ChipIcon = 12f; - /// Fixed nametag task label slot so text swaps do not autofit-tick the header. - private const float TaskLabelW = 78f; - private const float TraitIcon = 12f; - private const float HistoryIcon = 10f; + /// + /// Locked outer width whenever tabs/rail are shown so Dossier↔Saga never slides + /// right-anchored glyphs out from under the hover cursor. + /// + private const float PanelWidthMax = 240f; + private const float SpeciesSize = 14f; + private const float SexSize = 12f; + private const float LiveMax = 40f; + private const float ChipIcon = 11f; + /// Max nametag task label slot; PlaceHeader may shrink further to clear sex/favorite. + private const float TaskLabelW = 72f; + private const float TraitIcon = 11f; + private const float HistoryIcon = 9f; private const float HistoryColMinW = 118f; - private const float NameMinW = 28f; + private const float NameMinW = 24f; /// Hard cap so long "Name (Species/Job)" lines cannot paint under the level chip. - private const float NameMaxW = 230f; + private const float NameMaxW = 160f; private const float PadX = 4f; /// Inset from the canvas top-right corner (grows left via pivot). private const float ScreenInset = 12f; - private const float PadTop = 4f; - private const float HeaderH = 18f; - private const float BodyH = 44f; - private const float TraitsH = 14f; - private const float StatusesH = 14f; - private const float ReasonH = 14f; - private const float StorySpineH = 12f; - private const float HistoryLineMinH = 13f; + private const float PadTop = 3f; + private const float HeaderH = 15f; + private const float BodyH = 40f; + private const float TraitsH = 13f; + private const float StatusesH = 13f; + private const float ReasonH = 13f; + private const float StorySpineH = 11f; + private const float TabH = 14f; + private const float HistoryLineMinH = 12f; private const float HistoryLineMaxH = 100f; - private const float Gap = 2f; + private const float Gap = 1f; private const int HistoryPeekMax = 3; - private const float BtnSize = 16f; + private const float BtnSize = 14f; private const float StatusBannerSeconds = 5f; /// History column grows with content until the dossier hits . private static float HistoryColMaxW => Mathf.Max(HistoryColMinW, PanelWidthMax - PadX * 2f - LiveMax - 4f); + /// Inner width available to tabs/dossier/saga body. + private static float BodyColumnInnerWidth() => + Mathf.Max(120f, PanelWidthMax - PadX * 2f); + private static float _panelWidth = LiveMax + PadX * 2f; private static readonly Color NameColor = HudTheme.Name; @@ -66,6 +75,13 @@ public static class WatchCaption private static Text _reasonText; private static Text _storySpineText; private static long _reasonOtherId; + private static Button _tabDossierBtn; + private static Button _tabSagaBtn; + private static Text _tabDossierLabel; + private static Text _tabSagaLabel; + private static Image _tabDossierBg; + private static Image _tabSagaBg; + private static GameObject _tabsRow; private static Image _levelIcon; private static Text _levelValue; @@ -414,6 +430,8 @@ public static class WatchCaption LastDetail = ""; LastCaptionText = ""; LastStorySpine = ""; + LifeSagaViewController.Clear(); + LifeSagaPanel.Clear(); LifeSagaRail.Clear(); LastHistoryPreview = ""; LastHistoryJoined = ""; @@ -666,14 +684,23 @@ public static class WatchCaption // Safety net: any focus change that skipped SetFromActor (or rematched follow) // must not leave the nametag on the previous person for a frame. - ReconcileDossierToFocus(); - RefreshLivePortrait(); - RefreshLiveTask(); - RefreshLiveIdentity(); - RefreshLiveStatuses(); - RefreshOwnedReason(); - RefreshStorySpine(); - RefreshHistoryIfChanged(); + LifeSagaViewController.Tick(); + if (LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Dossier) + { + ReconcileDossierToFocus(); + RefreshLivePortrait(); + RefreshLiveTask(); + RefreshLiveIdentity(); + RefreshLiveStatuses(); + RefreshOwnedReason(); + RefreshStorySpine(); + RefreshHistoryIfChanged(); + } + else + { + RefreshStorySpine(); + RefreshSagaBody(); + } } /// @@ -732,13 +759,8 @@ public static class WatchCaption return; } - Relayout( - _boundActor != null || CountActiveHistorySlots() > 0 || _current != null, - CountActiveTraitSlots(), - CountActiveStatusSlots(), - _taskText != null && _taskText.gameObject.activeSelf, - _reasonText != null && _reasonText.gameObject.activeSelf, - CountActiveHistorySlots()); + // Route through spine/tab refresh so Saga body and rail stay consistent. + RefreshStorySpine(); } private static bool HasStatusBanner() @@ -914,34 +936,238 @@ public static class WatchCaption /// /// Quiet Kind · Phase line under the orange reason while StoryPlanner owns an arc. + /// Suppressed when the orange reason already names the same kind and phase is Climax. /// private static void RefreshStorySpine() { if (!_visible || HasStatusBanner()) { + LifeSagaViewController.CancelPreviewAndPause(); + LifeSagaPanel.Clear(); LifeSagaRail.Clear(); + SetTabsVisible(false); return; } string next = (SpectatorMode.Active || SpectatorMode.BrowsePaused) && ModSettings.ShowDossierCaption ? StoryPlanner.FormatSpineLabel() : ""; + next = FilterRedundantStorySpine(next, CurrentReasonText()); if (!string.Equals(next, LastStorySpine, StringComparison.Ordinal)) { ApplyStorySpine(next); } LifeSagaRail.Refresh(); - bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; - bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; - bool hasBody = _boundActor != null || CountActiveHistorySlots() > 0 || _current != null; + RefreshTabChrome(); + bool sagaMode = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga; + if (sagaMode) + { + RefreshSagaBody(); + } + else + { + LifeSagaPanel.SetVisible(false); + } + + bool hasReason = !sagaMode && _reasonText != null && _reasonText.gameObject.activeSelf; + bool hasTask = !sagaMode && _taskText != null && _taskText.gameObject.activeSelf; + bool hasBody = sagaMode + || _boundActor != null + || CountActiveHistorySlots() > 0 + || _current != null; Relayout( hasBody, - CountActiveTraitSlots(), - CountActiveStatusSlots(), + sagaMode ? 0 : CountActiveTraitSlots(), + sagaMode ? 0 : CountActiveStatusSlots(), hasTask, hasReason, - CountActiveHistorySlots()); + sagaMode ? 0 : CountActiveHistorySlots()); + } + + /// Orange reason line currently shown (or bound), for spine dedupe. + private static string CurrentReasonText() + { + if (_reasonText != null && _reasonText.gameObject.activeSelf + && !string.IsNullOrEmpty(_reasonText.text)) + { + return _reasonText.text; + } + + return _current != null ? (_current.ReasonLine ?? "") : ""; + } + + /// + /// Drop Kind · Climax when the reason already leads with that kind (e.g. "Duel - A vs B"). + /// Keep Aftermath / Epilogue and unmatched kinds. Combat Climax is also dropped when the + /// tip already leads with any combat scale word (covers brief Kind sync lag). + /// + private static string FilterRedundantStorySpine(string spine, string reason) + { + if (string.IsNullOrEmpty(spine)) + { + return ""; + } + + int sep = spine.IndexOf('·'); + if (sep < 0) + { + return spine; + } + + string kind = spine.Substring(0, sep).Trim(); + string phase = spine.Substring(sep + 1).Trim(); + if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(phase)) + { + return spine; + } + + if (!string.Equals(phase, "Climax", StringComparison.OrdinalIgnoreCase)) + { + return spine; + } + + if (string.IsNullOrEmpty(reason)) + { + return spine; + } + + if (ReasonLeadsWithToken(reason, kind)) + { + return ""; + } + + // Tip already carries combat scale; hide Kind · Climax even if spine Kind lags SyncActiveClimax. + if (IsCombatSpineKind(kind) && ReasonLeadsWithCombatScale(reason)) + { + return ""; + } + + return spine; + } + + private static bool IsCombatSpineKind(string kind) + { + return string.Equals(kind, "Duel", StringComparison.OrdinalIgnoreCase) + || string.Equals(kind, "Skirmish", StringComparison.OrdinalIgnoreCase) + || string.Equals(kind, "Battle", StringComparison.OrdinalIgnoreCase) + || string.Equals(kind, "Mass", StringComparison.OrdinalIgnoreCase); + } + + private static bool ReasonLeadsWithCombatScale(string reason) + { + return ReasonLeadsWithToken(reason, "Duel") + || ReasonLeadsWithToken(reason, "Skirmish") + || ReasonLeadsWithToken(reason, "Battle") + || ReasonLeadsWithToken(reason, "Mass"); + } + + /// True when reason starts with the token before " - " / " · " / whitespace. + private static bool ReasonLeadsWithToken(string reason, string token) + { + if (string.IsNullOrEmpty(reason) || string.IsNullOrEmpty(token)) + { + return false; + } + + string lead = reason.TrimStart(); + int cut = lead.Length; + for (int i = 0; i < lead.Length; i++) + { + char c = lead[i]; + if (c == '-' || c == '·' || char.IsWhiteSpace(c)) + { + cut = i; + break; + } + } + + string head = lead.Substring(0, cut).Trim(); + return string.Equals(head, token, StringComparison.OrdinalIgnoreCase); + } + + private static void RefreshSagaBody() + { + EnsureBuilt(); + LifeSagaPanel.EnsureBuilt(_root.transform); + LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation(); + bool lore = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga + && !LifeSagaViewController.IsHoverPreview; + LifeSagaPanel.Bind(model, lore); + } + + private static void RefreshTabChrome() + { + if (_tabsRow == null) + { + return; + } + + bool show = ModSettings.ShowDossierCaption && !HasStatusBanner() + && (SpectatorMode.Active || SpectatorMode.BrowsePaused); + SetTabsVisible(show); + if (!show) + { + return; + } + + bool saga = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga; + ApplyTabVisual(_tabDossierBtn, _tabDossierBg, _tabDossierLabel, !saga); + ApplyTabVisual(_tabSagaBtn, _tabSagaBg, _tabSagaLabel, saga); + // Keep the tab label stable - never "Saga · Name" (that made chrome feel jumpy). + if (_tabSagaLabel != null) + { + _tabSagaLabel.text = "Saga"; + } + } + + private static string TruncateTabName(string name) + { + if (string.IsNullOrEmpty(name)) + { + return ""; + } + + string trimmed = name.StartsWith("★ ") ? name.Substring(2) : name; + return trimmed.Length <= 12 ? trimmed : trimmed.Substring(0, 11) + "…"; + } + + private static void SetTabsVisible(bool visible) + { + if (_tabsRow != null) + { + _tabsRow.SetActive(visible); + } + } + + private static void ApplyTabVisual(Button btn, Image bg, Text label, bool selected) + { + if (bg != null) + { + bg.color = selected + ? new Color(0.28f, 0.3f, 0.38f, 0.95f) + : new Color(0.14f, 0.15f, 0.18f, 0.85f); + } + + if (label != null) + { + label.color = selected ? HudTheme.ValueOrange : HudTheme.Muted; + label.fontStyle = selected ? FontStyle.Bold : FontStyle.Normal; + } + + _ = btn; + } + + private static void OnTabDossier() + { + LifeSagaViewController.SelectTab(LifeSagaViewController.Tab.Dossier); + RefreshStorySpine(); + } + + private static void OnTabSaga() + { + LifeSagaViewController.SelectTab(LifeSagaViewController.Tab.Saga); + RefreshStorySpine(); } private static void ApplyStorySpine(string spine) @@ -2347,16 +2573,34 @@ public static class WatchCaption } } - // Chip rows must widen the panel - PlaceTraits/PlaceStatuses lay out natural widths. - _panelWidth = Mathf.Clamp( - PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, statusesRowW, LiveMax), - LiveMax + PadX * 2f, - PanelWidthMax); + bool sagaMode = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga + && !HasStatusBanner(); + bool tabbedChrome = (_tabsRow != null && _tabsRow.activeSelf) || LifeSagaRail.Visible; + + // Tabbed chrome keeps a constant width so hover preview cannot reflow the rail. + if (tabbedChrome) + { + _panelWidth = PanelWidthMax; + } + else if (sagaMode) + { + float sagaNeed = Mathf.Max( + LifeSagaPanel.BodyWidthPx, + LifeSagaRail.Visible ? LifeSagaRail.RowWidthPx : 0f); + _panelWidth = Mathf.Clamp(sagaNeed + PadX * 2f, 120f, PanelWidthMax); + } + else + { + _panelWidth = Mathf.Clamp( + PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, statusesRowW, LiveMax), + LiveMax + PadX * 2f, + PanelWidthMax); + } LastHistoryFillsBody = historyCount <= 0; - if (historyCount > 0) + if (!sagaMode && historyCount > 0) { - float fillW = _panelWidth - PadX * 2f - LiveMax - 4f; + float fillW = BodyColumnInnerWidth() - LiveMax - 4f; if (fillW > _historyColW + 0.5f) { RemeasureHistoryForWidth(fillW); @@ -2370,6 +2614,65 @@ public static class WatchCaption } float y = PadTop; + if (_tabsRow != null && _tabsRow.activeSelf) + { + PlaceTabs(y); + y += TabH + Gap; + } + + // Stable top chrome: rail under tabs so Dossier↔Saga body swaps cannot move glyphs. + if (LifeSagaRail.Visible) + { + y = LifeSagaRail.Place(y, PadX); + } + + SetDossierBodyVisible(!sagaMode); + if (sagaMode) + { + LifeSagaPanel.EnsureBuilt(_root.transform); + y = LifeSagaPanel.Place(y, PadX); + if (tabbedChrome) + { + _panelWidth = PanelWidthMax; + } + else + { + float sagaNeed = Mathf.Max( + LifeSagaPanel.BodyWidthPx, + LifeSagaRail.Visible ? LifeSagaRail.RowWidthPx : 0f); + _panelWidth = Mathf.Clamp(sagaNeed + PadX * 2f, 120f, PanelWidthMax); + } + + float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap); + if (_rootRt != null) + { + ApplyRootPlacement(); + _rootRt.sizeDelta = new Vector2(_panelWidth, height); + LastPanelSize = _rootRt.sizeDelta; + } + + BringHeaderFront(); + LastLayoutOk = true; + return; + } + + // Row visibility is owned by Place*/fill - never leave empty rows active at stale + // positions (SetDossierBodyVisible only hides on saga; it must not resurrect them). + if (historyCount <= 0 && _historyCol != null) + { + _historyCol.SetActive(false); + } + + if (statusCount <= 0 && _statusesRow != null) + { + _statusesRow.SetActive(false); + } + + if (traitCount <= 0 && _traitsRow != null) + { + _traitsRow.SetActive(false); + } + PlaceHeader(y, hasTask); y += HeaderH + Gap; @@ -2422,22 +2725,17 @@ public static class WatchCaption y += StorySpineH + Gap; } - if (LifeSagaRail.Visible) - { - y = LifeSagaRail.Place(y, PadX); - } - - float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap); + float heightDossier = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap); if (_rootRt != null) { ApplyRootPlacement(); - _rootRt.sizeDelta = new Vector2(_panelWidth, height); + _rootRt.sizeDelta = new Vector2(_panelWidth, heightDossier); LastPanelSize = _rootRt.sizeDelta; } BringHeaderFront(); - LastLayoutOk = ProbeLayoutInside(height) + LastLayoutOk = ProbeLayoutInside(heightDossier) && headerW > SpeciesSize && (historyCount <= 0 || LastHistoryFillsBody); if (!LastLayoutOk) @@ -2447,34 +2745,120 @@ public static class WatchCaption } } + private static void PlaceTabs(float yFromTop) + { + if (_tabsRow == null) + { + return; + } + + RectTransform rowRt = _tabsRow.GetComponent(); + rowRt.anchoredPosition = new Vector2(PadX, -yFromTop); + rowRt.sizeDelta = new Vector2(Mathf.Max(100f, BodyColumnInnerWidth()), TabH); + if (_tabDossierBtn != null) + { + RectTransform rt = _tabDossierBtn.GetComponent(); + rt.anchoredPosition = Vector2.zero; + rt.sizeDelta = new Vector2(56f, TabH); + } + + if (_tabSagaBtn != null) + { + RectTransform rt = _tabSagaBtn.GetComponent(); + rt.anchoredPosition = new Vector2(58f, 0f); + rt.sizeDelta = new Vector2(56f, TabH); + } + } + + private static void SetDossierBodyVisible(bool visible) + { + if (_nameText != null) _nameText.gameObject.SetActive(visible); + if (_speciesIcon != null) _speciesIcon.gameObject.SetActive(visible); + if (_sexIcon != null) _sexIcon.gameObject.SetActive(visible); + if (_levelIcon != null) _levelIcon.gameObject.SetActive(visible); + if (_levelValue != null) _levelValue.gameObject.SetActive(visible); + if (_taskIcon != null) _taskIcon.gameObject.SetActive(visible && _taskText != null && !string.IsNullOrEmpty(_taskText.text)); + if (_taskText != null) _taskText.gameObject.SetActive(visible && !string.IsNullOrEmpty(_taskText.text)); + if (_favoriteBtn != null) _favoriteBtn.gameObject.SetActive(visible); + if (_reasonText != null) _reasonText.gameObject.SetActive(visible && !string.IsNullOrEmpty(_reasonText.text)); + if (_storySpineText != null) + { + _storySpineText.gameObject.SetActive( + visible && !string.IsNullOrEmpty(LastStorySpine) && !HasStatusBanner()); + } + + // History / traits / statuses stay under Fill*/Place* ownership while dossier is + // visible. Only force-hide them when switching to Saga so empty rows cannot remain + // active at stale coordinates and fail ProbeLayoutInside. + if (!visible) + { + if (_historyCol != null) _historyCol.SetActive(false); + if (_traitsRow != null) _traitsRow.SetActive(false); + if (_statusesRow != null) _statusesRow.SetActive(false); + } + + // Saga reuses the same live portrait host for the MC - leave it visible there. + if (!LifeSagaPanel.OwnsAvatar) + { + DossierAvatar.SetHostVisible(visible); + } + } + /// /// Nametag: left-pack [species] Name [lv]n [task]; right-pin [sex] [★] to the panel edge. + /// Middle chips shrink/ellipsis so they never paint under the right cluster. /// private static float PlaceHeader(float yFromTop, bool hasTask) { - float x = PadX; + float panelW = Mathf.Max(_panelWidth, LiveMax + PadX * 2f); + // Sex + favorite hug the dossier's top-right (right-justified). + float right = panelW - PadX; + PlaceHeaderButton(_favoriteBtn, right - BtnSize, yFromTop); + right -= BtnSize; + if (_sexIcon != null && _sexIcon.gameObject.activeSelf) + { + right -= 2f; + PlaceLeftChip(_sexIcon.rectTransform, right - SexSize, yFromTop + 2f, SexSize, SexSize); + right -= SexSize; + } + const float minGap = 6f; + float leftBudget = Mathf.Max(NameMinW, right - PadX - minGap); + + float x = PadX; if (_speciesIcon != null) { PlaceLeftChip(_speciesIcon.rectTransform, x, yFromTop + 1f, SpeciesSize, SpeciesSize); x += SpeciesSize + 3f; } - if (_nameText != null) + bool hasLevel = _levelIcon != null && _levelIcon.gameObject.activeSelf + && _levelValue != null && _levelValue.gameObject.activeSelf; + float levelW = 0f; + if (hasLevel) { - float nameW = FitNameChipWidth(); - PlaceLeftChip(_nameText.rectTransform, x, yFromTop, nameW, HeaderH); - x += nameW + 5f; + levelW = ChipIcon + 1f + + Mathf.Clamp(MeasureTextWidth(_levelValue, 10f), 8f, 24f) + + 4f; } - if (_levelIcon != null && _levelIcon.gameObject.activeSelf) + float taskReserve = hasTask ? ChipIcon + 1f + 28f + 4f : 0f; + float nameBudget = Mathf.Clamp( + leftBudget - (x - PadX) - levelW - taskReserve, + NameMinW, + NameMaxW); + + if (_nameText != null) + { + float nameW = FitNameChipWidth(nameBudget); + PlaceLeftChip(_nameText.rectTransform, x, yFromTop, nameW, HeaderH); + x += nameW + 4f; + } + + if (hasLevel) { PlaceLeftChip(_levelIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon); x += ChipIcon + 1f; - } - - if (_levelValue != null && _levelValue.gameObject.activeSelf) - { float lvW = Mathf.Clamp(MeasureTextWidth(_levelValue, 10f), 8f, 24f); PlaceLeftChip(_levelValue.rectTransform, x, yFromTop, lvW, HeaderH); x += lvW + 4f; @@ -2484,10 +2868,15 @@ public static class WatchCaption { _taskIcon.gameObject.SetActive(true); _taskText.gameObject.SetActive(true); + float taskBudget = Mathf.Clamp( + leftBudget - (x - PadX) - ChipIcon - 1f, + 24f, + TaskLabelW); + float taskW = FitTaskChipWidth(taskBudget); PlaceLeftChip(_taskIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon); x += ChipIcon + 1f; - PlaceLeftChip(_taskText.rectTransform, x, yFromTop, TaskLabelW, HeaderH); - x += TaskLabelW + 4f; + PlaceLeftChip(_taskText.rectTransform, x, yFromTop, taskW, HeaderH); + x += taskW + 4f; } else { @@ -2502,17 +2891,6 @@ public static class WatchCaption } } - // Sex + favorite always hug the dossier's top-right (right-justified). - float right = Mathf.Max(_panelWidth, LiveMax + PadX * 2f) - PadX; - PlaceHeaderButton(_favoriteBtn, right - BtnSize, yFromTop); - right -= BtnSize; - if (_sexIcon != null && _sexIcon.gameObject.activeSelf) - { - right -= 2f; - PlaceLeftChip(_sexIcon.rectTransform, right - SexSize, yFromTop + 2f, SexSize, SexSize); - right -= SexSize; - } - RefreshFavoriteVisual(_boundActor); BringHeaderFront(); @@ -2524,7 +2902,7 @@ public static class WatchCaption float x = PadX + SpeciesSize + 3f; if (_nameText != null) { - x += FitNameChipWidth() + 5f; + x += Mathf.Min(FitNameChipWidth(NameMaxW), NameMaxW) + 4f; } if (_levelIcon != null && _levelIcon.gameObject.activeSelf) @@ -2544,7 +2922,7 @@ public static class WatchCaption } // Reserve a gap + right cluster so sex/favorite can pin to the panel edge. - const float minGap = 8f; + const float minGap = 6f; float rightCluster = BtnSize; if (_sexIcon != null && _sexIcon.gameObject.activeSelf) { @@ -2625,16 +3003,17 @@ public static class WatchCaption } /// - /// Size the name chip to the visible text and ellipsis-truncate when over . - /// Prevents Overflow paint from landing under the level badge (long "Name (species)" lines). + /// Size the name chip to the visible text and ellipsis-truncate when over . + /// Prevents Overflow paint from landing under level / task / sex chips. /// - private static float FitNameChipWidth() + private static float FitNameChipWidth(float maxW = NameMaxW) { if (_nameText == null) { return NameMinW; } + maxW = Mathf.Max(NameMinW, maxW); _nameText.resizeTextForBestFit = false; _nameText.horizontalOverflow = HorizontalWrapMode.Overflow; string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? ""); @@ -2646,9 +3025,9 @@ public static class WatchCaption _nameText.text = full; float natural = MeasureTextWidth(_nameText, 72f); - if (natural <= NameMaxW) + if (natural <= maxW) { - return Mathf.Clamp(natural, NameMinW, NameMaxW); + return Mathf.Clamp(natural, NameMinW, maxW); } const string ellipsis = "..."; @@ -2662,8 +3041,8 @@ public static class WatchCaption ? ellipsis : (mid >= full.Length ? full : full.Substring(0, mid).TrimEnd() + ellipsis); _nameText.text = candidate; - float w = MeasureTextWidth(_nameText, NameMaxW); - if (w <= NameMaxW) + float w = MeasureTextWidth(_nameText, maxW); + if (w <= maxW) { best = candidate; lo = mid + 1; @@ -2675,7 +3054,104 @@ public static class WatchCaption } _nameText.text = best; - return Mathf.Clamp(MeasureTextWidth(_nameText, NameMaxW), NameMinW, NameMaxW); + return Mathf.Clamp(MeasureTextWidth(_nameText, maxW), NameMinW, maxW); + } + + /// Ellipsis-fit the task chip into the remaining header budget. + private static float FitTaskChipWidth(float maxW) + { + if (_taskText == null) + { + return Mathf.Clamp(maxW, 24f, TaskLabelW); + } + + maxW = Mathf.Clamp(maxW, 24f, TaskLabelW); + _taskText.resizeTextForBestFit = false; + _taskText.horizontalOverflow = HorizontalWrapMode.Overflow; + string full = TruncateTaskLabel(_taskText.text ?? ""); + if (string.IsNullOrEmpty(full)) + { + _taskText.text = ""; + return maxW; + } + + _taskText.text = full; + float natural = MeasureTextWidth(_taskText, maxW); + if (natural <= maxW) + { + return Mathf.Clamp(natural, 24f, maxW); + } + + const string ellipsis = "..."; + int lo = 0; + int hi = full.Length; + string best = ellipsis; + while (lo <= hi) + { + int mid = (lo + hi) / 2; + string candidate = mid <= 0 + ? ellipsis + : (mid >= full.Length ? full : full.Substring(0, mid).TrimEnd() + ellipsis); + _taskText.text = candidate; + float w = MeasureTextWidth(_taskText, maxW); + if (w <= maxW) + { + best = candidate; + lo = mid + 1; + } + else + { + hi = mid - 1; + } + } + + _taskText.text = best; + return Mathf.Clamp(MeasureTextWidth(_taskText, maxW), 24f, maxW); + } + + private static void EllipsisFitLabel(Text label, float maxW) + { + if (label == null || maxW < 8f) + { + return; + } + + string full = label.text ?? ""; + if (string.IsNullOrEmpty(full)) + { + return; + } + + label.resizeTextForBestFit = false; + label.horizontalOverflow = HorizontalWrapMode.Overflow; + if (MeasureTextWidth(label, maxW) <= maxW) + { + return; + } + + const string ellipsis = "..."; + int lo = 0; + int hi = full.Length; + string best = ellipsis; + while (lo <= hi) + { + int mid = (lo + hi) / 2; + string candidate = mid <= 0 + ? ellipsis + : (mid >= full.Length ? full : full.Substring(0, mid).TrimEnd() + ellipsis); + label.text = candidate; + if (MeasureTextWidth(label, maxW) <= maxW) + { + best = candidate; + lo = mid + 1; + } + else + { + hi = mid - 1; + } + } + + label.text = best; } private static float MeasureTextWidth(Text text, float fallback) @@ -2931,6 +3407,7 @@ public static class WatchCaption return 0f; } + _statusesRow.SetActive(true); RectTransform row = _statusesRow.GetComponent(); row.anchorMin = new Vector2(0f, 1f); row.anchorMax = new Vector2(1f, 1f); @@ -2938,6 +3415,7 @@ public static class WatchCaption row.offsetMin = new Vector2(PadX, -(yFromTop + StatusesH)); row.offsetMax = new Vector2(-PadX, -yFromTop); + float maxRow = Mathf.Max(40f, BodyColumnInnerWidth()); float x = 0f; for (int i = 0; i < _statusSlots.Length; i++) { @@ -2951,6 +3429,19 @@ public static class WatchCaption ? Mathf.Max(MeasureTextWidth(slot.Label, 28f), 18f) : 28f; float slotW = TraitIcon + 2f + labelW; + if (x + slotW > maxRow && slot.Label != null) + { + labelW = Mathf.Max(12f, maxRow - x - TraitIcon - 2f); + EllipsisFitLabel(slot.Label, labelW); + slotW = TraitIcon + 2f + labelW; + } + + if (x + slotW > maxRow + 0.5f) + { + slot.Root.SetActive(false); + continue; + } + PlaceTraitSlot(slot, x, 0f, slotW, StatusesH); x += slotW + 3f; } @@ -2965,6 +3456,7 @@ public static class WatchCaption return 0f; } + _traitsRow.SetActive(true); RectTransform row = _traitsRow.GetComponent(); row.anchorMin = new Vector2(0f, 1f); row.anchorMax = new Vector2(1f, 1f); @@ -2972,6 +3464,7 @@ public static class WatchCaption row.offsetMin = new Vector2(PadX, -(yFromTop + TraitsH)); row.offsetMax = new Vector2(-PadX, -yFromTop); + float maxRow = Mathf.Max(40f, BodyColumnInnerWidth()); float x = 0f; for (int i = 0; i < _traitSlots.Length; i++) { @@ -2985,6 +3478,19 @@ public static class WatchCaption ? Mathf.Max(MeasureTextWidth(slot.Label, 28f), 18f) : 28f; float slotW = TraitIcon + 2f + labelW; + if (x + slotW > maxRow && slot.Label != null) + { + labelW = Mathf.Max(12f, maxRow - x - TraitIcon - 2f); + EllipsisFitLabel(slot.Label, labelW); + slotW = TraitIcon + 2f + labelW; + } + + if (x + slotW > maxRow + 0.5f) + { + slot.Root.SetActive(false); + continue; + } + PlaceTraitSlot(slot, x, 0f, slotW, TraitsH); x += slotW + 3f; } @@ -3000,6 +3506,7 @@ public static class WatchCaption return 0f; } + _traitsRow.SetActive(true); float colW = MeasureTraitsBesideWidth(); RectTransform row = _traitsRow.GetComponent(); @@ -3142,6 +3649,13 @@ public static class WatchCaption _storySpineText = null; _reasonOtherId = 0; LastStorySpine = ""; + _tabsRow = null; + _tabDossierBtn = null; + _tabSagaBtn = null; + _tabDossierLabel = null; + _tabSagaLabel = null; + _tabDossierBg = null; + _tabSagaBg = null; _levelIcon = null; _levelValue = null; _taskIcon = null; @@ -3216,13 +3730,13 @@ public static class WatchCaption button.targetGraphic = hit; button.interactable = false; Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", HistoryIcon); - Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8); + Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 7); label.color = HistoryTextColor; label.alignment = TextAnchor.MiddleLeft; - label.horizontalOverflow = HorizontalWrapMode.Overflow; + label.horizontalOverflow = HorizontalWrapMode.Wrap; label.supportRichText = true; label.resizeTextMinSize = 6; - label.resizeTextMaxSize = 8; + label.resizeTextMaxSize = 7; _historySlots[i] = new HistorySlot { Root = slotGo, @@ -3245,12 +3759,12 @@ public static class WatchCaption hit.color = new Color(1f, 1f, 1f, 0.001f); hit.raycastTarget = true; Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon); - Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8); + Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 7); label.color = StatusNameColor; label.alignment = TextAnchor.MiddleLeft; label.horizontalOverflow = HorizontalWrapMode.Overflow; label.resizeTextMinSize = 6; - label.resizeTextMaxSize = 8; + label.resizeTextMaxSize = 7; DossierChipTip tip = slotGo.AddComponent(); tip.enabled = false; _statusSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label, Tip = tip }; @@ -3267,19 +3781,19 @@ public static class WatchCaption hit.color = new Color(1f, 1f, 1f, 0.001f); hit.raycastTarget = true; Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon); - Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8); + Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 7); label.color = TraitNameColor; label.alignment = TextAnchor.MiddleLeft; label.horizontalOverflow = HorizontalWrapMode.Overflow; label.resizeTextMinSize = 6; - label.resizeTextMaxSize = 8; + label.resizeTextMaxSize = 7; DossierChipTip tip = slotGo.AddComponent(); tip.enabled = false; _traitSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label, Tip = tip }; slotGo.SetActive(false); } - _reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 8); + _reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 7); _reasonText.color = ReasonColor; _reasonText.supportRichText = true; _reasonText.alignment = TextAnchor.UpperLeft; @@ -3298,25 +3812,27 @@ public static class WatchCaption _storySpineText.raycastTarget = false; LifeSagaRail.EnsureBuilt(_root.transform); + LifeSagaPanel.EnsureBuilt(_root.transform); + BuildTabsRow(); _speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize); _sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize); - _nameText = HudCanvas.MakeText(_root.transform, "Name", "", 11); + _nameText = HudCanvas.MakeText(_root.transform, "Name", "", 10); _nameText.fontStyle = FontStyle.Bold; _nameText.color = NameColor; _nameText.alignment = TextAnchor.MiddleLeft; _nameText.horizontalOverflow = HorizontalWrapMode.Overflow; _levelIcon = HudCanvas.MakeIcon(_root.transform, "LevelIcon", ChipIcon); - _levelValue = HudCanvas.MakeText(_root.transform, "LevelValue", "", 9); + _levelValue = HudCanvas.MakeText(_root.transform, "LevelValue", "", 8); _levelValue.color = StatValueColor; _levelValue.fontStyle = FontStyle.Bold; _levelValue.alignment = TextAnchor.MiddleLeft; _levelValue.horizontalOverflow = HorizontalWrapMode.Overflow; _taskIcon = HudCanvas.MakeIcon(_root.transform, "TaskIcon", ChipIcon); - _taskText = HudCanvas.MakeText(_root.transform, "Task", "", 8); + _taskText = HudCanvas.MakeText(_root.transform, "Task", "", 7); _taskText.color = TaskColor; _taskText.alignment = TextAnchor.MiddleLeft; _taskText.horizontalOverflow = HorizontalWrapMode.Overflow; @@ -3345,6 +3861,58 @@ public static class WatchCaption _favoriteIcon = _favoriteBtn.transform.Find("Icon")?.GetComponent(); } + private static void BuildTabsRow() + { + if (_tabsRow != null || _root == null) + { + return; + } + + _tabsRow = new GameObject("SagaTabs", typeof(RectTransform)); + _tabsRow.transform.SetParent(_root.transform, false); + RectTransform rowRt = _tabsRow.GetComponent(); + rowRt.pivot = new Vector2(0f, 1f); + rowRt.anchorMin = new Vector2(0f, 1f); + rowRt.anchorMax = new Vector2(0f, 1f); + + _tabDossierBtn = BuildTabButton(_tabsRow.transform, "TabDossier", "Dossier", OnTabDossier, out _tabDossierBg, out _tabDossierLabel); + _tabSagaBtn = BuildTabButton(_tabsRow.transform, "TabSaga", "Saga", OnTabSaga, out _tabSagaBg, out _tabSagaLabel); + _tabsRow.SetActive(false); + } + + private static Button BuildTabButton( + Transform parent, + string name, + string label, + UnityAction onClick, + out Image bg, + out Text text) + { + GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button)); + go.transform.SetParent(parent, false); + RectTransform rt = go.GetComponent(); + rt.pivot = new Vector2(0f, 1f); + rt.anchorMin = new Vector2(0f, 1f); + rt.anchorMax = new Vector2(0f, 1f); + rt.sizeDelta = new Vector2(64f, TabH); + bg = go.GetComponent(); + bg.color = new Color(0.14f, 0.15f, 0.18f, 0.85f); + bg.raycastTarget = true; + Button button = go.GetComponent