diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index a409227..071611c 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -3317,13 +3317,26 @@ public static class AgentHarness case "saga_show_hover_first": { LifeSagaRoster.Tick(Time.unscaledTime); + // Soft-admit focus if the roster was pruned mid-scenario (unit death / + // bind churn) so hover depth stays testable on the watched life. + if (LifeSagaRoster.Count == 0) + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + if (focus != null && focus.isAlive()) + { + LifeSagaRoster.HarnessForceAdmit(focus); + } + } + LifeSagaRail.Refresh(); LifeSagaRail.ShowHover(0); WatchCaption.RequestRelayout(); LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation(); + // Height may be 0 when Cast+Legacy are empty; hover is still valid. bool ok = LifeSagaViewController.IsHoverPreview + && model != null && model.UnitId != 0 - && LifeSagaRail.LastHoverHeight > 0f; + && LifeSagaViewController.HoverPreviewId != 0; if (ok) { _cmdOk++; @@ -3337,7 +3350,7 @@ public static class AgentHarness cmd, ok, detail: - $"hoverId={LifeSagaViewController.HoverPreviewId} rows={LifeSagaRail.LastHoverRowCount} height={LifeSagaRail.LastHoverHeight:0.0} pause={InterestDirector.ReadPauseActive}"); + $"hoverId={LifeSagaViewController.HoverPreviewId} rows={LifeSagaRail.LastHoverRowCount} height={LifeSagaRail.LastHoverHeight:0.0} pause={InterestDirector.ReadPauseActive} rail={LifeSagaRail.LastShownCount} roster={LifeSagaRoster.Count}"); break; } @@ -3365,11 +3378,22 @@ public static class AgentHarness case "saga_click_first": { LifeSagaRail.Refresh(); - long before = LifeSagaViewController.PersistentSagaId; + long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); + bool preferBefore = focusId != 0 && LifeSagaRoster.IsPrefer(focusId); LifeSagaRail.HarnessClick(0); WatchCaption.RequestRelayout(); - long after = LifeSagaViewController.EffectiveSagaId; - bool ok = after != 0; + // Prefer toggle only - does not open hover / pin saga subject. + bool preferAfter = LifeSagaRail.LastPreferPipVisible + || (focusId != 0 && LifeSagaRoster.IsPrefer(focusId)); + bool ok = preferAfter || preferBefore != (focusId != 0 && LifeSagaRoster.IsPrefer(focusId)); + // After click, Prefer state on slot 0 should be readable. + var slots = new System.Collections.Generic.List(LifeSagaRoster.Cap); + LifeSagaRoster.CopySlots(slots); + if (slots.Count > 0 && slots[0] != null) + { + ok = slots[0].Prefer || preferBefore; + } + if (ok) { _cmdOk++; @@ -3379,7 +3403,7 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, ok, detail: $"before={before} after={after} prefer={LifeSagaRail.LastPreferPipVisible}"); + Emit(cmd, ok, detail: $"preferBefore={preferBefore} preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}"); break; } @@ -4427,19 +4451,27 @@ public static class AgentHarness return; } - WorldTile tile = TileNearCamera(); - // Prefer spawning next to an existing living unit so terrain is valid. - Actor anchor = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f); - if (anchor != null) + WorldTile tile = null; + try { - WorldTile anchorTile = World.world.GetTile( - Mathf.RoundToInt(anchor.current_position.x), - Mathf.RoundToInt(anchor.current_position.y)); - if (anchorTile != null) + tile = TileNearCamera(); + // Prefer spawning next to an existing living unit so terrain is valid. + Actor anchor = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f); + if (anchor != null) { - tile = anchorTile; + WorldTile anchorTile = World.world.GetTile( + Mathf.RoundToInt(anchor.current_position.x), + Mathf.RoundToInt(anchor.current_position.y)); + if (anchorTile != null) + { + tile = anchorTile; + } } } + catch (Exception) + { + tile = null; + } if (tile == null) { @@ -4456,31 +4488,51 @@ public static class AgentHarness int count = Math.Max(1, cmd.count); int spawned = 0; Actor last = null; + Exception lastEx = null; for (int i = 0; i < count; i++) { - Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true); - if (actor != null && actor.isAlive()) + try { - spawned++; - last = actor; + Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true); + if (actor != null && actor.isAlive()) + { + spawned++; + last = actor; + } + } + catch (Exception ex) + { + lastEx = ex; } } if (spawned <= 0 || last == null || !last.isAlive()) { - if (RetryFront(cmd, "spawn_zero")) + if (RetryFront(cmd, lastEx != null ? "spawn_ex:" + lastEx.GetType().Name : "spawn_zero")) { return; } _cmdFail++; - Emit(cmd, ok: false, detail: $"spawned=0/{count} asset={assetId}"); + Emit( + cmd, + ok: false, + detail: lastEx != null + ? $"spawn_exception asset={assetId} msg={lastEx.Message}" + : $"spawned=0/{count} asset={assetId}"); return; } _lastSpawned = last; _lastSpawnedAssetId = last.asset != null ? last.asset.id : assetId; - CameraDirector.FocusUnit(last); + try + { + CameraDirector.FocusUnit(last); + } + catch (Exception) + { + // Spawn succeeded; focus is best-effort. + } _cmdOk++; Emit(cmd, ok: true, detail: $"spawned={spawned}/{count} asset={_lastSpawnedAssetId} last={SafeName(last)}"); @@ -9205,6 +9257,163 @@ public static class AgentHarness $"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} maxH={LifeSagaPanel.BodyHeightMax} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}"; break; } + case "caption_mc_identity": + { + string idLine = WatchCaption.LastHeadline ?? ""; + pass = idLine.IndexOf(" · ", StringComparison.Ordinal) >= 0 + && (idLine.IndexOf("King", StringComparison.OrdinalIgnoreCase) >= 0 + || idLine.IndexOf("Leader", StringComparison.OrdinalIgnoreCase) >= 0 + || idLine.IndexOf("Alpha", StringComparison.OrdinalIgnoreCase) >= 0 + || idLine.IndexOf("Chief", StringComparison.OrdinalIgnoreCase) >= 0 + || idLine.IndexOf("Captain", StringComparison.OrdinalIgnoreCase) >= 0 + || idLine.IndexOf("Founder", StringComparison.OrdinalIgnoreCase) >= 0 + || idLine.IndexOf("Plotter", StringComparison.OrdinalIgnoreCase) >= 0); + detail = $"identity='{idLine}' pass={pass}"; + break; + } + case "caption_mc_identity_or_species": + { + string idLine = WatchCaption.LastHeadline ?? ""; + pass = !string.IsNullOrEmpty(idLine) + && idLine.IndexOf("A life in motion", StringComparison.OrdinalIgnoreCase) < 0; + detail = $"identity='{idLine}' pass={pass}"; + break; + } + case "caption_nobody_species": + { + string idLine = WatchCaption.LastHeadline ?? ""; + bool hasTitleSep = idLine.IndexOf(" · ", StringComparison.Ordinal) >= 0 + && (idLine.IndexOf("King", StringComparison.OrdinalIgnoreCase) >= 0 + || idLine.IndexOf("Pack Alpha", StringComparison.OrdinalIgnoreCase) >= 0); + pass = !string.IsNullOrEmpty(idLine) && !hasTitleSep; + detail = $"identity='{idLine}' pass={pass}"; + break; + } + case "caption_identity_not_in_label": + { + InterestCandidate cur = InterestDirector.CurrentCandidate; + string label = cur != null ? (cur.Label ?? "") : ""; + pass = label.IndexOf(" · King", StringComparison.OrdinalIgnoreCase) < 0 + && label.IndexOf("Pack Alpha", StringComparison.OrdinalIgnoreCase) < 0 + && !label.StartsWith("King of", StringComparison.OrdinalIgnoreCase); + detail = $"label='{label}' identity='{WatchCaption.LastHeadline}' pass={pass}"; + break; + } + case "caption_prose_no_jargon": + { + long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); + string tip = LifeSagaPresentation.BuildPlainText(id) ?? ""; + string beat = WatchCaption.LastBeatLine ?? ""; + string ctx = WatchCaption.LastContextLine ?? ""; + string all = tip + "\n" + beat + "\n" + ctx + "\n" + (WatchCaption.LastHeadline ?? ""); + pass = !SagaProse.ContainsBannedPhrase(all); + string sample = all.Replace('\n', ' '); + if (sample.Length > 120) + { + sample = sample.Substring(0, 120); + } + + detail = $"banned={!pass} sample='{sample}'"; + break; + } + case "caption_prose_casing_cast": + { + long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); + LifeSagaViewModel model = LifeSagaPresentation.Build(id); + string any = (cmd.value ?? "Lover").Trim(); + pass = false; + if (model != null) + { + string[] parts = any.Split('|'); + for (int i = 0; i < model.Cast.Count && !pass; i++) + { + string rel = model.Cast[i]?.Relation ?? ""; + for (int p = 0; p < parts.Length; p++) + { + string want = (parts[p] ?? "").Trim(); + if (want.Length > 0 && string.Equals(rel, want, StringComparison.Ordinal)) + { + pass = true; + break; + } + } + } + } + + detail = $"wantExact='{any}' pass={pass}"; + break; + } + case "caption_context_stake": + { + string ctx = WatchCaption.LastContextLine ?? ""; + pass = !string.IsNullOrEmpty(ctx) + && (ctx.IndexOf("war", StringComparison.OrdinalIgnoreCase) >= 0 + || ctx.IndexOf("Still", StringComparison.OrdinalIgnoreCase) >= 0 + || ctx.IndexOf("Feud", StringComparison.OrdinalIgnoreCase) >= 0 + || ctx.IndexOf("tangled", StringComparison.OrdinalIgnoreCase) >= 0 + || ctx.IndexOf("Bound", StringComparison.OrdinalIgnoreCase) >= 0 + || ctx.IndexOf("Rules", StringComparison.OrdinalIgnoreCase) >= 0); + bool looksLikeSpine = ctx.IndexOf(" · ", StringComparison.Ordinal) >= 0 + && (ctx.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) + || ctx.StartsWith("War front", StringComparison.OrdinalIgnoreCase) + || ctx.StartsWith("Love", StringComparison.OrdinalIgnoreCase) + || ctx.StartsWith("Grief", StringComparison.OrdinalIgnoreCase) + || ctx.StartsWith("Plot", StringComparison.OrdinalIgnoreCase) + || ctx.StartsWith("Mass", StringComparison.OrdinalIgnoreCase) + || ctx.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)); + if (looksLikeSpine && ctx.IndexOf("Still", StringComparison.OrdinalIgnoreCase) < 0) + { + pass = false; + } + + detail = $"context='{ctx}' pass={pass}"; + break; + } + case "caption_context_omits": + { + // Soft tip hitch: Context must not show banned biography fragments. + string ctx = WatchCaption.LastContextLine ?? ""; + string banned = (cmd.value ?? "Slew|Became|Founded").Trim(); + pass = true; + string[] parts = banned.Split('|'); + for (int i = 0; i < parts.Length; i++) + { + string want = (parts[i] ?? "").Trim(); + if (want.Length > 0 + && ctx.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0) + { + pass = false; + break; + } + } + + detail = $"context='{ctx}' banned='{banned}' pass={pass}"; + break; + } + case "saga_panel_cast_first": + { + bool panel = LifeSagaPanel.Visible && LifeSagaPanel.BoundUnitId != 0; + string dbg = LifeSagaPanel.LastLayoutDebug ?? ""; + bool castFirst = dbg.IndexOf("castFirst=1", StringComparison.Ordinal) >= 0; + bool noAvatar = !LifeSagaPanel.OwnsAvatar; + pass = panel && castFirst && noAvatar; + detail = + $"panel={panel} ownsAvatar={LifeSagaPanel.OwnsAvatar} dbg='{dbg}' pass={pass}"; + break; + } + case "caption_hover_keeps_beat": + { + bool hover = LifeSagaViewController.IsHoverPreview; + bool panel = LifeSagaPanel.Visible && LifeSagaPanel.BoundUnitId != 0; + long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); + string identity = WatchCaption.LastHeadline ?? ""; + bool identityOk = !string.IsNullOrEmpty(identity); + pass = hover && panel && identityOk && focusId != 0 + && LifeSagaPanel.BoundUnitId == LifeSagaViewController.HoverPreviewId; + detail = + $"hover={hover} panelBound={LifeSagaPanel.BoundUnitId} hoverId={LifeSagaViewController.HoverPreviewId} identity='{identity}' beat='{WatchCaption.LastBeatLine}' pass={pass}"; + break; + } case "saga_memory_has_focus": { long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 6056f21..d026580 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -218,6 +218,11 @@ internal static class HarnessScenarios return SagaRailPreferClick(); case "saga_soft_fill_no_pack": return SagaSoftFillNoPack(); + case "caption_character_beat": + case "caption_mc_identity": + return CaptionCharacterBeat(); + case "caption_prose_voice": + return CaptionProseVoice(); case "story_spine_scoped": return StorySpineScoped(); case "story_family_variety": @@ -439,6 +444,8 @@ internal static class HarnessScenarios 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_caption_beat", "caption_character_beat"), + Nested("reg_caption_prose", "caption_prose_voice"), Nested("reg_discovery", "discovery"), Nested("reg_worldlog", "world_log"), Nested("reg_presentability", "camera_presentability"), @@ -3571,7 +3578,7 @@ internal static class HarnessScenarios }; } - /// Dossier/Saga tabs, hover preview restore, Prefer-only click, fixed body. + /// Hover depth panel under always-on character beat caption (no Dossier/Saga tabs). private static List SagaAdaptiveDossier() { return new List @@ -3588,65 +3595,51 @@ internal static class HarnessScenarios Step("sad9", "focus", asset: "human"), Step("sad10", "saga_force_admit_focus"), Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"), + // saga_select_tab saga → hover preview of watched MC (compat). 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("sad14c", "assert", expect: "caption_hover_keeps_beat"), + Step("sad14d", "assert", expect: "saga_panel_cast_first"), + Step("sad15", "screenshot", value: "saga-hover-depth"), 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("sad15e2", "assert", expect: "saga_panel_lens", value: "true"), + Step("sad15e3", "assert", expect: "saga_overview_tip_not", + value: "Evidence|kills ·|remembered clashes|Open Lore|Earned rival|saga hero"), + Step("sad15f", "screenshot", value: "saga-hover-rich"), Step("sad16", "saga_select_tab", value: "dossier"), Step("sad16b", "assert", expect: "saga_panel_fixed_size"), - Step("sad16c", "screenshot", value: "saga-dossier-paired"), + Step("sad16c", "screenshot", value: "saga-caption-idle"), 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"), - // Leaving the glyph restores Dossier immediately (no panel-dwell keep-alive). Step("sad21b", "saga_hide_hover"), Step("sad21d", "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"), - // Dossier chrome must restore immediately (not wait for a tip ownership change). Step("sad21j2", "assert", expect: "saga_dossier_chrome_restored"), - 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("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_lens", value: "true"), - Step("sad29b", "assert", expect: "saga_overview_tip_not", - value: "Evidence|kills ·|remembered clashes|Open Lore"), - // 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"), + // Prefer click does not open hover / pin saga subject. + // Re-seed a human - long hover cycles can leave the map without living units. + Step("sad30", "saga_select_tab", value: "dossier"), + Step("sad30b", "spawn", asset: "human", count: 2), + Step("sad30c", "pick_unit", asset: "human"), + Step("sad30d", "focus", asset: "human"), + Step("sad30e", "interest_saga_clear"), + Step("sad30f", "saga_force_admit_focus"), + Step("sad30g", "saga_rail_refresh"), + Step("sad31", "saga_click_first"), + Step("sad32", "assert", expect: "saga_prefer_focus_on", value: "true"), + Step("sad33", "assert", expect: "saga_hover_preview", value: "false"), + Step("sad34", "assert", expect: "caption_mc_identity_or_species"), + Step("sad43", "screenshot", value: "saga-rail-prefer"), Step("sad90", "fast_timing", value: "false"), Step("sad99", "snapshot"), }; @@ -3707,7 +3700,7 @@ internal static class HarnessScenarios expect: "harness_war", label: "North"), Step("swm15", "assert", expect: "saga_memory_fact_focus", value: "WarJoin"), Step("swm16", "assert", expect: "saga_memory_fact_other", value: "WarJoin"), - Step("swm17", "assert", expect: "saga_stake_contains", value: "War with|North vs South"), + Step("swm17", "assert", expect: "saga_stake_contains", value: "Still at war|North vs South|War with"), Step("swm18", "saga_memory_resolve_war", value: "harness_war"), Step("swm19", "assert", expect: "saga_memory_fact_resolved", value: "harness_war", label: "true"), // Original partner from swm9 is the other principal - focus them directly. @@ -3740,8 +3733,8 @@ internal static class HarnessScenarios Step("spc13", "saga_memory_record_focus", asset: "Plot", value: "coup", expect: "harness_plot"), Step("spc14", "assert", expect: "saga_memory_fact_other", value: "PlotJoin"), Step("spc15", "saga_select_tab", value: "saga"), - Step("spc16", "assert", expect: "saga_cast_contains", value: "Plot ally"), - Step("spc17", "assert", expect: "saga_stake_contains", value: "Plot with|Joined"), + Step("spc16", "assert", expect: "saga_cast_contains", value: "Plot Partner"), + Step("spc17", "assert", expect: "saga_stake_contains", value: "Still tangled|Plotting|Joined|Plotted"), Step("spc90", "fast_timing", value: "false"), Step("spc99", "snapshot"), }; @@ -3773,7 +3766,7 @@ internal static class HarnessScenarios Step("sck18", "assert", expect: "saga_cast_contains", value: "Parent"), Step("sck19", "saga_memory_record_focus", asset: "War", value: "North vs South", expect: "harness_war_crown", label: "North"), - Step("sck20", "assert", expect: "saga_cast_contains", value: "War foe|Parent"), + Step("sck20", "assert", expect: "saga_cast_contains", value: "War Enemy|Parent"), Step("sck90", "fast_timing", value: "false"), Step("sck99", "snapshot"), }; @@ -3839,7 +3832,7 @@ internal static class HarnessScenarios }; } - /// Live roster role rise becomes RoleChange stake; combat is fallback when alone. + /// Live roster role rise updates Identity without duplicating its prior combat stake. private static List SagaStakeFromRoleLive() { return new List @@ -3855,11 +3848,11 @@ internal static class HarnessScenarios Step("srl8", "pick_unit", asset: "human"), Step("srl9", "focus", asset: "human"), Step("srl10", "saga_force_admit_focus"), - Step("srl11", "saga_memory_record_focus", asset: "HardArcCombat", value: "A fight marked them"), - Step("srl12", "assert", expect: "saga_stake_contains", value: "fight marked|A fight"), + Step("srl11", "saga_memory_record_focus", asset: "HardArcCombat", value: "Survived a hard fight"), + Step("srl12", "assert", expect: "saga_stake_contains", value: "Survived a hard fight|hard fight"), Step("srl13", "saga_role_rise_focus", value: "become_clan_chief"), Step("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"), - Step("srl15", "assert", expect: "saga_stake_contains", value: "Became clan chief"), + Step("srl15", "assert", expect: "saga_stake_contains", value: "Clan Chief"), Step("srl90", "fast_timing", value: "false"), Step("srl99", "snapshot"), }; @@ -3899,6 +3892,96 @@ internal static class HarnessScenarios }; } + /// Character beat caption: MC identity title, prose bans, hover keeps beat. + private static List CaptionCharacterBeat() + { + return new List + { + Step("ccb0", "dismiss_windows"), + Step("ccb1", "wait_world"), + Step("ccb2", "set_setting", expect: "enabled", value: "true"), + Step("ccb3", "fast_timing", value: "true"), + Step("ccb4", "spawn", asset: "human", count: 2), + Step("ccb5", "spectator", value: "off"), + Step("ccb6", "spectator", value: "on"), + Step("ccb7", "interest_saga_clear"), + Step("ccb8", "pick_unit", asset: "human"), + Step("ccb9", "focus", asset: "human"), + Step("ccb10", "saga_force_admit_focus"), + Step("ccb11", "saga_role_rise_focus", value: "become_king"), + Step("ccb12", "saga_rail_refresh"), + Step("ccb13", "interest_force_session", asset: "human", label: "{a} is fighting Waaaf", tier: "Action", + expect: "ccb_fight", value: "lead=event;evt=70;force=true"), + Step("ccb14", "wait", value: "0.3"), + Step("ccb15", "assert", expect: "caption_mc_identity"), + Step("ccb16", "assert", expect: "caption_identity_not_in_label"), + Step("ccb17", "assert", expect: "caption_prose_no_jargon"), + Step("ccb18", "saga_memory_record_focus", asset: "War", value: "Northreach", expect: "harness_cap_war"), + Step("ccb19", "interest_force_session", asset: "human", label: "{a} patrols the border", tier: "Action", + expect: "ccb_quiet", value: "lead=event;evt=55;force=true"), + Step("ccb20", "wait", value: "0.3"), + Step("ccb21", "assert", expect: "caption_context_stake"), + // Soft tip must omit kill / crowning biography hitch; war stake may remain. + Step("ccb21b", "saga_memory_record_focus", asset: "Kill", value: "Named foe"), + Step("ccb21c", "interest_force_session", asset: "human", label: "{a} mates with Waaaf", tier: "Action", + expect: "ccb_mate", value: "lead=event;evt=60;force=true"), + Step("ccb21d", "wait", value: "0.3"), + Step("ccb21e", "assert", expect: "caption_context_omits", value: "Slew|Became Pack|Became Clan"), + Step("ccb21f", "assert", expect: "caption_context_stake"), + Step("ccb21g", "interest_force_session", asset: "human", label: "{a} dreams pleasantly", tier: "Action", + expect: "ccb_dream", value: "lead=event;evt=50;force=true"), + Step("ccb21h", "wait", value: "0.3"), + Step("ccb21i", "assert", expect: "caption_context_omits", value: "Slew|Became Pack|Became Clan|Founded"), + Step("ccb22", "saga_show_hover_first"), + Step("ccb23", "assert", expect: "caption_hover_keeps_beat"), + Step("ccb23b", "assert", expect: "saga_panel_cast_first"), + Step("ccb24", "saga_hide_hover"), + Step("ccb25", "saga_hover_tick_away"), + Step("ccb26", "spawn", asset: "sheep", count: 1), + Step("ccb27", "pick_unit", asset: "sheep"), + Step("ccb28", "focus", asset: "sheep"), + Step("ccb29", "interest_force_session", asset: "sheep", label: "{a} grazes", tier: "Action", + expect: "ccb_sheep", value: "lead=event;evt=40;force=true"), + Step("ccb30", "wait", value: "0.3"), + Step("ccb31", "assert", expect: "caption_nobody_species"), + Step("ccb90", "fast_timing", value: "false"), + Step("ccb99", "snapshot"), + }; + } + + /// SagaProse cast/stake voice across bond, foe, plot partner classes. + private static List CaptionProseVoice() + { + return new List + { + Step("cpv0", "dismiss_windows"), + Step("cpv1", "wait_world"), + Step("cpv2", "set_setting", expect: "enabled", value: "true"), + Step("cpv3", "fast_timing", value: "true"), + Step("cpv4", "spawn", asset: "human", count: 2), + Step("cpv5", "spectator", value: "off"), + Step("cpv6", "spectator", value: "on"), + Step("cpv7", "interest_saga_clear"), + Step("cpv8", "pick_unit", asset: "human"), + Step("cpv9", "happiness_remember_partner"), + Step("cpv10", "pick_unit", asset: "human", value: "other"), + Step("cpv11", "focus", asset: "human"), + Step("cpv12", "saga_force_admit_focus"), + Step("cpv13", "happiness_bond_lovers"), + Step("cpv14", "assert", expect: "saga_cast_contains", value: "Lover"), + Step("cpv15", "assert", expect: "caption_prose_casing_cast", value: "Lover|Best Friend"), + Step("cpv16", "saga_note_encounters", value: "3"), + Step("cpv17", "assert", expect: "saga_rival_focus", value: "true"), + Step("cpv18", "assert", expect: "saga_cast_contains", value: "Old Foe|War Enemy|Blood Foe|Lover"), + Step("cpv19", "assert", expect: "caption_prose_no_jargon"), + Step("cpv20", "saga_memory_record_focus", asset: "Plot", value: "coup", expect: "harness_cpv_plot"), + Step("cpv21", "assert", expect: "saga_cast_contains", value: "Plot Partner|Lover|Old Foe"), + Step("cpv22", "assert", expect: "saga_stake_contains", value: "Still tangled|Still at war|Feud|Bound|Lover|Plot"), + Step("cpv90", "fast_timing", value: "false"), + Step("cpv99", "snapshot"), + }; + } + /// Hover acquires read-pause; camera owner stays fixed until release. private static List SagaHoverReadPause() { diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index e89f59f..cc4fba7 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -153,6 +153,15 @@ public static partial class InterestDirector return false; } + // Solo status tips: only the live carrier owns the orange reason. + // Stops Prefer/MC soft-follow from showing "Becomes pregnant" on a king who lacks it. + if (scene.Completion == InterestCompletionKind.StatusPhase + && !string.IsNullOrEmpty(scene.StatusId) + && !StatusGameApi.HasStatus(unit, scene.StatusId)) + { + return false; + } + bool ensemble = scene.Completion == InterestCompletionKind.CombatActive || scene.Completion == InterestCompletionKind.WarFront || scene.Completion == InterestCompletionKind.PlotActive diff --git a/IdleSpectator/Story/CaptionComposer.cs b/IdleSpectator/Story/CaptionComposer.cs new file mode 100644 index 0000000..be62657 --- /dev/null +++ b/IdleSpectator/Story/CaptionComposer.cs @@ -0,0 +1,539 @@ +using System; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Presentation-layer character beat: Identity + Beat + Context. +/// Does not mutate InterestCandidate.Label or weaken identity_filter. +/// +public static class CaptionComposer +{ + public const float ReentrySeconds = 12f; + + private static long _lastCaptionUnitId; + private static float _lastCaptionAt = -999f; + private static float _lastCutawayAt = -999f; + private static long _cutawayFromId; + private static string _lastFingerprint = ""; + + public static string LastIdentityLine { get; private set; } = ""; + public static string LastBeatLine { get; private set; } = ""; + public static string LastContextLine { get; private set; } = ""; + public static long LastReasonRelatedId { get; private set; } + public static string LastFingerprint => _lastFingerprint; + + public static void Clear() + { + _lastCaptionUnitId = 0; + _lastCaptionAt = -999f; + _lastCutawayAt = -999f; + _cutawayFromId = 0; + _lastFingerprint = ""; + LastIdentityLine = ""; + LastBeatLine = ""; + LastContextLine = ""; + LastReasonRelatedId = 0; + } + + public static CaptionModel Compose( + Actor focus, + InterestCandidate ownedTip, + string presentableBeat, + long reasonRelatedId, + string spineLabel, + bool statusBannerOwnsBeat) + { + var model = new CaptionModel(); + long focusId = EventFeedUtil.SafeId(focus); + float now = Time.time; + + bool isReentry = false; + if (focusId != 0 && focusId != _lastCaptionUnitId) + { + // Test the returning subject before replacing the cutaway record with + // the subject we are leaving. Updating first made this comparison + // impossible: on A -> B -> A, _cutawayFromId was changed to B before + // A could be recognized as the returning life. + if (_cutawayFromId == focusId + && _lastCutawayAt > 0f + && now - _lastCutawayAt >= ReentrySeconds + && LifeSagaRoster.IsMc(focusId)) + { + isReentry = true; + } + + if (_lastCaptionUnitId != 0) + { + _cutawayFromId = _lastCaptionUnitId; + _lastCutawayAt = now; + } + } + + if (focusId != 0) + { + _lastCaptionUnitId = focusId; + _lastCaptionAt = now; + } + + model.IdentityLine = BuildIdentityLine(focus, focusId); + model.ReasonRelatedId = reasonRelatedId; + + string beat = presentableBeat ?? ""; + if (statusBannerOwnsBeat) + { + // Banner owns orange row; composer still fills Identity + Context. + model.BeatLine = ""; + } + else + { + model.BeatLine = EnrichBeat(focus, focusId, ownedTip, beat, ref model.ReasonRelatedId, isReentry); + if (!isReentry) + { + model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine); + } + } + + model.ContextLine = BuildContext( + focusId, + ownedTip, + model.BeatLine, + model.IdentityLine, + spineLabel, + isReentry && !statusBannerOwnsBeat); + + model.Fingerprint = + focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine + + "|" + model.ReasonRelatedId + "|" + SagaProse.MemoryRevision; + + LastIdentityLine = model.IdentityLine; + LastBeatLine = model.BeatLine; + LastContextLine = model.ContextLine; + LastReasonRelatedId = model.ReasonRelatedId; + _lastFingerprint = model.Fingerprint; + return model; + } + + public static string TruncateIdentity(string identity, int maxChars) + { + if (string.IsNullOrEmpty(identity) || maxChars <= 0 || identity.Length <= maxChars) + { + return identity ?? ""; + } + + // Prefer Name · FirstTitleToken… + int sep = identity.IndexOf(" · ", StringComparison.Ordinal); + if (sep > 0) + { + string name = identity.Substring(0, sep); + string rest = identity.Substring(sep + 3).Trim(); + string firstToken = rest; + int sp = rest.IndexOf(' '); + if (sp > 0) + { + firstToken = rest.Substring(0, sp); + } + + string compact = name + " · " + firstToken; + if (compact.Length <= maxChars) + { + return compact + "…"; + } + + if (name.Length + 1 < maxChars) + { + return name.Substring(0, Math.Min(name.Length, maxChars - 1)) + "…"; + } + } + + return identity.Substring(0, Math.Max(1, maxChars - 1)) + "…"; + } + + private static string BuildIdentityLine(Actor focus, long focusId) + { + if (focus == null || focusId == 0) + { + return ""; + } + + string name = EventFeedUtil.SafeName(focus); + if (string.IsNullOrEmpty(name)) + { + name = "Someone"; + } + + // Strip Prefer star if presentation added it elsewhere. + if (name.StartsWith("★ ", StringComparison.Ordinal)) + { + name = name.Substring(2).Trim(); + } + + string speciesJob = UnitDossier.BuildHeadline( + name, + UnitDossier.BuildIdentityTag( + focus.asset != null ? focus.asset.id : "", + UnitDossier.ReadLiveJobLabel(focus))); + + if (!LifeSagaRoster.IsMc(focusId)) + { + return speciesJob; + } + + LifeSagaSlot slot = LifeSagaRoster.Get(focusId); + LifeSagaLifeMemory memory = LifeSagaMemory.Get(focusId); + string title = SagaProse.Title(slot, focus, memory); + if (string.IsNullOrEmpty(title)) + { + return speciesJob; + } + + return name + " · " + title; + } + + private static string EnrichBeat( + Actor focus, + long focusId, + InterestCandidate tip, + string beat, + ref long relatedId, + bool isReentry) + { + if (string.IsNullOrEmpty(beat)) + { + return ""; + } + + string result = beat; + long otherId = relatedId; + if (otherId == 0 && tip != null) + { + otherId = tip.RelatedId != 0 ? tip.RelatedId : tip.PairPartnerId; + } + + if (otherId == 0 && tip != null && tip.PairPartnerId != 0) + { + otherId = tip.PairPartnerId; + } + + if (otherId != 0) + { + relatedId = otherId; + } + + // Ensemble tips: no cast-name injection; rematch clause OK when pair/related already named. + bool ensemble = SagaProse.IsEnsembleTip(beat); + Actor other = otherId != 0 ? EventFeedUtil.FindAliveById(otherId) : null; + if (other == null && otherId != 0) + { + other = EventFeedUtil.FindUnitById(otherId); + } + + if (focusId != 0 && otherId != 0 && other != null) + { + RelationEvidence ev = SagaProse.ResolveRelation(focusId, otherId, focus, other); + string verb = SagaProse.ClassifyTipVerb(beat); + string clause = SagaProse.BeatClause(focus, other, ev, beat, verb); + if (!string.IsNullOrEmpty(clause)) + { + // Skip weak clauses on pure ensemble without the other name in tip. + if (ensemble) + { + string otherName = EventFeedUtil.SafeName(other); + if (!string.IsNullOrEmpty(otherName) + && beat.IndexOf(otherName, StringComparison.OrdinalIgnoreCase) < 0 + && tip != null + && tip.PairPartnerId == 0 + && tip.RelatedId == 0) + { + clause = ""; + } + } + + if (!string.IsNullOrEmpty(clause) + && result.IndexOf(clause, StringComparison.OrdinalIgnoreCase) < 0) + { + result = result.TrimEnd() + " · " + clause; + } + } + } + + if (isReentry && LifeSagaRoster.IsMc(focusId)) + { + string n = EventFeedUtil.SafeName(focus); + if (!string.IsNullOrEmpty(n) + && result.StartsWith(n, StringComparison.Ordinal) + && result.IndexOf(" again", StringComparison.OrdinalIgnoreCase) < 0) + { + // "Name again · rest" or "Name is …" → "Name again is …" is awkward. + // Prefer "Name again · " only when tip doesn't already start with Name + again. + if (result.StartsWith(n + " ", StringComparison.Ordinal)) + { + result = n + " again · " + result.Substring(n.Length).TrimStart(); + } + } + } + + return result; + } + + /// + /// Identity already names the focused life directly above the Beat. Remove only that + /// leading subject (plain or our exact rich-name wrapper), leaving other names intact. + /// + private static string RemoveIdentitySubjectPrefix(Actor focus, string beat) + { + if (focus == null || string.IsNullOrEmpty(beat)) + { + return beat ?? ""; + } + + string name = EventFeedUtil.SafeName(focus); + if (string.IsNullOrEmpty(name)) + { + return beat; + } + + string rest = ""; + string richName = ActivityProse.ColorName(name); + if (beat.StartsWith(richName + " ", StringComparison.Ordinal)) + { + rest = beat.Substring(richName.Length).TrimStart(); + } + else if (beat.StartsWith(name + " ", StringComparison.Ordinal)) + { + rest = beat.Substring(name.Length).TrimStart(); + } + + return string.IsNullOrEmpty(rest) ? beat : SagaProse.Sentence(rest); + } + + private static string BuildContext( + long focusId, + InterestCandidate tip, + string beatLine, + string identityLine, + string spineLabel, + bool isReentry) + { + string spine = spineLabel ?? ""; + if (!string.IsNullOrEmpty(spine)) + { + return spine; + } + + if (!LifeSagaRoster.IsMc(focusId)) + { + return ""; + } + + string tipMood = TipMood(beatLine, tip); + // Walk strongest → weaker so a soft tip can skip a kill/role hitch and still + // show an ongoing war/plot/rival stake when one exists. + LifeSagaFact stake = PickHitchStake(focusId, beatLine, tipMood, identityLine ?? ""); + if (stake == null) + { + return ""; + } + + return SagaProse.StakeLine(stake) ?? ""; + } + + private static LifeSagaFact PickHitchStake( + long focusId, + string beatLine, + string tipMood, + string identityLine) + { + LifeSagaFact best = LifeSagaMemory.StrongestUnresolved(focusId); + if (best != null + && !BeatCoversStake(beatLine, best) + && !ShouldOmitStakeHitch(best, tipMood, identityLine)) + { + return best; + } + + var facts = LifeSagaMemory.FactsFor(focusId); + LifeSagaFact next = null; + for (int i = 0; i < facts.Count; i++) + { + LifeSagaFact f = facts[i]; + if (f == null || f.Resolved || ReferenceEquals(f, best)) + { + continue; + } + + if (BeatCoversStake(beatLine, f) || ShouldOmitStakeHitch(f, tipMood, identityLine)) + { + continue; + } + + // Only promote stake-worthy / fallback-worthy unresolved facts. + if (LifeSagaMemory.IsUnresolvedStakeCandidate(f) + && (next == null + || f.Strength > next.Strength + || (Mathf.Approximately(f.Strength, next.Strength) && f.At > next.At))) + { + next = f; + } + } + + return next; + } + + /// + /// Soft live tips (love/rest/dream) should not hitch closed biography + /// (kills, crowning, founding). Ongoing pressure (war/plot/rival/grief) still may. + /// + private static bool ShouldOmitStakeHitch(LifeSagaFact stake, string tipMood, string identityLine) + { + if (stake == null) + { + return true; + } + + // RoleChange that already sits in Identity ("King of X") is always redundant. + if (stake.Kind == LifeSagaFactKind.RoleChange + && RoleChangeEchoesIdentity(stake, identityLine)) + { + return true; + } + + // "Founder" in Identity already carries the founding fact; repeating + // "Founded kingdom" immediately below it adds no new information. + if (stake.Kind == LifeSagaFactKind.Founding + && identityLine.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + bool soft = tipMood == "love" || tipMood == "rest"; + if (!soft) + { + return false; + } + + switch (stake.Kind) + { + case LifeSagaFactKind.Kill: + case LifeSagaFactKind.CombatEncounter: + case LifeSagaFactKind.HardArcCombat: + case LifeSagaFactKind.RoleChange: + case LifeSagaFactKind.Founding: + case LifeSagaFactKind.HomeGained: + case LifeSagaFactKind.HomeLost: + return true; + default: + return false; + } + } + + private static bool RoleChangeEchoesIdentity(LifeSagaFact stake, string identityLine) + { + if (string.IsNullOrEmpty(identityLine)) + { + return false; + } + + string body = SagaProse.StakeLine(stake) ?? ""; + if (string.IsNullOrEmpty(body)) + { + return false; + } + + // "Became Pack Alpha" under Identity "Name · Pack Alpha". + string lowerId = identityLine.ToLowerInvariant(); + string lowerBody = body.ToLowerInvariant(); + if (lowerBody.StartsWith("became ", StringComparison.Ordinal)) + { + string role = lowerBody.Substring("became ".Length).Trim(); + if (role.Length >= 4 && lowerId.IndexOf(role, StringComparison.Ordinal) >= 0) + { + return true; + } + } + + string[] roleTokens = + { + "king", "queen", "leader", "alpha", "chief", "captain", "founder" + }; + for (int i = 0; i < roleTokens.Length; i++) + { + string t = roleTokens[i]; + if (lowerBody.IndexOf(t, StringComparison.Ordinal) >= 0 + && lowerId.IndexOf(t, StringComparison.Ordinal) >= 0) + { + return true; + } + } + + return false; + } + + private static string TipMood(string beatLine, InterestCandidate tip) + { + string verb = SagaProse.ClassifyTipVerb(beatLine); + if (verb == "love" || verb == "rest" || verb == "grief" || verb == "combat" || verb == "plot") + { + return verb; + } + + if (tip != null && !string.IsNullOrEmpty(tip.StatusId)) + { + string sid = tip.StatusId.ToLowerInvariant(); + if (sid.IndexOf("pregnant", StringComparison.Ordinal) >= 0 + || sid.IndexOf("love", StringComparison.Ordinal) >= 0 + || sid.IndexOf("fell_in_love", StringComparison.Ordinal) >= 0) + { + return "love"; + } + + if (sid.IndexOf("sleep", StringComparison.Ordinal) >= 0 + || sid.IndexOf("dream", StringComparison.Ordinal) >= 0 + || sid.IndexOf("afterglow", StringComparison.Ordinal) >= 0) + { + return "rest"; + } + } + + return verb; + } + + private static bool BeatCoversStake(string beat, LifeSagaFact stake) + { + if (string.IsNullOrEmpty(beat) || stake == null) + { + return false; + } + + if (stake.OtherId != 0 && !string.IsNullOrEmpty(stake.Other.Name) + && beat.IndexOf(stake.Other.Name, StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + string stakeText = SagaProse.StakeLine(stake); + if (!string.IsNullOrEmpty(stakeText) + && beat.IndexOf(stakeText, StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + // Rematch clash clause already teaches the feud. + if (beat.IndexOf("clash", StringComparison.OrdinalIgnoreCase) >= 0 + && stake.Kind == LifeSagaFactKind.RivalEarned) + { + return true; + } + + return false; + } +} + +public sealed class CaptionModel +{ + public string IdentityLine = ""; + public string BeatLine = ""; + public string ContextLine = ""; + public long ReasonRelatedId; + public string Fingerprint = ""; +} diff --git a/IdleSpectator/Story/LifeSagaMemory.cs b/IdleSpectator/Story/LifeSagaMemory.cs index e5936eb..c13ca4e 100644 --- a/IdleSpectator/Story/LifeSagaMemory.cs +++ b/IdleSpectator/Story/LifeSagaMemory.cs @@ -56,6 +56,7 @@ public static class LifeSagaMemory PairEncounterCooldown.Clear(); _boundWorld = null; _worldEpoch++; + SagaProse.BumpMemoryRevision(); } public static void Clear() => ClearSession(); @@ -245,6 +246,7 @@ public static class LifeSagaMemory }; life.Facts.Insert(0, fact); life.TouchedAt = now; + SagaProse.BumpMemoryRevision(); while (life.Facts.Count > MaxFactsPerLife) { life.Facts.RemoveAt(life.Facts.Count - 1); @@ -1207,6 +1209,20 @@ public static class LifeSagaMemory return PickStrongest(life, strongOnly: false); } + /// + /// True when an unresolved fact is eligible as a Context hitch candidate + /// (primary stake-worthy or fallback-worthy). + /// + public static bool IsUnresolvedStakeCandidate(LifeSagaFact fact) + { + if (fact == null || fact.Resolved) + { + return false; + } + + return IsStakeWorthy(fact) || IsStakeFallbackWorthy(fact); + } + private static LifeSagaFact PickStrongest(LifeSagaLifeMemory life, bool strongOnly) { LifeSagaFact best = null; @@ -1331,21 +1347,7 @@ public static class LifeSagaMemory } /// Legacy display line: optional world date · beat prose. - public static string FormatLegacyLine(LifeSagaFact fact) - { - string line = FormatFactLine(fact); - if (string.IsNullOrEmpty(line)) - { - return ""; - } - - if (!string.IsNullOrEmpty(fact.DateLabel)) - { - return fact.DateLabel + " · " + line; - } - - return line; - } + public static string FormatLegacyLine(LifeSagaFact fact) => SagaProse.FormatLegacyLine(fact); public static string FormatFamilySummary(int childCount) { @@ -1402,153 +1404,7 @@ public static class LifeSagaMemory 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: - if (fact.McCrossover) - { - return string.IsNullOrEmpty(other) - ? "Bound to a fellow saga hero" - : "Bound to fellow saga hero " + other; - } - - 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: - if (!IsParentPerspective(fact)) - { - return string.IsNullOrEmpty(other) ? "Was born" : "Born to " + other; - } - - return string.IsNullOrEmpty(other) ? "Had a child" : "Had child " + other; - case LifeSagaFactKind.Kill: - if (fact.McCrossover) - { - return string.IsNullOrEmpty(other) - ? "Slew a fellow saga hero" - : "Slew fellow saga hero " + other; - } - - return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other; - case LifeSagaFactKind.Death: - if (fact.McCrossover) - { - return string.IsNullOrEmpty(other) - ? "Fallen to a fellow saga hero" - : "Slain by fellow saga hero " + other; - } - - return string.IsNullOrEmpty(other) ? "Died" : "Slain by " + other; - case LifeSagaFactKind.RivalEarned: - if (!string.IsNullOrEmpty(fact.Note)) - { - if (fact.Note.StartsWith("opposed_in_war:", StringComparison.Ordinal)) - { - return string.IsNullOrEmpty(other) - ? "Opposed in war" - : "Opposed " + other + " in war"; - } - - if (fact.Note.StartsWith("opposed_in_plot:", StringComparison.Ordinal)) - { - return string.IsNullOrEmpty(other) - ? "Opposed in a plot" - : "Opposed " + other + " in a plot"; - } - - if (fact.Note.StartsWith("killed_kin", StringComparison.Ordinal) - || fact.Note.StartsWith("kin_slain", StringComparison.Ordinal)) - { - return string.IsNullOrEmpty(other) - ? "Blood feud over kin" - : "Blood feud with " + other; - } - - if (fact.Note.StartsWith("mc_rematch:", StringComparison.Ordinal) - || fact.Note.StartsWith("repeated_encounters:", StringComparison.Ordinal)) - { - return string.IsNullOrEmpty(other) - ? "Clashed again and again" - : "Clashed thrice with " + other; - } - } - - return string.IsNullOrEmpty(other) ? "Earned a rival" : "Rivalry with " + other; - case LifeSagaFactKind.WarJoin: - { - string realm = FirstNonEmpty(fact.Note, fact.KingdomKey); - if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(realm)) - { - return "War with " + other + " (" + realm + ")"; - } - - if (!string.IsNullOrEmpty(other)) - { - return "War with " + other; - } - - return string.IsNullOrEmpty(realm) ? "Entered a war" : "War: " + realm; - } - case LifeSagaFactKind.WarEnd: - { - string realm = FirstNonEmpty(fact.Note, fact.KingdomKey); - return string.IsNullOrEmpty(realm) ? "War ended" : "War ended (" + realm + ")"; - } - case LifeSagaFactKind.PlotJoin: - { - string plot = fact.Note ?? ""; - if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(plot)) - { - return "Plot with " + other + " (" + plot + ")"; - } - - if (!string.IsNullOrEmpty(other)) - { - return "Plot with " + other; - } - - return string.IsNullOrEmpty(plot) ? "Joined a plot" : "Joined " + plot; - } - case LifeSagaFactKind.PlotEnd: - { - string plot = fact.Note ?? ""; - return string.IsNullOrEmpty(plot) ? "Plot resolved" : "Plot resolved (" + plot + ")"; - } - 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"); - } - } + public static string FormatFactLine(LifeSagaFact fact) => SagaProse.FormatFactLine(fact); private static void TryMarkKinKillRival(Actor killer, Actor victim) { diff --git a/IdleSpectator/Story/LifeSagaPanel.cs b/IdleSpectator/Story/LifeSagaPanel.cs index c3897e3..08ba292 100644 --- a/IdleSpectator/Story/LifeSagaPanel.cs +++ b/IdleSpectator/Story/LifeSagaPanel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Text; using UnityEngine; @@ -6,45 +7,39 @@ using UnityEngine.UI; namespace IdleSpectator; /// -/// Saga story card: identity + stake beside the portrait, Cast and Legacy full-width under it. -/// No Evidence stats dump and no Open Lore button (L still opens Lore). -/// Width is fixed to the tabbed chrome so right-anchored hover cannot slide the rail. +/// Saga depth card under the character beat caption: Cast + Legacy. +/// Bound to the hover preview id. Identity lives on the caption (name · title); +/// no portrait / name / title / stake chrome here - that space goes to Cast. +/// Width is fixed to the rail 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). + /// Matches WatchCaption rail 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 BodyHeightMax = 172f; public const float BodyWidthMin = BodyWidthFixed; public const float BodyWidthMax = BodyWidthFixed; - public const float BodyHeightMin = 56f; + public const float BodyHeightMin = 12f; public const float BodyHeightFixed = BodyHeightMax; + /// Shared cast budget for panel slots and presentation model. + public const int MaxCast = 8; + private const int MaxLegacy = 6; + private const int CastPerRow = 4; + 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 SecLabelH = 10f; private const float LineH = 10f; - private const int EyebrowFont = 7; - 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 = 8; + private const int BuildVersion = 10; private static GameObject _root; private static RectTransform _rootRt; - private static Text _eyebrowText; - private static Text _nameText; - private static Text _titleText; - private static Text _stakeText; private static Text _castHead; private static Text _legacyHead; private static Text _legacyText; @@ -52,7 +47,6 @@ public static class LifeSagaPanel 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; @@ -70,7 +64,8 @@ public static class LifeSagaPanel public static float BodyHeight => _laidOutHeight; public static float BodyWidthPx => BodyWidthFixed; public static long BoundUnitId => _boundId; - public static bool OwnsAvatar => _ownsAvatar && Visible; + /// Always false - caption owns identity; panel never steals DossierAvatar. + public static bool OwnsAvatar => false; public static string LastLayoutDebug { get; private set; } = ""; public static void EnsureBuilt(Transform parent) @@ -87,13 +82,9 @@ public static class LifeSagaPanel if (_root != null) { - Object.Destroy(_root); + UnityEngine.Object.Destroy(_root); _root = null; _rootRt = null; - _eyebrowText = null; - _nameText = null; - _titleText = null; - _stakeText = null; _castHead = null; _legacyHead = null; _legacyText = null; @@ -107,12 +98,6 @@ public static class LifeSagaPanel _rootRt.anchorMax = new Vector2(0f, 1f); _rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightMin); - _eyebrowText = MakeLabel(_root.transform, "SagaEyebrow", EyebrowFont, HudTheme.Muted, FontStyle.Normal); - _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"; _legacyHead = MakeLabel(_root.transform, "LegacyHead", BodyFont, HudTheme.Muted, FontStyle.Bold); @@ -135,7 +120,6 @@ public static class LifeSagaPanel _boundId = 0; _speciesId = ""; _fingerprint = ""; - _ownsAvatar = false; _laidOutHeight = BodyHeightMin; if (_root != null) { @@ -151,10 +135,6 @@ public static class LifeSagaPanel } _root.SetActive(visible); - if (!visible) - { - _ownsAvatar = false; - } } public static void Bind(LifeSagaViewModel model) @@ -166,7 +146,7 @@ public static class LifeSagaPanel if (model == null || model.UnitId == 0) { - BindEmptyPickState(); + Clear(); return; } @@ -178,27 +158,28 @@ public static class LifeSagaPanel if (!same) { - _eyebrowText.text = model.LensEyebrow ?? ""; - _nameText.text = StripStar(model.Name); - _titleText.text = model.Title ?? ""; - _stakeText.text = model.StakeLine ?? ""; - - for (int i = 0; i < CastSlots.Length; i++) + int shownCast = 0; + for (int i = 0; i < model.Cast.Count && shownCast < CastSlots.Length; i++) { - if (i < model.Cast.Count && model.Cast[i] != null) + LifeSagaCastMember member = model.Cast[i]; + if (member == null || CastDuplicatesCurrentBeat(member)) { - BindCast(CastSlots[i], model.Cast[i]); - } - else - { - CastSlots[i].Root.SetActive(false); - CastSlots[i].UnitId = 0; + continue; } + + BindCast(CastSlots[shownCast], member); + shownCast++; + } + + for (int i = shownCast; i < CastSlots.Length; i++) + { + CastSlots[i].Root.SetActive(false); + CastSlots[i].UnitId = 0; } var legacyParts = new List(MaxLegacy); var seen = new HashSet(); - string stakeKey = (_stakeText.text ?? "").Trim().ToLowerInvariant(); + string stakeKey = (model.StakeLine ?? "").Trim().ToLowerInvariant(); for (int i = 0; i < model.Legacy.Count && legacyParts.Count < MaxLegacy; i++) { if (model.Legacy[i] == null || string.IsNullOrEmpty(model.Legacy[i].Line)) @@ -225,7 +206,6 @@ public static class LifeSagaPanel _legacyText.text = legacyParts.Count > 0 ? string.Join("\n", legacyParts) : ""; } - RefreshAvatar(model); Layout(); _root.SetActive(true); } @@ -239,89 +219,35 @@ public static class LifeSagaPanel _rootRt.anchoredPosition = new Vector2(padX, -y); _rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight); - if (_ownsAvatar) - { - DossierAvatar.Place(padX + Pad, y + Pad, AvatarSize); - DossierAvatar.DrawBehind(_root.transform); - DossierAvatar.ForceCompactFrame(); - } - return y + _laidOutHeight + 2f; } - private static void RefreshAvatar(LifeSagaViewModel model) - { - if (model == null || model.UnitId == 0) - { - _ownsAvatar = false; - return; - } - - DossierAvatar.EnsureHost(_root.transform.parent, AvatarSize); - DossierAvatar.SetHostVisible(true); - Actor live = EventFeedUtil.FindAliveById(model.UnitId); - if (live != null && live.isAlive()) - { - DossierAvatar.Show(live); - DossierAvatar.ForceCompactFrame(); - } - else - { - DossierAvatar.ShowSpecies(model.SpeciesId); - } - - _ownsAvatar = true; - } - private static void Layout() { - bool useAvatarCol = _ownsAvatar; - float colX = useAvatarCol ? Pad + AvatarSize + ColGap : Pad; - float textW = BodyWidthFixed - colX - Pad; float fullX = Pad; float fullW = BodyWidthFixed - Pad * 2f; float y = Pad; - float avatarBottom = useAvatarCol ? Pad + AvatarSize : Pad; + float yMax = BodyHeightMax - Pad; - if (!string.IsNullOrEmpty(_eyebrowText.text)) + // The Beat can update after the presentation model binds in the same frame. + // Recheck visible slots at layout time so the current scene partner is never + // repeated immediately below ("Bound with X" + "Lover X"). + string liveBeat = WatchCaption.VisibleBeatLine ?? ""; + for (int i = 0; i < CastSlots.Length; i++) { - PlaceText(_eyebrowText, colX, y, textW, LineH); - y += LineH; - } - else - { - Hide(_eyebrowText); - } + CastSlot slot = CastSlots[i]; + if (slot?.Root == null || !slot.Root.activeSelf || slot.Name == null) + { + continue; + } - PlaceText(_nameText, colX, y, textW, NameFont + 2f); - y += NameFont + 2f; - if (!string.IsNullOrEmpty(_titleText.text)) - { - PlaceText(_titleText, colX, y, textW, LineH); - y += LineH; - } - else - { - Hide(_titleText); - } - - // Stake + Cast + Legacy wrap under the portrait (identity stays in the header band). - y = Mathf.Max(y + 2f, avatarBottom + 2f); - - if (!string.IsNullOrEmpty(_stakeText.text)) - { - float stakeH = Mathf.Clamp( - EstimateWrapHeight(_stakeText.text, fullW, StakeFont), - LineH, - LineH * 3f); - PlaceText(_stakeText, fullX, y, fullW, stakeH); - _stakeText.horizontalOverflow = HorizontalWrapMode.Wrap; - _stakeText.verticalOverflow = VerticalWrapMode.Overflow; - y += stakeH + 2f; - } - else - { - Hide(_stakeText); + string castName = slot.Name.text ?? ""; + if (!string.IsNullOrEmpty(castName) + && liveBeat.IndexOf(castName, StringComparison.OrdinalIgnoreCase) >= 0) + { + slot.Root.SetActive(false); + slot.UnitId = 0; + } } int castN = 0; @@ -333,13 +259,13 @@ public static class LifeSagaPanel } } - float yMax = BodyHeightMax - Pad; if (castN > 0) { PlaceText(_castHead, fullX, y, fullW, SecLabelH); y += SecLabelH; - float castX = fullX; float castRowH = CastFace + 18f; + float castX = fullX; + int col = 0; for (int i = 0; i < CastSlots.Length; i++) { if (!CastSlots[i].Root.activeSelf) @@ -347,11 +273,19 @@ public static class LifeSagaPanel continue; } + if (col >= CastPerRow) + { + y += castRowH + 2f; + castX = fullX; + col = 0; + } + RectTransform rt = CastSlots[i].Root.GetComponent(); rt.anchoredPosition = new Vector2(castX, -y); rt.sizeDelta = new Vector2(CastSlotW, castRowH); CastSlots[i].Root.transform.SetAsLastSibling(); castX += CastSlotW + 2f; + col++; } y += castRowH + 2f; @@ -369,7 +303,7 @@ public static class LifeSagaPanel float legacyH = Mathf.Clamp( EstimateWrapHeight(_legacyText.text, fullW, BodyFont), LineH, - LineH * 5f); + LineH * 6f); legacyH = Mathf.Min(legacyH, Mathf.Max(LineH, yMax - y)); PlaceText(_legacyText, fullX, y, fullW, legacyH); _legacyText.horizontalOverflow = HorizontalWrapMode.Wrap; @@ -383,7 +317,7 @@ public static class LifeSagaPanel hasLegacy = false; } - y = Mathf.Max(y + Pad, useAvatarCol ? AvatarSize + Pad * 2f : BodyHeightMin); + y = Mathf.Max(y + Pad, BodyHeightMin); _laidOutHeight = Mathf.Clamp(y, BodyHeightMin, BodyHeightMax); if (_rootRt != null) @@ -392,7 +326,7 @@ public static class LifeSagaPanel } LastLayoutDebug = - $"w={BodyWidthFixed} h={_laidOutHeight:0.#} name='{_nameText?.text}' eyebrow='{Short(_eyebrowText?.text)}' stake='{Short(_stakeText?.text)}' cast={castN} legacy={(hasLegacy ? 1 : 0)}"; + $"w={BodyWidthFixed} h={_laidOutHeight:0.#} cast={castN} legacy={(hasLegacy ? 1 : 0)} castFirst=1"; } private static float EstimateWrapHeight(string text, float width, float fontSize) @@ -449,14 +383,13 @@ public static class LifeSagaPanel faceRt.sizeDelta = new Vector2(CastFace - 2f, CastFace - 2f); Text name = MakeLabel(root.transform, "Name", BodyFont, HudTheme.Body, FontStyle.Normal); + Text relation = MakeLabel(root.transform, "Rel", BodyFont, HudTheme.Muted, FontStyle.Normal); RectTransform nameRt = name.rectTransform; - nameRt.anchoredPosition = new Vector2(CastFace + 2f, 0f); - nameRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f); - - Text relation = MakeLabel(root.transform, "Relation", BodyFont, HudTheme.Muted, FontStyle.Normal); + nameRt.anchoredPosition = new Vector2(0f, -(CastFace + 1f)); + nameRt.sizeDelta = new Vector2(CastSlotW, 9f); RectTransform relRt = relation.rectTransform; - relRt.anchoredPosition = new Vector2(CastFace + 2f, -9f); - relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f); + relRt.anchoredPosition = new Vector2(0f, -(CastFace + 9f)); + relRt.sizeDelta = new Vector2(CastSlotW, 9f); root.SetActive(false); return new CastSlot @@ -472,8 +405,10 @@ public static class LifeSagaPanel private static void BindCast(CastSlot slot, LifeSagaCastMember member) { slot.UnitId = member.UnitId; - slot.Name.text = FirstNonEmpty(member.Name, "Someone"); - slot.Relation.text = FirstNonEmpty(member.Relation, "Linked"); + string name = string.IsNullOrEmpty(member.Name) ? "Someone" : member.Name; + string rel = string.IsNullOrEmpty(member.Relation) ? "Linked" : member.Relation; + FitCastText(slot.Name, name); + FitCastText(slot.Relation, rel); Actor live = member.Alive ? EventFeedUtil.FindAliveById(member.UnitId) : null; Sprite sprite = HudIcons.FromActorRailFace(live, member.SpeciesId) @@ -482,67 +417,44 @@ public static class LifeSagaPanel slot.Face.sprite = sprite; slot.Face.enabled = sprite != null; slot.Face.color = sprite != null ? Color.white : new Color(0.35f, 0.36f, 0.4f, 1f); + slot.FaceBg.color = member.Alive + ? new Color(0.12f, 0.13f, 0.16f, 0.95f) + : new Color(0.18f, 0.12f, 0.12f, 0.95f); slot.Root.SetActive(true); } - private static void BindEmptyPickState() + /// The live Beat already explains the person currently on screen. + private static bool CastDuplicatesCurrentBeat(LifeSagaCastMember member) { - _boundId = 0; - _speciesId = ""; - _fingerprint = "empty-pick"; - _ownsAvatar = false; - if (_eyebrowText != null) - { - _eyebrowText.text = ""; - } - - if (_nameText != null) - { - _nameText.text = "Pick a life"; - } - - if (_titleText != null) - { - _titleText.text = "from the rail"; - } - - if (_stakeText != null) - { - _stakeText.text = "Hover or click a glyph to read their saga."; - } - - if (_legacyText != null) - { - _legacyText.text = ""; - } - - for (int i = 0; i < CastSlots.Length; i++) - { - if (CastSlots[i]?.Root != null) - { - CastSlots[i].Root.SetActive(false); - CastSlots[i].UnitId = 0; - } - } - - DossierAvatar.SetHostVisible(false); - Layout(); - if (_root != null) - { - _root.SetActive(true); - } + string beat = WatchCaption.VisibleBeatLine ?? ""; + return member != null + && !string.IsNullOrEmpty(member.Name) + && beat.IndexOf(member.Name, StringComparison.OrdinalIgnoreCase) >= 0; } - private static Text MakeLabel(Transform parent, string name, int size, Color color, FontStyle style) + private static void FitCastText(Text text, string value) { - int fitSize = size < 7 ? 7 : size; + if (text == null) + { + return; + } + + text.text = value ?? ""; + text.resizeTextForBestFit = true; + text.resizeTextMinSize = 6; + text.resizeTextMaxSize = BodyFont; + text.horizontalOverflow = HorizontalWrapMode.Overflow; + text.verticalOverflow = VerticalWrapMode.Truncate; + } + + private static Text MakeLabel(Transform parent, string name, int fontSize, Color color, FontStyle style) + { + int fitSize = fontSize < 7 ? 7 : fontSize; Text text = HudCanvas.MakeText(parent, name, "", fitSize); text.color = color; text.fontStyle = style; - text.fontSize = fitSize; - text.resizeTextForBestFit = false; text.alignment = TextAnchor.UpperLeft; - text.horizontalOverflow = HorizontalWrapMode.Wrap; + text.horizontalOverflow = HorizontalWrapMode.Overflow; text.verticalOverflow = VerticalWrapMode.Overflow; text.raycastTarget = false; RectTransform rt = text.rectTransform; @@ -585,8 +497,8 @@ public static class LifeSagaPanel private static string Fingerprint(LifeSagaViewModel model) { var sb = new StringBuilder(192); - sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.Title); - sb.Append('|').Append(model.LensEyebrow).Append('|').Append(model.StakeLine); + sb.Append(model.UnitId).Append('|').Append(model.StakeLine) + .Append('|').Append(WatchCaption.VisibleBeatLine); for (int i = 0; i < model.Cast.Count; i++) { var c = model.Cast[i]; @@ -609,41 +521,4 @@ public static class LifeSagaPanel return sb.ToString(); } - private static string StripStar(string name) - { - if (string.IsNullOrEmpty(name)) - { - return ""; - } - - return name.StartsWith("★ ") ? name.Substring(2) : name; - } - - private static string FirstNonEmpty(params string[] parts) - { - if (parts == null) - { - return ""; - } - - for (int i = 0; i < parts.Length; i++) - { - if (!string.IsNullOrEmpty(parts[i])) - { - return parts[i]; - } - } - - return ""; - } - - private static string Short(string s) - { - if (string.IsNullOrEmpty(s)) - { - return ""; - } - - return s.Length <= 28 ? s : s.Substring(0, 27) + "…"; - } } diff --git a/IdleSpectator/Story/LifeSagaPresentation.cs b/IdleSpectator/Story/LifeSagaPresentation.cs index 544cb5e..a7a4cd7 100644 --- a/IdleSpectator/Story/LifeSagaPresentation.cs +++ b/IdleSpectator/Story/LifeSagaPresentation.cs @@ -49,10 +49,10 @@ public static class LifeSagaPresentation model.PrimaryLens = primary; model.SecondaryLens = secondary; - LifeSagaFact stake = LifeSagaMemory.StrongestUnresolved(unitId); + LifeSagaFact stake = StrongestDisplayStake(unitId, model.Title); model.StakeLine = stake != null ? LifeSagaMemory.FormatFactLine(stake) - : BuildFallbackStake(slot, actor, memory); + : (string.IsNullOrEmpty(model.Title) ? BuildFallbackStake(slot, actor, memory) : ""); model.StakeProvenance = stake != null ? "observed" : (string.IsNullOrEmpty(model.StakeLine) ? "" : "is"); BuildCast(model, slot, actor, memory, primary); @@ -61,6 +61,42 @@ public static class LifeSagaPresentation return model; } + /// + /// Pick the strongest unresolved pressure that adds information beyond Identity. + /// Current-role/founding facts belong in the title, not a duplicate tooltip stake. + /// + private static LifeSagaFact StrongestDisplayStake(long unitId, string title) + { + LifeSagaFact best = null; + var facts = LifeSagaMemory.FactsFor(unitId); + for (int i = 0; i < facts.Count; i++) + { + LifeSagaFact fact = facts[i]; + if (fact == null || fact.Resolved || !LifeSagaMemory.IsUnresolvedStakeCandidate(fact)) + { + continue; + } + + bool repeatsIdentity = SagaProse.RoleChangeEchoesTitle(fact, title) + || (fact.Kind == LifeSagaFactKind.Founding + && !string.IsNullOrEmpty(title) + && title.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0); + if (repeatsIdentity) + { + continue; + } + + if (best == null + || fact.Strength > best.Strength + || (Mathf.Approximately(fact.Strength, best.Strength) && fact.At > best.At)) + { + best = fact; + } + } + + return best; + } + /// Compatibility plain text for harness diagnostics. public static string BuildPlainText(long unitId) { @@ -359,73 +395,32 @@ public static class LifeSagaPresentation private static string BuildTitle(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory) { - if (slot != null) - { - if (slot.IsKing) - { - string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor)); - return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom; - } - - if (slot.IsClanChief) return "Clan chief"; - if (slot.IsLeader) - { - string city = FirstNonEmpty(slot.CityKey, CityName(actor)); - return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city; - } - - if (slot.IsAlpha) return "Pack alpha"; - if (slot.IsArmyCaptain) return "Army captain"; - if (slot.IsFounder) return "Founder"; - if (slot.IsPlotAuthor) return "Plotter"; - if (slot.IsSingleton) return "Last of their kind"; - } - - if (memory != null) - { - for (int i = 0; i < memory.Facts.Count; i++) - { - if (memory.Facts[i]?.Kind == LifeSagaFactKind.Founding) - { - return "Founder"; - } - } - } - - return "A life in motion"; + return SagaProse.Title(slot, actor, memory); } private static string BuildFallbackStake(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory) { if (LifeSagaMemory.TryGetEarnedRival(slot?.UnitId ?? 0, out LifeSagaFact rival)) { - return LifeSagaMemory.FormatFactLine(rival); + return SagaProse.StakeLine(rival); } if (slot != null) { - switch (slot.AdmissionReason) + string roleStake = SagaProse.RoleFallbackStake(slot.AdmissionReason, slot, actor); + if (!string.IsNullOrEmpty(roleStake)) { - case LifeSagaAdmissionReason.King: - return "Carries a kingdom's fate"; - case LifeSagaAdmissionReason.Alpha: - return "Holds the pack together"; - case LifeSagaAdmissionReason.PlotAuthor: - return "A scheme is still unfolding"; - case LifeSagaAdmissionReason.Singleton: - return "Their lineage hangs by a thread"; - case LifeSagaAdmissionReason.Founder: - return "What they built still needs defending"; + return roleStake; } } if (actor != null && TryGetLikelySuccessor(actor, out Actor heir, out _)) { - return "Succession points toward " + SafeName(heir); + return SagaProse.Sentence("Succession points toward " + SafeName(heir)); } return memory != null && memory.Facts.Count > 0 - ? LifeSagaMemory.FormatFactLine(memory.Facts[0]) + ? SagaProse.FormatFactLine(memory.Facts[0]) : ""; } @@ -442,7 +437,7 @@ public static class LifeSagaPresentation void AddLive(Actor other, string label, string truth) { - if (other == null || model.Cast.Count >= 4) + if (other == null || model.Cast.Count >= LifeSagaPanel.MaxCast) { return; } @@ -466,7 +461,7 @@ public static class LifeSagaPresentation void AddObserved(LifeSagaIdentity identity, string label, string truth) { - if (identity.Id == 0 || model.Cast.Count >= 4 || !seen.Add(identity.Id)) + if (identity.Id == 0 || model.Cast.Count >= LifeSagaPanel.MaxCast || !seen.Add(identity.Id)) { return; } @@ -485,41 +480,47 @@ public static class LifeSagaPresentation if (actor != null) { - AddLive(ActorRelation.GetLover(actor), "Lover", "is"); - AddLive(ActorRelation.GetBestFriend(actor), "Best friend", "is"); + AddLive(ActorRelation.GetLover(actor), SagaProse.CastLabelLive("lover"), "is"); + AddLive(ActorRelation.GetBestFriend(actor), SagaProse.CastLabelLive("best friend"), "is"); foreach (Actor parent in ActorRelation.EnumerateParents(actor, 2)) { - AddLive(parent, "Parent", "is"); + AddLive(parent, SagaProse.CastLabelLive("parent"), "is"); } foreach (Actor child in ActorRelation.EnumerateChildren(actor, 2)) { - AddLive(child, "Child", "is"); + AddLive(child, SagaProse.CastLabelLive("child"), "is"); } if (TryGetLikelySuccessor(actor, out Actor heir, out _)) { - AddLive(heir, "Likely successor", "likely"); + AddLive(heir, SagaProse.CastLabelLive("heir"), "likely"); } } if (LifeSagaMemory.TryGetEarnedRival(model.UnitId, out LifeSagaFact rivalFact)) { + string foeLabel = SagaProse.CastLabel(rivalFact, EventFeedUtil.FindAliveById(rivalFact.OtherId)); + if (string.IsNullOrEmpty(foeLabel)) + { + foeLabel = SagaProse.TitleLabel("Old Foe"); + } + Actor liveRival = EventFeedUtil.FindAliveById(rivalFact.OtherId); if (liveRival != null) { - AddLive(liveRival, "Earned rival", "observed"); + AddLive(liveRival, foeLabel, "observed"); } else { - AddObserved(rivalFact.Other, "Earned rival", "observed"); + AddObserved(rivalFact.Other, foeLabel, "observed"); } } // Former bonds from memory when live cast still has room. - if (memory != null && model.Cast.Count < 4) + if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast) { - for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++) + for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; i++) { LifeSagaFact f = memory.Facts[i]; if (f == null || f.OtherId == 0 || seen.Contains(f.OtherId)) @@ -529,19 +530,19 @@ public static class LifeSagaPresentation if (f.Kind == LifeSagaFactKind.BondBroken) { - AddObserved(f.Other, "Lost lover", "observed"); + AddObserved(f.Other, SagaProse.CastLabelLive("once loved"), "observed"); } else if (f.Kind == LifeSagaFactKind.ParentChild && EventFeedUtil.FindAliveById(f.OtherId) == null) { - AddObserved(f.Other, "Child", "observed"); + AddObserved(f.Other, SagaProse.CastLabelLive("child"), "observed"); } } } // Living Others from unresolved war/plot joins. - if (memory != null && model.Cast.Count < 4) + if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast) { - for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++) + for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; i++) { LifeSagaFact f = memory.Facts[i]; if (f == null || f.Resolved || f.OtherId == 0 || seen.Contains(f.OtherId)) @@ -557,26 +558,28 @@ public static class LifeSagaPresentation if (f.Kind == LifeSagaFactKind.WarJoin) { - AddLive(liveOther, "War foe", "observed"); + AddLive(liveOther, SagaProse.TitleLabel("War Enemy"), "observed"); } else if (f.Kind == LifeSagaFactKind.PlotJoin) { - AddLive(liveOther, "Plot ally", "observed"); + AddLive(liveOther, SagaProse.TitleLabel("Plot Partner"), "observed"); } } } // Family peers fill remaining slots for crown/combat/plot/pack/bond lenses. if (actor != null - && model.Cast.Count < 4 + && model.Cast.Count < LifeSagaPanel.MaxCast && (primary == LifeSagaLens.PackAlpha || primary == LifeSagaLens.LoveGrief || primary == LifeSagaLens.CrownClan || primary == LifeSagaLens.WarriorKiller || primary == LifeSagaLens.Plotter)) { - string peerLabel = primary == LifeSagaLens.PackAlpha ? "Packmate" : "Kin"; - foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: 8)) + string peerLabel = primary == LifeSagaLens.PackAlpha + ? SagaProse.CastLabelLive("packmate") + : SagaProse.CastLabelLive("kin"); + foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCast + 2)) { if (peer == null || peer == actor) { @@ -584,7 +587,7 @@ public static class LifeSagaPresentation } AddLive(peer, peerLabel, "is"); - if (model.Cast.Count >= 4) + if (model.Cast.Count >= LifeSagaPanel.MaxCast) { break; } @@ -640,6 +643,9 @@ public static class LifeSagaPresentation int parentedCount = LifeSagaMemory.CountParentedChildren(unitId); // Many children: prefer one lineage summary over named birth crumbs. bool preferFamilySummary = parentedCount >= 3; + // Reserve the final Legacy slot for that summary; otherwise a death/bond/war + // burst can fill all four slots and make the lineage disappear nondeterministically. + int factLimit = parentedCount >= 2 ? 3 : 4; bool wroteParentChild = false; List facts = LifeSagaMemory.SelectLegacy(unitId, 8); @@ -667,6 +673,21 @@ public static class LifeSagaPresentation continue; } + // Current role is already stated in Identity (for example + // "Adisolf · King of Nuanand"). Do not repeat "Became King" below it. + if (f.Kind == LifeSagaFactKind.RoleChange + && SagaProse.RoleChangeEchoesTitle(f, model.Title)) + { + continue; + } + + if (f.Kind == LifeSagaFactKind.Founding + && !string.IsNullOrEmpty(model.Title) + && model.Title.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0) + { + continue; + } + if (f.Kind == LifeSagaFactKind.ParentChild) { if (preferFamilySummary || wroteParentChild) @@ -697,7 +718,7 @@ public static class LifeSagaPresentation wroteParentChild = true; } - if (model.Legacy.Count >= 4) + if (model.Legacy.Count >= factLimit) { break; } diff --git a/IdleSpectator/Story/LifeSagaRail.cs b/IdleSpectator/Story/LifeSagaRail.cs index 568db8b..c5903b4 100644 --- a/IdleSpectator/Story/LifeSagaRail.cs +++ b/IdleSpectator/Story/LifeSagaRail.cs @@ -8,8 +8,8 @@ using UnityEngine.UI; namespace IdleSpectator; /// -/// Dossier rail of life-saga MCs as a horizontal strip under the tabs. -/// Click toggles Prefer (and pins Saga subject while on Saga). Hover previews. +/// Dossier rail of life-saga MCs as a horizontal strip at the top of caption chrome. +/// Click toggles Prefer only. Hover previews Cast/Legacy depth under the caption. /// public static class LifeSagaRail { @@ -302,7 +302,7 @@ public static class LifeSagaRail } /// - /// Place the horizontal rail under the tabs (stable top chrome). + /// Place the horizontal rail at the top of caption chrome (stable under hover). /// Returns the next y below the rail. /// public static float Place(float y, float padX) @@ -318,16 +318,43 @@ public static class LifeSagaRail internal static void ShowHover(int index) { + if (index < 0) + { + index = 0; + } + + if (index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0) + { + // Rail glyphs can be empty after a Clear while roster still has MCs + // (fingerprint early-out). Force a structural rebuild once. + _fingerprint = ""; + Refresh(); + } + + if (index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0) + { + for (int i = 0; i < Slots.Length; i++) + { + if (Slots[i] != null && Slots[i].UnitId != 0) + { + index = i; + break; + } + } + } + if (index < 0 || index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0) { return; } LifeSagaViewController.BeginHoverPreview(Slots[index].UnitId); - LastHoverHeight = LifeSagaPanel.BodyHeight; + LastHoverHeight = Mathf.Max(LifeSagaPanel.BodyHeight, BodyHeightMinFallback); WatchCaption.RequestRelayout(); } + private const float BodyHeightMinFallback = 1f; + internal static void HideHover() { LifeSagaViewController.EndHoverPreview(); @@ -577,12 +604,6 @@ public static class LifeSagaRail } LifeSagaRoster.TogglePrefer(id); - if (LifeSagaViewController.SelectedTab == LifeSagaViewController.Tab.Saga - || (LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga - && !LifeSagaViewController.IsHoverPreview)) - { - LifeSagaViewController.SetPersistentSaga(id); - } _fingerprint = ""; LifeSagaRoster.Dirty = true; diff --git a/IdleSpectator/Story/LifeSagaViewController.cs b/IdleSpectator/Story/LifeSagaViewController.cs index f2dcc41..d3d6481 100644 --- a/IdleSpectator/Story/LifeSagaViewController.cs +++ b/IdleSpectator/Story/LifeSagaViewController.cs @@ -4,7 +4,9 @@ using UnityEngine; namespace IdleSpectator; /// -/// Owns Dossier/Saga tab selection, hover preview, and camera read-pause lease. +/// Owns hover preview and camera read-pause lease for the life-saga rail. +/// AFK chrome is always character beat caption (Identity + Beat + Context); +/// the saga panel is hover depth only (no Dossier/Saga tabs). /// public static class LifeSagaViewController { @@ -12,129 +14,71 @@ public static class LifeSagaViewController /// Only used if an exit was deferred; glyph leave finishes immediately. public const float HoverExitGraceSeconds = 0.05f; + /// + /// Compatibility enum for harness asserts. AFK chrome never pins Saga; + /// is Saga only while a hover preview is active. + /// public enum Tab { Dossier = 0, Saga = 1 } - private static Tab _selectedTab = Tab.Dossier; - private static long _persistentSagaId; - private static bool _persistentPinned; private static long _hoverPreviewId; - private static Tab _tabBeforeHover = Tab.Dossier; private static float _hoverExitAt = -1f; private static bool _pauseHeld; - private static bool _loreReturnToSaga; - private static long _loreReturnSagaId; - public static Tab SelectedTab => _selectedTab; - public static long PersistentSagaId => _persistentSagaId; + public static Tab SelectedTab => Tab.Dossier; + public static long PersistentSagaId => 0; public static long HoverPreviewId => _hoverPreviewId; public static bool IsHoverPreview => _hoverPreviewId != 0; + /// Saga while hovering a rail glyph; otherwise Dossier (caption body). public static Tab EffectiveTab => - _hoverPreviewId != 0 ? Tab.Saga : _selectedTab; + _hoverPreviewId != 0 ? Tab.Saga : Tab.Dossier; - public static long EffectiveSagaId - { - get - { - if (_hoverPreviewId != 0) - { - return _hoverPreviewId; - } - - return ResolvePersistentSagaId(); - } - } + public static long EffectiveSagaId => _hoverPreviewId; + /// + /// Harness/compat: Dossier cancels hover. Saga begins hover preview of the watched MC + /// when on the roster (no persistent pin / empty pick chrome). + /// public static void SelectTab(Tab tab) { - // Explicit tab click wins over hover preview restore. - if (_hoverPreviewId != 0 || _pauseHeld) + if (tab == Tab.Dossier) { CancelPreviewAndPause(); - _tabBeforeHover = tab; - } - - _selectedTab = tab; - if (tab == Tab.Saga) - { - long watch = WatchedMcId(); - if (watch != 0) - { - _persistentSagaId = watch; - _persistentPinned = true; - } - else if (!IsValidMc(_persistentSagaId) || !_persistentPinned) - { - _persistentSagaId = 0; - _persistentPinned = false; - } - } - - WatchCaption.RequestRelayout(); - } - - public static void SetPersistentSaga(long unitId) - { - if (unitId == 0 || !LifeSagaRoster.IsMc(unitId)) - { + WatchCaption.RequestRelayout(); return; } - _persistentSagaId = unitId; - _persistentPinned = true; - } - - /// Stash Saga return target before opening Lore from the Saga panel. - public static void StashLoreReturn(long unitId) - { - _loreReturnToSaga = true; - _loreReturnSagaId = unitId; - } - - /// - /// After Lore closes, restore Saga tab + subject when a stash is present. - /// Returns true when a restore was applied. - /// - public static bool TryRestoreAfterLore() - { - if (!_loreReturnToSaga) + long watch = WatchedMcId(); + if (watch != 0) { - return false; + BeginHoverPreview(watch); + return; } - long id = _loreReturnSagaId; - _loreReturnToSaga = false; - _loreReturnSagaId = 0; - CancelPreviewAndPause(); - _selectedTab = Tab.Saga; - _tabBeforeHover = Tab.Saga; - if (IsValidMc(id)) - { - _persistentSagaId = id; - _persistentPinned = true; - } - else - { - long watch = WatchedMcId(); - if (watch != 0) - { - _persistentSagaId = watch; - _persistentPinned = true; - } - else - { - _persistentSagaId = 0; - _persistentPinned = false; - } - } - WatchCaption.RequestRelayout(); - return true; + } + + /// No-op: Prefer click no longer pins a persistent Saga subject. + public static void SetPersistentSaga(long unitId) + { + _ = unitId; + } + + /// No-op: Lore return no longer restores a Saga tab. + public static void StashLoreReturn(long unitId) + { + _ = unitId; + } + + /// No-op: Lore close leaves caption chrome as-is. + public static bool TryRestoreAfterLore() + { + return false; } public static void BeginHoverPreview(long unitId) @@ -145,11 +89,6 @@ public static class LifeSagaViewController } _hoverExitAt = -1f; - if (_hoverPreviewId == 0) - { - _tabBeforeHover = _selectedTab; - } - _hoverPreviewId = unitId; if (!_pauseHeld) { @@ -168,7 +107,7 @@ public static class LifeSagaViewController } // Relayout / glyph→glyph can fire Exit while the pointer is still on a glyph. - // Keep the preview in that case; otherwise restore Dossier immediately. + // Keep the preview in that case; otherwise restore caption body immediately. if (LifeSagaRail.IsPointerOverGlyph()) { _hoverExitAt = -1f; @@ -220,12 +159,6 @@ public static class LifeSagaViewController public static void Clear() { CancelPreviewAndPause(); - _selectedTab = Tab.Dossier; - _persistentSagaId = 0; - _persistentPinned = false; - _tabBeforeHover = Tab.Dossier; - _loreReturnToSaga = false; - _loreReturnSagaId = 0; } /// @@ -251,7 +184,6 @@ public static class LifeSagaViewController { _hoverExitAt = -1f; _hoverPreviewId = 0; - _selectedTab = _tabBeforeHover; if (_pauseHeld) { InterestDirector.ReleaseReadPause(ReadPauseOwner); @@ -261,37 +193,6 @@ public static class LifeSagaViewController WatchCaption.RequestRelayout(); } - /// - /// Watched MC wins when on roster. Otherwise only an explicitly pinned alive MC. - /// Never falls back to first-roster / stale last-active filler. - /// - private static long ResolvePersistentSagaId() - { - long watch = WatchedMcId(); - if (watch != 0) - { - _persistentSagaId = watch; - _persistentPinned = true; - return watch; - } - - if (_persistentPinned && IsValidMc(_persistentSagaId)) - { - return _persistentSagaId; - } - - _persistentSagaId = 0; - _persistentPinned = false; - return 0; - } - - private static bool IsValidMc(long unitId) - { - return unitId != 0 - && LifeSagaRoster.IsMc(unitId) - && EventFeedUtil.FindAliveById(unitId) != null; - } - private static long WatchedMcId() { long watchId = EventFeedUtil.SafeId( diff --git a/IdleSpectator/Story/SagaProse.cs b/IdleSpectator/Story/SagaProse.cs new file mode 100644 index 0000000..add44cb --- /dev/null +++ b/IdleSpectator/Story/SagaProse.cs @@ -0,0 +1,1252 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Single voice owner for saga/caption English. +/// Evidence drives copy; enum/taxonomy names never appear in player-facing strings. +/// +public static class SagaProse +{ + public static readonly string[] BanList = + { + "earned rival", + "earned a rival", + "rivalry with", + "saga hero", + "fellow saga hero", + "war foe", + "plot ally", + "likely successor", + "a life in motion", + "carries a kingdom", + "holds the pack together", + "a scheme is still unfolding", + "lineage hangs by a thread", + "what they built still needs defending" + }; + + private static readonly HashSet TitleSmallWords = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "of", "the", "a", "an", "in", "on", "and", "for", "to" + }; + + private static int _memoryRevision; + private static long _cacheFocusId; + private static long _cacheOtherId; + private static int _cacheRevision = -1; + private static long _cacheLoverId; + private static long _cacheFriendId; + private static RelationEvidence _cacheEvidence; + + public static void BumpMemoryRevision() + { + _memoryRevision++; + _cacheRevision = -1; + } + + public static int MemoryRevision => _memoryRevision; + + public static bool ContainsBannedPhrase(string text) + { + if (string.IsNullOrEmpty(text)) + { + return false; + } + + string lower = text.ToLowerInvariant(); + for (int i = 0; i < BanList.Length; i++) + { + if (lower.IndexOf(BanList[i], StringComparison.Ordinal) >= 0) + { + return true; + } + } + + return false; + } + + public static string TitleLabel(string raw) + { + if (string.IsNullOrEmpty(raw)) + { + return ""; + } + + string titled = ActivityAssetCatalog.TitleCaseWords(raw.Trim()); + if (string.IsNullOrEmpty(titled)) + { + return ""; + } + + string[] parts = titled.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length <= 1) + { + return titled; + } + + var sb = new StringBuilder(titled.Length); + for (int i = 0; i < parts.Length; i++) + { + if (i > 0) + { + sb.Append(' '); + } + + string p = parts[i]; + bool small = i > 0 && i < parts.Length - 1 && TitleSmallWords.Contains(p); + if (small) + { + sb.Append(p.ToLowerInvariant()); + } + else + { + sb.Append(p); + } + } + + return sb.ToString(); + } + + public static string Sentence(string raw) + { + if (string.IsNullOrEmpty(raw)) + { + return ""; + } + + string s = raw.Trim(); + if (s.Length == 0) + { + return ""; + } + + char first = s[0]; + if (char.IsLetter(first) && char.IsLower(first)) + { + return char.ToUpperInvariant(first) + s.Substring(1); + } + + return s; + } + + public static string Clause(string raw) + { + if (string.IsNullOrEmpty(raw)) + { + return ""; + } + + string s = raw.Trim().TrimStart('·', ' ').Trim(); + if (s.Length == 0) + { + return ""; + } + + char first = s[0]; + if (char.IsLetter(first) && char.IsUpper(first)) + { + return char.ToLowerInvariant(first) + s.Substring(1); + } + + return s; + } + + /// Identity title for MCs. Empty when no real role (caller falls back to species/job). + public static string Title(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory) + { + if (slot != null) + { + if (slot.IsKing) + { + string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor)); + return string.IsNullOrEmpty(kingdom) + ? TitleLabel("King") + : TitleLabel("King of " + kingdom); + } + + if (slot.IsClanChief) + { + return TitleLabel("Clan Chief"); + } + + if (slot.IsLeader) + { + string city = FirstNonEmpty(slot.CityKey, CityName(actor)); + return string.IsNullOrEmpty(city) + ? TitleLabel("City Leader") + : TitleLabel("Leader of " + city); + } + + if (slot.IsAlpha) + { + return TitleLabel("Pack Alpha"); + } + + if (slot.IsArmyCaptain) + { + return TitleLabel("Army Captain"); + } + + if (slot.IsFounder) + { + return TitleLabel("Founder"); + } + + if (slot.IsPlotAuthor) + { + return TitleLabel("Plotter"); + } + + if (slot.IsSingleton) + { + return TitleLabel("Last of Their Kind"); + } + } + + if (memory != null) + { + for (int i = 0; i < memory.Facts.Count; i++) + { + LifeSagaFact f = memory.Facts[i]; + if (f == null) + { + continue; + } + + if (f.Kind == LifeSagaFactKind.Founding) + { + return TitleLabel("Founder"); + } + + if (f.Kind == LifeSagaFactKind.RoleChange) + { + string note = f.Note ?? ""; + if (note.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0) + { + string kingdom = FirstNonEmpty(slot?.KingdomKey, KingdomName(actor)); + return string.IsNullOrEmpty(kingdom) + ? TitleLabel("King") + : TitleLabel("King of " + kingdom); + } + + if (note.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0) + { + return TitleLabel("Pack Alpha"); + } + + if (note.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0 + || note.IndexOf("clan", StringComparison.OrdinalIgnoreCase) >= 0) + { + return TitleLabel("Clan Chief"); + } + + if (note.IndexOf("leader", StringComparison.OrdinalIgnoreCase) >= 0) + { + string city = FirstNonEmpty(slot?.CityKey, CityName(actor)); + return string.IsNullOrEmpty(city) + ? TitleLabel("City Leader") + : TitleLabel("Leader of " + city); + } + + if (note.IndexOf("captain", StringComparison.OrdinalIgnoreCase) >= 0 + || note.IndexOf("army", StringComparison.OrdinalIgnoreCase) >= 0) + { + return TitleLabel("Army Captain"); + } + } + } + } + + return ""; + } + + public static string FormatFactLine(LifeSagaFact fact) + { + if (fact == null) + { + return ""; + } + + bool unresolved = !fact.Resolved; + string line = FormatFactBody(fact, unresolved); + return Sentence(line); + } + + public static string FormatLegacyLine(LifeSagaFact fact) + { + string body = FormatFactLine(fact); + if (string.IsNullOrEmpty(body)) + { + return ""; + } + + if (!string.IsNullOrEmpty(fact?.DateLabel)) + { + return fact.DateLabel + " · " + body; + } + + return body; + } + + public static string StakeLine(LifeSagaFact fact) + { + return FormatFactLine(fact); + } + + public static string CastLabelFromEvidence(RelationEvidence e) + { + if (e == null || e.Class == RelationClass.None) + { + return ""; + } + + switch (e.Class) + { + case RelationClass.Bond: + return e.BondBroken ? TitleLabel("Once Loved") : TitleLabel("Lover"); + case RelationClass.Friend: + return e.BestFriend ? TitleLabel("Best Friend") : TitleLabel("Friend"); + case RelationClass.Kin: + if (e.KinKind == KinKind.Parent) return TitleLabel("Parent"); + if (e.KinKind == KinKind.Child) return TitleLabel("Child"); + if (e.KinKind == KinKind.Heir) return TitleLabel("Heir"); + if (e.KinKind == KinKind.Packmate) return TitleLabel("Packmate"); + return TitleLabel("Kin"); + case RelationClass.Foe: + if (e.FoeKind == FoeKind.War) return TitleLabel("War Enemy"); + if (e.FoeKind == FoeKind.Plot) return TitleLabel("Plot Enemy"); + if (e.FoeKind == FoeKind.Blood) return TitleLabel("Blood Foe"); + return TitleLabel("Old Foe"); + case RelationClass.PlotPartner: + return TitleLabel("Plot Partner"); + case RelationClass.Grief: + return TitleLabel("Once Loved"); + default: + return ""; + } + } + + public static string CastLabel(LifeSagaFact fact, Actor other) + { + if (fact == null) + { + return ""; + } + + RelationEvidence e = EvidenceFromFact(fact, other); + return CastLabelFromEvidence(e); + } + + public static string CastLabelLive(string kind) + { + switch ((kind ?? "").ToLowerInvariant()) + { + case "lover": return TitleLabel("Lover"); + case "bestfriend": + case "best friend": return TitleLabel("Best Friend"); + case "friend": return TitleLabel("Friend"); + case "parent": return TitleLabel("Parent"); + case "child": return TitleLabel("Child"); + case "heir": return TitleLabel("Heir"); + case "packmate": return TitleLabel("Packmate"); + case "kin": return TitleLabel("Kin"); + case "onceloved": + case "once loved": + case "lost lover": return TitleLabel("Once Loved"); + default: return TitleLabel(kind ?? ""); + } + } + + /// + /// Beat tip suffix without leading " · ". Empty when redundant or unknown. + /// tipVerbClass: "combat" | "love" | "grief" | "plot" | "other" + /// + public static string BeatClause( + Actor focus, + Actor other, + RelationEvidence e, + string tipLabel, + string tipVerbClass) + { + if (e == null || e.Class == RelationClass.None || other == null) + { + return ""; + } + + string tip = tipLabel ?? ""; + string tipLower = tip.ToLowerInvariant(); + string verb = (tipVerbClass ?? "other").ToLowerInvariant(); + + // Tip-verb class can flip bond vs foe when both apply. + RelationClass use = e.Class; + if (e.AlsoFoe && verb == "combat" && e.Class == RelationClass.Bond) + { + use = RelationClass.Foe; + } + else if (e.AlsoBond && (verb == "love" || verb == "grief") && e.Class == RelationClass.Foe) + { + use = RelationClass.Bond; + } + + switch (use) + { + case RelationClass.Bond: + if (LooksLikeLoveTip(tipLower)) + { + return ""; + } + + if (e.BondBroken) + { + return verb == "grief" || verb == "love" ? Clause("once their lover") : ""; + } + + return Clause("their lover"); + + case RelationClass.Friend: + return e.BestFriend ? Clause("their closest friend") : Clause("their friend"); + + case RelationClass.Kin: + if (e.KinKind == KinKind.Parent) return Clause("their parent"); + if (e.KinKind == KinKind.Child) return Clause("their child"); + if (e.KinKind == KinKind.Heir) return Clause("their heir"); + if (e.KinKind == KinKind.Packmate) return Clause("a packmate"); + return Clause("their kin"); + + case RelationClass.Foe: + { + RelationEvidence foe = e.AlsoFoe && e.FoeKind == FoeKind.None + ? e + : e; + FoeKind fk = foe.FoeKind != FoeKind.None ? foe.FoeKind : e.FoeKind; + int count = foe.RematchCount > 0 ? foe.RematchCount : e.RematchCount; + string realm = FirstNonEmpty(foe.RealmOrPlot, e.RealmOrPlot); + if (fk == FoeKind.Rematch || count > 0) + { + if (count >= 2) + { + return Clause("their " + Ordinal(count) + " clash"); + } + + return Clause("again and again"); + } + + if (fk == FoeKind.War) + { + return string.IsNullOrEmpty(realm) + ? Clause("an enemy from war") + : Clause("enemy from the " + realm + " war"); + } + + if (fk == FoeKind.Plot) + { + return string.IsNullOrEmpty(realm) + ? Clause("a foe in a scheme") + : Clause("foe in the " + realm + " scheme"); + } + + if (fk == FoeKind.Blood) + { + return Clause("blood feud"); + } + + return Clause("an old foe"); + } + + case RelationClass.PlotPartner: + { + string plot = e.RealmOrPlot; + return string.IsNullOrEmpty(plot) + ? Clause("their plot partner") + : Clause("plot partner in " + plot); + } + + case RelationClass.Grief: + if (tipLower.IndexOf("mourn", StringComparison.Ordinal) >= 0 + || tipLower.IndexOf("grief", StringComparison.Ordinal) >= 0) + { + return ""; + } + + return ""; + + default: + return ""; + } + } + + public static RelationEvidence ResolveRelation(long focusId, long otherId, Actor focus, Actor other) + { + if (focusId == 0 || otherId == 0 || focusId == otherId) + { + return RelationEvidence.None; + } + + long loverId = focus != null ? EventFeedUtil.SafeId(ActorRelation.GetLover(focus)) : 0; + long friendId = focus != null ? EventFeedUtil.SafeId(ActorRelation.GetBestFriend(focus)) : 0; + if (_cacheFocusId == focusId + && _cacheOtherId == otherId + && _cacheRevision == _memoryRevision + && _cacheLoverId == loverId + && _cacheFriendId == friendId + && _cacheEvidence != null) + { + return _cacheEvidence; + } + + RelationEvidence best = RelationEvidence.None; + int bestPri = 99; + + // Live bond + if (loverId == otherId) + { + Consider(ref best, ref bestPri, new RelationEvidence + { + Class = RelationClass.Bond, + OtherId = otherId, + Priority = 1 + }); + } + + // Live friend + if (friendId == otherId) + { + Consider(ref best, ref bestPri, new RelationEvidence + { + Class = RelationClass.Friend, + BestFriend = true, + OtherId = otherId, + Priority = 5 + }); + } + + // Live kin + if (focus != null && other != null) + { + foreach (Actor p in ActorRelation.EnumerateParents(focus, 4)) + { + if (EventFeedUtil.SafeId(p) == otherId) + { + Consider(ref best, ref bestPri, new RelationEvidence + { + Class = RelationClass.Kin, + KinKind = KinKind.Parent, + OtherId = otherId, + Priority = 4 + }); + break; + } + } + + foreach (Actor c in ActorRelation.EnumerateChildren(focus, 4)) + { + if (EventFeedUtil.SafeId(c) == otherId) + { + Consider(ref best, ref bestPri, new RelationEvidence + { + Class = RelationClass.Kin, + KinKind = KinKind.Child, + OtherId = otherId, + Priority = 4 + }); + break; + } + } + + if (LifeSagaPresentation.TryGetLikelySuccessor(focus, out Actor heir, out _) + && EventFeedUtil.SafeId(heir) == otherId) + { + Consider(ref best, ref bestPri, new RelationEvidence + { + Class = RelationClass.Kin, + KinKind = KinKind.Heir, + OtherId = otherId, + Priority = 4 + }); + } + } + + LifeSagaLifeMemory mem = LifeSagaMemory.Get(focusId); + bool alsoBond = loverId == otherId; + bool alsoFoe = false; + RelationEvidence foeEv = null; + + if (mem != null) + { + for (int i = 0; i < mem.Facts.Count; i++) + { + LifeSagaFact f = mem.Facts[i]; + if (f == null || f.OtherId != otherId) + { + continue; + } + + RelationEvidence fromFact = EvidenceFromFact(f, other); + if (fromFact.Class == RelationClass.Bond && fromFact.BondBroken) + { + Consider(ref best, ref bestPri, fromFact); + } + else if (fromFact.Class == RelationClass.Foe) + { + alsoFoe = true; + foeEv = fromFact; + Consider(ref best, ref bestPri, fromFact); + } + else if (fromFact.Class != RelationClass.None) + { + Consider(ref best, ref bestPri, fromFact); + } + } + } + + if (LifeSagaMemory.TryGetEarnedRival(focusId, out LifeSagaFact rival) + && rival != null + && rival.OtherId == otherId) + { + RelationEvidence r = EvidenceFromFact(rival, other); + alsoFoe = true; + foeEv = r; + Consider(ref best, ref bestPri, r); + } + + if (best != null && best.Class != RelationClass.None) + { + best.AlsoBond = alsoBond || best.Class == RelationClass.Bond; + best.AlsoFoe = alsoFoe || best.Class == RelationClass.Foe; + if (foeEv != null && best.Class == RelationClass.Bond) + { + best.FoeKind = foeEv.FoeKind; + best.RematchCount = foeEv.RematchCount; + best.RealmOrPlot = foeEv.RealmOrPlot; + } + } + + _cacheFocusId = focusId; + _cacheOtherId = otherId; + _cacheRevision = _memoryRevision; + _cacheLoverId = loverId; + _cacheFriendId = friendId; + _cacheEvidence = best ?? RelationEvidence.None; + return _cacheEvidence; + } + + public static string ClassifyTipVerb(string tipLabel) + { + string t = (tipLabel ?? "").ToLowerInvariant(); + if (t.IndexOf("fight", StringComparison.Ordinal) >= 0 + || t.IndexOf("duel", StringComparison.Ordinal) >= 0 + || t.IndexOf("battle", StringComparison.Ordinal) >= 0 + || t.IndexOf("skirmish", StringComparison.Ordinal) >= 0 + || t.IndexOf("mass -", StringComparison.Ordinal) >= 0 + || t.IndexOf("war -", StringComparison.Ordinal) >= 0 + || t.IndexOf("attack", StringComparison.Ordinal) >= 0 + || t.IndexOf("slay", StringComparison.Ordinal) >= 0 + || t.IndexOf("killed", StringComparison.Ordinal) >= 0) + { + return "combat"; + } + + if (t.IndexOf("lover", StringComparison.Ordinal) >= 0 + || t.IndexOf("love", StringComparison.Ordinal) >= 0 + || t.IndexOf("smitten", StringComparison.Ordinal) >= 0 + || t.IndexOf("kiss", StringComparison.Ordinal) >= 0 + || t.IndexOf("intimacy", StringComparison.Ordinal) >= 0 + || t.IndexOf("bound", StringComparison.Ordinal) >= 0 + || t.IndexOf("mate", StringComparison.Ordinal) >= 0 + || t.IndexOf("pregnant", StringComparison.Ordinal) >= 0) + { + return "love"; + } + + if (t.IndexOf("mourn", StringComparison.Ordinal) >= 0 + || t.IndexOf("grief", StringComparison.Ordinal) >= 0 + || t.IndexOf("fallen", StringComparison.Ordinal) >= 0 + || t.IndexOf("stands over", StringComparison.Ordinal) >= 0) + { + return "grief"; + } + + if (t.IndexOf("plot", StringComparison.Ordinal) >= 0) + { + return "plot"; + } + + // Sleep / dream / rest tips hitch poorly against closed biography stakes. + if (t.IndexOf("sleep", StringComparison.Ordinal) >= 0 + || t.IndexOf("dream", StringComparison.Ordinal) >= 0 + || t.IndexOf("asleep", StringComparison.Ordinal) >= 0 + || t.IndexOf("wakes", StringComparison.Ordinal) >= 0 + || t.IndexOf("rests", StringComparison.Ordinal) >= 0 + || t.IndexOf("naps", StringComparison.Ordinal) >= 0) + { + return "rest"; + } + + return "other"; + } + + public static string RoleFallbackStake(LifeSagaAdmissionReason reason, LifeSagaSlot slot, Actor actor) + { + switch (reason) + { + case LifeSagaAdmissionReason.King: + { + string k = FirstNonEmpty(slot?.KingdomKey, KingdomName(actor)); + return string.IsNullOrEmpty(k) ? Sentence("Rules a kingdom") : Sentence("Rules " + k); + } + case LifeSagaAdmissionReason.Alpha: + return Sentence("Leads the pack"); + case LifeSagaAdmissionReason.PlotAuthor: + return Sentence("Leads a plot"); + case LifeSagaAdmissionReason.Singleton: + return Sentence("Last of their kind"); + case LifeSagaAdmissionReason.Founder: + return Sentence("Defends what they founded"); + case LifeSagaAdmissionReason.CityLeader: + { + string c = FirstNonEmpty(slot?.CityKey, CityName(actor)); + return string.IsNullOrEmpty(c) ? Sentence("Leads a city") : Sentence("Leads " + c); + } + default: + return ""; + } + } + + /// True when a remembered role change merely restates the current Identity title. + public static bool RoleChangeEchoesTitle(LifeSagaFact fact, string title) + { + if (fact == null + || fact.Kind != LifeSagaFactKind.RoleChange + || string.IsNullOrEmpty(title)) + { + return false; + } + + string body = FormatFactLine(fact).ToLowerInvariant(); + string current = title.ToLowerInvariant(); + string[] roles = + { + "king", "queen", "leader", "alpha", "chief", "captain", "founder", "plotter" + }; + for (int i = 0; i < roles.Length; i++) + { + string role = roles[i]; + if (body.IndexOf(role, StringComparison.Ordinal) >= 0 + && current.IndexOf(role, StringComparison.Ordinal) >= 0) + { + return true; + } + } + + return false; + } + + public static bool IsEnsembleTip(string tip) + { + if (string.IsNullOrEmpty(tip)) + { + return false; + } + + string t = tip.TrimStart(); + return t.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) + || t.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase) + || t.StartsWith("Battle", StringComparison.OrdinalIgnoreCase) + || t.StartsWith("Mass", StringComparison.OrdinalIgnoreCase) + || t.StartsWith("War", StringComparison.OrdinalIgnoreCase) + || t.StartsWith("Plot", StringComparison.OrdinalIgnoreCase) + || t.StartsWith("Pack", StringComparison.OrdinalIgnoreCase) + || t.StartsWith("Outbreak", StringComparison.OrdinalIgnoreCase); + } + + private static void Consider(ref RelationEvidence best, ref int bestPri, RelationEvidence cand) + { + if (cand == null || cand.Class == RelationClass.None) + { + return; + } + + int pri = cand.Priority > 0 ? cand.Priority : ClassPriority(cand.Class); + if (pri < bestPri) + { + bestPri = pri; + best = cand; + best.Priority = pri; + } + } + + private static int ClassPriority(RelationClass c) + { + switch (c) + { + case RelationClass.Bond: return 1; + case RelationClass.Foe: return 2; + case RelationClass.PlotPartner: return 3; + case RelationClass.Kin: return 4; + case RelationClass.Friend: return 5; + case RelationClass.Grief: return 1; + default: return 9; + } + } + + private static RelationEvidence EvidenceFromFact(LifeSagaFact fact, Actor other) + { + if (fact == null) + { + return RelationEvidence.None; + } + + var e = new RelationEvidence { OtherId = fact.OtherId }; + switch (fact.Kind) + { + case LifeSagaFactKind.BondFormed: + e.Class = RelationClass.Bond; + e.Priority = 1; + return e; + case LifeSagaFactKind.BondBroken: + e.Class = RelationClass.Bond; + e.BondBroken = true; + e.Priority = 1; + return e; + case LifeSagaFactKind.FriendFormed: + e.Class = RelationClass.Friend; + e.Priority = 5; + return e; + case LifeSagaFactKind.ParentChild: + e.Class = RelationClass.Kin; + e.KinKind = LifeSagaMemory.IsParentPerspective(fact) ? KinKind.Child : KinKind.Parent; + e.Priority = 4; + return e; + case LifeSagaFactKind.RivalEarned: + e.Class = RelationClass.Foe; + e.Priority = 2; + FillFoeFromNote(e, fact.Note); + return e; + case LifeSagaFactKind.WarJoin: + e.Class = RelationClass.Foe; + e.FoeKind = FoeKind.War; + e.RealmOrPlot = FirstNonEmpty(fact.Note, fact.KingdomKey); + e.Priority = 2; + return e; + case LifeSagaFactKind.PlotJoin: + // Same-side plot join = partner; opposition notes use RivalEarned. + e.Class = RelationClass.PlotPartner; + e.RealmOrPlot = fact.Note ?? ""; + e.Priority = 3; + return e; + case LifeSagaFactKind.Death: + case LifeSagaFactKind.HardArcGrief: + e.Class = RelationClass.Grief; + e.Priority = 1; + return e; + case LifeSagaFactKind.Kill: + case LifeSagaFactKind.HardArcCombat: + case LifeSagaFactKind.CombatEncounter: + // Not automatically a cast foe unless RivalEarned. + return RelationEvidence.None; + default: + return RelationEvidence.None; + } + } + + private static void FillFoeFromNote(RelationEvidence e, string note) + { + if (string.IsNullOrEmpty(note)) + { + e.FoeKind = FoeKind.Generic; + return; + } + + if (note.StartsWith("opposed_in_war:", StringComparison.Ordinal)) + { + e.FoeKind = FoeKind.War; + e.RealmOrPlot = note.Substring("opposed_in_war:".Length).Trim(); + return; + } + + if (note.StartsWith("opposed_in_plot:", StringComparison.Ordinal)) + { + e.FoeKind = FoeKind.Plot; + e.RealmOrPlot = note.Substring("opposed_in_plot:".Length).Trim(); + return; + } + + if (note.StartsWith("killed_kin", StringComparison.Ordinal) + || note.StartsWith("kin_slain", StringComparison.Ordinal)) + { + e.FoeKind = FoeKind.Blood; + return; + } + + if (note.StartsWith("mc_rematch:", StringComparison.Ordinal) + || note.StartsWith("repeated_encounters:", StringComparison.Ordinal)) + { + e.FoeKind = FoeKind.Rematch; + int colon = note.IndexOf(':'); + if (colon >= 0 && colon + 1 < note.Length + && int.TryParse(note.Substring(colon + 1).Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)) + { + e.RematchCount = n; + } + + return; + } + + e.FoeKind = FoeKind.Generic; + } + + private static string FormatFactBody(LifeSagaFact fact, bool unresolved) + { + 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: + if (!LifeSagaMemory.IsParentPerspective(fact)) + { + return string.IsNullOrEmpty(other) ? "Was born" : "Born to " + other; + } + + return string.IsNullOrEmpty(other) ? "Had a child" : "Had 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 FormatRivalBody(fact, other); + case LifeSagaFactKind.WarJoin: + { + string realm = FirstNonEmpty(fact.Note, fact.KingdomKey); + if (unresolved) + { + if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(realm)) + { + return "Still at war with " + other + " of " + realm; + } + + if (!string.IsNullOrEmpty(other)) + { + return "Still at war with " + other; + } + + return string.IsNullOrEmpty(realm) ? "Still at war" : "Still at war over " + realm; + } + + if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(realm)) + { + return "Went to war against " + other + " (" + realm + ")"; + } + + if (!string.IsNullOrEmpty(other)) + { + return "Went to war against " + other; + } + + return string.IsNullOrEmpty(realm) ? "Entered a war" : "Went to war (" + realm + ")"; + } + case LifeSagaFactKind.WarEnd: + { + string realm = FirstNonEmpty(fact.Note, fact.KingdomKey); + return string.IsNullOrEmpty(realm) ? "War ended" : "War with " + realm + " ended"; + } + case LifeSagaFactKind.PlotJoin: + { + string plot = fact.Note ?? ""; + if (unresolved) + { + if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(plot)) + { + return "Still tangled in " + plot + " with " + other; + } + + if (!string.IsNullOrEmpty(other)) + { + return "Still plotting with " + other; + } + + return string.IsNullOrEmpty(plot) ? "Still tangled in a plot" : "Still tangled in " + plot; + } + + if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(plot)) + { + return "Plotted with " + other + " (" + plot + ")"; + } + + if (!string.IsNullOrEmpty(other)) + { + return "Plotted with " + other; + } + + return string.IsNullOrEmpty(plot) ? "Joined a plot" : "Joined " + plot; + } + case LifeSagaFactKind.PlotEnd: + { + string plot = fact.Note ?? ""; + return string.IsNullOrEmpty(plot) ? "Plot resolved" : "Plot resolved (" + plot + ")"; + } + case LifeSagaFactKind.Founding: + return !string.IsNullOrEmpty(fact.Note) ? "Founded " + fact.Note : "Founded a new home"; + case LifeSagaFactKind.RoleChange: + return FormatRoleNote(fact.Note); + case LifeSagaFactKind.HomeGained: + return "Found a home"; + case LifeSagaFactKind.HomeLost: + return "Lost a home"; + case LifeSagaFactKind.HardArcLove: + return FirstNonEmpty(StripBannedNote(fact.Note), "Bound with someone close"); + case LifeSagaFactKind.HardArcGrief: + return FirstNonEmpty(StripBannedNote(fact.Note), "Grief took hold"); + case LifeSagaFactKind.HardArcCombat: + return FirstNonEmpty(StripBannedNote(fact.Note), "Survived a hard fight"); + case LifeSagaFactKind.HardArcWar: + return FirstNonEmpty(StripBannedNote(fact.Note), "Marked by war"); + case LifeSagaFactKind.HardArcPlot: + return FirstNonEmpty(StripBannedNote(fact.Note), "Entangled in a plot"); + case LifeSagaFactKind.CombatEncounter: + return string.IsNullOrEmpty(other) ? "Fought again" : "Clashed with " + other; + default: + return FirstNonEmpty(StripBannedNote(fact.Note), "A turning point"); + } + } + + private static string FormatRivalBody(LifeSagaFact fact, string other) + { + string note = fact.Note ?? ""; + if (note.StartsWith("opposed_in_war:", StringComparison.Ordinal)) + { + string realm = note.Substring("opposed_in_war:".Length).Trim(); + if (!fact.Resolved) + { + if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(realm)) + { + return "Still at war with " + other + " of " + realm; + } + + return string.IsNullOrEmpty(other) ? "Still at war" : "Still at war with " + other; + } + + return string.IsNullOrEmpty(other) ? "Went to war" : "Went to war against " + other; + } + + if (note.StartsWith("opposed_in_plot:", StringComparison.Ordinal)) + { + string plot = note.Substring("opposed_in_plot:".Length).Trim(); + if (!fact.Resolved) + { + if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(plot)) + { + return "Locked against " + other + " in " + plot; + } + + return string.IsNullOrEmpty(other) ? "Locked in a plot" : "Locked against " + other; + } + + return string.IsNullOrEmpty(other) ? "Opposed in a plot" : "Opposed " + other + " in a plot"; + } + + if (note.StartsWith("killed_kin", StringComparison.Ordinal) + || note.StartsWith("kin_slain", StringComparison.Ordinal)) + { + return string.IsNullOrEmpty(other) ? "Blood feud over kin" : "Blood feud with " + other; + } + + if (note.StartsWith("mc_rematch:", StringComparison.Ordinal) + || note.StartsWith("repeated_encounters:", StringComparison.Ordinal)) + { + int colon = note.IndexOf(':'); + int n = 0; + if (colon >= 0) + { + int.TryParse(note.Substring(colon + 1).Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out n); + } + + if (n >= 2 && !string.IsNullOrEmpty(other)) + { + return "Has clashed " + n + " times with " + other; + } + + return string.IsNullOrEmpty(other) ? "Fought again and again" : "Fought " + other + " again and again"; + } + + return string.IsNullOrEmpty(other) ? "Feud continues" : "Feud with " + other; + } + + private static string FormatRoleNote(string roleId) + { + if (string.IsNullOrEmpty(roleId)) + { + return "Rose in standing"; + } + + if (roleId.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Became " + TitleLabel("King"); + } + + if (roleId.IndexOf("leader", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Became a " + TitleLabel("Leader"); + } + + if (roleId.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Became " + TitleLabel("Pack Alpha"); + } + + if (roleId.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0 + || roleId.IndexOf("clan", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Became " + TitleLabel("Clan Chief"); + } + + if (roleId.IndexOf("captain", StringComparison.OrdinalIgnoreCase) >= 0 + || roleId.IndexOf("army", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Became " + TitleLabel("Army Captain"); + } + + return "Role: " + roleId; + } + + private static string StripBannedNote(string note) + { + if (string.IsNullOrEmpty(note) || ContainsBannedPhrase(note)) + { + return ""; + } + + return note; + } + + private static bool LooksLikeLoveTip(string tipLower) + { + return tipLower.IndexOf("lover", StringComparison.Ordinal) >= 0 + || tipLower.IndexOf("love", StringComparison.Ordinal) >= 0 + || tipLower.IndexOf("smitten", StringComparison.Ordinal) >= 0 + || tipLower.IndexOf("kiss", StringComparison.Ordinal) >= 0 + || tipLower.IndexOf("intimacy", StringComparison.Ordinal) >= 0 + || tipLower.IndexOf("bound", StringComparison.Ordinal) >= 0; + } + + private static string Ordinal(int n) + { + if (n <= 0) + { + return "next"; + } + + int mod100 = n % 100; + if (mod100 >= 11 && mod100 <= 13) + { + return n + "th"; + } + + switch (n % 10) + { + case 1: return n + "st"; + case 2: return n + "nd"; + case 3: return n + "rd"; + default: return n + "th"; + } + } + + 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 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 RelationClass +{ + None = 0, + Bond, + Friend, + Kin, + Foe, + PlotPartner, + Grief +} + +public enum KinKind +{ + None = 0, + Parent, + Child, + Heir, + Packmate, + Kin +} + +public enum FoeKind +{ + None = 0, + Rematch, + War, + Plot, + Blood, + Generic +} + +public sealed class RelationEvidence +{ + public static readonly RelationEvidence None = new RelationEvidence(); + + public RelationClass Class; + public KinKind KinKind; + public FoeKind FoeKind; + public bool BondBroken; + public bool BestFriend; + public bool AlsoBond; + public bool AlsoFoe; + public int RematchCount; + public string RealmOrPlot = ""; + public long OtherId; + public int Priority; +} diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 6a7e976..d4e4bdb 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -8,12 +8,13 @@ using UnityEngine.UI; namespace IdleSpectator; /// -/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, statuses, traits, reason, story spine. +/// Compact dossier: character beat caption (Identity + Beat + Context), avatar + mini History, +/// statuses, traits; life-saga rail + hover Cast/Legacy depth. /// public static class WatchCaption { /// - /// Locked outer width whenever tabs/rail are shown so Dossier↔Saga never slides + /// Locked outer width whenever the rail is shown so hover depth cannot slide /// right-anchored glyphs out from under the hover cursor. /// private const float PanelWidthMax = 240f; @@ -39,7 +40,6 @@ public static class WatchCaption 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 = 1f; @@ -51,7 +51,7 @@ public static class WatchCaption private static float HistoryColMaxW => Mathf.Max(HistoryColMinW, PanelWidthMax - PadX * 2f - LiveMax - 4f); - /// Inner width available to tabs/dossier/saga body. + /// Inner width available to dossier/saga body under the rail. private static float BodyColumnInnerWidth() => Mathf.Max(120f, PanelWidthMax - PadX * 2f); @@ -75,13 +75,6 @@ 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; @@ -153,7 +146,19 @@ public static class WatchCaption public static string LastCaptionText { get; private set; } = ""; - /// Harness/UI: active story spine ("Duel · Aftermath"), empty when none. + /// Harness: composed beat line (may include relation clause). + public static string LastBeatLine => CaptionComposer.LastBeatLine; + + /// Actual Beat painted this frame; may lead composer state during a handoff. + internal static string VisibleBeatLine => + _reasonText != null && _reasonText.gameObject.activeSelf + ? (_reasonText.text ?? "") + : LastBeatLine; + + /// Harness: composed context line (spine or stake). + public static string LastContextLine => CaptionComposer.LastContextLine; + + /// Harness/UI: active context line under the beat (spine or stake), empty when none. public static string LastStorySpine { get; private set; } = ""; /// Harness: newest mini-history line currently shown (if any). @@ -435,6 +440,7 @@ public static class WatchCaption LastDetail = ""; LastCaptionText = ""; LastStorySpine = ""; + CaptionComposer.Clear(); LifeSagaViewController.Clear(); LifeSagaPanel.Clear(); LifeSagaRail.Clear(); @@ -689,23 +695,24 @@ 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. + // Caption Identity/Beat/Context always track camera focus; hover only adds panel depth. LifeSagaViewController.Tick(); - if (LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Dossier) + ReconcileDossierToFocus(); + if (!LifeSagaViewController.IsHoverPreview) { - ReconcileDossierToFocus(); RefreshLivePortrait(); RefreshLiveTask(); - RefreshLiveIdentity(); + } + + RefreshLiveIdentity(); + if (!LifeSagaViewController.IsHoverPreview) + { RefreshLiveStatuses(); - RefreshOwnedReason(); - RefreshStorySpine(); RefreshHistoryIfChanged(); } - else - { - // RefreshStorySpine already refreshes Saga body when the Saga tab is effective. - RefreshStorySpine(); - } + + RefreshOwnedReason(); + RefreshStorySpine(); } /// @@ -765,7 +772,7 @@ public static class WatchCaption } _layoutDirty = true; - // Route through spine/tab refresh so Saga body and rail stay consistent. + // Route through spine/rail refresh so caption compose and hover panel stay consistent. RefreshStorySpine(); } @@ -922,38 +929,25 @@ public static class WatchCaption bool headcountOnly = EventReason.IsHeadcountOnlyChange(prev, next); _current.ReasonLine = next; LastDetail = _current.DetailLine ?? ""; - LastCaptionText = JoinCaptionLines( - _current.Headline, - next, - _current.DetailLine, - StoryPlanner.FormatSpineLabel()); - - bool hasReason = !string.IsNullOrEmpty(next); - if (_reasonText != null) - { - ApplyReasonText(hasReason ? next : "", hasReason ? _current.ReasonRelatedId : 0); - } // Mass tip headcount ticks must not Relayout the whole dossier every second. + // RefreshStorySpine recomposes Beat+Context and Relayouts when chrome changes. + RefreshStorySpine(); if (headcountOnly) { - RefreshStorySpine(); return; } - bool hasTask = !string.IsNullOrEmpty(_current.TaskText); - Relayout( - _current.UnitId != 0, - CountActiveTraitSlots(), - CountActiveStatusSlots(), - hasTask, - hasReason, - CountActiveHistorySlots()); + LastCaptionText = JoinCaptionLines( + LastHeadline, + LastBeatLine, + detail: null, + LastContextLine); } /// - /// 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. + /// Quiet context under the orange beat (story spine or stake). + /// Recompose Identity/Beat/Context; show Cast/Legacy panel only while hovering. /// private static void RefreshStorySpine() { @@ -962,31 +956,29 @@ public static class WatchCaption LifeSagaViewController.CancelPreviewAndPause(); LifeSagaPanel.Clear(); LifeSagaRail.Clear(); - SetTabsVisible(false); _layoutDirty = true; _dossierRowsNeedRestore = true; return; } - string next = (SpectatorMode.Active || SpectatorMode.BrowsePaused) && ModSettings.ShowDossierCaption - ? StoryPlanner.FormatSpineLabel() - : ""; - next = FilterRedundantStorySpine(next, CurrentReasonText()); - if (!string.Equals(next, LastStorySpine, StringComparison.Ordinal)) + Actor focus = ResolveBoundLiveActor(); + if (focus == null && MoveCamera.hasFocusUnit()) { - ApplyStorySpine(next); - _layoutDirty = true; + focus = MoveCamera._focus_unit; } + string presentable = _current != null ? (_current.ReasonLine ?? "") : ""; + long related = _current != null ? _current.ReasonRelatedId : 0; + ApplyComposedCaption(focus, presentable, related, statusBannerOwnsBeat: false); + LifeSagaRail.Refresh(); - RefreshTabChrome(); - bool sagaMode = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga; + bool panelMode = LifeSagaViewController.IsHoverPreview; int traitCount = 0; int statusCount = 0; int historyCount = 0; bool hasTask = false; bool hasReason = false; - if (sagaMode) + if (panelMode) { RefreshSagaBody(); _dossierRowsNeedRestore = true; @@ -994,8 +986,8 @@ public static class WatchCaption else { LifeSagaPanel.SetVisible(false); - // Saga force-hides dossier rows and deactivates the reason GO. Restore once when - // leaving Saga/hover so hover-exit does not wait for a tip change (not every frame). + // Hover force-hides dossier rows. Restore once when leaving hover so exit does + // not wait for a tip change (not every frame). if (_dossierRowsNeedRestore) { RestoreDossierRowsAfterSaga(); @@ -1007,29 +999,114 @@ public static class WatchCaption statusCount = CountActiveStatusSlots(); historyCount = CountActiveHistorySlots(); hasTask = _taskText != null && !string.IsNullOrEmpty(_taskText.text); - hasReason = (_current != null && !string.IsNullOrEmpty(_current.ReasonLine)) + hasReason = !string.IsNullOrEmpty(LastBeatLine) || (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text)); } - bool hasBody = sagaMode - || _boundActor != null - || historyCount > 0 - || _current != null; + if (panelMode) + { + // Hover depth prioritizes the full Identity. The live task is already represented + // by the Beat and returns with the dossier rows as soon as hover ends. + hasTask = false; + hasReason = !string.IsNullOrEmpty(LastBeatLine) + || (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text)); + } + + bool hasBody = !panelMode + && (_boundActor != null + || historyCount > 0 + || _current != null); string layoutKey = - $"{(sagaMode ? 1 : 0)}|{next}|{traitCount}|{statusCount}|{historyCount}" + $"{(panelMode ? 1 : 0)}|{LastContextLine}|{traitCount}|{statusCount}|{historyCount}" + $"|{(hasTask ? 1 : 0)}|{(hasReason ? 1 : 0)}|{hasBody}" - + $"|{LifeSagaPanel.BodyHeight:F0}|{LifeSagaRail.LastShownCount}|{LifeSagaRail.Visible}"; + + $"|{LifeSagaPanel.BodyHeight:F0}|{LifeSagaRail.LastShownCount}|{LifeSagaRail.Visible}" + + $"|{LastBeatLine}|{LastHeadline}"; if (_layoutDirty || !string.Equals(layoutKey, _layoutKey, StringComparison.Ordinal)) { _layoutKey = layoutKey; _layoutDirty = false; - Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount); + Relayout(hasBody || panelMode, traitCount, statusCount, hasTask, hasReason, historyCount); } } /// - /// Re-show trait/status/history/reason chrome after Saga hid them. - /// Uses the bound dossier snapshot so hover-exit is instant. + /// Apply CaptionComposer Identity + Beat + Context to nametag / reason / spine. + /// Filters spine against the presentable beat before EnrichBeat appends clauses. + /// + private static void ApplyComposedCaption( + Actor focus, + string presentableBeat, + long reasonRelatedId, + bool statusBannerOwnsBeat) + { + string spineRaw = (SpectatorMode.Active || SpectatorMode.BrowsePaused) && ModSettings.ShowDossierCaption + ? StoryPlanner.FormatSpineLabel() + : ""; + string spineFiltered = FilterRedundantStorySpine(spineRaw, presentableBeat ?? ""); + InterestCandidate tip = InterestDirector.CurrentCandidate; + CaptionModel model = CaptionComposer.Compose( + focus, + tip, + presentableBeat ?? "", + reasonRelatedId, + spineFiltered, + statusBannerOwnsBeat); + + if (!_headlineLocked) + { + if (!string.IsNullOrEmpty(model.IdentityLine)) + { + LastHeadline = model.IdentityLine; + if (_current != null) + { + _current.Headline = LastHeadline; + } + + if (_nameText != null) + { + string shown = LastHeadline; + if (shown.Length > 48) + { + shown = CaptionComposer.TruncateIdentity(shown, 48); + } + + _nameText.text = shown; + } + } + else if (_current != null && !string.IsNullOrEmpty(_current.Headline)) + { + // Fallen/archive: Compose has no live focus - keep dossier headline. + LastHeadline = _current.Headline; + if (_nameText != null) + { + _nameText.text = LastHeadline; + } + } + } + + if (!statusBannerOwnsBeat) + { + bool hasBeat = !string.IsNullOrEmpty(model.BeatLine); + ApplyReasonText(hasBeat ? model.BeatLine : "", hasBeat ? model.ReasonRelatedId : 0); + if (_current != null && model.ReasonRelatedId != 0) + { + _current.ReasonRelatedId = model.ReasonRelatedId; + } + } + + ApplyStorySpine(model.ContextLine ?? ""); + // Character beat CAPTION fingerprint: Identity / Beat / Context only. + // DetailLine is the live task chip - keep it out of the joined caption log. + LastCaptionText = JoinCaptionLines( + LastHeadline, + statusBannerOwnsBeat ? (_reasonText != null ? _reasonText.text : "") : model.BeatLine, + detail: null, + model.ContextLine); + } + + /// + /// Re-show trait/status/history chrome after hover hid them. + /// Uses the bound dossier snapshot so hover-exit is instant; recomposes caption. /// private static void RestoreDossierRowsAfterSaga() { @@ -1041,15 +1118,31 @@ public static class WatchCaption ApplyTraitChips(_current); ApplyStatusChips(_current); FillHistory(_current.UnitId); + bool hasLevel = _current.UnitId != 0 + && ((_boundActor != null && _boundActor.isAlive()) || _current.Level > 0); + if (_levelIcon != null) + { + HudIcons.Apply(_levelIcon, hasLevel ? HudIcons.Level() : null); + _levelIcon.gameObject.SetActive(hasLevel); + } + + if (_levelValue != null) + { + _levelValue.text = hasLevel ? _current.Level.ToString() : ""; + _levelValue.gameObject.SetActive(hasLevel); + } + if (!string.IsNullOrEmpty(_current.TaskText)) { ApplyTaskChip(_current.TaskText); } - if (!string.IsNullOrEmpty(_current.ReasonLine)) - { - ApplyReasonText(_current.ReasonLine, _current.ReasonRelatedId); - } + Actor focus = ResolveBoundLiveActor(); + ApplyComposedCaption( + focus, + _current.ReasonLine ?? "", + _current.ReasonRelatedId, + statusBannerOwnsBeat: false); } /// Harness: dossier nametag/reason visible after leaving Saga hover. @@ -1071,22 +1164,12 @@ public static class WatchCaption && !string.IsNullOrEmpty(_reasonText.text); } - /// 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"). + /// Drop Kind · Climax when the presentable beat 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). + /// Filter against the pre-enrichment beat so trailing ` · clause` does not matter; + /// Kind token detection already uses the start of the tip. /// private static string FilterRedundantStorySpine(string spine, string reason) { @@ -1180,80 +1263,6 @@ public static class WatchCaption LifeSagaPanel.Bind(model); } - 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) { LastStorySpine = spine ?? ""; @@ -1266,15 +1275,6 @@ public static class WatchCaption _storySpineText.text = show ? LastStorySpine : ""; _storySpineText.color = StorySpineColor; _storySpineText.gameObject.SetActive(show); - - if (_current != null) - { - LastCaptionText = JoinCaptionLines( - _current.Headline, - _current.ReasonLine, - _current.DetailLine, - LastStorySpine); - } } private static string JoinCaptionLines(string headline, string reason, string detail, string storySpine = null) @@ -1369,7 +1369,7 @@ public static class WatchCaption BringHeaderFront(); } - /// Keep nametag Species/Job identity tag in sync with live citizen job. + /// Keep nametag Identity in sync via CaptionComposer (MC title or species/job). private static void RefreshLiveIdentity() { if (!_visible || _current == null || HasStatusBanner() || _headlineLocked) @@ -1385,26 +1385,22 @@ public static class WatchCaption string job = UnitDossier.ReadLiveJobLabel(actor); string tag = UnitDossier.BuildIdentityTag(_current.SpeciesId, job); - string headline = UnitDossier.BuildHeadline(_current.Name, tag); - if (headline == (_current.Headline ?? "") - && tag == (_current.IdentityTag ?? "") - && job == (_current.JobLabel ?? "")) - { - return; - } - + string prevHeadline = LastHeadline ?? ""; + string prevJob = _current.JobLabel ?? ""; + string prevTag = _current.IdentityTag ?? ""; + ApplyComposedCaption( + actor, + _current.ReasonLine ?? "", + _current.ReasonRelatedId, + statusBannerOwnsBeat: false); _current.JobLabel = job; _current.IdentityTag = tag; - _current.Headline = headline; - LastHeadline = headline; - LastCaptionText = JoinCaptionLines( - headline, - _current.ReasonLine, - _current.DetailLine, - LastStorySpine); - if (_nameText != null) + + if (string.Equals(LastHeadline, prevHeadline, StringComparison.Ordinal) + && string.Equals(job, prevJob, StringComparison.Ordinal) + && string.Equals(tag, prevTag, StringComparison.Ordinal)) { - _nameText.text = headline; + return; } bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; @@ -1789,7 +1785,9 @@ public static class WatchCaption } bool hasBody = dossier != null && dossier.UnitId != 0; - DossierAvatar.SetActive(hasBody); + bool hoverDepth = LifeSagaViewController.IsHoverPreview; + // Hover Cast/Legacy owns the body band - keep the large portrait off. + DossierAvatar.SetActive(hasBody && !hoverDepth); // Live units always show level; archive snapshots hide a blank 0. bool hasLevel = hasBody && (hasLive || dossier.Level > 0); @@ -1805,7 +1803,11 @@ public static class WatchCaption _levelValue.gameObject.SetActive(hasLevel); } - if (hasLive) + if (hoverDepth) + { + DossierAvatar.SetHostVisible(false); + } + else if (hasLive) { DossierAvatar.Show(actor); ActivityLog.EnsureCurrentTask(actor); @@ -1845,18 +1847,11 @@ public static class WatchCaption } } - bool hasReason = dossier != null && !string.IsNullOrEmpty(dossier.ReasonLine); - if (_reasonText != null) - { - ApplyReasonText( - hasReason ? dossier.ReasonLine : "", - hasReason && dossier != null ? dossier.ReasonRelatedId : 0); - } - - string spine = SpectatorMode.Active && ModSettings.ShowDossierCaption && dossier != null - ? StoryPlanner.FormatSpineLabel() - : ""; - ApplyStorySpine(spine); + string presentable = dossier != null ? (dossier.ReasonLine ?? "") : ""; + long related = dossier != null ? dossier.ReasonRelatedId : 0; + ApplyComposedCaption(hasLive ? actor : null, presentable, related, statusBannerOwnsBeat: false); + bool hasReason = !string.IsNullOrEmpty(LastBeatLine) + || !string.IsNullOrEmpty(presentable); Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount); RefreshFavoriteVisual(hasLive ? actor : null); @@ -2659,22 +2654,14 @@ public static class WatchCaption } } - bool sagaMode = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga - && !HasStatusBanner(); - bool tabbedChrome = (_tabsRow != null && _tabsRow.activeSelf) || LifeSagaRail.Visible; + bool panelMode = LifeSagaViewController.IsHoverPreview && !HasStatusBanner(); + bool railChrome = LifeSagaRail.Visible; - // Tabbed chrome keeps a constant width so hover preview cannot reflow the rail. - if (tabbedChrome) + // Rail chrome keeps a constant width so hover preview cannot reflow the glyphs. + if (railChrome || panelMode) { _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( @@ -2684,7 +2671,7 @@ public static class WatchCaption } LastHistoryFillsBody = historyCount <= 0; - if (!sagaMode && historyCount > 0) + if (!panelMode && historyCount > 0) { float fillW = BodyColumnInnerWidth() - LiveMax - 4f; if (fillW > _historyColW + 0.5f) @@ -2700,99 +2687,70 @@ 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. + // Stable top chrome: rail first so hover 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); - } + // Hover depth hides avatar/traits/history/statuses but keeps Identity + Beat + Context. + SetDossierBodyVisible(!panelMode); PlaceHeader(y, hasTask); y += HeaderH + Gap; - if (hasBody) + if (!panelMode) { - float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH; - PlaceBody(y, historyCount, bodyH); - if (historyCount > 0) + // Row visibility is owned by Place*/fill - never leave empty rows active at stale + // positions (SetDossierBodyVisible only hides on hover; it must not resurrect them). + if (historyCount <= 0 && _historyCol != null) { - y += bodyH + Gap; + _historyCol.SetActive(false); } - else if (traitsBeside) - { - PlaceTraitsBesideAvatar(y, traitCount); - y += BodyH + Gap; - } - else - { - y += BodyH + Gap; - } - } - if (statusCount > 0) - { - float statusesUsed = PlaceStatuses(y, statusCount); - WidenPanelForChipRow(statusesUsed); - y += StatusesH + Gap; - } + if (statusCount <= 0 && _statusesRow != null) + { + _statusesRow.SetActive(false); + } - if (traitCount > 0 && !traitsBeside) - { - float traitsUsed = PlaceTraits(y, traitCount); - WidenPanelForChipRow(traitsUsed); - y += TraitsH + Gap; + if (traitCount <= 0 && _traitsRow != null) + { + _traitsRow.SetActive(false); + } + + if (hasBody) + { + float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH; + PlaceBody(y, historyCount, bodyH); + if (historyCount > 0) + { + y += bodyH + Gap; + } + else if (traitsBeside) + { + PlaceTraitsBesideAvatar(y, traitCount); + y += BodyH + Gap; + } + else + { + y += BodyH + Gap; + } + } + + if (statusCount > 0) + { + float statusesUsed = PlaceStatuses(y, statusCount); + WidenPanelForChipRow(statusesUsed); + y += StatusesH + Gap; + } + + if (traitCount > 0 && !traitsBeside) + { + float traitsUsed = PlaceTraits(y, traitCount); + WidenPanelForChipRow(traitsUsed); + y += TraitsH + Gap; + } } if (hasReason) @@ -2811,19 +2769,30 @@ public static class WatchCaption y += StorySpineH + Gap; } - float heightDossier = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap); + if (panelMode) + { + LifeSagaPanel.EnsureBuilt(_root.transform); + y = LifeSagaPanel.Place(y, PadX); + if (railChrome) + { + _panelWidth = PanelWidthMax; + } + } + + float heightFinal = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap); if (_rootRt != null) { ApplyRootPlacement(); - _rootRt.sizeDelta = new Vector2(_panelWidth, heightDossier); + _rootRt.sizeDelta = new Vector2(_panelWidth, heightFinal); LastPanelSize = _rootRt.sizeDelta; } BringHeaderFront(); - LastLayoutOk = ProbeLayoutInside(heightDossier) - && headerW > SpeciesSize - && (historyCount <= 0 || LastHistoryFillsBody); + LastLayoutOk = panelMode + || (ProbeLayoutInside(heightFinal) + && headerW > SpeciesSize + && (historyCount <= 0 || LastHistoryFillsBody)); if (!LastLayoutOk) { LogService.LogInfo( @@ -2831,62 +2800,31 @@ 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); - } - } - + /// + /// Toggle dossier depth rows (avatar / traits / statuses / history). + /// Never hides nametag Identity, Beat reason, or Context spine - those stay for camera focus + /// even while the hover Cast/Legacy panel is open. + /// 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); + if (!LifeSagaPanel.OwnsAvatar) + { + DossierAvatar.SetHostVisible(false); + } + + return; } - // Saga reuses the same live portrait host for the MC - leave it visible there. + // Showing dossier body again - avatar host is restored when panel does not own it. + // History / traits / statuses stay under Fill*/Place* ownership. if (!LifeSagaPanel.OwnsAvatar) { - DossierAvatar.SetHostVisible(visible); + DossierAvatar.SetHostVisible(true); } } @@ -2897,6 +2835,19 @@ public static class WatchCaption private static float PlaceHeader(float yFromTop, bool hasTask) { float panelW = Mathf.Max(_panelWidth, LiveMax + PadX * 2f); + bool depthMode = LifeSagaViewController.IsHoverPreview; + if (depthMode) + { + // Identity is the only header datum needed while reading Saga depth. + // Explicitly hide these so a live task refresh cannot leave stale chips + // over a long title between relayouts. + if (_levelIcon != null) _levelIcon.gameObject.SetActive(false); + if (_levelValue != null) _levelValue.gameObject.SetActive(false); + if (_taskIcon != null) _taskIcon.gameObject.SetActive(false); + if (_taskText != null) _taskText.gameObject.SetActive(false); + hasTask = false; + } + // Sex + favorite hug the dossier's top-right (right-justified). float right = panelW - PadX; PlaceHeaderButton(_favoriteBtn, right - BtnSize, yFromTop); @@ -3089,8 +3040,8 @@ public static class WatchCaption } /// - /// Size the name chip to the visible text and ellipsis-truncate when over . - /// Prevents Overflow paint from landing under level / task / sex chips. + /// Size the name chip to the visible text and shrink long Identity titles to fit. + /// Keep the full title rather than character-truncating role/place information. /// private static float FitNameChipWidth(float maxW = NameMaxW) { @@ -3102,6 +3053,7 @@ public static class WatchCaption maxW = Mathf.Max(NameMinW, maxW); _nameText.resizeTextForBestFit = false; _nameText.horizontalOverflow = HorizontalWrapMode.Overflow; + _nameText.fontSize = 10; string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? ""); if (string.IsNullOrEmpty(full)) { @@ -3116,30 +3068,10 @@ public static class WatchCaption return Mathf.Clamp(natural, NameMinW, 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); - _nameText.text = candidate; - float w = MeasureTextWidth(_nameText, maxW); - if (w <= maxW) - { - best = candidate; - lo = mid + 1; - } - else - { - hi = mid - 1; - } - } - - _nameText.text = best; + int fittedSize = Mathf.Clamp(Mathf.FloorToInt(10f * maxW / natural), 7, 10); + _nameText.fontSize = fittedSize; + _nameText.text = full; + _nameText.verticalOverflow = VerticalWrapMode.Truncate; return Mathf.Clamp(MeasureTextWidth(_nameText, maxW), NameMinW, maxW); } @@ -3735,13 +3667,6 @@ 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; @@ -3899,7 +3824,6 @@ public static class WatchCaption LifeSagaRail.EnsureBuilt(_root.transform); LifeSagaPanel.EnsureBuilt(_root.transform); - BuildTabsRow(); _speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize); _sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize); @@ -3947,58 +3871,6 @@ 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